diff --git a/compose.go b/compose.go index bf1b3dd..8a46ae2 100644 --- a/compose.go +++ b/compose.go @@ -15,6 +15,17 @@ type composedDocument struct { inputNodes int } +// cloneComposedDocument detaches mutable composition and provenance state; +// history application trees are immutable and retain their existing sharing. +func cloneComposedDocument(base *composedDocument) *composedDocument { + return &composedDocument{ + root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims), + origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays), + applications: cloneHistoryApplications(base.applications), + inputBytes: base.inputBytes, inputNodes: base.inputNodes, + } +} + type compositionClaim struct { path Path kind document.Kind diff --git a/directives.go b/directives.go new file mode 100644 index 0000000..c27dd46 --- /dev/null +++ b/directives.go @@ -0,0 +1,227 @@ +package configkit + +import ( + "path" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +type importDeclaration struct { + path string + glob string + optional bool + location Location +} + +func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) { + body := document.Clone(root) + var declarations []importDeclaration + directiveIndex := -1 + for index, entry := range body.Mapping { + if entry.Key != "_configkit" { + continue + } + directiveIndex = index + parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit")) + if len(diagnostics) != 0 { + return nil, nil, diagnostics + } + declarations = parsed + break + } + if directiveIndex >= 0 { + body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...) + } + if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 { + return nil, nil, diagnostics + } + return body, declarations, nil +} + +func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) { + if node.Kind != document.Mapping { + return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)} + } + var declarations []importDeclaration + for _, entry := range node.Mapping { + fieldPath := directivePath.Key(entry.Key) + if entry.Key != "imports" { + return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)} + } + if entry.Value.Kind != document.Sequence { + return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)} + } + for index, item := range entry.Value.Sequence { + declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index)) + if len(diagnostics) != 0 { + return nil, diagnostics + } + declarations = append(declarations, declaration) + } + } + return declarations, nil +} + +func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) { + if node.Kind != document.Mapping { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)} + } + declaration := importDeclaration{location: publicLocation(node.Location)} + seenTarget := false + for _, entry := range node.Mapping { + fieldPath := declarationPath.Key(entry.Key) + switch entry.Key { + case "path", "glob": + if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)} + } + seenTarget = true + declaration.location = publicLocation(entry.Value.Location) + if entry.Key == "path" { + declaration.path = entry.Value.Text + } else { + declaration.glob = entry.Value.Text + } + case "optional": + if entry.Value.Kind != document.Boolean { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)} + } + declaration.optional = entry.Value.Text == "true" + default: + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)} + } + } + if !seenTarget { + return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)} + } + return declaration, nil +} + +func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic { + if node.Kind == document.Mapping { + for _, entry := range node.Mapping { + childPath := current.Key(entry.Key) + if entry.Key == "_configkit" { + location := publicLocation(entry.KeyLocation) + return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}} + } + if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { + return diagnostics + } + } + } else if node.Kind == document.Sequence { + for index, child := range node.Sequence { + if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 { + return diagnostics + } + } + } + return nil +} + +func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node { + if rule == nil { + return nil + } + current := root + if len(rule.Path.segments) == 0 { + return current + } + for _, segment := range rule.Path.segments { + if current.Kind != document.Mapping { + return current + } + if segment.kind == pathSegmentIndex { + return nil + } + key := segment.key + index := documentEntryIndex(current.Mapping, key) + if index < 0 { + return nil + } + current = current.Mapping[index].Value + } + return current +} + +func validImportPath(value string) bool { + if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") { + return false + } + return !hasImportScheme(value) +} + +func hasImportScheme(value string) bool { + colon := strings.IndexByte(value, ':') + slash := strings.IndexByte(value, '/') + if colon <= 0 || (slash >= 0 && slash < colon) { + return false + } + for index := 0; index < colon; index++ { + character := value[index] + if index == 0 { + if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') { + return false + } + continue + } + if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && + (character < '0' || character > '9') && character != '+' && character != '-' && character != '.' { + return false + } + } + return true +} + +func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic { + location := publicLocation(node.Location) + return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location} +} + +func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic { + if node.Kind == document.Mapping { + for _, entry := range node.Mapping { + childPath := current.Key(entry.Key) + if entry.Key == "_configkit" { + location := publicLocation(entry.KeyLocation) + return []Diagnostic{{ + Code: CodeProfileDirective, Severity: SeverityError, Path: childPath, + Message: "profiles cannot contain configuration directives", Primary: &location, + }} + } + if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { + return diagnostics + } + } + } else if node.Kind == document.Sequence { + for index, child := range node.Sequence { + if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 { + return diagnostics + } + } + } + return nil +} + +func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { + var diagnostics []Diagnostic + if node.Kind == document.Mapping { + for _, entry := range node.Mapping { + childPath := path.Key(entry.Key) + if entry.Key == "_configkit" { + location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column} + diagnostics = append(diagnostics, Diagnostic{ + Code: CodeImportDisabled, Severity: SeverityError, Path: childPath, + Message: "configuration directives require imports to be enabled", Primary: &location, + }) + } + diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...) + } + } else if node.Kind == document.Sequence { + for index, child := range node.Sequence { + diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...) + } + } + return diagnostics +} diff --git a/docs/internal/overview.md b/docs/internal/overview.md index d8289c8..d77bc3a 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -19,8 +19,9 @@ module and package documentation boundary. Its implemented public primitives are - fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay with displacement records in `overlay.go`; overlay records retain origins, while value explanation uses separate history applications; -- detached parser-neutral CLI override validation and synthetic-source application - in `override.go`, sharing overlay, source, byte, node, and depth policies; +- CLI override validation/preparation in `override.go`, parser-neutral values in + `override_value.go`, and synthetic-source application in `override_apply.go`, + sharing overlay, source, byte, node, and depth policies; - a per-operation private reflection descriptor graph in `schema.go` for supported types, YAML field binding, inlining, secret tags, recursion, and semantic paths; - descriptor-driven canonical projection in `projection.go`, detached typed @@ -31,7 +32,9 @@ module and package documentation boundary. Its implemented public primitives are mapping/sequence semantics, null and numeric policy, safe codec errors, presence, baseline/final trees, and initial default/input origins; - public lifecycle coordination in `load.go`, bounded canonical import DFS in - `imports.go`, pattern-directed glob expansion in `glob.go`, version-report contexts + `imports.go`, filesystem context/containment in `filesystem.go`, directive + parsing/restrictions in `directives.go`, pattern-directed glob expansion in + `glob.go`, version-report contexts in `validation.go`, and detached layered metadata/history assembly in `load_metadata.go`; - recorded application defaulting and normalization in `resolution.go`, including diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index efd7258..45b066e 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -249,7 +249,16 @@ borrowed values or reread the environment. Run race tests for this unit. ## Stage 6 — Clarify source assembly and override ownership -Status: pending. +Status: complete. + +Completion: filesystem context/canonicalization/containment moved to +`filesystem.go`; directive parsing and restrictions moved to `directives.go`; +input node counting now shares the document owner with explicit key accounting. +Override preparation, parser-neutral value building, and application are separate; +profile and CLI application share the composition clone. Deferred profile cleanup +preserves primary failures and explicitly checks the success-path close once. +Full offline tests, vet, build, and whitespace checks passed with Go 1.26.7 on +Linux/amd64, including imports/globs, profiles, CLI, limits, and provenance. Evidence: [imports.go](../../imports.go) mixes filesystem context, directives, graph traversal, and diagnostics; [override.go](../../override.go) combines value diff --git a/document_walk.go b/document_walk.go index e60f68a..c44300f 100644 --- a/document_walk.go +++ b/document_walk.go @@ -64,3 +64,23 @@ func documentAtPath(root *document.Node, path Path) (*document.Node, bool) { } return current, true } + +// countInputNodes includes mapping-key nodes, unlike walkDocument's value-only +// traversal. Aggregate source budgets charge every input occurrence. +func countInputNodes(node *document.Node) int { + if node == nil { + return 0 + } + count := 1 + if node.Kind == document.Mapping { + count += len(node.Mapping) + for _, entry := range node.Mapping { + count += countInputNodes(entry.Value) + } + } else if node.Kind == document.Sequence { + for _, child := range node.Sequence { + count += countInputNodes(child) + } + } + return count +} diff --git a/filesystem.go b/filesystem.go new file mode 100644 index 0000000..e2904dc --- /dev/null +++ b/filesystem.go @@ -0,0 +1,121 @@ +package configkit + +import ( + "os" + "path/filepath" + "strings" +) + +func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic { + rootPath, _, _, diagnostic := importFilesystem(source, context, main) + if diagnostic != nil { + return diagnostic + } + root, err := os.OpenRoot(rootPath) + if err != nil { + value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{}) + value.Primary = nil + return &value + } + if err := root.Close(); err != nil { + value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil) + return &value + } + return nil +} + +func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic { + switch source.kind { + case sourceNone: + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")} + case sourceFile: + if context.BaseDir != "" { + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")} + } + case sourceBytes, sourceReader: + if context.BaseDir == "" || context.Root == "" { + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")} + } + if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) { + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")} + } + } + return nil +} + +func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) { + if main.source == nil || main.document == nil { + value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil) + return "", "", "", &value + } + + if source.kind == sourceFile { + if context != nil && context.BaseDir != "" { + value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory") + return "", "", "", &value + } + canonical, err := canonicalExistingPath(source.path) + if err != nil { + value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil) + return "", "", "", &value + } + mainPath = canonical + basePath = filepath.Dir(canonical) + rootPath = basePath + if context != nil && context.Root != "" { + rootPath, err = canonicalExistingPath(context.Root) + if err != nil { + value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil) + return "", "", "", &value + } + } + } else { + if context == nil || context.BaseDir == "" || context.Root == "" { + value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories") + return "", "", "", &value + } + if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) { + value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute") + return "", "", "", &value + } + var err error + basePath, err = canonicalExistingPath(context.BaseDir) + if err != nil { + value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil) + return "", "", "", &value + } + rootPath, err = canonicalExistingPath(context.Root) + if err != nil { + value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil) + return "", "", "", &value + } + } + if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) { + value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil) + return "", "", "", &value + } + for _, candidate := range []string{rootPath, basePath} { + info, err := os.Stat(candidate) + if err != nil || !info.IsDir() { + value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil) + return "", "", "", &value + } + } + return rootPath, basePath, mainPath, nil +} + +func canonicalExistingPath(name string) (string, error) { + absolute, err := filepath.Abs(name) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(filepath.Clean(absolute)) +} + +func containedPath(root, target string) bool { + relative, err := filepath.Rel(root, target) + if err != nil || filepath.IsAbs(relative) { + return false + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/imports.go b/imports.go index f29e1a2..f3bec59 100644 --- a/imports.go +++ b/imports.go @@ -35,13 +35,6 @@ func defaultImportLimits() importLimits { } } -type importDeclaration struct { - path string - glob string - optional bool - location Location -} - type importRoute struct { canonical string locations []Location @@ -120,120 +113,6 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse return composed, cloneSourceInfos(state.sources), diagnostics } -func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic { - rootPath, _, _, diagnostic := importFilesystem(source, context, main) - if diagnostic != nil { - return diagnostic - } - root, err := os.OpenRoot(rootPath) - if err != nil { - value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{}) - value.Primary = nil - return &value - } - if err := root.Close(); err != nil { - value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil) - return &value - } - return nil -} - -func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic { - switch source.kind { - case sourceNone: - return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")} - case sourceFile: - if context.BaseDir != "" { - return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")} - } - case sourceBytes, sourceReader: - if context.BaseDir == "" || context.Root == "" { - return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")} - } - if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) { - return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")} - } - } - return nil -} - -func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) { - if main.source == nil || main.document == nil { - value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil) - return "", "", "", &value - } - - if source.kind == sourceFile { - if context != nil && context.BaseDir != "" { - value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory") - return "", "", "", &value - } - canonical, err := canonicalExistingPath(source.path) - if err != nil { - value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil) - return "", "", "", &value - } - mainPath = canonical - basePath = filepath.Dir(canonical) - rootPath = basePath - if context != nil && context.Root != "" { - rootPath, err = canonicalExistingPath(context.Root) - if err != nil { - value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil) - return "", "", "", &value - } - } - } else { - if context == nil || context.BaseDir == "" || context.Root == "" { - value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories") - return "", "", "", &value - } - if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) { - value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute") - return "", "", "", &value - } - var err error - basePath, err = canonicalExistingPath(context.BaseDir) - if err != nil { - value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil) - return "", "", "", &value - } - rootPath, err = canonicalExistingPath(context.Root) - if err != nil { - value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil) - return "", "", "", &value - } - } - if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) { - value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil) - return "", "", "", &value - } - for _, candidate := range []string{rootPath, basePath} { - info, err := os.Stat(candidate) - if err != nil || !info.IsDir() { - value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil) - return "", "", "", &value - } - } - return rootPath, basePath, mainPath, nil -} - -func canonicalExistingPath(name string) (string, error) { - absolute, err := filepath.Abs(name) - if err != nil { - return "", err - } - return filepath.EvalSymlinks(filepath.Clean(absolute)) -} - -func containedPath(root, target string) bool { - relative, err := filepath.Rel(root, target) - if err != nil || filepath.IsAbs(relative) { - return false - } - return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) -} - func (s *importTraversal) checkInitialLimits() *Diagnostic { if s.limits.sources < 1 { value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil) @@ -404,184 +283,6 @@ func (s *importTraversal) currentRoute(location Location) importRoute { return importRoute{locations: locations} } -func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) { - body := document.Clone(root) - var declarations []importDeclaration - directiveIndex := -1 - for index, entry := range body.Mapping { - if entry.Key != "_configkit" { - continue - } - directiveIndex = index - parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit")) - if len(diagnostics) != 0 { - return nil, nil, diagnostics - } - declarations = parsed - break - } - if directiveIndex >= 0 { - body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...) - } - if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 { - return nil, nil, diagnostics - } - return body, declarations, nil -} - -func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) { - if node.Kind != document.Mapping { - return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)} - } - var declarations []importDeclaration - for _, entry := range node.Mapping { - fieldPath := directivePath.Key(entry.Key) - if entry.Key != "imports" { - return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)} - } - if entry.Value.Kind != document.Sequence { - return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)} - } - for index, item := range entry.Value.Sequence { - declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index)) - if len(diagnostics) != 0 { - return nil, diagnostics - } - declarations = append(declarations, declaration) - } - } - return declarations, nil -} - -func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) { - if node.Kind != document.Mapping { - return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)} - } - declaration := importDeclaration{location: publicLocation(node.Location)} - seenTarget := false - for _, entry := range node.Mapping { - fieldPath := declarationPath.Key(entry.Key) - switch entry.Key { - case "path", "glob": - if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text { - return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)} - } - seenTarget = true - declaration.location = publicLocation(entry.Value.Location) - if entry.Key == "path" { - declaration.path = entry.Value.Text - } else { - declaration.glob = entry.Value.Text - } - case "optional": - if entry.Value.Kind != document.Boolean { - return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)} - } - declaration.optional = entry.Value.Text == "true" - default: - return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)} - } - } - if !seenTarget { - return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)} - } - return declaration, nil -} - -func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic { - if node.Kind == document.Mapping { - for _, entry := range node.Mapping { - childPath := current.Key(entry.Key) - if entry.Key == "_configkit" { - location := publicLocation(entry.KeyLocation) - return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}} - } - if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { - return diagnostics - } - } - } else if node.Kind == document.Sequence { - for index, child := range node.Sequence { - if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 { - return diagnostics - } - } - } - return nil -} - -func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node { - if rule == nil { - return nil - } - current := root - if len(rule.Path.segments) == 0 { - return current - } - for _, segment := range rule.Path.segments { - if current.Kind != document.Mapping { - return current - } - if segment.kind == pathSegmentIndex { - return nil - } - key := segment.key - index := documentEntryIndex(current.Mapping, key) - if index < 0 { - return nil - } - current = current.Mapping[index].Value - } - return current -} - -func countInputNodes(node *document.Node) int { - if node == nil { - return 0 - } - count := 1 - if node.Kind == document.Mapping { - count += len(node.Mapping) - for _, entry := range node.Mapping { - count += countInputNodes(entry.Value) - } - } else if node.Kind == document.Sequence { - for _, child := range node.Sequence { - count += countInputNodes(child) - } - } - return count -} - -func validImportPath(value string) bool { - if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") { - return false - } - return !hasImportScheme(value) -} - -func hasImportScheme(value string) bool { - colon := strings.IndexByte(value, ':') - slash := strings.IndexByte(value, '/') - if colon <= 0 || (slash >= 0 && slash < colon) { - return false - } - for index := 0; index < colon; index++ { - character := value[index] - if index == 0 { - if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') { - return false - } - continue - } - if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && - (character < '0' || character > '9') && character != '+' && character != '-' && character != '.' { - return false - } - } - return true -} - func sourceInfos(source *SourceInfo) []SourceInfo { if source == nil { return nil @@ -593,11 +294,6 @@ func importDiagnostic(code Code, message string, location *Location) Diagnostic return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)} } -func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic { - location := publicLocation(node.Location) - return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location} -} - func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic { paths := make([]string, 0, len(cycle)+1) related := make([]Location, 0, len(cycle)) diff --git a/load.go b/load.go index e211905..599735d 100644 --- a/load.go +++ b/load.go @@ -339,28 +339,6 @@ func optionDiagnostic(code Code, message string) Diagnostic { return Diagnostic{Code: code, Severity: SeverityError, Message: message} } -func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { - var diagnostics []Diagnostic - if node.Kind == document.Mapping { - for _, entry := range node.Mapping { - childPath := path.Key(entry.Key) - if entry.Key == "_configkit" { - location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column} - diagnostics = append(diagnostics, Diagnostic{ - Code: CodeImportDisabled, Severity: SeverityError, Path: childPath, - Message: "configuration directives require imports to be enabled", Primary: &location, - }) - } - diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...) - } - } else if node.Kind == document.Sequence { - for index, child := range node.Sequence { - diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...) - } - } - return diagnostics -} - func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic { if rule == nil { return nil diff --git a/overlay.go b/overlay.go index 17bada5..2a231f2 100644 --- a/overlay.go +++ b/overlay.go @@ -24,15 +24,7 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed Message: "profile overlay requires mapping document roots", }} } - result := &composedDocument{ - root: document.Clone(base.root), - claims: cloneCompositionClaims(base.claims), - origins: cloneOriginIndex(base.origins), - overlays: cloneOverlayRecords(base.overlays), - applications: cloneHistoryApplications(base.applications), - inputBytes: base.inputBytes, - inputNodes: base.inputNodes, - } + result := cloneComposedDocument(base) mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile) mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true) result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay}) diff --git a/override.go b/override.go index 43da63f..fe37fb4 100644 --- a/override.go +++ b/override.go @@ -1,9 +1,6 @@ package configkit import ( - "math" - "reflect" - "slices" "strconv" "strings" "unicode" @@ -22,17 +19,7 @@ type preparedOverride struct { nodes int } -type overrideValueBuilder struct { - sourceID string - active map[valueVisit]bool - bytes int64 - nodes int -} - -func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { - if len(overrides) == 0 { - return base, cloneSourceInfos(sources), nil - } +func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) { if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources { return nil, cloneSourceInfos(sources), []Diagnostic{{ Code: CodeOverrideLimitExceeded, Severity: SeverityError, @@ -147,13 +134,7 @@ func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Ov return nil, allSources, canonicalDiagnostics(diagnostics) } - result := cloneComposedDocument(base) - for _, override := range prepared { - applyPreparedOverride(result, override) - } - result.inputBytes += usedBytes - result.inputNodes += usedNodes - return result, allSources, nil + return prepared, allSources, nil } func validOverrideLabel(label string) bool { @@ -240,220 +221,3 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) { } return Path{}, false } - -func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) { - if depth > maxDocumentDepth { - return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit") - } - b.nodes++ - if b.nodes > maxDocumentNodes { - return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit") - } - location := document.Location{SourceID: b.sourceID} - switch value := value.(type) { - case nil: - return &document.Node{Kind: document.Null, Location: location}, nil - case string: - if !b.addBytes(len(value)) { - return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit") - } - return &document.Node{Kind: document.String, Text: value, Location: location}, nil - case bool: - return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil - case int: - return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil - case int8: - return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil - case int16: - return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil - case int32: - return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil - case int64: - return overrideNumber(strconv.FormatInt(value, 10), location), nil - case uint: - return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil - case uint8: - return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil - case uint16: - return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil - case uint32: - return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil - case uint64: - return overrideNumber(strconv.FormatUint(value, 10), location), nil - case float32: - if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) { - return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite") - } - return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil - case float64: - if math.IsNaN(value) || math.IsInf(value, 0) { - return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite") - } - return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil - case []any: - if value == nil { - return &document.Node{Kind: document.Null, Location: location}, nil - } - leave, diagnostic := b.enter(reflect.ValueOf(value), path) - if diagnostic != nil { - return nil, diagnostic - } - defer leave() - if len(value) > maxDocumentNodes-b.nodes { - return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit") - } - node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))} - for index, child := range value { - built, diagnostic := b.build(child, path.Index(index), depth+1) - if diagnostic != nil { - return nil, diagnostic - } - node.Sequence[index] = built - } - return node, nil - case map[string]any: - if value == nil { - return &document.Node{Kind: document.Null, Location: location}, nil - } - leave, diagnostic := b.enter(reflect.ValueOf(value), path) - if diagnostic != nil { - return nil, diagnostic - } - defer leave() - if len(value) > (maxDocumentNodes-b.nodes)/2 { - return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit") - } - keys := make([]string, 0, len(value)) - for key := range value { - keys = append(keys, key) - } - slices.Sort(keys) - node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))} - for _, key := range keys { - b.nodes++ // mapping key - if !b.addBytes(len(key)) { - return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit") - } - built, diagnostic := b.build(value[key], path.Key(key), depth+1) - if diagnostic != nil { - return nil, diagnostic - } - node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built}) - } - return node, nil - default: - return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type") - } -} - -func (b *overrideValueBuilder) addBytes(count int) bool { - if count < 0 || int64(count) > maxSourceBytes-b.bytes { - return false - } - b.bytes += int64(count) - return true -} - -func (b *overrideValueBuilder) enter(value reflect.Value, path Path) (func(), *Diagnostic) { - visit, ok := visitFor(value) - if !ok { - return func() {}, nil - } - if b.active[visit] { - return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override values must not contain cycles") - } - b.active[visit] = true - return func() { delete(b.active, visit) }, nil -} - -func overrideValueDiagnostic(code Code, path Path, message string) *Diagnostic { - return &Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message} -} - -func overrideNumber(text string, location document.Location) *document.Node { - return &document.Node{Kind: document.Number, Text: text, Location: location} -} - -func cloneComposedDocument(base *composedDocument) *composedDocument { - return &composedDocument{ - root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims), - origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays), - applications: cloneHistoryApplications(base.applications), - inputBytes: base.inputBytes, inputNodes: base.inputNodes, - } -} - -func applyPreparedOverride(result *composedDocument, override preparedOverride) { - location := document.Location{SourceID: override.source.ID} - mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI) - current := result.root - currentPath := Path{} - for index, segment := range override.path.segments { - path := currentPath.Key(segment.key) - position := documentEntryIndex(current.Mapping, segment.key) - if index < len(override.path.segments)-1 { - if position < 0 { - child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}} - current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child}) - collectOverlayOrigins(result.origins, child, path, LayerCLI) - current = child - } else { - current = current.Mapping[position].Value - mergeOverlayOrigin(result.origins, path, location, LayerCLI) - } - currentPath = path - continue - } - - previous := cloneOrigin(result.origins[path.String()]) - var displaced []displacedDefinition - if position < 0 { - incoming := document.Clone(override.value) - current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming}) - collectOverlayOrigins(result.origins, incoming, path, LayerCLI) - } else { - existing := current.Mapping[position].Value - displaced = affectedOverrideOrigins(existing, override.value, result.origins, path) - if existing.Kind == document.Mapping && override.value.Kind == document.Mapping { - mergeOverlayOrigin(result.origins, path, location, LayerCLI) - mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false) - } else { - deleteOriginSubtree(result.origins, path) - current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)} - collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI) - } - } - result.overlays = append(result.overlays, overlayRecord{change: Change{ - Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous), - }, displaced: displaced}) - result.applications = append(result.applications, historyApplication{ - layer: LayerCLI, path: path.clone(), value: override.value, - origin: overlayOrigin(LayerCLI, location), - }) - } -} - -func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path) []displacedDefinition { - displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}} - if existing.Kind == document.Mapping && incoming.Kind == document.Mapping { - for _, entry := range incoming.Mapping { - position := documentEntryIndex(existing.Mapping, entry.Key) - if position < 0 { - continue - } - childPath := path.Key(entry.Key) - childExisting := existing.Mapping[position].Value - if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping { - displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath)...) - } else { - displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...) - } - } - } else { - displaced = displacedOrigins(existing, origins, path) - } - slices.SortFunc(displaced, func(left, right displacedDefinition) int { - return strings.Compare(left.path.String(), right.path.String()) - }) - return displaced -} diff --git a/override_apply.go b/override_apply.go new file mode 100644 index 0000000..2b4b013 --- /dev/null +++ b/override_apply.go @@ -0,0 +1,100 @@ +package configkit + +import ( + "slices" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { + if len(overrides) == 0 { + return base, cloneSourceInfos(sources), nil + } + prepared, allSources, diagnostics := prepareOverrides(base, sources, overrides, rule, limits) + if len(diagnostics) != 0 { + return nil, allSources, diagnostics + } + result := cloneComposedDocument(base) + for _, override := range prepared { + applyPreparedOverride(result, override) + result.inputBytes += override.bytes + result.inputNodes += override.nodes + } + return result, allSources, nil +} + +func applyPreparedOverride(result *composedDocument, override preparedOverride) { + location := document.Location{SourceID: override.source.ID} + mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI) + current := result.root + currentPath := Path{} + for index, segment := range override.path.segments { + path := currentPath.Key(segment.key) + position := documentEntryIndex(current.Mapping, segment.key) + if index < len(override.path.segments)-1 { + if position < 0 { + child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}} + current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child}) + collectOverlayOrigins(result.origins, child, path, LayerCLI) + current = child + } else { + current = current.Mapping[position].Value + mergeOverlayOrigin(result.origins, path, location, LayerCLI) + } + currentPath = path + continue + } + + previous := cloneOrigin(result.origins[path.String()]) + var displaced []displacedDefinition + if position < 0 { + incoming := document.Clone(override.value) + current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming}) + collectOverlayOrigins(result.origins, incoming, path, LayerCLI) + } else { + existing := current.Mapping[position].Value + displaced = affectedOverrideOrigins(existing, override.value, result.origins, path) + if existing.Kind == document.Mapping && override.value.Kind == document.Mapping { + mergeOverlayOrigin(result.origins, path, location, LayerCLI) + mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false) + } else { + deleteOriginSubtree(result.origins, path) + current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)} + collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI) + } + } + result.overlays = append(result.overlays, overlayRecord{change: Change{ + Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous), + }, displaced: displaced}) + result.applications = append(result.applications, historyApplication{ + layer: LayerCLI, path: path.clone(), value: override.value, + origin: overlayOrigin(LayerCLI, location), + }) + } +} + +func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path) []displacedDefinition { + displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}} + if existing.Kind == document.Mapping && incoming.Kind == document.Mapping { + for _, entry := range incoming.Mapping { + position := documentEntryIndex(existing.Mapping, entry.Key) + if position < 0 { + continue + } + childPath := path.Key(entry.Key) + childExisting := existing.Mapping[position].Value + if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping { + displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath)...) + } else { + displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...) + } + } + } else { + displaced = displacedOrigins(existing, origins, path) + } + slices.SortFunc(displaced, func(left, right displacedDefinition) int { + return strings.Compare(left.path.String(), right.path.String()) + }) + return displaced +} diff --git a/override_value.go b/override_value.go new file mode 100644 index 0000000..9092456 --- /dev/null +++ b/override_value.go @@ -0,0 +1,150 @@ +package configkit + +import ( + "math" + "reflect" + "slices" + "strconv" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +type overrideValueBuilder struct { + sourceID string + active map[valueVisit]bool + bytes int64 + nodes int +} + +func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) { + if depth > maxDocumentDepth { + return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit") + } + b.nodes++ + if b.nodes > maxDocumentNodes { + return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit") + } + location := document.Location{SourceID: b.sourceID} + switch value := value.(type) { + case nil: + return &document.Node{Kind: document.Null, Location: location}, nil + case string: + if !b.addBytes(len(value)) { + return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit") + } + return &document.Node{Kind: document.String, Text: value, Location: location}, nil + case bool: + return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil + case int: + return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil + case int8: + return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil + case int16: + return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil + case int32: + return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil + case int64: + return overrideNumber(strconv.FormatInt(value, 10), location), nil + case uint: + return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil + case uint8: + return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil + case uint16: + return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil + case uint32: + return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil + case uint64: + return overrideNumber(strconv.FormatUint(value, 10), location), nil + case float32: + if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) { + return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite") + } + return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil + case float64: + if math.IsNaN(value) || math.IsInf(value, 0) { + return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite") + } + return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil + case []any: + if value == nil { + return &document.Node{Kind: document.Null, Location: location}, nil + } + leave, diagnostic := b.enter(reflect.ValueOf(value), path) + if diagnostic != nil { + return nil, diagnostic + } + defer leave() + if len(value) > maxDocumentNodes-b.nodes { + return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit") + } + node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))} + for index, child := range value { + built, diagnostic := b.build(child, path.Index(index), depth+1) + if diagnostic != nil { + return nil, diagnostic + } + node.Sequence[index] = built + } + return node, nil + case map[string]any: + if value == nil { + return &document.Node{Kind: document.Null, Location: location}, nil + } + leave, diagnostic := b.enter(reflect.ValueOf(value), path) + if diagnostic != nil { + return nil, diagnostic + } + defer leave() + if len(value) > (maxDocumentNodes-b.nodes)/2 { + return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit") + } + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + slices.Sort(keys) + node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))} + for _, key := range keys { + b.nodes++ // mapping key + if !b.addBytes(len(key)) { + return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit") + } + built, diagnostic := b.build(value[key], path.Key(key), depth+1) + if diagnostic != nil { + return nil, diagnostic + } + node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built}) + } + return node, nil + default: + return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type") + } +} + +func (b *overrideValueBuilder) addBytes(count int) bool { + if count < 0 || int64(count) > maxSourceBytes-b.bytes { + return false + } + b.bytes += int64(count) + return true +} + +func (b *overrideValueBuilder) enter(value reflect.Value, path Path) (func(), *Diagnostic) { + visit, ok := visitFor(value) + if !ok { + return func() {}, nil + } + if b.active[visit] { + return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override values must not contain cycles") + } + b.active[visit] = true + return func() { delete(b.active, visit) }, nil +} + +func overrideValueDiagnostic(code Code, path Path, message string) *Diagnostic { + return &Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message} +} + +func overrideNumber(text string, location document.Location) *document.Node { + return &document.Node{Kind: document.Number, Text: text, Location: location} +} diff --git a/profile.go b/profile.go index 75becba..a638d4b 100644 --- a/profile.go +++ b/profile.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "gitea.maximumdirect.net/eric/ConfigKit/internal/document" yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" ) @@ -67,9 +66,14 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS value.Primary = nil return nil, cloneSourceInfos(sources), []Diagnostic{value} } + rootClosed := false + defer func() { + if !rootClosed { + _ = root.Close() + } + }() relative, err := filepath.Rel(rootPath, canonical) if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - _ = root.Close() return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)} } @@ -79,32 +83,29 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS sources = append(cloneSourceInfos(sources), profileSource) data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes) if diagnostic != nil { - _ = root.Close() return nil, sources, []Diagnostic{*diagnostic} } profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes}) if len(issues) != 0 { - _ = root.Close() return nil, sources, parserDiagnostics(sourceID, issues) } nodeCount := countInputNodes(profileRoot) if base.inputNodes+nodeCount > limits.nodes { - _ = root.Close() return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)} } if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 { - _ = root.Close() return nil, sources, diagnostics } if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil { location := publicLocation(versionNode.Location) - _ = root.Close() return nil, sources, []Diagnostic{{ Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(), Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location, }} } - if err := root.Close(); err != nil { + closeErr := root.Close() + rootClosed = true + if closeErr != nil { return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)} } @@ -128,28 +129,3 @@ func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) } return data, nil } - -func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic { - if node.Kind == document.Mapping { - for _, entry := range node.Mapping { - childPath := current.Key(entry.Key) - if entry.Key == "_configkit" { - location := publicLocation(entry.KeyLocation) - return []Diagnostic{{ - Code: CodeProfileDirective, Severity: SeverityError, Path: childPath, - Message: "profiles cannot contain configuration directives", Primary: &location, - }} - } - if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 { - return diagnostics - } - } - } else if node.Kind == document.Sequence { - for index, child := range node.Sequence { - if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 { - return diagnostics - } - } - } - return nil -}