zdd-4 third fix wave: pins and the restage check compare fully resolved real paths (N4, R19 item 5)
BoxPins also pins filepath.EvalSymlinks of every live chain's end (not existing ends the chain as before; any other error fails closed), and ensureStagedChecked asks StagedPaths.Refers, which compares the candidate's real path as well as its name. A symlinked opt dir with live/h -> ../current -> versions/v1/app (two relative hops) no longer lets a re-shipped v1 be restaged over the running copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+107
−5
internal/release/adopt.go+7 −1internal/release/pins.go+9 −3internal/release/pins_test.go+51 −0internal/release/stage_reuse_test.go+40 −1
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 800b76e85..53daf3fce 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -1799,7 +1799,13 @@ func ensureStagedChecked(ctx context.Context, s Store, optDir, acct, app, ver, h |
| if perr != nil { |
| return nil, fmt.Errorf("%s/%s: the staged copy differs from the release and what refers to it is unknown (%v), so it was not restaged", app, ver, perr) |
| } |
| - if pins.Has(path) { |
| + // By the copy's real path as well as its name, so a symlinked opt dir |
| + // cannot make a pinned copy look unpinned (Astra r3 N4). |
| + referred, rerr := pins.Refers(path) |
| + if rerr != nil { |
| + return nil, fmt.Errorf("%s/%s: the staged copy differs from the release and what refers to it is unknown (%v), so it was not restaged", app, ver, rerr) |
| + } |
| + if referred { |
| return nil, fmt.Errorf("%s/%s: %w", app, ver, ErrVersionReused) |
| } |
| fmt.Printf(" %s: staged %s differs from the release in the store and nothing on this box refers to it; restaging\n", host, ver) |
diff --git a/internal/release/pins.go b/internal/release/pins.go| index bc86e2e52..ebbb3472a 100644 |
| --- a/internal/release/pins.go |
| +++ b/internal/release/pins.go |
| @@ -83,9 +83,9 @@ func (f PinSourceFunc) StagedPins(ctx context.Context, optDir string) (StagedPat |
| // swap's <host>.tmp included, as LivePins includes it), so a live link that |
| // reaches a staged copy through another link still pins that copy (a |
| // relative hop pinned at the real path the OS reaches, and at its lexical |
| -// join too when the two differ), and the |
| -// staged path of every row's Version and |
| -// PrevVersion. routes is every registry row, unfiltered: a hibernating route, |
| +// join too when the two differ), plus the fully resolved real path of the |
| +// chain's end when it exists (StagedPaths.addReal), and the staged path of |
| +// every row's Version and PrevVersion. routes is every registry row, unfiltered: a hibernating route, |
| // an expired canary and a mid-provision row all still refer to their builds. |
| // |
| // An entry that cannot be read is an error, never a skipped entry: a pin set |
| @@ -108,6 +108,12 @@ func BoxPins(optDir string, routes []registry.Route) (StagedPaths, error) { |
| for _, h := range aliases { |
| pins.add(h) |
| } |
| + // And the fully resolved real path of where the chain ends (Astra r3 |
| + // N4, ruling R19 item 5): a hop written through a symlinked directory |
| + // names the copy under another spelling than VersionPath's. |
| + if err := pins.addReal(hops[len(hops)-1]); err != nil { |
| + return nil, fmt.Errorf("live link %s: %w", filepath.Join(dir, e.Name()), err) |
| + } |
| } |
| for _, rt := range routes { |
| if rt.App == "" { |
diff --git a/internal/release/pins_test.go b/internal/release/pins_test.go| index 7fd86386d..1a1ef766b 100644 |
| --- a/internal/release/pins_test.go |
| +++ b/internal/release/pins_test.go |
| @@ -289,3 +289,54 @@ func TestBoxPinsRelativeLinkUnderASymlinkedOptDir(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// Ruling R19 item 5: an absolute final hop reached through a symlinked |
| +// directory is pinned at the fully resolved real path too, the copy's |
| +// VersionPath here. |
| +func TestBoxPinsPinsTheRealPathOfTheChainsEnd(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + staged := VersionPath(opt, "bab", "v1", "km") |
| + alias := filepath.Join(t.TempDir(), "alias") |
| + if err := os.Symlink(filepath.Dir(staged), alias); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(filepath.Join(alias, "km"), LiveLink(opt, "h.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + pins, err := BoxPins(opt, nil) |
| + if err != nil { |
| + t.Fatalf("BoxPins: %v", err) |
| + } |
| + for _, p := range []string{filepath.Join(alias, "km"), staged} { |
| + if !pins.Has(p) { |
| + t.Errorf("BoxPins = %v, missing %s", pins, p) |
| + } |
| + } |
| +} |
| + |
| +// Refers compares a candidate's real path; a candidate that does not exist |
| +// is compared as written. |
| +func TestStagedPathsRefersComparesTheRealPath(t *testing.T) { |
| + real := t.TempDir() |
| + opt := filepath.Join(t.TempDir(), "opt") |
| + if err := os.Symlink(real, opt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + pins := StagedPaths{} |
| + pins.add(VersionPath(real, "bab", "v1", "km")) |
| + if ok, err := pins.Refers(VersionPath(opt, "bab", "v1", "km")); err != nil || !ok { |
| + t.Fatalf("Refers(the copy under the symlinked opt) = %v, %v; want true", ok, err) |
| + } |
| + if ok, err := pins.Refers(VersionPath(opt, "bab", "v9", "km")); err != nil || ok { |
| + t.Fatalf("Refers(a missing copy) = %v, %v; want false, nil", ok, err) |
| + } |
| +} |
diff --git a/internal/release/stage_reuse_test.go b/internal/release/stage_reuse_test.go| index 7a370bf1a..3d45f7fa2 100644 |
| --- a/internal/release/stage_reuse_test.go |
| +++ b/internal/release/stage_reuse_test.go |
| @@ -27,9 +27,15 @@ type reuseFixture struct { |
| } |
| func newReuseFixture(t *testing.T) reuseFixture { |
| + t.Helper() |
| + return newReuseFixtureAt(t, t.TempDir()) |
| +} |
| + |
| +// newReuseFixtureAt is newReuseFixture with the opt dir given. |
| +func newReuseFixtureAt(t *testing.T, opt string) reuseFixture { |
| t.Helper() |
| ctx := context.Background() |
| - f := reuseFixture{s: NewDirStore(t.TempDir()), opt: t.TempDir(), reg: testRegistry(t)} |
| + f := reuseFixture{s: NewDirStore(t.TempDir()), opt: opt, reg: testRegistry(t)} |
| f.priv, f.pub = testKeys(t) |
| shipRelease(t, f.s, "bab", "myapp", "v1", adoptPlatform, []byte("v1")) |
| if err := Promote(ctx, f.s, f.priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| @@ -308,3 +314,36 @@ func TestStageHeldNeverRestagesADifferingCopy(t *testing.T) { |
| t.Fatalf("staged v1 = %q, %v; want it untouched", b, rerr) |
| } |
| } |
| + |
| +// Astra r3 N4, ruling R19 item 5: the opt dir is itself a symlink |
| +// (/opt/carlos -> /srv/carlos), and live/h2 reaches the stale v2 through two |
| +// relative hops: live/h2 -> ../current, current -> versions/v2/bab/myapp. |
| +// The hops resolve under the real dir, so no pin is spelt the way VersionPath |
| +// spells the copy under the opt dir's own name. The restage's membership |
| +// check compares the copy's real path too, and refuses. |
| +func TestStaleCopyTwoRelativeHopsUnderASymlinkedOptDirNameIsNotOverwritten(t *testing.T) { |
| + real := t.TempDir() |
| + opt := filepath.Join(t.TempDir(), "opt") |
| + if err := os.Symlink(real, opt); err != nil { |
| + t.Fatal(err) |
| + } |
| + f := newReuseFixtureAt(t, opt) |
| + rel, err := filepath.Rel(opt, VersionPath(opt, "bab", "v2", "myapp")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(rel, filepath.Join(opt, "current")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(filepath.Join("..", "current"), LiveLink(opt, "h2.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + pins, err := BoxPins(opt, nil) |
| + if err != nil { |
| + t.Fatalf("BoxPins: %v", err) |
| + } |
| + if pins.Has(VersionPath(opt, "bab", "v2", "myapp")) { |
| + t.Fatalf("setup: BoxPins = %v already names the copy lexically; the test would not exercise the real-path check", pins) |
| + } |
| + f.assertRefused(t, f.pass(t), ErrVersionReused.Error()) |
| +} |