hostfiles: immutable objects and one manifest decide which file content is current
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4 files changed,
+1067
−0
internal/hostfiles/hostfiles_test.go+489 −0internal/hostfiles/manifest.go+91 −0internal/hostfiles/publisher.go+192 −0internal/hostfiles/restore.go+295 −0
diff --git a/internal/hostfiles/hostfiles_test.go b/internal/hostfiles/hostfiles_test.go| new file mode 100644 |
| index 000000000..ca603ac23 |
| --- /dev/null |
| +++ b/internal/hostfiles/hostfiles_test.go |
| @@ -0,0 +1,489 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const pfx = "instances/shop.test/abc/files/g1/" |
| + |
| +func open(t *testing.T, s release.Store) *Publisher { |
| + t.Helper() |
| + p, err := Open(context.Background(), s, pfx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +func stage(t *testing.T, p *Publisher, path, body string) { |
| + t.Helper() |
| + if err := p.Stage(context.Background(), path, []byte(body), 0o600, false); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func commit(t *testing.T, p *Publisher) int64 { |
| + t.Helper() |
| + seq, err := p.Commit(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return seq |
| +} |
| + |
| +func read(t *testing.T, dir, rel string) string { |
| + t.Helper() |
| + b, err := os.ReadFile(filepath.Join(dir, rel)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +func TestPublishThenRestoreRoundTrips(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "instance.key", "k1") |
| + stage(t, p, "uploads/a/b.txt", "hello") |
| + if seq := commit(t, p); seq != 2 { |
| + t.Fatalf("seq = %d, want 2", seq) |
| + } |
| + dir := t.TempDir() |
| + m, st, err := Restore(ctx, s, pfx, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Seq != 2 || st.Written != 2 { |
| + t.Fatalf("manifest seq %d, written %d", m.Seq, st.Written) |
| + } |
| + if read(t, dir, "instance.key") != "k1" || read(t, dir, "uploads/a/b.txt") != "hello" { |
| + t.Fatal("content differs") |
| + } |
| + fi, _ := os.Stat(filepath.Join(dir, "instance.key")) |
| + if fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("mode = %v, want 0600", fi.Mode().Perm()) |
| + } |
| +} |
| + |
| +func TestALaterWriteWinsAndAnUnchangedOneTakesNoSequence(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "instance.key", "old") |
| + commit(t, p) |
| + stage(t, p, "instance.key", "new") |
| + stage(t, p, "instance.key", "new") // unchanged: no new sequence number |
| + if seq := commit(t, p); seq != 2 { |
| + t.Fatalf("seq = %d, want 2", seq) |
| + } |
| + dir := t.TempDir() |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := read(t, dir, "instance.key"); got != "new" { |
| + t.Fatalf("restored %q, want new", got) |
| + } |
| +} |
| + |
| +// A1-4: a stalled upload of the OLD content that lands after the new |
| +// content was published must not come back. |
| +func TestALateUploadCannotResurrectOldContent(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "instance.key", "new") |
| + commit(t, p) |
| + // The late arrival: any object at all under objects/, with a higher number. |
| + if _, err := s.Put(ctx, pfx+"objects/0000000000000099-deadbeef", []byte("old"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := t.TempDir() |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := read(t, dir, "instance.key"); got != "new" { |
| + t.Fatalf("restored %q, want new", got) |
| + } |
| +} |
| + |
| +func TestADeleteIsATombstoneAndRestoreRemovesTheFile(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "a.txt", "a") |
| + stage(t, p, "b.txt", "b") |
| + commit(t, p) |
| + dir := t.TempDir() |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.StageDelete("a.txt"); err != nil { |
| + t.Fatal(err) |
| + } |
| + commit(t, p) |
| + _, st, err := Restore(ctx, s, pfx, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "a.txt")); !os.IsNotExist(err) { |
| + t.Fatalf("a.txt still there: %v", err) |
| + } |
| + if st.Removed != 1 || read(t, dir, "b.txt") != "b" { |
| + t.Fatalf("removed %d", st.Removed) |
| + } |
| + if got := p.Paths(); len(got) != 1 || got[0] != "b.txt" { |
| + t.Fatalf("Paths = %v", got) |
| + } |
| +} |
| + |
| +func TestASecondWriterIsSupersededNotMerged(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + a, b := open(t, s), open(t, s) |
| + stage(t, a, "x", "from a") |
| + commit(t, a) |
| + stage(t, b, "x", "from b") |
| + if _, err := b.Commit(context.Background()); !errors.Is(err, ErrSuperseded) { |
| + t.Fatalf("err = %v, want ErrSuperseded", err) |
| + } |
| + dir := t.TempDir() |
| + if _, _, err := Restore(context.Background(), s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := read(t, dir, "x"); got != "from a" { |
| + t.Fatalf("restored %q, want from a", got) |
| + } |
| +} |
| + |
| +func TestAReopenedPublisherContinuesTheSequence(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "x", "1") |
| + commit(t, p) |
| + q := open(t, s) |
| + stage(t, q, "y", "2") |
| + if seq := commit(t, q); seq != 2 { |
| + t.Fatalf("seq = %d, want 2", seq) |
| + } |
| +} |
| + |
| +// Review Focus 1. |
| +func TestCleanPathRefusesEscapes(t *testing.T) { |
| + for _, bad := range []string{"", "/etc/passwd", "../x", "a/../../x", "a/../b", "./a", "a//b", "a/", ".", "..", "a\x00b"} { |
| + if _, err := CleanPath(bad); err == nil { |
| + t.Errorf("CleanPath(%q) accepted", bad) |
| + } |
| + } |
| + for _, ok := range []string{"a", "a/b.txt", ".hidden", "a b/c", `back\slash`} { |
| + if got, err := CleanPath(ok); err != nil || got != ok { |
| + t.Errorf("CleanPath(%q) = %q, %v", ok, got, err) |
| + } |
| + } |
| +} |
| + |
| +func TestStageAndRestoreRefuseAnEscapingPath(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + if err := p.Stage(ctx, "../evil", []byte("x"), 0o600, false); err == nil { |
| + t.Fatal("Stage accepted ../evil") |
| + } |
| + // A manifest written by something else with a hostile path. |
| + body := `{"v":1,"seq":1,"entries":{"../evil":{"object":"objects/0000000000000001-aa","sha256":"aa","seq":1}},"platform":{}}` |
| + hostile := "instances/h/i/files/g/" |
| + if _, err := s.Put(ctx, ManifestKey(hostile), []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + parent := t.TempDir() |
| + dir := filepath.Join(parent, "files") |
| + if err := os.Mkdir(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := Restore(ctx, s, hostile, dir); err == nil { |
| + t.Fatal("Restore accepted a manifest with ../evil") |
| + } |
| + if _, err := os.Stat(filepath.Join(parent, "evil")); !os.IsNotExist(err) { |
| + t.Fatal("../evil was written") |
| + } |
| +} |
| + |
| +func TestRestoreRefusesContentThatDoesNotMatchItsHash(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "x", "good") |
| + commit(t, p) |
| + m, _, err := Load(ctx, s, pfx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := pfx + m.Entries["x"].Object |
| + _, etag, _ := s.Get(ctx, key) |
| + if _, err := s.Put(ctx, key, []byte("tampered"), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := Restore(ctx, s, pfx, t.TempDir()); err == nil || !strings.Contains(err.Error(), "sha256") { |
| + t.Fatalf("err = %v, want a sha256 mismatch", err) |
| + } |
| +} |
| + |
| +func TestAnAbsentManifestRestoresNothing(t *testing.T) { |
| + m, st, err := Restore(context.Background(), release.NewDirStore(t.TempDir()), pfx, t.TempDir()) |
| + if err != nil || m.Seq != 0 || st.Written != 0 { |
| + t.Fatalf("m=%+v st=%+v err=%v", m, st, err) |
| + } |
| +} |
| + |
| +func TestPlatformItemsAreNotWrittenIntoTheFilesDirectory(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + if err := p.StagePlatform(ctx, "wake.json", []byte(`{"v":1}`)); err != nil { |
| + t.Fatal(err) |
| + } |
| + commit(t, p) |
| + dir := t.TempDir() |
| + m, _, err := Restore(ctx, s, pfx, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if entries, _ := os.ReadDir(dir); len(entries) != 0 { |
| + t.Fatalf("files directory has %d entries, want 0", len(entries)) |
| + } |
| + got, err := ReadPlatform(ctx, s, pfx, m, "wake.json") |
| + if err != nil || string(got) != `{"v":1}` { |
| + t.Fatalf("ReadPlatform = %q, %v", got, err) |
| + } |
| + if _, err := ReadPlatform(ctx, s, pfx, m, "absent"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("absent item: err = %v, want os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +// Review Focus 3: a skipped file stays in the manifest, visibly. |
| +func TestASkippedFileIsRecordedAndReportedOnRestore(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + if err := p.StageSkipped("huge.bin", "too-large"); err != nil { |
| + t.Fatal(err) |
| + } |
| + commit(t, p) |
| + _, st, err := Restore(ctx, s, pfx, t.TempDir()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(st.Skipped) != 1 || st.Skipped[0] != "huge.bin" { |
| + t.Fatalf("Skipped = %v", st.Skipped) |
| + } |
| +} |
| + |
| +// A destination that already holds a symlink pointing elsewhere must not |
| +// let a write or a delete reach through it. |
| +func TestRestoreNeverWritesOrDeletesThroughASymlink(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "sub/x", "ours") |
| + stage(t, p, "gone/y", "temp") |
| + commit(t, p) |
| + if err := p.StageDelete("gone/y"); err != nil { |
| + t.Fatal(err) |
| + } |
| + commit(t, p) |
| + |
| + outside, dir := t.TempDir(), t.TempDir() |
| + if err := os.WriteFile(filepath.Join(outside, "y"), []byte("not ours"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, link := range []string{"sub", "gone"} { |
| + if err := os.Symlink(outside, filepath.Join(dir, link)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(outside, "x")); !os.IsNotExist(err) { |
| + t.Fatal("a file was written through the symlink") |
| + } |
| + if got := read(t, outside, "y"); got != "not ours" { |
| + t.Fatalf("a file outside was changed to %q", got) |
| + } |
| + if fi, err := os.Lstat(filepath.Join(dir, "sub")); err != nil || !fi.IsDir() { |
| + t.Fatalf("sub is not a real directory: %v %v", fi, err) |
| + } |
| + if got := read(t, dir, "sub/x"); got != "ours" { |
| + t.Fatalf("sub/x = %q", got) |
| + } |
| +} |
| + |
| +func TestRestoreHandlesADirectoryBecomingAFileAndBack(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "a/b", "child") |
| + commit(t, p) |
| + prior := t.TempDir() // a copy restored while "a" was a directory |
| + if _, _, err := Restore(ctx, s, pfx, prior); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // "a" becomes a file. |
| + if err := p.StageDelete("a/b"); err != nil { |
| + t.Fatal(err) |
| + } |
| + stage(t, p, "a", "now a file") |
| + commit(t, p) |
| + for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} { |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if got := read(t, dir, "a"); got != "now a file" { |
| + t.Fatalf("%s: a = %q", name, got) |
| + } |
| + } |
| + |
| + // And back: "a" becomes a directory again. |
| + if err := p.StageDelete("a"); err != nil { |
| + t.Fatal(err) |
| + } |
| + stage(t, p, "a/c", "child again") |
| + commit(t, p) |
| + for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} { |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if got := read(t, dir, "a/c"); got != "child again" { |
| + t.Fatalf("%s: a/c = %q", name, got) |
| + } |
| + } |
| +} |
| + |
| +func TestRestoreIntoAMissingDirectoryIsAnError(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "x", "x") |
| + commit(t, p) |
| + if _, _, err := Restore(ctx, s, pfx, filepath.Join(t.TempDir(), "absent")); err == nil { |
| + t.Fatal("restored into a directory that does not exist") |
| + } |
| +} |
| + |
| +// Plan round 2, finding 3: a restore that returns nil has made its names |
| +// durable, and one that could not must say so. |
| +func TestRestoreSyncsEveryDirectoryItChangedBeforeReturning(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "top.txt", "t") |
| + stage(t, p, "a/b/deep.txt", "d") |
| + stage(t, p, "gone/old.txt", "o") |
| + commit(t, p) |
| + dir := t.TempDir() |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.StageDelete("gone/old.txt"); err != nil { |
| + t.Fatal(err) |
| + } |
| + commit(t, p) |
| + |
| + var synced []string |
| + orig := syncDir |
| + t.Cleanup(func() { syncDir = orig }) |
| + syncDir = func(root *os.Root, rel string) error { |
| + // Everything must already be in place when the first sync runs. |
| + if len(synced) == 0 { |
| + if _, err := os.Stat(filepath.Join(dir, "gone", "old.txt")); !os.IsNotExist(err) { |
| + t.Error("a directory was synced before the tombstone was applied") |
| + } |
| + } |
| + synced = append(synced, rel) |
| + return orig(root, rel) |
| + } |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{"a/b", "a", "gone", "."} |
| + if !reflect.DeepEqual(synced, want) { |
| + t.Fatalf("synced %v, want %v (deepest first, the root last)", synced, want) |
| + } |
| + |
| + syncDir = func(*os.Root, string) error { return errors.New("disk says no") } |
| + if _, _, err := Restore(ctx, s, pfx, dir); err == nil || !strings.Contains(err.Error(), "disk says no") { |
| + t.Fatalf("err = %v; a failed directory sync must fail the restore", err) |
| + } |
| +} |
| + |
| +// Plan round 3: a restore that failed at its directory sync has already |
| +// removed the file. The retry finds nothing to remove, and must still |
| +// make that removal durable before it reports success. |
| +func TestARetriedRestoreStillSyncsADeletionAnEarlierAttemptMade(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "gone/old.txt", "o") |
| + commit(t, p) |
| + dir := t.TempDir() |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.StageDelete("gone/old.txt"); err != nil { |
| + t.Fatal(err) |
| + } |
| + commit(t, p) |
| + |
| + orig := syncDir |
| + t.Cleanup(func() { syncDir = orig }) |
| + syncDir = func(*os.Root, string) error { return errors.New("disk says no") } |
| + if _, _, err := Restore(ctx, s, pfx, dir); err == nil { |
| + t.Fatal("the first attempt must fail") |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "gone", "old.txt")); !os.IsNotExist(err) { |
| + t.Fatal("the first attempt should have removed the file before failing") |
| + } |
| + var synced []string |
| + syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) } |
| + _, st, err := Restore(ctx, s, pfx, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.Removed != 0 { |
| + t.Fatalf("Removed = %d; the retry had nothing left to remove", st.Removed) |
| + } |
| + if want := []string{"gone", "."}; !reflect.DeepEqual(synced, want) { |
| + t.Fatalf("the retry synced %v, want %v", synced, want) |
| + } |
| +} |
| + |
| +// A deeper directory replaced by a file: the directories that vanish |
| +// under the new file are not synced (they do not exist), and the restore |
| +// still succeeds and syncs what survives. |
| +func TestRestoreHandlesADeepDirectoryBecomingAFile(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + p := open(t, s) |
| + stage(t, p, "a/b/c", "leaf") |
| + commit(t, p) |
| + dir := t.TempDir() |
| + if _, _, err := Restore(ctx, s, pfx, dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.StageDelete("a/b/c"); err != nil { |
| + t.Fatal(err) |
| + } |
| + stage(t, p, "a", "now a file") |
| + commit(t, p) |
| + |
| + var synced []string |
| + orig := syncDir |
| + t.Cleanup(func() { syncDir = orig }) |
| + syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) } |
| + for name, d := range map[string]string{"a previously restored destination": dir, "an empty one": t.TempDir()} { |
| + synced = nil |
| + if _, _, err := Restore(ctx, s, pfx, d); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if got := read(t, d, "a"); got != "now a file" { |
| + t.Fatalf("%s: a = %q", name, got) |
| + } |
| + if want := []string{"."}; !reflect.DeepEqual(synced, want) { |
| + t.Fatalf("%s: synced %v, want %v: a/b and a are gone, the root records it", name, synced, want) |
| + } |
| + } |
| +} |
diff --git a/internal/hostfiles/manifest.go b/internal/hostfiles/manifest.go| new file mode 100644 |
| index 000000000..80111a6ee |
| --- /dev/null |
| +++ b/internal/hostfiles/manifest.go |
| @@ -0,0 +1,91 @@ |
| +// Package hostfiles publishes an instance's files directory, and the small |
| +// platform-managed items that travel with it, to object storage in an order |
| +// that survives late and duplicate uploads (region HA spec 1 §5.6). |
| +// |
| +// Nothing is overwritten: each change is a new immutable object, and one |
| +// manifest, replaced by compare-and-swap, says which object is current. |
| +package hostfiles |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// MaxFileBytes is the largest file published as one object. release.Store |
| +// takes a whole body in memory, so a larger file is recorded as skipped |
| +// rather than read. Plan 1b lifts this with a streaming put. |
| +const MaxFileBytes = 64 << 20 |
| + |
| +type Entry struct { |
| + Object string `json:"object,omitempty"` |
| + SHA256 string `json:"sha256,omitempty"` |
| + Size int64 `json:"size,omitempty"` |
| + Mode uint32 `json:"mode,omitempty"` |
| + Seq int64 `json:"seq"` |
| + Deleted bool `json:"deleted,omitempty"` |
| + Torn bool `json:"torn,omitempty"` |
| + Skipped string `json:"skipped,omitempty"` |
| +} |
| + |
| +type Manifest struct { |
| + V int `json:"v"` |
| + Seq int64 `json:"seq"` |
| + Entries map[string]Entry `json:"entries"` |
| + Platform map[string]Entry `json:"platform"` |
| +} |
| + |
| +func ManifestKey(prefix string) string { return prefix + "manifest.json" } |
| + |
| +func objectKey(seq int64, sha string) string { return fmt.Sprintf("objects/%016d-%s", seq, sha) } |
| + |
| +// CleanPath accepts only a relative, already-clean, slash-separated path |
| +// that stays inside its root. It never repairs a path: a manifest is |
| +// written by this package, so anything else is damage or an attack. |
| +func CleanPath(p string) (string, error) { |
| + if p == "" || strings.ContainsRune(p, 0) || strings.HasPrefix(p, "/") { |
| + return "", fmt.Errorf("hostfiles: path %q is not a relative path", p) |
| + } |
| + c := path.Clean(p) |
| + if c != p || c == "." || c == ".." || strings.HasPrefix(c, "../") { |
| + return "", fmt.Errorf("hostfiles: path %q is not clean or leaves its root", p) |
| + } |
| + return c, nil |
| +} |
| + |
| +// Load reads the manifest. The bool is false when there is none yet. |
| +func Load(ctx context.Context, s release.Store, prefix string) (Manifest, bool, error) { |
| + m, _, found, err := load(ctx, s, prefix) |
| + return m, found, err |
| +} |
| + |
| +func load(ctx context.Context, s release.Store, prefix string) (Manifest, string, bool, error) { |
| + empty := Manifest{V: 1, Entries: map[string]Entry{}, Platform: map[string]Entry{}} |
| + raw, etag, err := s.Get(ctx, ManifestKey(prefix)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return empty, "", false, nil |
| + } |
| + if err != nil { |
| + return Manifest{}, "", false, err |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s: %w", prefix, err) |
| + } |
| + if m.V != 1 { |
| + return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s is version %d; this binary reads 1", prefix, m.V) |
| + } |
| + if m.Entries == nil { |
| + m.Entries = map[string]Entry{} |
| + } |
| + if m.Platform == nil { |
| + m.Platform = map[string]Entry{} |
| + } |
| + return m, etag, true, nil |
| +} |
diff --git a/internal/hostfiles/publisher.go b/internal/hostfiles/publisher.go| new file mode 100644 |
| index 000000000..2d9880b89 |
| --- /dev/null |
| +++ b/internal/hostfiles/publisher.go |
| @@ -0,0 +1,192 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "sort" |
| + "strings" |
| + "sync" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ErrSuperseded means another writer replaced the manifest. The caller |
| +// stops publishing: it is no longer this host's executor, or two |
| +// publishers were started for one host. |
| +var ErrSuperseded = errors.New("hostfiles: another writer published this manifest") |
| + |
| +// Publisher stages changes and commits them as one manifest revision. It |
| +// is safe for concurrent use. |
| +type Publisher struct { |
| + s release.Store |
| + prefix string |
| + |
| + mu sync.Mutex |
| + man Manifest |
| + etag string |
| + next int64 // the last sequence number handed out; man.Seq is the last committed |
| + dirty bool |
| +} |
| + |
| +func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, error) { |
| + if !strings.HasSuffix(prefix, "/") { |
| + return nil, fmt.Errorf("hostfiles: prefix %q must end in /", prefix) |
| + } |
| + m, etag, _, err := load(ctx, s, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Publisher{s: s, prefix: prefix, man: m, etag: etag, next: m.Seq}, nil |
| +} |
| + |
| +func sum(body []byte) string { |
| + h := sha256.Sum256(body) |
| + return hex.EncodeToString(h[:]) |
| +} |
| + |
| +// put writes body as the next immutable object and returns its entry. |
| +// Callers hold p.mu. |
| +func (p *Publisher) put(ctx context.Context, body []byte, mode uint32, torn bool) (Entry, error) { |
| + sha := sum(body) |
| + seq := p.next + 1 |
| + key := objectKey(seq, sha) |
| + if _, err := p.s.Put(ctx, p.prefix+key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + // A conflict means this exact key exists: the same sequence number |
| + // and the same hash, left by an attempt that crashed before its |
| + // commit. The bytes are the ones we were about to write. |
| + return Entry{}, err |
| + } |
| + p.next = seq |
| + return Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode & 0o777, Seq: seq, Torn: torn}, nil |
| +} |
| + |
| +// Stage records path's content. Content identical to what is already |
| +// staged or committed takes no sequence number and writes nothing. |
| +func (p *Publisher) Stage(ctx context.Context, path string, body []byte, mode uint32, torn bool) error { |
| + clean, err := CleanPath(path) |
| + if err != nil { |
| + return err |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if cur, ok := p.man.Entries[clean]; ok && !cur.Deleted && cur.Skipped == "" && |
| + cur.SHA256 == sum(body) && cur.Mode == mode&0o777 && cur.Torn == torn { |
| + return nil |
| + } |
| + e, err := p.put(ctx, body, mode, torn) |
| + if err != nil { |
| + return err |
| + } |
| + p.man.Entries[clean], p.dirty = e, true |
| + return nil |
| +} |
| + |
| +// StageSkipped records that path exists but was not published, and why. |
| +func (p *Publisher) StageSkipped(path, why string) error { |
| + clean, err := CleanPath(path) |
| + if err != nil { |
| + return err |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if cur, ok := p.man.Entries[clean]; ok && cur.Skipped == why { |
| + return nil |
| + } |
| + p.next++ |
| + p.man.Entries[clean], p.dirty = Entry{Seq: p.next, Skipped: why}, true |
| + return nil |
| +} |
| + |
| +// StageDelete records that path is gone. Deleting a path the manifest never |
| +// had, or already has as deleted, is a no-op. |
| +func (p *Publisher) StageDelete(path string) error { |
| + clean, err := CleanPath(path) |
| + if err != nil { |
| + return err |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if cur, ok := p.man.Entries[clean]; !ok || cur.Deleted { |
| + return nil |
| + } |
| + p.next++ |
| + p.man.Entries[clean], p.dirty = Entry{Seq: p.next, Deleted: true}, true |
| + return nil |
| +} |
| + |
| +// StagePlatform records a platform-managed item (wake state, an env file). |
| +// It shares the manifest's ordering but is never written into the files |
| +// directory by Restore. |
| +func (p *Publisher) StagePlatform(ctx context.Context, name string, body []byte) error { |
| + if name == "" || strings.ContainsAny(name, "/\x00") { |
| + return fmt.Errorf("hostfiles: platform item %q is not a plain name", name) |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if cur, ok := p.man.Platform[name]; ok && cur.SHA256 == sum(body) { |
| + return nil |
| + } |
| + e, err := p.put(ctx, body, 0o600, false) |
| + if err != nil { |
| + return err |
| + } |
| + p.man.Platform[name], p.dirty = e, true |
| + return nil |
| +} |
| + |
| +// Commit publishes everything staged as one manifest revision and returns |
| +// its sequence number. With nothing staged it returns the current one. A |
| +// failed commit leaves the staged changes in place, so the caller retries; |
| +// ErrSuperseded is final. |
| +func (p *Publisher) Commit(ctx context.Context) (int64, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if !p.dirty { |
| + return p.man.Seq, nil |
| + } |
| + out := p.man |
| + out.Seq = p.next |
| + body, err := json.Marshal(out) |
| + if err != nil { |
| + return 0, fmt.Errorf("hostfiles: marshal manifest: %w", err) |
| + } |
| + etag, err := p.s.Put(ctx, ManifestKey(p.prefix), body, p.etag) |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return 0, fmt.Errorf("%w: %s", ErrSuperseded, p.prefix) |
| + } |
| + if err != nil { |
| + return 0, err |
| + } |
| + p.man.Seq, p.etag, p.dirty = out.Seq, etag, false |
| + return out.Seq, nil |
| +} |
| + |
| +func (p *Publisher) Dirty() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.dirty } |
| + |
| +func (p *Publisher) Seq() int64 { p.mu.Lock(); defer p.mu.Unlock(); return p.man.Seq } |
| + |
| +// Paths lists every path the manifest holds as present: published or |
| +// skipped, not deleted. |
| +func (p *Publisher) Paths() []string { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + var out []string |
| + for path, e := range p.man.Entries { |
| + if !e.Deleted { |
| + out = append(out, path) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// pending is the last sequence number handed out, committed or not. |
| +func (p *Publisher) pending() int64 { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return p.next |
| +} |
diff --git a/internal/hostfiles/restore.go b/internal/hostfiles/restore.go| new file mode 100644 |
| index 000000000..37f44fb41 |
| --- /dev/null |
| +++ b/internal/hostfiles/restore.go |
| @@ -0,0 +1,295 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "io/fs" |
| + "os" |
| + "path" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// syncDir makes a directory's entries durable. A test seam. |
| +var syncDir = func(root *os.Root, rel string) error { |
| + d, err := root.Open(filepath.FromSlash(rel)) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.Close() |
| + return d.Sync() |
| +} |
| + |
| +type RestoreStats struct { |
| + Written, Removed int |
| + Skipped []string // paths the manifest holds as present but unpublished |
| +} |
| + |
| +// Restore makes dir match the manifest under prefix: it removes every |
| +// tombstoned entry, writes every published one, and reports the skipped |
| +// ones. It reads nothing but the manifest and the objects the manifest |
| +// names, and verifies each object against the hash the manifest records. |
| +// |
| +// Every path is validated before anything is touched, so a manifest with |
| +// one bad path changes nothing. Every file operation goes through an |
| +// os.Root opened on dir, so nothing is written or removed outside it even |
| +// if dir already holds a symlink pointing elsewhere. |
| +// |
| +// When Restore returns nil, what it did is durable: each file was synced |
| +// before its rename, and every directory in which a name was created, |
| +// replaced or removed has been synced since. A caller may record the |
| +// installation as complete on that basis, and on no weaker one. |
| +func Restore(ctx context.Context, s release.Store, prefix, dir string) (Manifest, RestoreStats, error) { |
| + var st RestoreStats |
| + m, _, _, err := load(ctx, s, prefix) |
| + if err != nil { |
| + return Manifest{}, st, err |
| + } |
| + var live, dead []string |
| + for p, e := range m.Entries { |
| + if _, err := CleanPath(p); err != nil { |
| + return Manifest{}, st, err |
| + } |
| + if e.Deleted { |
| + dead = append(dead, p) |
| + } else { |
| + live = append(live, p) |
| + } |
| + } |
| + if len(live)+len(dead) == 0 { |
| + return m, st, nil |
| + } |
| + root, err := os.OpenRoot(dir) |
| + if err != nil { |
| + return Manifest{}, st, fmt.Errorf("hostfiles: restore into %s: %w", dir, err) |
| + } |
| + defer root.Close() |
| + |
| + // Tombstones first, deepest first, so a directory that became a file |
| + // is emptied before the file is written over it. |
| + // Every directory on the way to a path the manifest speaks of, live or |
| + // tombstoned, is a directory whose entries this restore may have |
| + // changed: now, or in an earlier attempt that removed or created the |
| + // name and then failed before its sync. So the set does not depend on |
| + // what this call happened to find already done. |
| + touched := map[string]bool{".": true} |
| + mark := func(p string) { |
| + for d := path.Dir(p); d != "."; d = path.Dir(d) { |
| + touched[d] = true |
| + } |
| + } |
| + sort.Slice(dead, func(i, j int) bool { |
| + di, dj := strings.Count(dead[i], "/"), strings.Count(dead[j], "/") |
| + if di != dj { |
| + return di > dj |
| + } |
| + return dead[i] < dead[j] |
| + }) |
| + for _, p := range dead { |
| + mark(p) |
| + removed, err := removeInside(root, p) |
| + if err != nil { |
| + return Manifest{}, st, fmt.Errorf("hostfiles: remove %s: %w", p, err) |
| + } |
| + if removed { |
| + st.Removed++ |
| + } |
| + } |
| + sort.Strings(live) |
| + for _, p := range live { |
| + e := m.Entries[p] |
| + if e.Skipped != "" { |
| + st.Skipped = append(st.Skipped, p) |
| + continue |
| + } |
| + mark(p) |
| + body, err := fetch(ctx, s, prefix, e) |
| + if err != nil { |
| + return Manifest{}, st, fmt.Errorf("hostfiles: %s: %w", p, err) |
| + } |
| + if err := writeInside(root, p, body, os.FileMode(e.Mode)); err != nil { |
| + return Manifest{}, st, fmt.Errorf("hostfiles: write %s: %w", p, err) |
| + } |
| + st.Written++ |
| + } |
| + // Sync the directories that survive, deepest first and the root last, |
| + // so a directory is durable before the entry that names it is. One |
| + // that no longer exists as a real directory (it was emptied and then |
| + // replaced by a file, or it sat under a path that became a file) has |
| + // nothing to sync: the directory above it records its going. |
| + dirs := make([]string, 0, len(touched)) |
| + for d := range touched { |
| + dirs = append(dirs, d) |
| + } |
| + sort.Slice(dirs, func(i, j int) bool { |
| + if (dirs[i] == ".") != (dirs[j] == ".") { |
| + return dirs[j] == "." |
| + } |
| + di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/") |
| + if di != dj { |
| + return di > dj |
| + } |
| + return dirs[i] < dirs[j] |
| + }) |
| + for _, d := range dirs { |
| + if d != "." { |
| + if ok, err := realParents(root, d+"/x"); err != nil { |
| + return Manifest{}, st, fmt.Errorf("hostfiles: sync directory %s: %w", d, err) |
| + } else if !ok { |
| + continue |
| + } |
| + } |
| + if err := syncDir(root, d); err != nil { |
| + return Manifest{}, st, fmt.Errorf("hostfiles: sync directory %s: %w", d, err) |
| + } |
| + } |
| + return m, st, nil |
| +} |
| + |
| +// ReadPlatform returns a platform-managed item's bytes, verified. A name |
| +// the manifest does not hold is os.ErrNotExist. |
| +func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string) ([]byte, error) { |
| + e, ok := m.Platform[name] |
| + if !ok { |
| + return nil, fmt.Errorf("hostfiles: platform item %q: %w", name, os.ErrNotExist) |
| + } |
| + return fetch(ctx, s, prefix, e) |
| +} |
| + |
| +func fetch(ctx context.Context, s release.Store, prefix string, e Entry) ([]byte, error) { |
| + body, _, err := s.Get(ctx, prefix+e.Object) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if got := sum(body); got != e.SHA256 { |
| + return nil, fmt.Errorf("object %s has sha256 %s, the manifest says %s", e.Object, got, e.SHA256) |
| + } |
| + return body, nil |
| +} |
| + |
| +// realParents reports whether every ancestor of rel inside root is a real |
| +// directory: not a symlink, not a file, not missing. |
| +func realParents(root *os.Root, rel string) (bool, error) { |
| + parts := strings.Split(rel, "/") |
| + cur := "" |
| + for _, part := range parts[:len(parts)-1] { |
| + cur = path.Join(cur, part) |
| + fi, err := root.Lstat(filepath.FromSlash(cur)) |
| + if errors.Is(err, fs.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if !fi.IsDir() { |
| + return false, nil |
| + } |
| + } |
| + return true, nil |
| +} |
| + |
| +// removeInside removes rel if it is a file of ours. A path whose ancestor |
| +// is a symlink, a file or missing is not ours to remove and is left alone: |
| +// that is how a tombstone for "a/b" behaves once "a" has become a file, |
| +// and why a symlinked ancestor never lets a delete reach outside root. |
| +func removeInside(root *os.Root, rel string) (bool, error) { |
| + ok, err := realParents(root, rel) |
| + if err != nil || !ok { |
| + return false, err |
| + } |
| + name := filepath.FromSlash(rel) |
| + fi, err := root.Lstat(name) |
| + if errors.Is(err, fs.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if fi.IsDir() { |
| + // The path is a directory now: a later entry lives under it, and |
| + // the tombstone is for a file that no longer exists. |
| + return false, nil |
| + } |
| + if err := root.Remove(name); err != nil && !errors.Is(err, fs.ErrNotExist) { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| +// writeInside writes rel atomically inside root. Anything in the way that |
| +// is not a real directory (a file where a directory must be, a symlink) |
| +// is removed first, and a directory where the file must be is removed |
| +// with its contents: the manifest is the truth about this tree. |
| +func writeInside(root *os.Root, rel string, body []byte, mode os.FileMode) error { |
| + if mode == 0 { |
| + mode = 0o600 |
| + } |
| + parent := path.Dir(rel) |
| + if parent != "." { |
| + cur := "" |
| + for _, part := range strings.Split(parent, "/") { |
| + cur = path.Join(cur, part) |
| + fi, err := root.Lstat(filepath.FromSlash(cur)) |
| + if errors.Is(err, fs.ErrNotExist) { |
| + break |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if !fi.IsDir() { |
| + if err := root.Remove(filepath.FromSlash(cur)); err != nil { |
| + return err |
| + } |
| + break |
| + } |
| + } |
| + if err := root.MkdirAll(filepath.FromSlash(parent), 0o755); err != nil { |
| + return err |
| + } |
| + } |
| + name := filepath.FromSlash(rel) |
| + if fi, err := root.Lstat(name); err == nil && fi.IsDir() { |
| + if err := root.RemoveAll(name); err != nil { |
| + return err |
| + } |
| + } |
| + var rnd [8]byte |
| + if _, err := rand.Read(rnd[:]); err != nil { |
| + return err |
| + } |
| + tmp := filepath.FromSlash(path.Join(parent, ".carlos-restore-"+hex.EncodeToString(rnd[:]))) |
| + f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| + if err != nil { |
| + return err |
| + } |
| + done := false |
| + defer func() { |
| + if !done { |
| + f.Close() |
| + root.Remove(tmp) |
| + } |
| + }() |
| + if _, err := f.Write(body); err != nil { |
| + return err |
| + } |
| + if err := f.Chmod(mode); err != nil { |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + return err |
| + } |
| + if err := root.Rename(tmp, name); err != nil { |
| + return err |
| + } |
| + done = true |
| + return nil |
| +} |