Files
2026-08-15 07:13:00 +00:00

229 lines
7.5 KiB
Go

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": "<h1>hi</h1>",
"assets/app.js": "console.log(1)",
"assets/css/app.css": "body{}",
"a/b/c/d/deep.txt": "deep",
"copy.html": "<h1>hi</h1>", // 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)
}
}