Recover stale floor marker scratch after process death
3 files changed,
+77
−6
internal/provisioner/floor_linux.go+22 −5internal/provisioner/floor_linux_test.go+41 −0internal/provisioner/journal_linux_test.go+14 −1
CI passed — run details
diff --git a/internal/provisioner/floor_linux.go b/internal/provisioner/floor_linux.go| index bcf9bb9..2661e0c 100644 |
| --- a/internal/provisioner/floor_linux.go |
| +++ b/internal/provisioner/floor_linux.go |
| @@ -41,9 +41,10 @@ type floorEnvelope struct { |
| Signature string `json:"signature"` |
| } |
| type nativeFloor struct { |
| - dir *privateDirectory |
| - identity floorIdentity |
| - public []byte |
| + dir *privateDirectory |
| + identity floorIdentity |
| + public []byte |
| + markerBoundary func() error |
| } |
| func tokenBytes(s string, n int) ([]byte, bool) { |
| @@ -81,7 +82,7 @@ func newNativeFloor(dir *privateDirectory, id floorIdentity) (*nativeFloor, erro |
| if dir == nil || !a || !b || !c || x == nil || y == nil { |
| return nil, ErrRefused |
| } |
| - return &nativeFloor{dir, id, pub}, nil |
| + return &nativeFloor{dir: dir, identity: id, public: pub}, nil |
| } |
| func (f *nativeFloor) signed(k *family.Keypair, p floorPoint, previous string) ([]byte, error) { |
| if k == nil || k.SignPriv == nil || !bytes.Equal(k.SignPub(), f.public) { |
| @@ -210,9 +211,25 @@ func (f *nativeFloor) updateMarker(point floorPoint) error { |
| } |
| if e = f.dir.create("head.tmp", raw); e != nil { |
| existing, readErr := f.dir.read("head.tmp", 8192) |
| - if readErr != nil || !bytes.Equal(existing, raw) { |
| + if readErr != nil { |
| return ErrRefused |
| } |
| + if !bytes.Equal(existing, raw) { |
| + // Only the signed immutable chain carries authority. This private, |
| + // regular scratch file can survive a marker rename crash; unlinking |
| + // its directory entry never opens or truncates a replacement target. |
| + if unix.Unlinkat(int(f.dir.file.Fd()), "head.tmp", 0) != nil || f.dir.file.Sync() != nil { |
| + return ErrUnavailable |
| + } |
| + if e = f.dir.create("head.tmp", raw); e != nil { |
| + return e |
| + } |
| + } |
| + } |
| + if f.markerBoundary != nil { |
| + if e := f.markerBoundary(); e != nil { |
| + return e |
| + } |
| } |
| if unix.Renameat(int(f.dir.file.Fd()), "head.tmp", int(f.dir.file.Fd()), "head") != nil || f.dir.file.Sync() != nil { |
| return ErrUnavailable |
diff --git a/internal/provisioner/floor_linux_test.go b/internal/provisioner/floor_linux_test.go| index c0c61e9..d54b5dc 100644 |
| --- a/internal/provisioner/floor_linux_test.go |
| +++ b/internal/provisioner/floor_linux_test.go |
| @@ -170,3 +170,44 @@ func TestCanonicalMetadataPreservesTypedIntegerPrecision(t *testing.T) { |
| t.Fatal("typed integer did not round trip") |
| } |
| } |
| + |
| +func TestFloorMarkerRecoveryRefusesUnsafeScratch(t *testing.T) { |
| + for _, kind := range []string{"symlink", "hardlink", "public", "directory"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f, k := floorFixture(t) |
| + if e := f.initialize(k); e != nil { |
| + t.Fatal(e) |
| + } |
| + path := filepath.Join(f.dir.file.Name(), "head.tmp") |
| + target := filepath.Join(persistentDirectory(t), "target") |
| + original := []byte("must remain unchanged") |
| + if e := os.WriteFile(target, original, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + var e error |
| + switch kind { |
| + case "symlink": |
| + e = os.Symlink(target, path) |
| + case "hardlink": |
| + e = os.Link(target, path) |
| + case "public": |
| + e = os.WriteFile(path, original, 0644) |
| + case "directory": |
| + e = os.Mkdir(path, 0700) |
| + } |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = f.advance(k, floorPoint{}, floorPoint{1, testToken(3)}); e != ErrRefused { |
| + t.Fatal("unsafe scratch accepted", e) |
| + } |
| + got, e := os.ReadFile(target) |
| + if e != nil || string(got) != string(original) { |
| + t.Fatal("target changed", e) |
| + } |
| + if _, e := os.Lstat(path); e != nil { |
| + t.Fatal("unsafe scratch removed", e) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/provisioner/journal_linux_test.go b/internal/provisioner/journal_linux_test.go| index b5010ad..53252c9 100644 |
| --- a/internal/provisioner/journal_linux_test.go |
| +++ b/internal/provisioner/journal_linux_test.go |
| @@ -145,6 +145,12 @@ func TestJournalProcessDeathRecovery(t *testing.T) { |
| t.Fatal(e) |
| } |
| defer j.close() |
| + floor.markerBoundary = func() error { |
| + if in.Boundary == "marker-created" { |
| + os.Exit(72) |
| + } |
| + return nil |
| + } |
| j.boundary = func(stage string) error { |
| if stage == in.Boundary { |
| os.Exit(72) |
| @@ -156,7 +162,7 @@ func TestJournalProcessDeathRecovery(t *testing.T) { |
| } |
| t.Fatal("death boundary not reached") |
| } |
| - for _, stage := range []string{"prepared", "floor", "finalized"} { |
| + for _, stage := range []string{"prepared", "marker-created", "floor", "finalized"} { |
| t.Run(stage, func(t *testing.T) { |
| j := journalFixture(t) |
| j.close() |
| @@ -188,6 +194,13 @@ func TestJournalProcessDeathRecovery(t *testing.T) { |
| if e != nil || point.Sequence != 1 { |
| t.Fatal("sequence reused", e) |
| } |
| + if _, e = reopened.append(context.Background(), testToken(5), testToken(7), "directory.seed-team", "empty", []byte("next operation")); e != nil { |
| + t.Fatal("next operation did not self-heal marker", e) |
| + } |
| + point, _, e = j.floor.current() |
| + if e != nil || point.Sequence != 2 { |
| + t.Fatal("next sequence", e) |
| + } |
| }) |
| } |
| } |