package deploy import ( "context" "errors" "io/fs" "os" "path/filepath" "strings" "testing" "github.com/iceBear67/simplepages/internal/cas" "github.com/iceBear67/simplepages/internal/store" ) // fixture is a CAS holding some content plus the manifest that names it. type fixture struct { cas *cas.Store dir string // deployments root files []store.FileRow } func newFixture(t *testing.T, contents map[string]string) *fixture { t.Helper() base := t.TempDir() deployDir := filepath.Join(base, "deployments") cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir}) if err != nil { t.Fatalf("cas.Open: %v", err) } t.Cleanup(func() { cs.Close() }) f := &fixture{cas: cs, dir: deployDir} for path, content := range contents { d := cas.Sum([]byte(content)) if _, err := cs.Put(t.Context(), d, int64(len(content)), 1<<20, strings.NewReader(content)); err != nil { t.Fatalf("put %s: %v", path, err) } f.files = append(f.files, store.FileRow{Path: path, Digest: d, Size: int64(len(content))}) } return f } // walk reads back an assembled tree as path -> content. func walk(t *testing.T, dir string) map[string]string { t.Helper() out := map[string]string{} err := filepath.WalkDir(dir, func(p string, e fs.DirEntry, err error) error { if err != nil || e.IsDir() { return err } b, err := os.ReadFile(p) if err != nil { return err } rel, err := filepath.Rel(dir, p) if err != nil { return err } out[filepath.ToSlash(rel)] = string(b) return nil }) if err != nil { t.Fatalf("walk %s: %v", dir, err) } return out } func TestAssembleBuildsTheTree(t *testing.T) { want := map[string]string{ "index.html": "

hi

", "assets/app.js": "console.log(1)", "assets/css/app.css": "body{}", "a/b/c/d/deep.txt": "deep", "copy.html": "

hi

", // shares a blob with index.html } f := newFixture(t, want) dest := DeploymentDir(f.dir, 7, "dpl_0123456789abcdef") if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil { t.Fatalf("Assemble: %v", err) } got := walk(t, dest) if len(got) != len(want) { t.Fatalf("assembled %d files, want %d: %v", len(got), len(want), got) } for p, content := range want { if got[p] != content { t.Errorf("%s = %q, want %q", p, got[p], content) } } // The staging directory is gone: it became the tree by rename. if _, err := os.Stat(dest + stagingSuffix); !errors.Is(err, fs.ErrNotExist) { t.Errorf("staging directory survived: %v", err) } // The project id is a path segment, so the tree is where the registry will // later expect to find it. if !strings.HasSuffix(filepath.Dir(dest), string(filepath.Separator)+"7") { t.Errorf("deployment dir %q is not under its project id", dest) } // Where the filesystem allows it, an assembled file is the blob rather than a // copy of it. This is what keeps a hundred deployments of one site costing // one site's worth of disk, so it is worth asserting rather than assuming. if f.cas.LinkMode() != cas.LinkHard { t.Skipf("link mode is %s on this filesystem; skipping the hardlink assertion", f.cas.LinkMode()) } for _, e := range f.files { blob, err := os.Stat(f.cas.Path(e.Digest)) if err != nil { t.Fatal(err) } placed, err := os.Stat(filepath.Join(dest, e.Path)) if err != nil { t.Fatal(err) } if !os.SameFile(blob, placed) { t.Errorf("%s is a copy of its blob, not a link to it", e.Path) } } } // Finalize can be retried, so assembling onto a finished tree must be a no-op // rather than a rebuild — a rebuild would briefly unlink files that an // external consumer of $WEBROOT is reading. func TestAssembleIsIdempotent(t *testing.T) { f := newFixture(t, map[string]string{"index.html": "one"}) dest := DeploymentDir(f.dir, 1, "dpl_a") if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil { t.Fatal(err) } before, err := os.Stat(filepath.Join(dest, "index.html")) if err != nil { t.Fatal(err) } if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil { t.Fatalf("second Assemble: %v", err) } after, err := os.Stat(filepath.Join(dest, "index.html")) if err != nil { t.Fatal(err) } if !os.SameFile(before, after) { t.Error("the second Assemble replaced a file that was already in place") } } // A crash leaves a staging tree behind. The next attempt must clear it rather // than build on top of files it did not put there. func TestAssembleDiscardsALeftoverStagingTree(t *testing.T) { f := newFixture(t, map[string]string{"index.html": "real"}) dest := DeploymentDir(f.dir, 1, "dpl_a") staging := dest + stagingSuffix if err := os.MkdirAll(staging, 0o755); err != nil { t.Fatal(err) } // Same path as a manifest entry, so a build that did not clear this would // fail on the exclusive create rather than silently serve the wrong bytes. if err := os.WriteFile(filepath.Join(staging, "index.html"), []byte("stale"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(staging, "orphan.txt"), []byte("stale"), 0o644); err != nil { t.Fatal(err) } if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil { t.Fatalf("Assemble: %v", err) } got := walk(t, dest) if len(got) != 1 || got["index.html"] != "real" { t.Errorf("tree = %v, want just the real index.html", got) } } // A failure must leave nothing behind: no partial tree at the destination, and // no staging directory quietly consuming disk until recovery notices it. func TestAssembleLeavesNothingBehindOnFailure(t *testing.T) { f := newFixture(t, map[string]string{"index.html": "real"}) missing := store.FileRow{Path: "gone.txt", Digest: cas.Sum([]byte("never stored")), Size: 12} dest := DeploymentDir(f.dir, 1, "dpl_a") err := Assemble(t.Context(), f.cas, append(f.files, missing), dest) if !errors.Is(err, cas.ErrNotFound) { t.Fatalf("err = %v, want cas.ErrNotFound", err) } for _, p := range []string{dest, dest + stagingSuffix} { if _, err := os.Stat(p); !errors.Is(err, fs.ErrNotExist) { t.Errorf("%s still exists: %v", p, err) } } } // Assembly is the one place that turns stored strings into filesystem paths, so // it re-checks them even though the API validated the manifest on the way in. // A row that got past the API — or into the table by some other route — must not // be able to place a file outside the tree being built. func TestAssembleRejectsAnEscapingPath(t *testing.T) { f := newFixture(t, map[string]string{"index.html": "real"}) dest := DeploymentDir(f.dir, 1, "dpl_a") for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "a/../../b", "a//b", `a\..\b`, "a/./b", ""} { row := store.FileRow{Path: bad, Digest: f.files[0].Digest, Size: f.files[0].Size} if err := Assemble(t.Context(), f.cas, []store.FileRow{row}, dest); err == nil { t.Errorf("Assemble accepted %q", bad) } // Nothing is left behind for the next attempt to inherit, and in // particular no directory was created on the way to the rejection. for _, p := range []string{dest, dest + stagingSuffix} { if _, err := os.Stat(p); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("%q left %s behind: %v", bad, p, err) } } } } func TestAssembleHonoursCancellation(t *testing.T) { f := newFixture(t, map[string]string{"index.html": "real"}) ctx, cancel := context.WithCancel(t.Context()) cancel() dest := DeploymentDir(f.dir, 1, "dpl_a") if err := Assemble(ctx, f.cas, f.files, dest); !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled", err) } if _, err := os.Stat(dest + stagingSuffix); !errors.Is(err, fs.ErrNotExist) { t.Errorf("staging directory survived cancellation: %v", err) } }