oficina / home Public

Recover stale floor marker scratch after process death

Paul Campbell pushed by paul@keymail.dev f20f83e627cae1572fdf1c8b03b089617f7f0d9a parent 7e282e2
3 files changed, +77 −6
  • internal/provisioner/floor_linux.go +22 −5
  • internal/provisioner/floor_linux_test.go +41 −0
  • internal/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)
+ }
})
}
}