From 975c717cc3cbec677c1efede5a657561ebd35627 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 22:48:37 -0400 Subject: [PATCH] fix: harden application reconciliation reporting --- internal/application/add/execute.go | 1 + internal/application/add/infer.go | 2 +- internal/application/add/preflight.go | 2 +- internal/application/add/types.go | 3 + internal/application/add/write_secret.go | 4 +- internal/application/apply/decisions.go | 11 +++- internal/application/apply/evaluate.go | 18 +++--- internal/application/apply/execute_aliases.go | 6 +- internal/application/apply/execute_files.go | 6 +- internal/application/apply/hooks.go | 14 +++-- internal/application/apply/prepare.go | 29 ++++----- internal/application/apply/verify.go | 39 ++++++++---- internal/application/inspect/diff.go | 60 ++++++++++++------- internal/application/inspect/service.go | 15 +++-- internal/application/inspect/status.go | 12 +++- internal/application/validate/service.go | 3 + internal/diff/safe.go | 10 +++- internal/reconcile/source_snapshot.go | 2 +- internal/repository/controls.go | 7 ++- internal/repository/overlay.go | 4 +- 20 files changed, 159 insertions(+), 89 deletions(-) diff --git a/internal/application/add/execute.go b/internal/application/add/execute.go index ce0017f..916c154 100644 --- a/internal/application/add/execute.go +++ b/internal/application/add/execute.go @@ -40,6 +40,7 @@ func (exec *executor) runItem(ctx context.Context, item ItemPlan) { outcome, err := exec.write(ctx, item) if err != nil { exec.errors = append(exec.errors, err) + exec.records = append(exec.records, partialRecord(item)) return } defer clearBytes(outcome.target) diff --git a/internal/application/add/infer.go b/internal/application/add/infer.go index 74eeee1..e8b5b12 100644 --- a/internal/application/add/infer.go +++ b/internal/application/add/infer.go @@ -137,7 +137,7 @@ func (location sourceLocation) sourcePath(target string) string { builder.WriteString("/") } if location.kind == deployment.FileSecret { - builder.WriteString("_secrets/") + builder.WriteString(repository.SecretDirectoryName + "/") } builder.WriteString(target) return builder.String() diff --git a/internal/application/add/preflight.go b/internal/application/add/preflight.go index 9db2b2c..d0d8380 100644 --- a/internal/application/add/preflight.go +++ b/internal/application/add/preflight.go @@ -176,7 +176,7 @@ func rejectBatchCollisions(items []ItemPlanInput) error { // sourceUsedEarlier reports whether item's source matches an earlier item's. func sourceUsedEarlier(previous []ItemPlanInput, current ItemPlanInput) error { for _, candidate := range previous { - if candidate.SourceRepositoryPath == current.SourceRepositoryPath { + if sourcePathsOverlap(candidate.SourceRepositoryPath, current.SourceRepositoryPath) { return failure.New(failure.InvalidInput, "add: two targets derive the same source "+current.SourceRepositoryPath, nil) } diff --git a/internal/application/add/types.go b/internal/application/add/types.go index 162f959..9b4dcf6 100644 --- a/internal/application/add/types.go +++ b/internal/application/add/types.go @@ -153,6 +153,9 @@ func validateItemPlan(candidate ItemPlanInput) error { if !candidate.Kind.Valid() { return fmt.Errorf("add: item plan has invalid kind %q", candidate.Kind) } + if candidate.ExecutableBits&^deployment.ExecutableBitMask != 0 { + return fmt.Errorf("add: item plan has invalid executable bits %o", candidate.ExecutableBits) + } return nil } diff --git a/internal/application/add/write_secret.go b/internal/application/add/write_secret.go index 2759234..4a1705d 100644 --- a/internal/application/add/write_secret.go +++ b/internal/application/add/write_secret.go @@ -2,8 +2,8 @@ package add import ( "context" - "fmt" + "github.com/alyraffauf/cattery/internal/failure" "github.com/alyraffauf/cattery/internal/filesystem" "github.com/alyraffauf/cattery/internal/secrets" ) @@ -41,7 +41,7 @@ func (service *Service) writeSecret(ctx context.Context, identity RepositoryIden // caller owns the plaintext buffer and clears it after verification. func (service *Service) publishSecret(ctx context.Context, input publishInput) ([]byte, filesystem.ReplaceResult, error) { if service.write.Secrets == nil { - return nil, filesystem.ReplaceResult{}, fmt.Errorf("add: secret writer is not configured") + return nil, filesystem.ReplaceResult{}, failure.New(failure.Operational, "add: secret writer is not configured", nil) } ciphertext, err := service.write.Secrets.Encrypt(ctx, input.plaintext, input.item.SourceRepositoryPath()) if err != nil { diff --git a/internal/application/apply/decisions.go b/internal/application/apply/decisions.go index 539b5f2..e633691 100644 --- a/internal/application/apply/decisions.go +++ b/internal/application/apply/decisions.go @@ -16,6 +16,7 @@ type ResolvedDecision struct { // CollectedDecisions freezes the resolved decisions in target-path order. type CollectedDecisions struct { decisions []ResolvedDecision + specs []reconcile.DecisionSpec } // All returns the resolved decisions in bytewise target-path order. @@ -23,6 +24,11 @@ func (c CollectedDecisions) All() []ResolvedDecision { return append([]ResolvedDecision(nil), c.decisions...) } +// Specs returns the decision specifications collected for this apply. +func (c CollectedDecisions) Specs() []reconcile.DecisionSpec { + return append([]reconcile.DecisionSpec(nil), c.specs...) +} + // CollectDecisions resolves every candidate that requires an explicit // decision, in bytewise target-path order, and validates each response // before any hook or mutation (PLAN.md Section 11.5). An abort answer stops @@ -45,7 +51,7 @@ func (service *Service) CollectDecisions(ctx context.Context, candidates Candida } decisions = append(decisions, decision) } - return CollectedDecisions{decisions: decisions}, nil + return CollectedDecisions{decisions: decisions, specs: ordered}, nil } // collectOne projects one spec into a request, resolves it, and validates @@ -135,6 +141,9 @@ func projectChoice(choice reconcile.DecisionChoice) DecisionChoice { // difference and ask again. func (service *Service) resolveRepeatedly(ctx context.Context, request DecisionRequest) (DecisionResponse, error) { for { + if err := ctx.Err(); err != nil { + return DecisionResponse{}, err + } response, err := service.resolveOnce(ctx, request) if err != nil { return DecisionResponse{}, err diff --git a/internal/application/apply/evaluate.go b/internal/application/apply/evaluate.go index ea55553..de2d690 100644 --- a/internal/application/apply/evaluate.go +++ b/internal/application/apply/evaluate.go @@ -32,6 +32,7 @@ type Service struct { resolver DecisionResolver protectedTrees []string platform deployment.Layer + platformError error } func NewService(dependencies Dependencies) *Service { @@ -39,6 +40,10 @@ func NewService(dependencies Dependencies) *Service { if err != nil || platform == deployment.LayerBase { platform = "" } + var platformError error + if dependencies.Platform != "" && err != nil { + platformError = failure.New(failure.InvalidInput, "apply: invalid configured platform "+dependencies.Platform, err) + } return &Service{ source: dependencies.RepositorySource, compiler: dependencies.Compiler, @@ -54,6 +59,7 @@ func NewService(dependencies Dependencies) *Service { resolver: dependencies.Resolver, protectedTrees: dependencies.ProtectedTrees, platform: platform, + platformError: platformError, } } @@ -66,6 +72,9 @@ func (service *Service) evaluate(ctx context.Context, request Request) (Candidat return Candidates{}, err } if service.platform == "" { + if service.platformError != nil { + return Candidates{}, service.platformError + } return Candidates{}, failure.New(failure.InvalidInput, "apply: platform must be linux or darwin", nil) } identity, err := service.resolve(request.Repository) @@ -219,13 +228,11 @@ func persistedGroups(rows stateRows) selection.PersistedGroups { return selection.PersistedGroups{Active: sortedKeys(sets.active), All: sortedKeys(sets.all)} } -// groupSets accumulates the distinct group names of the persisted rows. type groupSets struct { active map[string]bool all map[string]bool } -// remember records one row group in the active and all sets. func (sets *groupSets) remember(name string, active bool) { if name == "" { return @@ -245,7 +252,6 @@ func sortedKeys(names map[string]bool) []string { return keys } -// selectedRows keeps root rows only for root selections and rows of the selected groups otherwise. func selectedRows(identity RepositoryIdentity, rows stateRows, chosen selection.Selection) reconcile.StateRows { return reconcile.StateRows{ RepositoryRoot: identity.Root, @@ -324,7 +330,6 @@ func (service *Service) classify(ctx context.Context, assembly reconcile.Evaluat return candidates, nil } -// semanticState carries the per-evaluation hash key, recovered once at most and only when a secret record needs fingerprints. type semanticState struct { reader StateReader client *secrets.Client @@ -332,7 +337,6 @@ type semanticState struct { haveKey bool } -// fingerprints derives the semantic fingerprints of one evaluation record; secrets fingerprint on demand per PLAN.md Section 9.1. func (state *semanticState) fingerprints(ctx context.Context, home string, record reconcile.Evaluation) (reconcile.FileSemantics, error) { if record.Entry != reconcile.PlanEntryFile { if record.Target.Kind() == reconcile.KindFile { @@ -349,8 +353,6 @@ func (state *semanticState) fingerprints(ctx context.Context, home string, recor return state.secretFingerprints(ctx, home, record) } -// secretFingerprints derives the keyed fingerprints of one secret record: the source decrypts only when its raw storage changed or the row is -// unbaselined with a regular target. func (state *semanticState) secretFingerprints(ctx context.Context, home string, record reconcile.Evaluation) (reconcile.FileSemantics, error) { semantics := reconcile.FileSemantics{} targetFile := record.Target.Kind() == reconcile.KindFile @@ -377,7 +379,6 @@ func (state *semanticState) secretFingerprints(ctx context.Context, home string, return semantics, nil } -// secretDecryptNeeded reports whether a secret source must decrypt for classification (PLAN.md Section 9.1). func secretDecryptNeeded(record reconcile.Evaluation) bool { if record.FileState == nil { return record.Target.Kind() == reconcile.KindFile @@ -385,7 +386,6 @@ func secretDecryptNeeded(record reconcile.Evaluation) bool { return record.Source.Snapshot().Storage() != record.FileState.BaselineSource() } -// recover loads the per-installation hash key once for the evaluation. func (state *semanticState) recover() error { if state.haveKey { return nil diff --git a/internal/application/apply/execute_aliases.go b/internal/application/apply/execute_aliases.go index 6dc2401..6105d07 100644 --- a/internal/application/apply/execute_aliases.go +++ b/internal/application/apply/execute_aliases.go @@ -153,11 +153,11 @@ func aliasPayload(alias deployment.Alias) (string, error) { if len(remaining) == 0 { return "", failure.New(failure.InvalidInput, "apply: alias descends into its canonical target", nil) } - up := len(parent) - common - if up == 0 { + backtrack := len(parent) - common + if backtrack == 0 { return strings.Join(remaining, "/"), nil } - return strings.Repeat("../", up) + strings.Join(remaining, "/"), nil + return strings.Repeat("../", backtrack) + strings.Join(remaining, "/"), nil } // commonPrefix counts the shared leading segments of two path lists. diff --git a/internal/application/apply/execute_files.go b/internal/application/apply/execute_files.go index a8cb14a..27719a1 100644 --- a/internal/application/apply/execute_files.go +++ b/internal/application/apply/execute_files.go @@ -90,10 +90,10 @@ type baselineCommit struct { // commitBaseline switches an active alias row to the file representation or // upserts the file baseline, only after a durable write. func (service *Service) commitBaseline(ctx context.Context, job fileJob, commit baselineCommit) error { + if !commit.durable { + return failure.New(failure.Operational, "apply: baseline write is not durable: "+job.action.TargetPath, nil) + } if job.candidate.record.AliasState != nil && job.candidate.record.AliasState.Active() { - if !commit.durable { - return failure.New(failure.Operational, "apply: alias-to-file transition is not durable: "+job.action.TargetPath, nil) - } _, err := service.transitions.TransitionToFile(job.root, job.home, baselineRow(job.candidate, commit.contentHash)) if err != nil { return failure.New(failure.Operational, "apply: transition to file "+job.action.TargetPath, err) diff --git a/internal/application/apply/hooks.go b/internal/application/apply/hooks.go index 461bd35..1393c15 100644 --- a/internal/application/apply/hooks.go +++ b/internal/application/apply/hooks.go @@ -8,6 +8,12 @@ import ( "github.com/alyraffauf/cattery/internal/hooks" ) +const ( + hookResultPending = "pending" + hookResultSuccess = "success" + hookResultPartial = "partial" +) + // RunHookPipeline runs the hook-gated apply filesystem phase: before hooks // with CATTERY_RESULT=pending, the all-source guard and the file and alias // executors, then after hooks only when the phase completed, with @@ -25,7 +31,7 @@ type PipelineInput struct { func (service *Service) RunHookPipeline(ctx context.Context, input PipelineInput) ([]ItemResult, error) { records := input.Plan.Records() if input.Plan.WithHooks() { - if err := service.runHooks(ctx, input, hookRequest{phase: deployment.HookBefore, result: "pending"}); err != nil { + if err := service.runHooks(ctx, input, hookRequest{phase: deployment.HookBefore, result: hookResultPending}); err != nil { return nil, failure.New(failure.Hook, "apply: before hooks failed", err) } } @@ -60,9 +66,9 @@ func (service *Service) runExecutors(ctx context.Context, input PipelineInput, r // runAfterHooks aggregates the after phase with success or partial. func (service *Service) runAfterHooks(ctx context.Context, input PipelineInput, records []ItemResult) ([]ItemResult, error) { phase := deployment.HookAfter - result := "success" + result := hookResultSuccess if hasSkipped(records) { - result = "partial" + result = hookResultPartial } if err := service.runHooks(ctx, input, hookRequest{phase: phase, result: result}); err != nil { return records, failure.New(failure.Hook, "apply: after hooks failed", err) @@ -95,8 +101,6 @@ func (service *Service) runHooks(ctx context.Context, input PipelineInput, reque Platform: input.Candidates.Platform(), Phase: request.phase, Result: request.result, - DryRun: input.Request.DryRun, - NoHooks: input.Request.NoHooks, } return service.hooks.Execute(ctx, execution, input.Candidates.Hooks()) } diff --git a/internal/application/apply/prepare.go b/internal/application/apply/prepare.go index b09b3f5..f0dd290 100644 --- a/internal/application/apply/prepare.go +++ b/internal/application/apply/prepare.go @@ -29,18 +29,7 @@ func (p PreparedPlan) WithHooks() bool { return p.withHooks } // Summary counts the per-target records of the plan. func (p PreparedPlan) Summary() Summary { - summary := Summary{} - for _, record := range p.records { - switch record.Status { - case StatusPlanned: - summary.Planned++ - case StatusCompleted: - summary.Completed++ - case StatusPartial: - summary.Partial++ - } - } - return summary + return summarize(p.records) } // PrepareInput bundles the request, candidates, and decisions of one apply @@ -59,9 +48,13 @@ func (service *Service) Prepare(ctx context.Context, input PrepareInput) (Prepar if err := ctx.Err(); err != nil { return PreparedPlan{}, err } - pending, err := decisionSpecs(input.Candidates) - if err != nil { - return PreparedPlan{}, err + pending := input.Decisions.Specs() + if pending == nil { + var err error + pending, err = decisionSpecs(input.Candidates) + if err != nil { + return PreparedPlan{}, err + } } if len(pending) > 0 && !input.Request.DryRun && input.Request.NonInteractive { return PreparedPlan{}, failure.New(failure.InvalidInput, "apply: non-interactive apply requires no pending decisions", nil) @@ -146,6 +139,10 @@ func confirmedReplace(candidate Candidate, decided bool, choice DecisionChoice) // needsDecision reports whether one candidate required an explicit choice. func needsDecision(candidate Candidate) bool { + return candidateNeedsDecision(candidate) +} + +func candidateNeedsDecision(candidate Candidate) bool { return candidate.file.Convergence == reconcile.DecisionRequired || candidate.alias.Convergence == reconcile.DecisionRequired } @@ -163,7 +160,7 @@ func plannedRecord(candidate Candidate, kind ActionKind) ItemResult { // classification action for automatic rows, the representation named by the // plan entry for rows that required a decision. func underlyingAction(candidate Candidate) (reconcile.Action, string, error) { - decided := candidate.file.Convergence == reconcile.DecisionRequired || candidate.alias.Convergence == reconcile.DecisionRequired + decided := candidateNeedsDecision(candidate) if !decided { if action, source, pending := classificationAction(candidate); pending { return action, source, nil diff --git a/internal/application/apply/verify.go b/internal/application/apply/verify.go index 5296384..6817a5e 100644 --- a/internal/application/apply/verify.go +++ b/internal/application/apply/verify.go @@ -20,25 +20,38 @@ func (service *Service) Verify(ctx context.Context, records []ItemResult, candid byPath := candidatesByPath(candidates) verified := append([]ItemResult(nil), records...) for index := range verified { - record := &verified[index] - if record.Status != StatusCompleted { - continue - } - candidate, ok := byPath[record.TargetPath] - if !ok { - continue - } - equal, err := service.verifyOne(ctx, candidate, candidates.Home()) - if err != nil { + if err := (verificationContext{service: service, context: ctx, candidates: byPath, home: candidates.Home()}).verify(&verified[index]); err != nil { return nil, err } - if !equal { - record.Status = StatusPartial - } } return verified, nil } +type verificationContext struct { + service *Service + context context.Context + candidates map[string]Candidate + home string +} + +func (verification verificationContext) verify(record *ItemResult) error { + if record.Status != StatusCompleted { + return nil + } + candidate, ok := verification.candidates[record.TargetPath] + if !ok || candidate.record.Entry == reconcile.PlanEntryNone { + return nil + } + equal, err := verification.service.verifyOne(verification.context, candidate, verification.home) + if err != nil { + return err + } + if !equal { + record.Status = StatusPartial + } + return nil +} + // verifyOne re-snapshots one candidate's source and target and reports // whether the deployed target still matches the source facts. func (service *Service) verifyOne(ctx context.Context, candidate Candidate, home string) (bool, error) { diff --git a/internal/application/inspect/diff.go b/internal/application/inspect/diff.go index cfbec36..814d93f 100644 --- a/internal/application/inspect/diff.go +++ b/internal/application/inspect/diff.go @@ -2,6 +2,7 @@ package inspect import ( "context" + "io" "os" "path/filepath" @@ -143,35 +144,52 @@ func readTargetContent(home string, record reconcile.Evaluation) ([]byte, error) if record.Target.Kind() != reconcile.KindFile { return nil, nil } - content, err := os.ReadFile(filepath.Join(home, filepath.FromSlash(record.TargetPath))) + path := filepath.Join(home, filepath.FromSlash(record.TargetPath)) + file, err := openValidatedTarget(path, record) + if err != nil { + return nil, err + } + defer file.Close() + content, err := io.ReadAll(file) if err != nil { return nil, failure.New(failure.Operational, "diff: read target "+record.TargetPath, err) } + if err := validateOpenedTarget(file, record, path); err != nil { + return nil, err + } return content, nil } -// diffCounts tallies the per-kind records of one diff result. -func diffCounts(records []DiffRecord) (files, aliases, retired int) { - for _, record := range records { - switch record.Kind() { - case StatusKindFile: - files++ - case StatusKindAlias: - aliases++ - case StatusKindRetired: - retired++ - } +func openValidatedTarget(path string, record reconcile.Evaluation) (*os.File, error) { + entry, err := os.Lstat(path) + if err != nil { + return nil, failure.New(failure.Operational, "diff: read target "+record.TargetPath, err) } - return files, aliases, retired + if !entry.Mode().IsRegular() || !record.Target.Identity().SameFileInfo(entry) { + return nil, failure.New(failure.Operational, "diff: target changed "+record.TargetPath, nil) + } + file, err := os.Open(path) + if err != nil { + return nil, failure.New(failure.Operational, "diff: read target "+record.TargetPath, err) + } + if err := validateOpenedTarget(file, record, path); err != nil { + file.Close() + return nil, err + } + return file, nil } -// diffConverged reports whether every diff record is converged; a -// record-free evaluation is converged by definition. -func diffConverged(records []DiffRecord) bool { - for _, record := range records { - if !record.Converged() { - return false - } +func validateOpenedTarget(file *os.File, record reconcile.Evaluation, path string) error { + info, err := file.Stat() + if err != nil || !record.Target.Identity().SameFileInfo(info) || info.Mode().Perm() != record.Target.Mode() { + return failure.New(failure.Operational, "diff: target changed "+path, err) } - return true + return nil } + +// diffCounts tallies the per-kind records of one diff result. +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/service.go b/internal/application/inspect/service.go index e8a3b04..494bdfb 100644 --- a/internal/application/inspect/service.go +++ b/internal/application/inspect/service.go @@ -2,8 +2,6 @@ package inspect import ( "context" - "os" - "path/filepath" "slices" "sort" @@ -26,6 +24,7 @@ type Service struct { secrets *secrets.Client protectedTrees []string platform deployment.Layer + platformError error } // NewService constructs the inspection service bound to the dependencies. @@ -34,6 +33,10 @@ func NewService(dependencies Dependencies) *Service { if err != nil || platform == deployment.LayerBase { platform = "" } + var platformError error + if dependencies.Platform != "" && err != nil { + platformError = failure.New(failure.InvalidInput, "inspect: invalid configured platform "+dependencies.Platform, err) + } return &Service{ source: dependencies.RepositorySource, compiler: dependencies.Compiler, @@ -41,6 +44,7 @@ func NewService(dependencies Dependencies) *Service { secrets: dependencies.Secrets, protectedTrees: dependencies.ProtectedTrees, platform: platform, + platformError: platformError, } } @@ -59,6 +63,9 @@ func (service *Service) evaluate(ctx context.Context, request Request) (Result, return Result{}, err } if service.platform == "" { + if service.platformError != nil { + return Result{}, service.platformError + } return Result{}, failure.New(failure.InvalidInput, "inspect: platform must be linux or darwin", nil) } identity, err := service.resolve(request.Repository) @@ -193,7 +200,6 @@ func (service *Service) selectedPlan(identity RepositoryIdentity, full deploymen return service.compile(identity, selected) } -// intersectGroups keeps selected names that are also current groups. func intersectGroups(selected, current []string) []string { var common []string for _, name := range selected { @@ -238,7 +244,6 @@ type groupSets struct { all map[string]bool } -// remember records one row group in the active and all sets. func (sets *groupSets) remember(name string, active bool) { if name == "" { return @@ -355,7 +360,7 @@ func (state *semanticState) secretFingerprints(ctx context.Context, home string, return semantics, err } if targetFile { - content, err := os.ReadFile(filepath.Join(home, filepath.FromSlash(record.TargetPath))) + content, err := readTargetContent(home, record) if err != nil { return semantics, failure.New(failure.Operational, "inspect: read target "+record.TargetPath, err) } diff --git a/internal/application/inspect/status.go b/internal/application/inspect/status.go index 14d197c..8a98c0b 100644 --- a/internal/application/inspect/status.go +++ b/internal/application/inspect/status.go @@ -145,8 +145,12 @@ 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 { + switch record.Kind() { case StatusKindFile: files++ case StatusKindAlias: @@ -161,8 +165,12 @@ func statusCounts(records []StatusRecord) (files, aliases, retired int) { // 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 { + if !record.Converged() { return false } } diff --git a/internal/application/validate/service.go b/internal/application/validate/service.go index e6b54fa..3b9cd82 100644 --- a/internal/application/validate/service.go +++ b/internal/application/validate/service.go @@ -50,6 +50,9 @@ func (service *Service) Validate(ctx context.Context, request Request) (Result, if err != nil { return Result{}, failure.New(failure.InvalidInput, "validate: select groups", err) } + if len(request.Groups) == 0 { + return Result{Platforms: platformCounts(linux, darwin)}, nil + } linux, darwin, err = service.compilePair(identity, chosen.Groups) if err != nil { return Result{}, err diff --git a/internal/diff/safe.go b/internal/diff/safe.go index a320234..9ce4079 100644 --- a/internal/diff/safe.go +++ b/internal/diff/safe.go @@ -71,6 +71,8 @@ func (r SafeRecord) TargetHash() deployment.Digest { // output purposes (PLAN.md Section 9.6). const maxTextBytes = 1 << 20 +const unifiedContextLines = 3 + // textEligible reports whether one side may appear in a unified diff: valid // UTF-8, at most maxTextBytes, and only newline, tab, or printable runes. // Controls, carriage return, DEL, bidi and zero-width format runes, and every @@ -130,7 +132,7 @@ func textRecord(record SafeRecord, sourceBytes, targetBytes []byte) (SafeRecord, B: textLines(targetBytes), FromFile: record.sourceLabel, ToFile: record.targetLabel, - Context: 3, + Context: unifiedContextLines, }) if err != nil { return SafeRecord{}, fmt.Errorf("diff: render unified diff: %w", err) @@ -178,7 +180,11 @@ func escapeLabel(label string) string { builder.WriteRune(r) continue } - builder.WriteString(fmt.Sprintf("\\x%x", r)) + var encoded [utf8.UTFMax]byte + width := utf8.EncodeRune(encoded[:], r) + for _, value := range encoded[:width] { + builder.WriteString(fmt.Sprintf("\\x%02x", value)) + } } return builder.String() } diff --git a/internal/reconcile/source_snapshot.go b/internal/reconcile/source_snapshot.go index b79723a..1da2df2 100644 --- a/internal/reconcile/source_snapshot.go +++ b/internal/reconcile/source_snapshot.go @@ -57,7 +57,7 @@ func captureVerified(file deployment.ManagedFile, client *secrets.Client) (Sourc clear(data) return SourceObservation{}, err } - if err := validateCapture(captureInput{file, before, after, data}); err != nil { + if err := validateCapture(captureInput{file: file, before: before, after: after, data: data}); err != nil { clear(data) return SourceObservation{}, err } diff --git a/internal/repository/controls.go b/internal/repository/controls.go index d6d62a0..3ad23e3 100644 --- a/internal/repository/controls.go +++ b/internal/repository/controls.go @@ -4,6 +4,9 @@ package repository import "strings" +// SecretDirectoryName is the reserved scope directory for encrypted sources. +const SecretDirectoryName = "_secrets" + // Control classifies one scope-root entry name. type Control int @@ -35,7 +38,7 @@ func ClassifyRoot(name string) Control { return ControlDarwin case "_linux": return ControlLinux - case "_secrets": + case SecretDirectoryName: return ControlSecrets case "_hooks": return ControlHooks @@ -55,7 +58,7 @@ func ClassifyRoot(name string) Control { // entry; ordinary names are literal deployable entries and repository-root // metadata is not special here. func ClassifyPlatformLayer(name string) Control { - if name == "_secrets" { + if name == SecretDirectoryName { return ControlSecrets } if strings.HasPrefix(name, "_") { diff --git a/internal/repository/overlay.go b/internal/repository/overlay.go index 4e222bd..fe289eb 100644 --- a/internal/repository/overlay.go +++ b/internal/repository/overlay.go @@ -119,7 +119,7 @@ func baseTarget(candidate Candidate) (string, error) { if candidate.Kind != deployment.FileSecret { return target, nil } - target = strings.TrimPrefix(target, "_secrets/") + target = strings.TrimPrefix(target, SecretDirectoryName+"/") if candidate.Scope.IsRoot() && !representableRootSecretTarget(target) { return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) } @@ -242,7 +242,7 @@ func (walker *layerWalker) target(path string, kind deployment.FileKind) (string if kind != deployment.FileSecret { return path, nil } - target := strings.TrimPrefix(strings.TrimPrefix(path, "_secrets"), "/") + target := strings.TrimPrefix(strings.TrimPrefix(path, SecretDirectoryName), "/") if walker.scope.IsRoot() && !representableRootSecretTarget(target) { return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) } -- 2.51.2