diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f26ac95..f3306617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,41 @@ source layout, CLI surface, and state schema are stabilizing but may still chang ### Fixed +- **A FIFO at a managed destination no longer hangs `status`, `diff`, `explain`, + or `reconcile`'s drift walk and write-back.** (A directory there never hung — `os.ReadFile` + fails it immediately with `EISDIR` — and for `status` and `diff` nothing about + it changes; only `reconcile`'s write-back now names the shape rather than + reporting `EISDIR`.) `os.ReadFile` on a FIFO does + not fail — it blocks in the open waiting for a writer that never comes — so + the read's own error path never runs and the command never returns. Measured + on the previous release: a FIFO at a whole-file destination wedged `diff` and + `reconcile`, and a FIFO-shaped key-merge destination (a `~/.claude.json`, + say) additionally wedged `status`, which is advertised as read-only. Each had + to be killed. + + `status` was already safe for the whole-file shape, because its `hashFile` + applied `render.IsRegularOrAbsent` and said it "shares render's predicate so + the destination-read guards cannot disagree about what is safe to read". That + was true of the hash and false of every other destination read. Every + destination read in `internal/cli` — `hashFile` included, which held a second + copy of the rule — now goes through one gate, `readDestBytes`, which refuses a + non-regular path before the open and lets an ABSENT one through to the read, + whose ENOENT is the answer every caller already handles. A `reconcile` + write-back (`[w]`) is covered too: with only the classification reads guarded, + a non-regular destination would classify as drift and then hang one keystroke + later. + + **`apply`, `apply --dry-run`, `reconcile`'s `[o]verride`, `import ` and + `doctor` are NOT fixed by this** and still hang on the same fixture — their reads are + in `internal/render` and the adapter `Ingest` paths, a far wider sweep. + `[o]verride` re-applies through `render.Writer.Write`, so it shares `apply`'s + unguarded read; the refusal message therefore points at removing or replacing + the file rather than at `[o]`, which would wedge. `doctor` reads no + destination itself but reaches one through its plugin check — a FIFO at + `~/.claude/settings.json` wedges it after it prints `Plugins`. Tracked as + [#241](https://github.com/spxrogers/agentsync/issues/241) and + [#242](https://github.com/spxrogers/agentsync/issues/242). + - **`agentsync check` no longer rejects a `[secrets].backend` that `apply` accepts.** `secrets.SelectBackend` — the function `apply` actually resolves through — lower-cases the backend name, but `check` compared it against the diff --git a/docs/components.md b/docs/components.md index 42e3bba4..11237e28 100644 --- a/docs/components.md +++ b/docs/components.md @@ -391,7 +391,18 @@ symmetric with the dest→source write boundary (see architecture §7). this component KIND reclaimed at all — drives reconcile's prompt wording) and `OrphanDeleteWillProceed` (will THIS destination actually be removed on this run — keeps the apply summary from counting a skipped delete). `IsRegularOrAbsent` - is shared with `internal/cli`'s destination reads so a FIFO cannot block them. + is shared with `internal/cli`'s destination reads so a FIFO cannot block + **those**: every one of them goes through `readDestBytes` + (`internal/cli/destread.go`), which applies this predicate before the open. + `TestEveryDestinationReadGoesThroughTheGate` backs this up by failing on a + bare `os.ReadFile(op.Path)` anywhere under `internal/cli` — a two-spelling + text matcher, so it catches the copy-paste that happened rather than every + possible spelling. + It is **not** yet true of this package's own `Writer.Write` convergence read + or of the adapter `Ingest` paths, so `apply`, `apply --dry-run`, + `reconcile --auto-override` (which re-applies through `Writer.Write`), + `import ` and `doctor` (through its plugin check) still block on a + non-regular destination — issues #241 and #242. - **Depends on:** adapter, secrets, source, state, paths, iox, drift. - **Files:** `pipeline.go`, `writer.go`, `state_apply.go`, `report.go`. diff --git a/internal/cli/dest_fifo_e2e_unix_test.go b/internal/cli/dest_fifo_e2e_unix_test.go new file mode 100644 index 00000000..e6a1e841 --- /dev/null +++ b/internal/cli/dest_fifo_e2e_unix_test.go @@ -0,0 +1,355 @@ +//go:build unix + +package cli_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/spxrogers/agentsync/internal/cli" +) + +// TestCommandsDoNotHangOnNonRegularDestination is the end-to-end half of the +// destination-read guard: it drives the real commands against a real applied +// home, which is the only way to prove the guard actually sits on the path the +// user reaches. +// +// Both shapes are covered because they are read by different code. A whole-file +// destination goes through the per-op reads in `diff` and `reconcile`; a +// key-merge destination (~/.claude.json) goes through the shared readDestFile +// that every drift walk uses, including `status` — which is advertised as +// read-only and hung on it. +// +// Every run is bounded. runCLI executes the command IN-PROCESS, so an +// unguarded read does not fail the test, it wedges the whole test binary until +// the package timeout kills it with a stack dump and no useful diagnostic. +func TestCommandsDoNotHangOnNonRegularDestination(t *testing.T) { + tmp := t.TempDir() + env := map[string]string{"AGENTSYNC_TARGET_ROOT": tmp} + + if _, err := runCLI(t, env, "init"); err != nil { + t.Fatal(err) + } + if _, err := runCLI(t, env, "agent", "add", "claude"); err != nil { + t.Fatal(err) + } + // A key-merge destination (~/.claude.json) … + mcp := filepath.Join(tmp, ".agentsync", "mcp", "github.toml") + if err := os.MkdirAll(filepath.Dir(mcp), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mcp, []byte("[server]\ntype=\"stdio\"\ncommand=\"npx\"\n"), 0o644); err != nil { + t.Fatal(err) + } + // … and a whole-file one (a rendered skill). + skill := filepath.Join(tmp, ".agentsync", "skills", "demo", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(skill), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(skill, []byte("---\nname: demo\ndescription: d\n---\nbody\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runCLI(t, env, "apply"); err != nil { + t.Fatal(err) + } + + for _, dest := range []struct{ name, path string }{ + {"whole-file destination", filepath.Join(tmp, ".claude", "skills", "demo", "SKILL.md")}, + {"key-merge destination", filepath.Join(tmp, ".claude.json")}, + } { + t.Run(dest.name, func(t *testing.T) { + if _, err := os.Stat(dest.path); err != nil { + t.Fatalf("fixture never applied %s: %v — this test would pass vacuously", dest.path, err) + } + // Swap the applied destination for a FIFO, and RESTORE it after. + // Removing the FIFO is not enough: the subtests share one applied + // home, so leaving the path absent means the next shape's subtest + // runs against a home that was never fully applied — contradicting + // this test's own premise. + // + // That is a structural property, not a measured pass/hang flip. An + // earlier version of this comment claimed the un-skipped rows + // changed outcome without the restore; eight configurations could + // not reproduce that. What actually varies there is goroutines left + // parked in open(2) by PRECEDING timed-out rows, which can pair + // with a later opener — nondeterministic, and orthogonal to this. + applied, err := os.ReadFile(dest.path) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(dest.path) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(dest.path); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(dest.path, 0o600); err != nil { + // Safe to write directly here: mkfifo failed, so the path is + // still absent rather than a FIFO. + _ = os.WriteFile(dest.path, applied, info.Mode().Perm()) + t.Skipf("mkfifo unsupported here: %v", err) + } + t.Cleanup(func() { restoreDest(t, dest.path, applied, info.Mode().Perm()) }) + + // The first group is what this change fixes. The second still hangs + // on this exact fixture and is SKIPPED rather than asserted or + // omitted: asserting the hang costs 8s a row and would start + // failing the day it is fixed, reading as a regression; omitting it + // leaves nothing to find. A skip is greppable, shows up in -v, and + // the person who closes the issue deletes one line to inherit a + // ready-made assertion. + for _, tc := range []struct { + args []string + skip string + }{ + {args: []string{"status"}}, + {args: []string{"diff"}}, + {args: []string{"reconcile", "--auto-safe"}}, + {args: []string{"apply", "--dry-run"}, skip: "#241: render.Writer.Write's convergence read is unguarded"}, + {args: []string{"apply"}, skip: "#241: render.Writer.Write's convergence read is unguarded"}, + {args: []string{"reconcile", "--auto-override"}, skip: "#241: [o]verride queues into render.Writer.Write"}, + {args: []string{"import", "claude"}, skip: "#242: the adapter Ingest reads are unguarded"}, + } { + args := tc.args + t.Run(strings.Join(args, " "), func(t *testing.T) { + if tc.skip != "" { + t.Skip(tc.skip) + } + // Exit status is deliberately not asserted: a non-regular + // destination may legitimately report drift, or refuse. The + // contract under test is that the command RETURNS. + runBounded(t, 8*time.Second, args...) + }) + } + }) + } +} + +// runBounded executes the CLI in a goroutine and fails if it has not returned +// within d, or if the command never actually ran. The environment must already +// be set by the caller on the test goroutine (runCLI's t.Setenv persists for the +// whole test), so nothing here touches testing.T off the test goroutine except +// the final t.Fatalf. +func runBounded(t *testing.T, d time.Duration, args ...string) { + t.Helper() + ran, err := runBoundedE(t, d, args...) + if !ran { + t.Fatalf("`agentsync %s` never reached its command body (err=%v) — the row executed "+ + "none of the code it names, so \"it returned\" proves nothing", strings.Join(args, " "), err) + } +} + +// runBoundedE runs the CLI in a goroutine, bounded by d, and reports whether the +// resolved command's body actually STARTED. It REPORTS that verdict rather than +// failing on it, so the anti-vacuity check itself can be tested; runBounded is +// the fatal wrapper every real row uses. (The timeout path still fails here — +// a wedged command has no verdict to report.) +// +// `ran` is OBSERVED, by wrapping the resolved command's RunE, rather than +// inferred from cobra's error text. Two earlier versions inferred it and both +// were wrong: the first matched substrings against the output buffer, which +// NewRoot's SilenceErrors leaves empty, so it could never fire — while the +// buffer's ordinary stdout made it fire on user content instead. The second +// matched cobra's error prose, which covers only the arg/flag layer: anything +// rejecting later still scored as "it ran", including this repo's own +// enforceScopeStance (internal/cli/scope_flags.go), a PersistentPreRunE refusal +// that never reaches RunE. Wrapping the body answers the actual question and +// cannot drift with cobra's wording. +func runBoundedE(t *testing.T, d time.Duration, args ...string) (ran bool, err error) { + t.Helper() + detachSlog(t) + type result struct { + out string + err error + ran bool + } + done := make(chan result, 1) + go func() { + var buf bytes.Buffer + root := cli.NewRoot() + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(args) + + started := false + if cmd, _, ferr := root.Find(args); ferr == nil && cmd != nil { + switch { + case cmd.RunE != nil: + inner := cmd.RunE + cmd.RunE = func(c *cobra.Command, a []string) error { + started = true + return inner(c, a) + } + case cmd.Run != nil: + inner := cmd.Run + cmd.Run = func(c *cobra.Command, a []string) { + started = true + inner(c, a) + } + } + } + rerr := root.Execute() + done <- result{buf.String(), rerr, started} + }() + select { + case r := <-done: + return r.ran, r.err + case <-time.After(d): + t.Fatalf("`agentsync %s` BLOCKED on a non-regular destination — os.ReadFile on a "+ + "FIFO waits for a writer that never comes, so the read's own error path never "+ + "runs and the command never returns", strings.Join(args, " ")) + return false, nil + } +} + +// TestRunBoundedDetectsACommandThatNeverRan is the positive control for +// runBounded's anti-vacuity check. Without it the check is unfalsifiable: it +// only ever fires on a broken invocation, so nothing in a green suite proves it +// still works — and its first two versions shipped unable to fire at all. +// +// It drives runBoundedE, which reports instead of failing, so a row that never +// ran can be asserted rather than crashing the test. +func TestRunBoundedDetectsACommandThatNeverRan(t *testing.T) { + cases := []struct { + name string + args []string + }{ + // Exactly the invocation that silently passed for two rounds: `import` + // without the agent selector its ExactArgs(1) requires. + {name: "a missing required argument", args: []string{"import"}}, + {name: "an unknown command", args: []string{"nosuchcommand"}}, + // The case cobra's error prose could not catch: a refusal from + // PersistentPreRunE, which runs AFTER argument validation and never + // reaches the command body. `secret list` is scope-unaware, so passing + // --scope is refused by enforceScopeStance. + {name: "a refusal from PersistentPreRunE", args: []string{"secret", "list", "--scope", "user"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ran, err := runBoundedE(t, 8*time.Second, tc.args...) + if err == nil { + t.Fatalf("`agentsync %s` returned nil; expected it to be refused", + strings.Join(tc.args, " ")) + } + if ran { + t.Errorf("runBoundedE reported ran=true for %q, which was refused with %v — "+ + "runBounded would let this row pass as if the command had executed", + strings.Join(tc.args, " "), err) + } + }) + } + + // The other direction: a command that DOES run must report ran=true, or + // every real row would fail as vacuous. + t.Run("a command that runs is reported as having run", func(t *testing.T) { + if ran, _ := runBoundedE(t, 8*time.Second, "version"); !ran { + t.Error("runBoundedE reported ran=false for `version`, which has no arguments to " + + "get wrong — the wrap is not finding the resolved command") + } + }) +} + +// restoreDest puts a captured destination back, by RENAME rather than by +// writing to the path. +// +// os.WriteFile opens the destination, and opening a FIFO blocks until the other +// end appears — inside t.Cleanup, where no per-row timeout applies, that wedges +// the suite outright. Worse, when a reader IS present — which is what a +// timed-out row leaves parked in open(2), and becomes common once the #241/#242 +// skips are deleted — the write succeeds, drains into the pipe, returns nil, +// and leaves the FIFO in place, so an error check never fires and the +// destination is silently not restored. Rename never opens the target. +// TestRestoreDestReplacesAFIFOEvenWithAReaderAttached measures exactly that. +func restoreDest(t *testing.T, path string, data []byte, mode os.FileMode) { + t.Helper() + tmp := path + ".restore" + if err := os.WriteFile(tmp, data, mode); err != nil { + t.Errorf("restoring %s: %v", path, err) + return + } + // On success the rename consumes tmp and this is a no-op; on any failure + // below it stops a stray .restore file being left in the applied home — + // which is the very contamination this helper exists to prevent. + defer func() { _ = os.Remove(tmp) }() + // os.WriteFile's mode is masked by umask on create; chmod pins it. + if err := os.Chmod(tmp, mode); err != nil { + t.Errorf("restoring %s: %v", path, err) + return + } + if err := os.Rename(tmp, path); err != nil { + t.Errorf("restoring %s: %v — the next subtest would run against a "+ + "partially-applied home", path, err) + } +} + +// TestRestoreDestReplacesAFIFOEvenWithAReaderAttached pins the reason +// restoreDest renames instead of writing. +// +// The reader goroutine reproduces what a timed-out row leaves behind. Without +// the rename, os.WriteFile succeeds here — the bytes vanish into the pipe, the +// error is nil, and the FIFO survives, so the destination is silently NOT +// restored and the next subtest runs against a partially-applied home. +func TestRestoreDestReplacesAFIFOEvenWithAReaderAttached(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dest") + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Skipf("mkfifo unsupported here: %v", err) + } + + // A reader ATTACHED to the FIFO, standing in for what a timed-out row leaves + // parked in open(2). + // + // O_NONBLOCK is load-bearing. A blocking os.Open in a goroutine looks like + // the real thing but is a RACE against restoreDest: if the rename lands + // first the open returns instantly on a regular file, and if the reader + // wins it parks forever, because restoreDest's entire purpose is never to + // open the target. The first version of this test did exactly that and hung + // most of the time — reproducing, in the test meant to prevent it, the + // failure mode this whole PR is about. O_NONBLOCK returns immediately and + // leaves a genuinely attached reader, so the counterfactual below (a direct + // os.WriteFile succeeding into the pipe) is real and deterministic. + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) + if err != nil { + t.Fatalf("attaching a reader to %s: %v", path, err) + } + defer func() { _ = syscall.Close(fd) }() + + // Called on the test goroutine: with a reader attached neither restoreDest + // nor the os.WriteFile variant can block, so no timeout wrapper is needed — + // and restoreDest's t.Errorf stays on the goroutine that owns the test. + // 0666, not 0644, and the choice is load-bearing: at the ambient umask 0022 + // os.WriteFile(_, _, 0o644) already yields 0644, so the mode assertion below + // would hold with or without the chmod — the pin would be vacuous in CI, + // which is exactly where it needs to work. 0666 masks down to 0644 on + // create, so only the chmod can produce it. + restoreDest(t, path, []byte("applied"), 0o666) + + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() { + t.Fatalf("destination is %s after restore, want a regular file: the write went INTO "+ + "the FIFO instead of replacing it, so the fixture was never restored", + info.Mode().Type()) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != "applied" { + t.Errorf("restored content = (%q, %v), want %q", got, err, "applied") + } + // The chmod, which os.WriteFile alone cannot guarantee: its mode argument is + // masked by umask on create, so without the explicit chmod a restored + // destination can come back more restrictive than the one that was captured. + if perm := info.Mode().Perm(); perm != 0o666 { + t.Errorf("restored mode = %04o, want 0666: os.WriteFile's mode is umask-masked, so "+ + "restoreDest must chmod to pin what it captured", perm) + } +} diff --git a/internal/cli/destread.go b/internal/cli/destread.go new file mode 100644 index 00000000..d8aa3992 --- /dev/null +++ b/internal/cli/destread.go @@ -0,0 +1,106 @@ +package cli + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/spxrogers/agentsync/internal/render" +) + +// errDestNotRegular is returned by readDestBytes for a destination path that is +// present but is not a regular file. It is a sentinel so callers can match it +// with errors.Is; it deliberately carries NO path, because every caller either +// discards it or wraps it with the path itself, and embedding one here would +// double it in the wrapped message. +var errDestNotRegular = errors.New("not a regular file") + +// errDestUnstattable is returned when the destination cannot be STAT'd for a +// reason other than absence — EACCES on a parent, ELOOP, ENOTDIR. It wraps the +// real errno. +// +// It is separate from errDestNotRegular because the two are not the same claim +// and one caller shows its sentinel to a user: reconcile's write-back refusal +// says "remove or replace the non-regular file at that path", which is false +// for a permission problem. hashFile, whose sentinels are opaque tokens +// compared only for equality, deliberately treats both alike — see its comment. +var errDestUnstattable = errors.New("cannot stat destination") + +// readDestBytes reads a destination file's bytes, refusing before the open any +// path whose shape cannot be read as a file. +// +// The guard is not defensive tidying. os.ReadFile on a FIFO does not fail — it +// BLOCKS in the open, waiting for a writer that never comes, so no error path +// below the read ever runs and the command never returns. Measured on the +// unguarded code: a FIFO at a managed destination wedged `diff` and +// `reconcile`, and a FIFO-shaped key-merge destination (a ~/.claude.json, say) +// wedged `status` — which is advertised as read-only — through the shared +// readDestFile. +// +// Every destination read in THIS PACKAGE routes here, cli.hashFile included, so +// they cannot disagree about what is safe to read. Other packages are not +// covered: `apply`, `apply --dry-run`, `reconcile --auto-override` and +// `import ` still hang, and `doctor` is exposed through its plugin +// check, because those reads live in internal/render and the adapter Ingest +// paths (#241, #242). `doctor` is not merely exposed there: with a FIFO at +// ~/.claude/settings.json it hangs outright (measured, rc=124, wedged after +// printing "Plugins"), through claude.IngestPlugins. +// +// Three things to know about the shape check: +// +// - The check is a stat, so it is racy against a reshape between stat and +// open. That window needs write access to the destination's own directory, +// which is already game over; closing it properly needs O_RDONLY|O_NONBLOCK +// plus fstat. +// - A destination that cannot be stat'd comes back as errDestUnstattable +// wrapping the real errno, NOT as a shape error. "Present and the wrong +// shape" and "shape unknown" are different facts, and one of them reaches a +// user. +// - Symlinks are followed here but refused outright by hashFile, so `status` +// calls a symlinked destination drifted while `diff` reads through it. The +// reads this gate replaced followed links too, so changing that is a +// behavior decision for #229 — which also owns the consequence that under +// AGENTSYNC_ALLOW_SYMLINK_DEST=1, where apply writes THROUGH the link, +// `status` reports drift no apply can clear. TestHashFileSentinels asserts +// both halves. +// +// Callers mostly do not surface the refusal — reconcile's write-back is the +// only one that names the shape today — so it is more a diagnosis available to +// them than one the user sees. Narrowing that means changing what several +// commands print; it is catalogued on #229 rather than here. +// +// An ABSENT path is not refused: os.ReadFile runs and its ENOENT reaches the +// caller unchanged, because manufacturing a shape error for a file that is not +// there would name the wrong problem. +// pathlessStatErr strips the redundant path from a *fs.PathError, mirroring +// secrets.pathlessErr. errors.Is still matches the underlying errno. +func pathlessStatErr(err error) error { + var pe *fs.PathError + if errors.As(err, &pe) { + return pe.Err + } + return err +} + +func readDestBytes(path string) ([]byte, error) { + // render.IsRegularOrAbsent stays the single authority on SHAPE, and it is + // asked FIRST so the ordinary read costs exactly one stat. It answers false + // for two different situations, though — a path that is present and the + // wrong shape, and one that cannot be stat'd at all — so the refusal path + // pays a second stat to tell them apart. Collapsing them was a real defect: + // reconcile's refusal names errDestNotRegular and told someone with a + // permission problem to "remove or replace the non-regular file". + if !render.IsRegularOrAbsent(path) { + if _, serr := os.Stat(path); serr != nil { + // Pathless, for the reason errDestNotRegular carries no path: the + // caller supplies it, and a *fs.PathError would make reconcile print + // "read dest X: cannot stat destination: stat X: ...". Unwrapping to + // the bare errno keeps errors.Is matching BOTH this sentinel and the + // underlying syscall error. + return nil, fmt.Errorf("%w: %w", errDestUnstattable, pathlessStatErr(serr)) + } + return nil, errDestNotRegular + } + return os.ReadFile(path) +} diff --git a/internal/cli/destread_guard_internal_test.go b/internal/cli/destread_guard_internal_test.go new file mode 100644 index 00000000..40c12d26 --- /dev/null +++ b/internal/cli/destread_guard_internal_test.go @@ -0,0 +1,114 @@ +package cli + +import ( + "strings" + "testing" +) + +// TestEveryDestinationReadGoesThroughTheGate enforces the invariant that makes +// the FIFO guard hold: no production code in this package reads an op's +// destination path with a bare os.ReadFile. It is an invariant over the two +// spellings below, not over every possible one — see LIMITS. +// +// Why an invariant and not just the behavior tests: the four reads MEASURED to +// hang (readDestFile, diff's and reconcile's per-op reads, and writeBackFileItem) +// are each pinned by a timeout-bounded test. The three in import's state-seeding +// path are not, and cannot be from here — `agentsync import ` does hang +// on a non-regular destination, but upstream of these sites, in the adapter +// Ingest reads this gate does not cover (#242). Guarding them neither fixed that +// nor could have. +// +// They were routed through the gate anyway, because three structurally +// identical unguarded reads sitting beside four guarded ones invite exactly the +// question "why those and not these", and there is no answer that survives +// being written down. This test is what keeps that decision from rotting: a new +// bare destination read fails here even where no hang is demonstrable at the +// site itself. +// +// LIMITS, stated so a reader does not over-trust it: +// - The pattern is TEXT-shaped. A read spelled through a variable +// (`p := op.Path; os.ReadFile(p)`), via afero, or with os.Open + io.ReadAll +// slips past. It catches the copy-paste that actually happened seven times, +// not every conceivable spelling. Concretely: reverting readDestFile's own +// read to `os.ReadFile(path)` is NOT caught here — the behavior tests are +// what cover that one. +// - It is scoped to THIS PACKAGE. `apply` and `import ` still hang on +// a non-regular destination through internal/render and the adapter Ingest +// paths (#241, #242); this guard says nothing about them, and passing it +// does not mean agentsync as a whole is safe. +// - It scans comments and string literals too, so a mention of the pattern in +// prose would trip it. That is the safe direction to fail. +func TestEveryDestinationReadGoesThroughTheGate(t *testing.T) { + // The forbidden spellings: a bare read of an op's destination path. + forbidden := []string{ + "os.ReadFile(op.Path)", + "os.ReadFile(it.op.Path)", + } + match := func(src string) string { + for _, pat := range forbidden { + if strings.Contains(src, pat) { + return pat + } + } + return "" + } + + // Negative control, run every time and not only when the tree is dirty: + // a guard that passes whatever the tree looks like is worse than none, + // because it reads as coverage. Both prior guards in this package do the + // same. This one drives the REAL matcher over synthetic sources — if the + // matcher is ever narrowed or a pattern is dropped, this fails here rather + // than silently stopping the repo walk from finding anything. + // The planted sources are LITERALS, deliberately not built from `forbidden`. + // A control assembled out of the same slice it is checking is tautological: + // it passes by construction and cannot notice a pattern being dropped, which + // is the most likely way this guard dies. + for _, planted := range []string{ + "func f() { data, _ := os.ReadFile(op.Path) }", + "func f(it reconcileItem) { data, _ := os.ReadFile(it.op.Path) }", + } { + if got := match(planted); got == "" { + t.Fatalf("negative control: matcher missed a planted bare destination read in %q — "+ + "a pattern has been dropped from `forbidden`, and the repo walk below "+ + "cannot be trusted", planted) + } + } + if got := match("func f() { data, _ := readDestBytes(op.Path) }"); got != "" { + t.Fatalf("negative control: matcher flagged the CORRECT spelling as %q — "+ + "it would fail on a compliant tree", got) + } + + repoRoot := repoRootFromCaller(t) + scanned := 0 + if err := walkRepoGoFiles(repoRoot, func(rel, src string) { + // Scoped to this package deliberately. internal/render and the adapter + // Apply paths also read op.Path, but on the WRITE path, which this + // change does not cover. Do NOT read that as "handled elsewhere": + // render.isRegularOrAbsent's own doc says it is what stops + // `apply --dry-run` hanging on a FIFO. That is true of the orphan-delete + // read it actually guards, and false of Writer.Write's convergence read, + // which never calls it — so `apply --dry-run` still hangs (#241). This + // guard makes no claim about those reads either way. + if !strings.HasPrefix(rel, "internal/cli/") { + return + } + scanned++ + if pat := match(src); pat != "" { + t.Errorf("%s contains %q — destination reads must go through readDestBytes "+ + "(internal/cli/destread.go), which refuses a non-regular path BEFORE the "+ + "open. os.ReadFile on a FIFO does not fail, it BLOCKS, so the read's own "+ + "error path never runs and the command never returns.", rel, pat) + } + }); err != nil { + t.Fatal(err) + } + + // Proof of life. Without this the guard passes vacuously the day the walk + // stops finding files — the failure mode that makes a green guard worthless. + // It already earned its keep once: the first draft used os.ReadDir(".") and + // silently scanned nothing. + if scanned < 10 { + t.Fatalf("scanned only %d production files under internal/cli/; the guard is not "+ + "looking at the code it claims to check", scanned) + } +} diff --git a/internal/cli/destread_unix_internal_test.go b/internal/cli/destread_unix_internal_test.go new file mode 100644 index 00000000..b940dc50 --- /dev/null +++ b/internal/cli/destread_unix_internal_test.go @@ -0,0 +1,408 @@ +//go:build unix + +package cli + +import ( + "errors" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/spxrogers/agentsync/internal/adapter" +) + +// TestReadDestBytesShape pins the gate every destination read now passes +// through. +// +// Every assertion here is bounded by a timeout, and that is the point rather +// than caution: this defect class does not FAIL, it HANGS. os.ReadFile on a +// FIFO blocks in the open waiting for a writer that never comes, so a test +// written as a plain call would wedge the suite with no diagnostic instead of +// reporting in 5s. Nothing in this file ever opens the FIFO it creates — +// mkfifo, chmod and os.Stat do not. +func TestReadDestBytesShape(t *testing.T) { + cases := []struct { + name string + // setup returns the path to read. It must never OPEN that path. + setup func(t *testing.T, tmp string) string + wantErr error // errDestNotRegular, os.ErrNotExist, or nil + wantData string + }{ + { + // The sharp one: present, stats fine, and its open never returns. + name: "a FIFO destination is refused by shape", + setup: mkfifoDest, + wantErr: errDestNotRegular, + }, + { + name: "a directory destination is refused by shape", + setup: func(t *testing.T, tmp string) string { + t.Helper() + p := filepath.Join(tmp, "dest") + if err := os.Mkdir(p, 0o700); err != nil { + t.Fatal(err) + } + return p + }, + wantErr: errDestNotRegular, + }, + { + // The fail-open row, and the reason the gate does not refuse on any + // stat failure: an absent destination is ordinary, and every caller + // already handles its ENOENT. A shape error here would name the + // wrong problem. + name: "an absent destination still reports the truthful ENOENT", + setup: func(t *testing.T, tmp string) string { + return filepath.Join(tmp, "absent") + }, + wantErr: os.ErrNotExist, + }, + { + // The guard against an over-broad arm. + name: "an ordinary regular destination is read", + setup: func(t *testing.T, tmp string) string { + t.Helper() + p := filepath.Join(tmp, "dest") + if err := os.WriteFile(p, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + return p + }, + wantData: "payload", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := tc.setup(t, t.TempDir()) + + type result struct { + data []byte + err error + } + // By channel, not a captured variable, so a read that DID block + // cannot race the assertions under -race. + ch := make(chan result, 1) + go func() { + d, err := readDestBytes(path) + ch <- result{d, err} + }() + var got result + select { + case got = <-ch: + case <-time.After(5 * time.Second): + t.Fatalf("readDestBytes BLOCKED on %s — the destination must be STAT'd for "+ + "shape before it is opened: os.ReadFile on a FIFO waits for a writer "+ + "that never comes, so the read's own error path never runs", path) + } + + if tc.wantErr == nil { + if got.err != nil { + t.Fatalf("readDestBytes(%s) = %v, want success", path, got.err) + } + if string(got.data) != tc.wantData { + t.Errorf("data = %q, want %q", got.data, tc.wantData) + } + return + } + if !errors.Is(got.err, tc.wantErr) { + t.Fatalf("readDestBytes(%s) error = %v, want errors.Is(_, %v)", path, got.err, tc.wantErr) + } + if got.data != nil { + t.Errorf("data = %q on an error, want nil", got.data) + } + }) + } +} + +// TestKeyMergeAndWriteBackReadsAreGuarded covers the two callers whose own read +// is not reachable from an end-to-end CLI run in this package. +// +// readDestFile is the read ALL FOUR drift walks share for key-merge ops, so an +// unguarded one wedged `status` — a command advertised as read-only — on a +// FIFO-shaped ~/.claude.json. writeBackFileItem is the one a user reaches one +// keystroke LATER: with only the classification reads guarded, a FIFO +// destination classifies as drift, the user presses [w], and the hang lands +// there instead. +func TestKeyMergeAndWriteBackReadsAreGuarded(t *testing.T) { + t.Run("readDestFile returns an empty map rather than blocking", func(t *testing.T) { + p := mkfifoDest(t, t.TempDir()) + ch := make(chan map[string]any, 1) + go func() { ch <- readDestFile("merge-json-keys", p) }() + select { + case got := <-ch: + if len(got) != 0 { + t.Errorf("readDestFile = %v, want an empty map", got) + } + case <-time.After(5 * time.Second): + t.Fatalf("readDestFile BLOCKED on a FIFO destination (%s) — this is the read "+ + "every key-merge drift walk shares, including read-only `status`", p) + } + }) + + t.Run("writeBackFileItem returns an error rather than blocking", func(t *testing.T) { + p := mkfifoDest(t, t.TempDir()) + it := reconcileItem{op: adapter.FileOp{Path: p, SourceID: "demo"}} + ch := make(chan error, 1) + go func() { ch <- writeBackFileItem(t.TempDir(), it) }() + select { + case err := <-ch: + if err == nil { + t.Fatal("writeBackFileItem = nil, want an error: a FIFO carries no dest content to write back") + } + // The sentinel is pathless on the rationale that every caller wraps + // it with the path itself. This is the caller that does, and the one + // whose message a user reads mid-prompt, so both halves are pinned + // here — otherwise dropping the wrap leaves the suite green and the + // rationale untested. + if !strings.Contains(err.Error(), p) { + t.Errorf("error = %q, want it to name the destination %q: errDestNotRegular "+ + "carries no path, so this caller must supply it", err, p) + } + if !strings.Contains(err.Error(), "[i]gnore") || !strings.Contains(err.Error(), "re-run") { + t.Errorf("error = %q, want a next step: the user is mid-prompt choosing a "+ + "keystroke, and this function's other refusals all name one", err) + } + // The remedy must not be one that hangs. This function's peers all + // offer [o]verride, and an earlier version of this message copied + // them — but [o] re-applies through render.Writer.Write's unguarded + // convergence read, so on a non-regular destination it wedges + // instead of failing (#241). Suggesting it is worse than saying + // nothing, and this assertion is what stops it coming back by + // symmetry with the neighbours. + if strings.Contains(err.Error(), "[o]verride") { + t.Errorf("error = %q recommends [o]verride, which HANGS on a non-regular "+ + "destination (#241) — offer it again only once that is fixed", err) + } + case <-time.After(5 * time.Second): + t.Fatalf("writeBackFileItem BLOCKED on a FIFO destination (%s) — [w] must refuse, "+ + "not wedge the reconcile session", p) + } + }) +} + +// TestWriteBackFileItemMessageMatchesTheFailure pins that the remedy named in +// writeBackFileItem's refusal depends on WHY the read failed. +// +// The shape sentence ("remove or replace the non-regular file") was once +// appended to every readDestBytes error, so deleting a managed file — the +// common case, which is itself drift and offers [w] — produced +// "no such file or directory — remove or replace the non-regular file at that +// path", advice that describes a situation the user is not in. +func TestWriteBackFileItemMessageMatchesTheFailure(t *testing.T) { + absent := filepath.Join(t.TempDir(), "deleted.md") + err := writeBackFileItem(t.TempDir(), reconcileItem{op: adapter.FileOp{Path: absent, SourceID: "demo"}}) + if err == nil { + t.Fatal("writeBackFileItem = nil for an absent destination, want an error") + } + if strings.Contains(err.Error(), "non-regular") { + t.Errorf("error = %q calls an ABSENT destination non-regular; the remedy must match "+ + "the failure that actually occurred", err) + } + if !strings.Contains(err.Error(), "[i]gnore") { + t.Errorf("error = %q, want it to still name a next step", err) + } + // [o]verride is withheld only for a NON-REGULAR destination, where it would + // hang (#241). For an absent one it is safe — Writer.Write's convergence + // read gets ENOENT and falls through to the write — and it is the actual + // fix, so withholding it here would deny the user the remedy that works. + if !strings.Contains(err.Error(), "[o]verride") { + t.Errorf("error = %q, want it to offer [o]verride: the destination is absent, not "+ + "non-regular, so re-applying canonical is safe and is the fix", err) + } +} + +// mkfifoDest creates a 0600 FIFO at a destination path and returns it. mkfifo +// and chmod do not open the FIFO; nothing in this file ever does. +func mkfifoDest(t *testing.T, tmp string) string { + t.Helper() + p := filepath.Join(tmp, "dest") + if err := syscall.Mkfifo(p, 0o600); err != nil { + t.Skipf("mkfifo unsupported here: %v", err) + } + if err := os.Chmod(p, 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// TestHashFileSentinels pins hashFile's three-way answer, which every drift +// verdict in this package is built on. The values are opaque — callers only +// ever compare them for equality — so a changed sentinel does not fail a build +// or a type check. It silently changes what drift.Classify decides, at four +// call sites. +// +// Two of these rows cover behavior that a mutation sweep found UNPINNED: the +// whole suite stayed green with hashFile returning the shape sentinel for every +// error (absent included), and green again with the symlink arm removed +// entirely. +func TestHashFileSentinels(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, tmp string) string + want string + }{ + { + // The absent contract. "" is what drift.Classify reads as "no + // destination", which is what makes an unrendered-but-recorded file + // an Orphan rather than drift. Returning the shape sentinel here + // instead would silently reclassify every absent destination — and + // the ENOENT branch is taken 43 times in this package's own suite, + // none of which asserted the value. + name: "an absent destination hashes to the empty sentinel", + setup: func(t *testing.T, tmp string) string { return filepath.Join(tmp, "gone") }, + want: "", + }, + { + // The symlink arm, which runs BEFORE the shape gate and is the one + // place status and diff deliberately disagree: this refuses the + // link, while readDestBytes (and so diff) follows it. destread.go's + // doc comment asserts exactly that divergence; this is the test + // behind the claim. + name: "a symlink to a regular file is refused as a symlink, not followed", + setup: func(t *testing.T, tmp string) string { + t.Helper() + target := filepath.Join(tmp, "target") + if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + return link + }, + want: "symlink-not-regular-file", + }, + { + name: "a FIFO is refused by shape", + setup: mkfifoDest, + want: "not-a-regular-file", + }, + { + // The parity row. A destination that cannot be STAT'd at all — + // here because a parent component is a regular file, so the walk + // gets ENOTDIR — answered "not-a-regular-file" before this package + // had a read gate, and must still. Splitting it off to "" would + // read as absent, moving such a destination from ForeignCollision + // to New when nothing was applied — and New is SafeForAutoApply. + name: "an unstattable destination keeps the shape sentinel", + setup: func(t *testing.T, tmp string) string { + t.Helper() + blocker := filepath.Join(tmp, "notadir") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + return filepath.Join(blocker, "dest") + }, + want: "not-a-regular-file", + }, + { + name: "an ordinary regular file hashes its content", + setup: func(t *testing.T, tmp string) string { + t.Helper() + p := filepath.Join(tmp, "dest") + if err := os.WriteFile(p, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + return p + }, + // A literal, not hashContent([]byte("payload")): computing the + // expectation with the function under test is a tautology — salting + // hashContent leaves this row green. This digest is what a state + // file records for that content. + want: "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := tc.setup(t, t.TempDir()) + // Bounded for the same reason as everything else here: a regression + // that reintroduces an unguarded read hangs rather than fails. + got := make(chan string, 1) + go func() { got <- hashFile(path) }() + select { + case h := <-got: + if h != tc.want { + t.Errorf("hashFile = %q, want %q — these sentinels are compared only for "+ + "equality, so changing one silently changes drift.Classify's verdict", + h, tc.want) + } + case <-time.After(5 * time.Second): + t.Fatalf("hashFile BLOCKED on %s", path) + } + }) + } + + // The divergence destread.go documents, asserted in both directions in one + // place so the claim cannot rot: hashFile refuses the link, readDestBytes + // reads through it. + t.Run("readDestBytes follows the symlink hashFile refuses", func(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "target") + if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + data, err := readDestBytes(link) + if err != nil || string(data) != "payload" { + t.Fatalf("readDestBytes(symlink) = (%q, %v), want the target's content: this "+ + "asymmetry with hashFile is what makes status report drift on a symlinked "+ + "destination while diff reads through it, and destread.go says so", data, err) + } + }) +} + +// TestReadDestBytesReportsAStatFailureAsItself pins that a stat failure which +// is not absence surfaces as the real error rather than as errDestNotRegular. +// +// It matters because the sentinel is not private: reconcile's write-back names +// it and tells the user to "remove or replace the non-regular file at that +// path". Answering it for a symlink loop or a permission problem states +// something false about the destination. +// +// ELOOP rather than EACCES because these tests run as root in the container, +// where permission bits are not enforced and an EACCES fixture would not fail. +func TestReadDestBytesReportsAStatFailureAsItself(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a") + b := filepath.Join(dir, "b") + if err := os.Symlink(b, a); err != nil { + t.Fatal(err) + } + if err := os.Symlink(a, b); err != nil { + t.Fatal(err) + } + + _, err := readDestBytes(a) + if err == nil { + t.Fatal("readDestBytes on a symlink loop = nil, want ELOOP") + } + if errors.Is(err, errDestNotRegular) { + t.Errorf("error = %v, want the real stat failure: a symlink loop is not a shape "+ + "problem, and reporting it as one makes reconcile tell the user to remove a "+ + "non-regular file that isn't there", err) + } + if !errors.Is(err, syscall.ELOOP) { + t.Errorf("error = %v, want it to wrap ELOOP", err) + } + if !errors.Is(err, errDestUnstattable) { + t.Errorf("error = %v, want it to wrap errDestUnstattable so hashFile can map it "+ + "to the shape sentinel and keep base parity", err) + } + // Pathless, like its sibling sentinel: the caller supplies the path, and a + // *fs.PathError here makes reconcile print "read dest X: cannot stat + // destination: stat X: ...". Counting occurrences rather than asserting a + // literal keeps this from breaking on a reworded message. + if n := strings.Count(err.Error(), a); n != 0 { + t.Errorf("error = %q names the path %d time(s); it must carry none — the caller "+ + "wraps it with the path and a *fs.PathError would double it", err, n) + } +} diff --git a/internal/cli/diff.go b/internal/cli/diff.go index a66609c5..6e0ab399 100644 --- a/internal/cli/diff.go +++ b/internal/cli/diff.go @@ -175,7 +175,7 @@ func newDiffCmd() *cobra.Command { } seen[op.Path] = true srcStr := secrets.MaskResolved(string(op.Content), redact) - dstBytes, readErr := os.ReadFile(op.Path) + dstBytes, readErr := readDestBytes(op.Path) dstStr := "" if readErr == nil { dstStr = secrets.MaskResolved(string(dstBytes), redact) diff --git a/internal/cli/import.go b/internal/cli/import.go index 8f3db00e..7220a97d 100644 --- a/internal/cli/import.go +++ b/internal/cli/import.go @@ -75,7 +75,7 @@ func decodeDestBytes(strategy string, data []byte, v *map[string]any) error { // unreadable dest classifies as "absent" rather than crashing. Replaces the // JSON-only readJSONFile so a TOML config.toml decodes correctly. func readDestFile(strategy, path string) map[string]any { - data, err := os.ReadFile(path) + data, err := readDestBytes(path) if err != nil { return map[string]any{} } @@ -599,7 +599,7 @@ func unimportedDestPointers(agentsyncHome, srcHome, agentName string, reg *adapt if !render.IsKeyMerge(op.MergeStrategy) { continue } - data, readErr := os.ReadFile(op.Path) + data, readErr := readDestBytes(op.Path) if readErr != nil { continue } @@ -714,7 +714,7 @@ func seedStateFromCurrentDest(agentsyncHome, srcHome, agentName string, reg *ada // Per-key seed: hash the *current* value at each pointer the // rendered op claims to own. The dest is decoded per strategy (TOML // for merge-toml-keys); op.Content is always JSON. - data, readErr := os.ReadFile(op.Path) + data, readErr := readDestBytes(op.Path) if readErr != nil { continue // dest doesn't exist yet; nothing to seed } @@ -742,7 +742,7 @@ func seedStateFromCurrentDest(agentsyncHome, srcHome, agentName string, reg *ada } } default: - data, readErr := os.ReadFile(op.Path) + data, readErr := readDestBytes(op.Path) if readErr != nil { continue } diff --git a/internal/cli/reconcile.go b/internal/cli/reconcile.go index 9828336d..419d480a 100644 --- a/internal/cli/reconcile.go +++ b/internal/cli/reconcile.go @@ -655,7 +655,7 @@ func collectItems(plan render.RenderPlan, reg *adapter.Registry, s *state.Target happlied := s.Files[stateFileKey(userHome, name, sc, projectRoot, op.Path)].SHA256 hdest := hashFile(op.Path) cls := drift.Classify(hsrc, happlied, hdest) - dstBytes, _ := os.ReadFile(op.Path) + dstBytes, _ := readDestBytes(op.Path) items = append(items, reconcileItem{ agentName: name, op: op, @@ -1151,9 +1151,32 @@ func writeBackKeyItem(cmd *cobra.Command, home string, it reconcileItem) error { // // Both used to return nil with a success message, hiding data loss. func writeBackFileItem(home string, it reconcileItem) error { - data, err := os.ReadFile(it.op.Path) + data, err := readDestBytes(it.op.Path) if err != nil { - return fmt.Errorf("read dest %s: %w", it.op.Path, err) + // Named next steps, like this function's other refusals: the user is + // mid-prompt with a keystroke to choose, and "read dest X: not a regular + // file" alone does not tell them which one gets them unstuck. + // + // [o]verride is deliberately NOT offered for THIS arm, unlike the peer + // refusals in this file and unlike the arm below. It re-applies + // through render.Writer.Write, whose convergence read is not + // shape-guarded, so on this exact item it does not fail — it HANGS + // (measured: `reconcile --auto-override` rc=124). + // An earlier version of this message recommended it, which walked the + // user out of a clean refusal and into an unbounded wedge. Restore that + // suggestion only once #241 is fixed. + if errors.Is(err, errDestNotRegular) { + return fmt.Errorf("read dest %s: %w — remove or replace the non-regular file at "+ + "that path and re-run, or [i]gnore to suppress this item", it.op.Path, err) + } + // Any other read failure keeps the peers' remedy set. The common one is + // an ABSENT destination — the user deleted a managed file, which is + // itself drift — and there [o]verride is both safe and usually the fix: + // Writer.Write's convergence read gets ENOENT and falls straight + // through to the write. Withholding it is only correct for the + // non-regular case above. + return fmt.Errorf("read dest %s: %w — use [o]verride to restore it from canonical, "+ + "or [i]gnore to suppress this item", it.op.Path, err) } srcID := it.op.SourceID if srcID == "" { diff --git a/internal/cli/status.go b/internal/cli/status.go index df7e61e1..662ce8a3 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -962,8 +963,10 @@ func hashContent(b []byte) string { } // hashFile returns the SHA-256 hex digest of the file at path. Returns -// the empty string on missing-file errors (which `drift.Classify` reads -// as "absent" — the expected signal for Orphan / OrphanDrifted). +// the empty string when the destination cannot be read as content at all — +// absent, or present-and-unreadable — which `drift.Classify` reads as "absent", +// the expected signal for Orphan / OrphanDrifted. A destination whose SHAPE is +// wrong, or which cannot be stat'd, answers the opaque marker below instead. // // If the path is a symlink, hashFile returns a special marker so the // drift classifier can flag the file as drifted in a way the user can @@ -985,13 +988,22 @@ func hashFile(path string) string { // computing, so answer a sentinel that can never match one. It is a DIFFERENT // sentinel from the symlink case above so a diagnostic never calls a FIFO a // symlink; both are opaque to callers, which only ever compare hashes for - // equality. Shares render's predicate so the destination-read guards cannot - // disagree about what is safe to read. - if !render.IsRegularOrAbsent(path) { - return "not-a-regular-file" - } - data, err := os.ReadFile(path) + // equality. + // + // The shape rule itself lives in readDestBytes, the one gate every + // destination read in this package passes through; this function maps its + // refusal onto the sentinel above rather than re-deciding it. + data, err := readDestBytes(path) if err != nil { + // Both refusals map to the SAME opaque token, deliberately. These + // sentinels are never shown; they exist only to never equal a content + // hash. Before this gate existed the predicate answered false for an + // unstattable destination too, so splitting them here would move a + // parent-ENOTDIR dest from ForeignCollision to New — and New is + // SafeForAutoApply. A plain read failure still answers "", as it did. + if errors.Is(err, errDestNotRegular) || errors.Is(err, errDestUnstattable) { + return "not-a-regular-file" + } return "" } return hashContent(data) diff --git a/internal/render/writer.go b/internal/render/writer.go index a87db052..15d6efa3 100644 --- a/internal/render/writer.go +++ b/internal/render/writer.go @@ -366,7 +366,8 @@ func OrphanDeleteWillProceed(op adapter.FileOp) bool { } // IsRegularOrAbsent is the exported view of isRegularOrAbsent, for the sibling -// destination reads outside this package (internal/cli's hashFile). They face +// destination reads outside this package (internal/cli's readDestBytes, the one +// gate every destination read in that package passes through). They face // the identical hazard and must not answer it differently. func IsRegularOrAbsent(path string) bool { return isRegularOrAbsent(path) } @@ -376,9 +377,11 @@ func IsRegularOrAbsent(path string) bool { return isRegularOrAbsent(path) } // // It uses Stat, which FOLLOWS symlinks, so a symlink pointing at a FIFO is // caught as well as a bare one. That matters because os.Open on a FIFO with no -// writer BLOCKS rather than failing: without this, `apply --dry-run` (advertised -// as read-only) and the real apply's pre-delete read would both hang forever on -// a FIFO left at a destination path. A dangling symlink reports absent, which is +// writer BLOCKS rather than failing: without this, the pre-delete read below +// would hang forever on a FIFO left at a destination path, in the real apply and +// in `apply --dry-run` alike. It does NOT cover Writer.Write's convergence read, +// which never calls it — so `apply --dry-run` still hangs on a FIFO at a +// RENDERED destination (#241). A dangling symlink reports absent, which is // the right answer — the link is removable and carries nothing to preserve. func isRegularOrAbsent(path string) bool { fi, err := os.Stat(path)