diff --git a/internal/application/inspect/diff.go b/internal/application/inspect/diff.go index 63aaa64..46e70fb 100644 --- a/internal/application/inspect/diff.go +++ b/internal/application/inspect/diff.go @@ -143,7 +143,7 @@ func diffOutcome(evaluation Result) (DiffResult, error) { } files, aliases, retired := diffCounts(records) result := DiffResult{records: records, files: files, aliases: aliases, - retired: retired, converged: diffConverged(records)} + retired: retired, converged: recordsConvergedGeneric(records)} if !result.converged { return result, failure.New(failure.Difference, "diff: selected state is not converged", nil) } @@ -197,5 +197,3 @@ func fileDiffRecord(home string, evaluated evaluatedRecord) (DiffRecord, error) func diffCounts(records []DiffRecord) (files, aliases, retired int) { return countRecordKinds(records) } - -func diffConverged(records []DiffRecord) bool { return recordsConvergedGeneric(records) } diff --git a/internal/application/inspect/status.go b/internal/application/inspect/status.go index 2f3b063..206e489 100644 --- a/internal/application/inspect/status.go +++ b/internal/application/inspect/status.go @@ -25,26 +25,16 @@ func NewStatusRecord(targetPath string, kind StatusKind, action string) StatusRe // NewStatusResult freezes one status result over the given records and the // convergence flag, keeping the record slice defensive. func NewStatusResult(records []StatusRecord, converged bool) StatusResult { + files, aliases, retired := countRecordKinds(records) return StatusResult{ records: append([]StatusRecord(nil), records...), - files: countKind(records, StatusKindFile), - aliases: countKind(records, StatusKindAlias), - retired: countKind(records, StatusKindRetired), + files: files, + aliases: aliases, + retired: retired, converged: converged, } } -// countKind counts the records of one status kind. -func countKind(records []StatusRecord, kind StatusKind) int { - count := 0 - for _, record := range records { - if record.kind == kind { - count++ - } - } - return count -} - // String returns the stable lowercase name of the kind. func (kind StatusKind) String() string { switch kind { @@ -115,9 +105,9 @@ func statusOutcome(evaluation Result) (StatusResult, error) { for _, evaluated := range evaluation.records { records = append(records, statusRecordsOf(evaluated)...) } - files, aliases, retired := statusCounts(records) + files, aliases, retired := countRecordKinds(records) result := StatusResult{records: records, files: files, aliases: aliases, - retired: retired, converged: recordsConverged(records)} + retired: retired, converged: recordsConvergedGeneric(records)} if !result.converged { return result, failure.New(failure.Difference, "status: selected state is not converged", nil) } @@ -176,10 +166,6 @@ func statusRecord(input classificationInput) StatusRecord { } // statusCounts tallies the per-kind records of one status result. -func statusCounts(records []StatusRecord) (files, aliases, retired int) { - return countRecordKinds(records) -} - func countRecordKinds[T interface{ Kind() StatusKind }](records []T) (files, aliases, retired int) { for _, record := range records { switch record.Kind() { @@ -194,12 +180,6 @@ func countRecordKinds[T interface{ Kind() StatusKind }](records []T) (files, ali return files, aliases, retired } -// recordsConverged reports whether every status record is converged; a -// record-free evaluation is converged by definition. -func recordsConverged(records []StatusRecord) bool { - return recordsConvergedGeneric(records) -} - func recordsConvergedGeneric[T interface{ Converged() bool }](records []T) bool { for _, record := range records { if !record.Converged() { diff --git a/internal/reconcile/snapshot.go b/internal/reconcile/snapshot.go index 25b24d9..73adebd 100644 --- a/internal/reconcile/snapshot.go +++ b/internal/reconcile/snapshot.go @@ -139,18 +139,18 @@ func joinedRecords(input joinInput) ([]Evaluation, error) { // unionPaths merges plan and state paths into one deduplicated list. func unionPaths(input joinInput) []string { - paths := make(map[string]bool, len(input.files)+len(input.aliases)+len(input.fileRows)+len(input.aliasRows)) + paths := make(map[string]struct{}, len(input.files)+len(input.aliases)+len(input.fileRows)+len(input.aliasRows)) for path := range input.files { - paths[path] = true + paths[path] = struct{}{} } for path := range input.aliases { - paths[path] = true + paths[path] = struct{}{} } for path := range input.fileRows { - paths[path] = true + paths[path] = struct{}{} } for path := range input.aliasRows { - paths[path] = true + paths[path] = struct{}{} } joined := make([]string, 0, len(paths)) for path := range paths { diff --git a/internal/reconcile/source_snapshot.go b/internal/reconcile/source_snapshot.go index 4125511..977c2e1 100644 --- a/internal/reconcile/source_snapshot.go +++ b/internal/reconcile/source_snapshot.go @@ -12,7 +12,10 @@ import ( "github.com/alyraffauf/cattery/internal/secrets" ) -const sourceGrowthDetectionSlack int64 = 1 +const ( + sourceGrowthDetectionSlack int64 = 1 + jsonNullLiteral = "null" +) // SourceObservation pairs a frozen source snapshot with the exact bytes read // during capture. The bytes are retained for the write phase and can be @@ -200,7 +203,7 @@ func checkSecretEnvelope(path string, data []byte) error { if err := json.Unmarshal(data, &envelope); err != nil { return fmt.Errorf("reconcile: secret source %s is not a sops JSON document: %w", path, err) } - if len(envelope.Sops) == 0 || string(envelope.Sops) == "null" { + if len(envelope.Sops) == 0 || string(envelope.Sops) == jsonNullLiteral { return fmt.Errorf("reconcile: secret source %s lacks sops metadata", path) } return nil diff --git a/internal/reconcile/types.go b/internal/reconcile/types.go index 8b2c287..3d68c50 100644 --- a/internal/reconcile/types.go +++ b/internal/reconcile/types.go @@ -172,9 +172,13 @@ func (snapshot SourceSnapshot) Path() string { return snapshot.pa func (snapshot SourceSnapshot) Identity() pathsafe.Identity { return snapshot.identity } func (snapshot SourceSnapshot) Kind() EntryKind { return snapshot.kind } func (snapshot SourceSnapshot) Token() ContentToken { return snapshot.token } + +// Semantic returns the ordinary semantic digest; secret snapshots leave it zero. func (snapshot SourceSnapshot) Semantic() deployment.Digest { return snapshot.semantic } -func (snapshot SourceSnapshot) Storage() deployment.Digest { return snapshot.storage } -func (snapshot SourceSnapshot) Executable() fs.FileMode { return snapshot.executable } + +// Storage returns the encrypted storage digest; ordinary snapshots leave it zero. +func (snapshot SourceSnapshot) Storage() deployment.Digest { return snapshot.storage } +func (snapshot SourceSnapshot) Executable() fs.FileMode { return snapshot.executable } // TargetSnapshot freezes the immutable facts of one destination observation // and doubles as the immutable target precondition (PLAN.md Section 12.4). diff --git a/internal/repository/overlay.go b/internal/repository/overlay.go index e586dc0..d270445 100644 --- a/internal/repository/overlay.go +++ b/internal/repository/overlay.go @@ -92,18 +92,27 @@ func resolveScopeFiles(base ScanResult, scope deployment.Scope, platform layerVi if platform.covers(target) { continue } - existing, ok := merged.files[target] - if ok && existing.Kind != candidate.Kind { - return nil, fmt.Errorf("repository: ordinary and secret sources collide at %q", target) + if err := mergeCandidate(merged.files, target, candidate); err != nil { + return nil, err } - merged.files[target] = candidate } for target, candidate := range platform.files { - merged.files[target] = candidate + if err := mergeCandidate(merged.files, target, candidate); err != nil { + return nil, err + } } return recordsFor(merged.files) } +func mergeCandidate(files map[string]Candidate, target string, candidate Candidate) error { + existing, ok := files[target] + if ok && existing.Kind != candidate.Kind { + return fmt.Errorf("repository: ordinary and secret sources collide at %q", target) + } + files[target] = candidate + return nil +} + // representableRootSecretTarget reports whether target can be produced by a // root-scope secret source under Section 2.1: a root secret must target either // a dot-prefixed tree (ungrouped HOME tree) or a single non-underscore segment. @@ -236,12 +245,7 @@ func (walker *layerWalker) visit(path string, entry os.DirEntry, kind deployment SourceRepoPath: filepath.Join(walker.relative, path), SourceAbsPath: filepath.Join(walker.absolute, path), ExecutableBits: info.Mode() & deployment.ExecutableBitMask, } - existing, ok := walker.view.files[target] - if ok && existing.Kind != kind { - return fmt.Errorf("repository: ordinary and secret sources collide at %q", target) - } - walker.view.files[target] = candidate - return nil + return mergeCandidate(walker.view.files, target, candidate) } func (walker *layerWalker) target(path string, kind deployment.FileKind) (string, error) { diff --git a/internal/repository/scan.go b/internal/repository/scan.go index 61d7e47..ecc4452 100644 --- a/internal/repository/scan.go +++ b/internal/repository/scan.go @@ -94,6 +94,7 @@ func (scanner *scopeScanner) scanEntry(entry os.DirEntry) error { return scanner.scanHooks(entry) case control == ControlMetadata: if scanner.rootTree { + // Repository metadata is ignored only at the repository root. return nil } return scanner.scanOrdinary(entry) @@ -103,6 +104,7 @@ func (scanner *scopeScanner) scanEntry(entry os.DirEntry) error { } func (scanner *scopeScanner) beginGroup(entry os.DirEntry) error { + // Save and restore scope state so recursive scanning returns to the parent. name := entry.Name() if err := pathsafe.GroupName(name); err != nil { return err