diff --git a/PLAN.md b/PLAN.md index fbf1689..6621c8e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1134,10 +1134,17 @@ internal/application/add/execute.go internal/application/add/service.go internal/application/version/types.go internal/application/version/service.go +internal/application/outcome/types.go + +internal/application/evaluation/types.go +internal/application/evaluation/service.go +internal/application/evaluation/target.go +internal/application/evaluation/semantics.go internal/deployment/scope.go internal/deployment/file.go internal/deployment/alias.go +internal/deployment/hook.go internal/deployment/plan.go internal/deployment/hash.go internal/deployment/sort.go @@ -1152,10 +1159,12 @@ internal/subprocess/process_unix.go internal/state/types.go internal/state/database.go +internal/state/private_file.go internal/state/lock.go internal/state/migrations.go internal/state/migrations/001_initial.sql internal/state/store.go +internal/state/transaction.go internal/state/repositories.go internal/state/keyfile.go internal/state/keyid.go @@ -1219,6 +1228,8 @@ internal/testfixture/database/store.go This is the exhaustive initial-release Go package/file layout, not permission to collect unrelated behavior. Root documentation, Nix, workflow, and script paths are exhaustively owned in Section 16. A needed file rename, split, or addition requires a plan amendment before coding; a card may not invent it locally. Every non-test Go file receives a focused test named by exactly one roadmap card. `internal/testfixture` is a directory only, never a Go package: its three child packages expose one narrowly named fixture role each, and non-fixture production code may not import them. +**Amendment 1 (delivery review, 2026-08-10):** Register the files completed below the exhaustive list and never registered by their cards: `internal/deployment/hook.go` (Task 7), `internal/state/private_file.go` (Task 15), `internal/state/transaction.go` (Task 23), `internal/application/outcome/types.go` (Task 64), the `internal/application/evaluation` package (Task 61), `internal/application/apply/difference.go` (Task 64), `internal/repository/compiler_determinism_test.go` (Task 35), `internal/secrets/client_buffers_test.go` (Task 37), `internal/filesystem/race_test.go` (Task 45), `internal/reconcile/state_snapshot_invalid_test.go` (Task 49), `internal/reconcile/snapshot_helpers_test.go` (Task 50), `internal/application/inspect/state_rows_test.go` (Task 61), and `scripts/check-credentials.py` (Task 119). Each card's `Owns` list below carries the exact files; the evaluation package sits below the application command packages and above the backend families in the Section 12.5 policy flow, and its consumers are `application/inspect` and `application/apply`. The `just check-credentials` recipe and the CI `documentation` job already referenced `scripts/check-credentials.py`; this amendment registers the file with its Task 119 owner. The Task 4 shell additionally exposes `go_1_25` and `go_1_25fmt` wrapper executables so the Task 115 go-floor job can run the pinned Go 1.25.12 toolchain through the frozen `just` recipes. + **CLI/application seam:** - `cmd/cattery/main.go` creates the signal-aware root context, asks `internal/bootstrap` for an opaque `cli.Application`, calls `internal/cli.Execute`, and is the only production file that calls `os.Exit`. @@ -1454,8 +1465,9 @@ The diagram describes policy flow, not permission for bootstrap to move behavior | `selection` | `deployment`, `pathsafe`, `state` | | `application/initialize` | `failure`, `pathsafe`, `state` | | `application/validate` | `deployment`, `failure`, `repository`, `selection` | -| `application/inspect` | `deployment`, `diff`, `failure`, `reconcile`, `repository`, `secrets`, `selection`, `state` | -| `application/apply` | `deployment`, `diff`, `failure`, `filesystem`, `hooks`, `pathsafe`, `reconcile`, `repository`, `secrets`, `selection`, `state` | +| `application/inspect` | `application/evaluation`, `deployment`, `diff`, `failure`, `reconcile`, `repository`, `secrets`, `selection`, `state` | +| `application/evaluation` | `deployment`, `failure`, `reconcile`, `repository`, `secrets`, `selection`, `state` | +| `application/apply` | `application/evaluation`, `deployment`, `diff`, `failure`, `filesystem`, `hooks`, `pathsafe`, `reconcile`, `repository`, `secrets`, `selection`, `state` | | `application/add` | `deployment`, `failure`, `filesystem`, `pathsafe`, `reconcile`, `repository`, `secrets`, `selection`, `state` | | `application/version` | `buildinfo` | | `cli` | `application/...`, `failure` only, subject to the per-file restrictions in Section 12.1 | @@ -1890,7 +1902,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 6. -**Owns:** `internal/deployment/scope.go`, `internal/deployment/file.go`, `internal/deployment/alias.go`, `internal/deployment/plan.go`, `internal/deployment/scope_test.go`, `internal/deployment/file_test.go`, `internal/deployment/alias_test.go`, `internal/deployment/plan_test.go`. +**Owns:** `internal/deployment/scope.go`, `internal/deployment/file.go`, `internal/deployment/alias.go`, `internal/deployment/hook.go`, `internal/deployment/plan.go`, `internal/deployment/scope_test.go`, `internal/deployment/file_test.go`, `internal/deployment/alias_test.go`, `internal/deployment/plan_test.go`. **Deliverable:** Freeze scope, layer, file, alias, hook, and platform-plan values from Section 12.2 with defensive-copy constructors. @@ -2018,7 +2030,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 14. -**Owns:** `internal/state/database.go`, `internal/state/database_test.go`. +**Owns:** `internal/state/database.go`, `internal/state/private_file.go`, `internal/state/database_test.go`. **Deliverable:** Resolve the canonical XDG state path, enforce entry modes/types, open modernc SQLite v1.56.0 with one connection, and set required PRAGMAs without locking or migration. @@ -2146,7 +2158,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 20, 22. -**Owns:** `internal/state/files.go`, `internal/state/files_read.go`, `internal/state/files_decode.go`, plus `internal/state/files_test.go`, `internal/state/files_retire_test.go`. +**Owns:** `internal/state/files.go`, `internal/state/files_read.go`, `internal/state/files_decode.go`, `internal/state/transaction.go`, plus `internal/state/files_test.go`, `internal/state/files_retire_test.go`. **Deliverable:** Implement active/retired file reads, ordinary/keyed-secret baselines, raw storage hashes, modes, reactivation, state-only scopes, and per-file transactions. @@ -2338,7 +2350,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 9, 29, 31, 33, 34. -**Owns:** `internal/repository/compiler.go`, `internal/repository/compiler_test.go`. +**Owns:** `internal/repository/compiler.go`, `internal/repository/compiler_test.go`, `internal/repository/compiler_determinism_test.go`. **Deliverable:** Compose the exact nine Section 12.3 phases for Linux and Darwin using frozen scanner, route, hook, collision, and sorting contracts. @@ -2370,7 +2382,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 2, 13, 36. -**Owns:** `internal/secrets/client.go`, `internal/secrets/client_test.go`. +**Owns:** `internal/secrets/client.go`, `internal/secrets/client_test.go`, `internal/secrets/client_buffers_test.go`. **Deliverable:** Own exact SOPS executable lookup, repository cwd, bounded capture, safe launch/exit diagnostics, redaction, cancellation, and buffer clearing. @@ -2498,7 +2510,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 41, 42. -**Owns:** `internal/filesystem/alias.go`, `internal/filesystem/alias_test.go`. +**Owns:** `internal/filesystem/alias.go`, `internal/filesystem/alias_test.go`, `internal/filesystem/race_test.go`. **Deliverable:** Create, verify, and replace exact relative symlink entries without following final referents; payloads are slash-relative without absolute or empty segments and may traverse upward with `..`. @@ -2562,7 +2574,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 25, 46. -**Owns:** `internal/reconcile/state_snapshot.go`, `internal/reconcile/state_records.go`, `internal/reconcile/state_snapshot_test.go`. +**Owns:** `internal/reconcile/state_snapshot.go`, `internal/reconcile/state_records.go`, `internal/reconcile/state_snapshot_test.go`, `internal/reconcile/state_snapshot_invalid_test.go`. **Deliverable:** Convert active/retired/state-only file and alias rows into immutable evaluation records and reject cross-table corruption. @@ -2578,7 +2590,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 47, 48, 49. -**Owns:** `internal/reconcile/snapshot.go`, `internal/reconcile/snapshot_test.go`. +**Owns:** `internal/reconcile/snapshot.go`, `internal/reconcile/snapshot_test.go`, `internal/reconcile/snapshot_helpers_test.go`. **Deliverable:** Join sorted source, target, and state observations for one complete selected platform plan without classification. @@ -2754,7 +2766,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 2, 35, 40, 47, 48, 49, 50, 51, 52, 53, 55, 57. -**Owns:** `internal/application/inspect/types.go`, `internal/application/inspect/service.go`, `internal/application/inspect/types_test.go`, `internal/application/inspect/service_test.go`. +**Owns:** `internal/application/evaluation/types.go`, `internal/application/evaluation/service.go`, `internal/application/evaluation/target.go`, `internal/application/evaluation/semantics.go`, `internal/application/inspect/types.go`, `internal/application/inspect/service.go`, `internal/application/inspect/types_test.go`, `internal/application/inspect/service_test.go`, `internal/application/inspect/state_rows_test.go`. **Deliverable:** Freeze inspection DTOs/ports and perform one immutable selection, compile, snapshot, and classification evaluation with on-demand secret semantics. @@ -2802,7 +2814,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 2, 46, 54, 55, 57. -**Owns:** `internal/application/apply/types.go`, `internal/application/apply/types_test.go`. +**Owns:** `internal/application/outcome/types.go`, `internal/application/apply/types.go`, `internal/application/apply/difference.go`, `internal/application/apply/types_test.go`. **Deliverable:** Define repository input, request/result/action-plan, application-owned decision class/choice/safe-difference request/response DTOs, and narrow phase ports used by apply; no exported CLI-facing DTO mentions a backend package type. @@ -3682,7 +3694,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Depends on:** 4, 118. -**Owns:** `scripts/check-docs.py`, `scripts/tests/check_docs_test.py`. +**Owns:** `scripts/check-docs.py`, `scripts/check-credentials.py`, `scripts/tests/check_docs_test.py`. **Deliverable:** Implement the immutable `just check-docs [PATH ...]` and `just check-credentials [PATH ...]` backends using only Python's standard library: validate local links, documented command/flag vocabulary, forbidden feature claims, placeholder-only credentials, and deterministic UTF-8 output. diff --git a/integration/failures_test.go b/integration/failures_test.go index 3baa2f6..72a42a9 100644 --- a/integration/failures_test.go +++ b/integration/failures_test.go @@ -3,6 +3,7 @@ package integration import ( "os" "path/filepath" + "runtime" "testing" ) @@ -14,6 +15,8 @@ func TestExecutableFailures(t *testing.T) { {"pre-rename keeps the old target", testFailuresPreRename}, {"later item preserves earlier", testFailuresLaterItem}, {"retry recovers by equality", testFailuresRecovery}, + {"locked state store fails before mutation", testFailuresStateLock}, + {"asynchronous apply converges", testFailuresAsyncApply}, } for _, scenario := range scenarios { t.Run(scenario.name, scenario.run) @@ -84,3 +87,43 @@ func testFailuresRecovery(t *testing.T) { t.Fatal("the retry must converge the target") } } + +// testFailuresStateLock proves that a concurrently held state-store lock +// is an operational failure before any target mutation. The open-file +// description locks are Linux-only. +func testFailuresStateLock(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("open-file description locks are Linux-only") + } + race := NewRaceFixture(t) + race.initRepository(t) + race.source(t, ".config/app", "v1") + if result := race.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("first apply: %+v", result) + } + race.source(t, ".config/app", "v2") + race.lockStateWrites(t) + result := race.run(t, nil, "apply") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a locked state store", result.Code) + } + if string(race.target(t, ".config/app")) != "v1" { + t.Fatal("a locked state store must not touch the target") + } +} + +// testFailuresAsyncApply proves the fixture's asynchronous launch, +// content polling, and completion helpers against a real apply. +func testFailuresAsyncApply(t *testing.T) { + race := NewRaceFixture(t) + race.initRepository(t) + race.source(t, ".config/a", "a") + race.source(t, "x/bin/b", "b") + handle := race.start(t, "apply") + race.awaitTarget(t, ".config/a", "a") + race.awaitTarget(t, "bin/b", "b") + result := handle.finish(t) + if result.Code != 0 { + t.Fatalf("async apply: %+v", result) + } +} diff --git a/integration/secrets_test.go b/integration/secrets_test.go index df28c98..c66995d 100644 --- a/integration/secrets_test.go +++ b/integration/secrets_test.go @@ -1,6 +1,8 @@ package integration import ( + "bytes" + "encoding/json" "os" "path/filepath" "strings" @@ -19,12 +21,27 @@ func TestExecutableSecrets(t *testing.T) { {"secret apply round trip", testSecretsApply}, {"dependency failure", testSecretsDependency}, {"hash key recovery", testSecretsKey}, + {"real age round trip", testSecretsRealRoundTrip}, } for _, scenario := range scenarios { t.Run(scenario.name, scenario.run) } } +// sopsFreePath returns the current PATH with every directory containing +// an executable named sops removed, so dependency scenarios observe a +// missing dependency regardless of the host environment. +func sopsFreePath() string { + dirs := filepath.SplitList(os.Getenv("PATH")) + var kept []string + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "sops")); os.IsNotExist(err) { + kept = append(kept, dir) + } + } + return strings.Join(kept, string(os.PathListSeparator)) +} + // fakeEnv builds an environment whose PATH exposes the fake executable as // sops together with its behavior variables. func fakeEnv(t *testing.T, env execEnv) execEnv { @@ -122,13 +139,107 @@ func testSecretsDependency(t *testing.T) { env.initRepository(t) writeFile(t, filepath.Join(env.repo, "_secrets", "token"), []byte(`{"data":"eA==","sops":{"version":"3.9.0"}}`)) writeFile(t, filepath.Join(env.home, "token"), []byte("plaintext")) - env.extraEnv = append(env.extraEnv, "PATH="+os.Getenv("PATH")) + env.extraEnv = append(env.extraEnv, "PATH="+sopsFreePath()) result := env.secretRun(t, nil, "apply") if result.Code != 4 { t.Fatalf("code = %d, want 4 for a missing sops dependency", result.Code) } } +// installIdentity copies the fixture identity into one home so sops can +// decrypt ciphertext produced for the shared recipient. +func installIdentity(t *testing.T, home, keyHome string) { + t.Helper() + source, err := os.ReadFile(filepath.Join(keyHome, ".config", "sops", "age", "keys.txt")) + if err != nil { + t.Fatalf("identity: %v", err) + } + dir := filepath.Join(home, ".config", "sops", "age") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "keys.txt"), source, 0o600); err != nil { + t.Fatal(err) + } +} + +// testSecretsRealRoundTrip proves an actual binary-mode encrypt/decrypt +// round trip through the pinned real sops and age tools with an ephemeral +// identity. It skips only when the real tools are absent, which never +// happens under just test-sops in the pinned shell. +func testSecretsRealRoundTrip(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + fixture := NewSOPSFixture(t) + if !fixture.RealAvailable() { + t.Skip("the pinned real sops and age tools are not installed") + } + keyHome := t.TempDir() + fixture.SetupAge(t, keyHome) + fixture.SetupConfig(t, keyHome, env.repo) + payload := realSecretPayload() + env.addRealSecret(t, keyHome, payload) + env.applyRealSecret(t, keyHome, payload) +} + +// realSecretPayload builds the Section 13 27-byte fixture containing NUL +// and invalid UTF-8 bytes. +func realSecretPayload() []byte { + payload := make([]byte, 27) + for index := range payload { + payload[index] = byte(0x41 + index) + } + payload[3] = 0 + payload[8] = 0xff + return payload +} + +// addRealSecret adopts one binary target through the real sops pipeline +// and verifies the stored ciphertext shape. +func (env execEnv) addRealSecret(t *testing.T, keyHome string, payload []byte) { + t.Helper() + installIdentity(t, env.home, keyHome) + writeFile(t, filepath.Join(env.home, "token"), payload) + result := env.secretRun(t, nil, "add", "--secret", "token") + if result.Code != 0 { + t.Fatalf("add: code=%d stderr=%q", result.Code, result.Stderr) + } + ciphertext, err := os.ReadFile(filepath.Join(env.repo, "_secrets", "token")) + if err != nil { + t.Fatalf("source: %v", err) + } + if !json.Valid(ciphertext) { + t.Fatal("the encrypted source must be valid JSON") + } + if bytes.Contains(ciphertext, payload) { + t.Fatal("plaintext must never appear in the encrypted source") + } +} + +// applyRealSecret decrypts the stored ciphertext into a second home and +// verifies a byte-exact round trip. +func (env execEnv) applyRealSecret(t *testing.T, keyHome string, payload []byte) { + t.Helper() + second := execEnv{fixture: env.fixture, repo: env.repo, home: t.TempDir()} + if result := second.run(t, nil, "init", second.repo); result.Code != 0 { + t.Fatalf("second init: %+v", result) + } + installIdentity(t, second.home, keyHome) + if result := second.secretRun(t, nil, "apply"); result.Code != 0 { + t.Fatalf("second apply: code=%d stderr=%q", result.Code, result.Stderr) + } + decrypted, err := os.ReadFile(filepath.Join(second.home, "token")) + if err != nil { + t.Fatalf("decrypted target: %v", err) + } + if !bytes.Equal(decrypted, payload) { + t.Fatalf("round trip mismatch: got %x, want %x", decrypted, payload) + } + if status := second.secretRun(t, nil, "status"); status.Code != 0 { + t.Fatalf("status: %+v", status) + } +} + func testSecretsKey(t *testing.T) { env := newExecEnv(t) env.initRepository(t) diff --git a/integration/sops_fixture_test.go b/integration/sops_fixture_test.go index b2a35de..a7fc12b 100644 --- a/integration/sops_fixture_test.go +++ b/integration/sops_fixture_test.go @@ -19,6 +19,7 @@ type SOPSFixture struct { Client *secrets.Client RealSOPS string RealAge string + RealAgeGen string AgeKey string ConfigDir string CleanupDirs []string @@ -41,6 +42,7 @@ func NewSOPSFixture(t *testing.T) SOPSFixture { } fixture.RealSOPS = probeTool(t, "sops", "--version") fixture.RealAge = probeTool(t, "age", "--version") + fixture.RealAgeGen = probeTool(t, "age-keygen", "--version") return fixture } @@ -64,18 +66,19 @@ func probeTool(t *testing.T, name string, args ...string) string { // RealAvailable reports whether the pinned real tools exist. func (fixture SOPSFixture) RealAvailable() bool { - return fixture.RealSOPS != "" && fixture.RealAge != "" + return fixture.RealSOPS != "" && fixture.RealAge != "" && fixture.RealAgeGen != "" } -// SetupAge generates one ephemeral age identity in the fixture home. +// SetupAge generates one ephemeral age identity in the sops-discovered +// location of the fixture home. func (fixture SOPSFixture) SetupAge(t *testing.T, home string) string { t.Helper() - ageDir := filepath.Join(home, ".config", "age") + ageDir := filepath.Join(home, ".config", "sops", "age") if err := os.MkdirAll(ageDir, 0o700); err != nil { t.Fatal(err) } key := filepath.Join(ageDir, "keys.txt") - command := exec.Command(fixture.RealAge, "-o", key) + command := exec.Command(fixture.RealAgeGen, "-o", key) command.Env = append(os.Environ(), "HOME="+home) if output, err := command.CombinedOutput(); err != nil { t.Fatalf("age-keygen: %v\n%s", err, output) @@ -83,18 +86,40 @@ func (fixture SOPSFixture) SetupAge(t *testing.T, home string) string { return key } -// SetupConfig writes one ephemeral sops config bound to the age identity. -func (fixture SOPSFixture) SetupConfig(t *testing.T, home string) { +// SetupConfig writes one ephemeral sops config bound to the age identity +// created by SetupAge under the given home into the given directory, the +// repository root cattery uses as the SOPS working directory. +func (fixture SOPSFixture) SetupConfig(t *testing.T, home, directory string) { t.Helper() - config := filepath.Join(home, ".config", "sops", "sops.yaml") - if err := os.MkdirAll(filepath.Dir(config), 0o700); err != nil { + publicKey := fixture.agePublicKey(t, home) + config := "creation_rules:\n - age: " + publicKey + "\n" + configPath := filepath.Join(directory, ".sops.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(config, []byte("creation_rules:\n - unencrypted_suffix: _unencrypted\n"), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil { t.Fatal(err) } } +// agePublicKey extracts the public key line from the fixture identity, +// located under the given home directory. +func (fixture SOPSFixture) agePublicKey(t *testing.T, home string) string { + t.Helper() + keyBytes, err := os.ReadFile(filepath.Join(home, ".config", "sops", "age", "keys.txt")) + if err != nil { + t.Fatalf("age identity: %v", err) + } + for _, line := range strings.Split(string(keyBytes), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# public key: ") { + return strings.TrimPrefix(line, "# public key: ") + } + } + t.Fatal("no public key in the age identity") + return "" +} + // Cleanup removes every created identity, config, and binary copy. func (fixture SOPSFixture) Cleanup(t *testing.T, paths ...string) { t.Helper() diff --git a/internal/application/add/service.go b/internal/application/add/service.go index 21c3144..a7efb95 100644 --- a/internal/application/add/service.go +++ b/internal/application/add/service.go @@ -76,6 +76,9 @@ func (service *Service) Add(ctx context.Context, request Request) (Result, error if err != nil { return Result{}, err } + if service.write.Secrets != nil { + service.write.Secrets.SetDirectory(identity.Root) + } plan, err := service.compile(identity) if err != nil { return Result{}, err diff --git a/internal/application/evaluation/service.go b/internal/application/evaluation/service.go index 01697cf..c078003 100644 --- a/internal/application/evaluation/service.go +++ b/internal/application/evaluation/service.go @@ -2,8 +2,6 @@ package evaluation import ( "context" - "errors" - "os" "slices" "sort" @@ -68,6 +66,9 @@ func (service *Service) Evaluate(ctx context.Context, request Request) (Result, if err != nil { return Result{}, err } + if service.secrets != nil { + service.secrets.SetDirectory(identity.Root) + } rows, err := service.readRows(identity) if err != nil { return Result{}, err @@ -166,19 +167,11 @@ func (service *Service) compile(identity RepositoryIdentity, selected []string) Selected: selected, }) if err != nil { - return deployment.Plan{}, compileFailure(service.commandLabel+": compile plan", err) + return deployment.Plan{}, failure.FromPathError(service.commandLabel+": compile plan", err) } return plan, nil } -func compileFailure(message string, cause error) error { - var pathError *os.PathError - if errors.As(cause, &pathError) { - return failure.New(failure.Operational, message, cause) - } - return failure.New(failure.InvalidInput, message, cause) -} - func (service *Service) selectedPlan(identity RepositoryIdentity, full deployment.Plan, chosen selection.Selection) (deployment.Plan, error) { if chosen.Root { return full, nil diff --git a/internal/secrets/client.go b/internal/secrets/client.go index a821d03..0f25726 100644 --- a/internal/secrets/client.go +++ b/internal/secrets/client.go @@ -36,6 +36,14 @@ func NewClient(executable string, directory string, environment []string) *Clien return &Client{executable: executable, directory: directory, environment: slices.Clone(environment)} } +// SetDirectory rebinds the client to the repository root that Section 4.3 +// SOPS invocations must run in. Bootstrap cannot know the repository, so the +// binding happens once the selected command resolves it; the client is used +// by one single-use application for exactly one repository. +func (client *Client) SetDirectory(directory string) { + client.directory = directory +} + // Request describes one SOPS invocation. Operation and SourcePath appear only // in sanitized diagnostics; Arguments are the exact sops arguments after the // executable name; Stdin is written once to the /dev/stdin argument; and diff --git a/scripts/tests/package_release_test.sh b/scripts/tests/package_release_test.sh index eb23b63..8055962 100755 --- a/scripts/tests/package_release_test.sh +++ b/scripts/tests/package_release_test.sh @@ -11,6 +11,14 @@ fail() { exit 1 } +# run_package_release invokes the packaging script without a caller-set +# SOURCE_DATE_EPOCH so the tag-derived epoch is always exercised; the Nix +# development shell exports a placeholder SOURCE_DATE_EPOCH that would +# otherwise override it. +run_package_release() { + (cd "$TEMP_ROOT" && env -u SOURCE_DATE_EPOCH scripts/package-release.sh "$@") +} + prepare_repository() { git archive HEAD | tar -x -C "$TEMP_ROOT" mkdir -p "$TEMP_ROOT/scripts" @@ -51,22 +59,22 @@ assert_archives() { assert_rejects_invalid_checkout() { printf 'dirty\n' > "$TEMP_ROOT/dirty.txt" - if (cd "$TEMP_ROOT" && scripts/package-release.sh >/dev/null 2>&1); then + if run_package_release >/dev/null 2>&1; then fail "dirty checkout was accepted" fi rm -f "$TEMP_ROOT/dirty.txt" git -C "$TEMP_ROOT" commit --allow-empty -qm second - if (cd "$TEMP_ROOT" && scripts/package-release.sh >/dev/null 2>&1); then + if run_package_release >/dev/null 2>&1; then fail "non-HEAD tag was accepted" fi git -C "$TEMP_ROOT" tag -d v0.0.1 >/dev/null git -C "$TEMP_ROOT" tag v0.0.2 - if (cd "$TEMP_ROOT" && scripts/package-release.sh >/dev/null 2>&1); then + if run_package_release >/dev/null 2>&1; then fail "lightweight tag was accepted" fi git -C "$TEMP_ROOT" tag -d v0.0.2 >/dev/null git -C "$TEMP_ROOT" tag -a -m malformed vbad - if (cd "$TEMP_ROOT" && scripts/package-release.sh >/dev/null 2>&1); then + if run_package_release >/dev/null 2>&1; then fail "malformed tag was accepted" fi git -C "$TEMP_ROOT" tag -d vbad >/dev/null @@ -79,13 +87,13 @@ main() { grep -q -- '-buildvcs=false' scripts/package-release.sh || fail "buildvcs=false is missing" grep -q -- '-trimpath' scripts/package-release.sh || fail "trimpath is missing" grep -q -- 'CGO_ENABLED=0' scripts/package-release.sh || fail "CGO_ENABLED=0 is missing" - (cd "$TEMP_ROOT" && scripts/package-release.sh) + run_package_release assert_archives "$TEMP_ROOT/dist" first=$(sha256sum "$TEMP_ROOT/dist"/*.tar.gz "$TEMP_ROOT/dist/SHA256SUMS") - (cd "$TEMP_ROOT" && scripts/package-release.sh) + run_package_release second=$(sha256sum "$TEMP_ROOT/dist"/*.tar.gz "$TEMP_ROOT/dist/SHA256SUMS") [[ $first == "$second" ]] || fail "repeated package builds differ" - [[ $(cd "$TEMP_ROOT" && scripts/package-release.sh --print-reproducibility-status) == reproducible ]] || fail "manifest was not reproducible" + [[ $(run_package_release --print-reproducibility-status) == reproducible ]] || fail "manifest was not reproducible" printf 'package release tests passed\n' }