Select named imports before source admission
This commit is contained in:
130
internal/engine/import_selection.go
Normal file
130
internal/engine/import_selection.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
type importSelectionOutcome uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
importSelectionNone importSelectionOutcome = iota
|
||||||
|
importSelectionHasCandidates
|
||||||
|
)
|
||||||
|
|
||||||
|
type selectedImportCandidate struct {
|
||||||
|
path string
|
||||||
|
accessPath string
|
||||||
|
site importFieldSite
|
||||||
|
}
|
||||||
|
|
||||||
|
// importSelection contains lexical candidates only; admission assigns source identity later.
|
||||||
|
type importSelection struct {
|
||||||
|
candidates []selectedImportCandidate
|
||||||
|
outcome importSelectionOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) {
|
||||||
|
normalized := make([]selectedImportCandidate, 0, len(spec.paths))
|
||||||
|
seen := make(map[string]struct{}, len(spec.paths))
|
||||||
|
for index, relative := range spec.paths {
|
||||||
|
accessPath := access.join(basePath, relative)
|
||||||
|
candidatePath := accessPath
|
||||||
|
if access.native == nil {
|
||||||
|
candidatePath = filepath.ToSlash(filepath.Clean(accessPath))
|
||||||
|
}
|
||||||
|
if _, exists := seen[candidatePath]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[candidatePath] = struct{}{}
|
||||||
|
var site importFieldSite
|
||||||
|
if index < len(spec.pathSites) {
|
||||||
|
site = spec.pathSites[index]
|
||||||
|
} else {
|
||||||
|
site = spec.fieldSites["path"]
|
||||||
|
}
|
||||||
|
normalized = append(normalized, selectedImportCandidate{path: candidatePath, accessPath: accessPath, site: site})
|
||||||
|
}
|
||||||
|
sort.Slice(normalized, func(left, right int) bool {
|
||||||
|
return normalized[left].path < normalized[right].path
|
||||||
|
})
|
||||||
|
candidates := make([]selectedImportCandidate, 0, len(normalized))
|
||||||
|
for _, candidate := range normalized {
|
||||||
|
missing, diagnostic := validateNamedImportTarget(access, candidate, policy.required)
|
||||||
|
if diagnostic != nil {
|
||||||
|
return importSelection{}, []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if excludedBasename(path.Base(candidate.path), policy.exclude) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
selection := importSelection{candidates: candidates}
|
||||||
|
if len(candidates) != 0 {
|
||||||
|
selection.outcome = importSelectionHasCandidates
|
||||||
|
}
|
||||||
|
return selection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNamedImportTarget(access *filesystemAccess, candidate selectedImportCandidate, required bool) (bool, *Diagnostic) {
|
||||||
|
if !access.contains(candidate.accessPath) {
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportOutsideRoot, "import target is outside the configuration root")
|
||||||
|
}
|
||||||
|
canonical, err := access.canonical(candidate.accessPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
if required {
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportMissing, "required import target does not exist")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, fs.ErrPermission) {
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target cannot be accessed")
|
||||||
|
}
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target could not be canonicalized")
|
||||||
|
}
|
||||||
|
if !access.contains(canonical) {
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportOutsideRoot, "import target resolves outside the configuration root")
|
||||||
|
}
|
||||||
|
info, err := access.stat(canonical)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
if required {
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportMissing, "required import target does not exist")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, fs.ErrPermission) {
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target cannot be accessed")
|
||||||
|
}
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target could not be inspected")
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return false, importSelectionDiagnostic(candidate.site, CodeSourceNotRegular, "source is not a regular file")
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func excludedBasename(name string, patterns []string) bool {
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
matched, err := path.Match(pattern, name)
|
||||||
|
if err == nil && matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func importSelectionDiagnostic(site importFieldSite, code Code, message string) *Diagnostic {
|
||||||
|
location := site.location
|
||||||
|
return &Diagnostic{
|
||||||
|
Code: code, Severity: SeverityError, Path: site.path.clone(),
|
||||||
|
Message: message, Primary: &location,
|
||||||
|
}
|
||||||
|
}
|
||||||
257
internal/engine/import_selection_test.go
Normal file
257
internal/engine/import_selection_test.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "present.yaml"), []byte("value: present\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - path: [missing.yaml, present.yaml]\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectNamedImportCandidates(access, root, specs[0], resolvedImportPolicy{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("optional partial selection diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" {
|
||||||
|
t.Fatalf("partial selection = %#v", selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredSpecs, diagnostics := parseImportSpecsYAML(t, " - path: [missing.yaml, present.yaml]\n required: true\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = selectNamedImportCandidates(access, root, requiredSpecs[0], resolvedImportPolicy{required: true})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportMissing {
|
||||||
|
t.Fatalf("required partial selection diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptySpecs, diagnostics := parseImportSpecsYAML(t, " - path: []\n required: true\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true})
|
||||||
|
if len(diagnostics) != 0 || empty.outcome != importSelectionNone || len(empty.candidates) != 0 {
|
||||||
|
t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectNamedImportsChecksRequiredTargetsBeforeExclusions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - path: missing.yaml\n required: true\n exclude: '*.yaml'\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = selectNamedImportCandidates(access, root, specs[0], policy)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportMissing || diagnostics[0].Path.String() != "/configkit/imports/0/path" {
|
||||||
|
t.Fatalf("required excluded missing target diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectNamedImportsDeduplicatesLexicalPathsButRetainsAliases(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "real.yaml"), []byte("value: real\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("real.yaml", filepath.Join(root, "alias.yaml")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - path: [real.yaml, nested/../real.yaml, alias.yaml]\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectNamedImportCandidates(access, root, specs[0], resolvedImportPolicy{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(selection.candidates) != 2 {
|
||||||
|
t.Fatalf("candidates = %#v, want two alias spellings", selection.candidates)
|
||||||
|
}
|
||||||
|
if filepath.Base(selection.candidates[0].path) != "alias.yaml" || filepath.Base(selection.candidates[1].path) != "real.yaml" {
|
||||||
|
t.Fatalf("candidate order = %#v", selection.candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := &statOnlyImportFilesystem{files: map[string]bool{"secret.yaml": true, ".private.yaml": true}}
|
||||||
|
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - path: secret.yaml\n required: true\n exclude: '*.yaml'\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy)
|
||||||
|
if len(diagnostics) != 0 || selection.outcome != importSelectionNone {
|
||||||
|
t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
|
}
|
||||||
|
if filesystem.openCalls != 0 || filesystem.statCalls == 0 {
|
||||||
|
t.Fatalf("excluded target access = %d opens, %d stats", filesystem.openCalls, filesystem.statCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
private, diagnostics := parseImportSpecsYAML(t, " - path: .private.yaml\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
hidden := false
|
||||||
|
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], importDefaults{hidden: &hidden})
|
||||||
|
if len(policyDiagnostics) != 0 || !privatePolicy.hidden {
|
||||||
|
t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics = selectNamedImportCandidates(access, ".", private[0], privatePolicy)
|
||||||
|
if len(diagnostics) != 0 || len(selection.candidates) != 1 || selection.candidates[0].path != ".private.yaml" {
|
||||||
|
t.Fatalf("explicit hidden path was filtered: %#v, %#v", selection, diagnostics)
|
||||||
|
}
|
||||||
|
if filesystem.openCalls != 0 {
|
||||||
|
t.Fatalf("named selection opened content %d times", filesystem.openCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectNamedImportsChecksNormalizedPathsLexically(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := &statOnlyImportFilesystem{files: map[string]bool{"a.yaml": true, "z.yaml": true}}
|
||||||
|
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - path: [z.yaml, a.yaml]\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], resolvedImportPolicy{})
|
||||||
|
if len(diagnostics) != 0 || len(selection.candidates) != 2 {
|
||||||
|
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||||
|
}
|
||||||
|
if strings.Join(filesystem.statPaths, ",") != "a.yaml,a.yaml,z.yaml,z.yaml" {
|
||||||
|
t.Fatalf("target stat order = %#v", filesystem.statPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectNamedImportsDoesNotTreatInvalidTargetsAsAbsence(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "directory"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside := filepath.Join(t.TempDir(), "outside.yaml")
|
||||||
|
if err := os.WriteFile(outside, []byte("value: outside\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, filepath.Join(root, "escape.yaml")); err != nil {
|
||||||
|
t.Skipf("symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
access, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer access.close()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, value string
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{name: "nonregular target", value: "directory", code: CodeSourceNotRegular},
|
||||||
|
{name: "outside symlink", value: "escape.yaml", code: CodeImportOutsideRoot},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - path: "+test.value+"\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = selectNamedImportCandidates(access, root, specs[0], resolvedImportPolicy{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
|
||||||
|
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectNamedImportsDoesNotTreatPermissionErrorsAsAbsence(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
filesystem := &statOnlyImportFilesystem{statErrors: map[string]error{"denied.yaml": fs.ErrPermission}}
|
||||||
|
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
specs, diagnostics := parseImportSpecsYAML(t, " - path: denied.yaml\n")
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = selectNamedImportCandidates(access, ".", specs[0], resolvedImportPolicy{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid {
|
||||||
|
t.Fatalf("optional permission failure diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type statOnlyImportFilesystem struct {
|
||||||
|
files map[string]bool
|
||||||
|
statErrors map[string]error
|
||||||
|
openCalls int
|
||||||
|
statCalls int
|
||||||
|
statPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
|
||||||
|
filesystem.openCalls++
|
||||||
|
return nil, fs.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) {
|
||||||
|
filesystem.statCalls++
|
||||||
|
filesystem.statPaths = append(filesystem.statPaths, name)
|
||||||
|
if err := filesystem.statErrors[name]; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !filesystem.files[name] {
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
return importSelectionFileInfo{name: pathBaseForTest(name)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type importSelectionFileInfo struct{ name string }
|
||||||
|
|
||||||
|
func (info importSelectionFileInfo) Name() string { return info.name }
|
||||||
|
func (importSelectionFileInfo) Size() int64 { return 1 }
|
||||||
|
func (importSelectionFileInfo) Mode() fs.FileMode { return 0o600 }
|
||||||
|
func (importSelectionFileInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (importSelectionFileInfo) IsDir() bool { return false }
|
||||||
|
func (importSelectionFileInfo) Sys() any { return nil }
|
||||||
|
func pathBaseForTest(name string) string { return strings.TrimPrefix(name, "./") }
|
||||||
@@ -25,6 +25,7 @@ type importDeclarationSpec struct {
|
|||||||
location Location
|
location Location
|
||||||
selector importSelector
|
selector importSelector
|
||||||
paths []string
|
paths []string
|
||||||
|
pathSites []importFieldSite
|
||||||
glob string
|
glob string
|
||||||
globSegments []string
|
globSegments []string
|
||||||
required *bool
|
required *bool
|
||||||
@@ -285,6 +286,8 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
|||||||
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
||||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)}
|
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)}
|
||||||
}
|
}
|
||||||
|
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
||||||
|
spec.pathSites = append(spec.pathSites, importFieldSite{path: badPath.clone(), location: publicLocation(badNode.Location)})
|
||||||
}
|
}
|
||||||
spec.paths = values
|
spec.paths = values
|
||||||
case importSelectorGlob:
|
case importSelectorGlob:
|
||||||
|
|||||||
Reference in New Issue
Block a user