package webroot import ( "errors" "io/fs" "os" "path/filepath" "strings" "testing" ) // env is a webroot plus the deployments directory it is allowed to point at. type env struct { *Webroot dir string deployDir string } func newEnv(t *testing.T) *env { t.Helper() base := t.TempDir() dir := filepath.Join(base, "www") deployDir := filepath.Join(base, "deployments") if err := os.MkdirAll(deployDir, 0o755); err != nil { t.Fatal(err) } w, err := Open(dir, deployDir) if err != nil { t.Fatal(err) } return &env{Webroot: w, dir: dir, deployDir: deployDir} } // deployment creates a deployment directory and returns its path. func (e *env) deployment(t *testing.T, rel string) string { t.Helper() p := filepath.Join(e.deployDir, rel) if err := os.MkdirAll(p, 0o755); err != nil { t.Fatal(err) } return p } func (e *env) readlink(t *testing.T, name string) string { t.Helper() target, err := os.Readlink(filepath.Join(e.dir, name)) if err != nil { t.Fatalf("readlink %s: %v", name, err) } return target } func (e *env) names(t *testing.T) []string { t.Helper() entries, err := os.ReadDir(e.dir) if err != nil { t.Fatal(err) } out := make([]string, 0, len(entries)) for _, entry := range entries { out = append(out, entry.Name()) } return out } func TestOpenCreatesTheDirectory(t *testing.T) { base := t.TempDir() dir := filepath.Join(base, "a", "b", "www") w, err := Open(dir, filepath.Join(base, "deployments")) if err != nil { t.Fatal(err) } if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { t.Fatalf("Open did not create %s: %v", dir, err) } if !filepath.IsAbs(w.Dir()) { t.Errorf("Dir() = %q, want an absolute path", w.Dir()) } if _, err := Open("", base); err == nil { t.Error("Open accepted an empty directory") } } func TestPoint(t *testing.T) { e := newEnv(t) first := e.deployment(t, "1/dpl_aaaa") if err := e.Point("demo", first); err != nil { t.Fatal(err) } if got := e.readlink(t, "~demo"); got != first { t.Errorf("~demo -> %q, want %q", got, first) } // Pointing at the same target again is a no-op, not an error. if err := e.Point("demo", first); err != nil { t.Fatal(err) } second := e.deployment(t, "1/dpl_bbbb") if err := e.Point("demo", second); err != nil { t.Fatal(err) } if got := e.readlink(t, "~demo"); got != second { t.Errorf("after repoint ~demo -> %q, want %q", got, second) } // The rename must not leave the temporary link behind. if names := e.names(t); len(names) != 1 || names[0] != "~demo" { t.Errorf("webroot contains %v, want just [~demo]", names) } } // Point replaces the link with rename(2), which is atomic within a directory: // an external reader either sees the old target or the new one, never a missing // or half-written link. Observing that from a test means checking that the name // resolves to a valid deployment at every moment, which the atomicity suite // does concurrently; here we pin the mechanism it relies on — the link is never // unlinked first. func TestPointNeverUnlinksBeforeRenaming(t *testing.T) { e := newEnv(t) first := e.deployment(t, "1/dpl_aaaa") second := e.deployment(t, "1/dpl_bbbb") if err := e.Point("demo", first); err != nil { t.Fatal(err) } link := filepath.Join(e.dir, "~demo") before, err := os.Lstat(link) if err != nil { t.Fatal(err) } if err := e.Point("demo", second); err != nil { t.Fatal(err) } after, err := os.Lstat(link) if err != nil { t.Fatal(err) } if os.SameFile(before, after) { t.Error("Point reused the same inode; it must create a new link and rename over") } } func TestPointRefusesANameThatIsNotAProject(t *testing.T) { e := newEnv(t) target := e.deployment(t, "1/dpl_aaaa") // Anything that could escape the webroot, plus the merely invalid. for _, name := range []string{"", ".", "..", "../evil", "a/b", "/abs", "UPPER", ".hidden", "~demo", strings.Repeat("a", 64)} { if err := e.Point(name, target); err == nil { t.Errorf("Point(%q) was accepted", name) } if err := e.Unpoint(name); err == nil { t.Errorf("Unpoint(%q) was accepted", name) } } if names := e.names(t); len(names) != 0 { t.Errorf("a refused name still created %v", names) } } func TestUnpoint(t *testing.T) { e := newEnv(t) if err := e.Unpoint("demo"); err != nil { t.Fatalf("Unpoint on a missing link = %v, want nil", err) } if err := e.Point("demo", e.deployment(t, "1/dpl_aaaa")); err != nil { t.Fatal(err) } if err := e.Unpoint("demo"); err != nil { t.Fatal(err) } if _, err := os.Lstat(filepath.Join(e.dir, "~demo")); !errors.Is(err, fs.ErrNotExist) { t.Errorf("~demo survived Unpoint: %v", err) } } // The webroot is very likely a directory the operator also keeps other things // in. Unpoint must not remove a name that this package did not create, even // though the name matches the pattern it uses. func TestUnpointLeavesForeignEntriesAlone(t *testing.T) { e := newEnv(t) regular := filepath.Join(e.dir, "~demo") if err := os.WriteFile(regular, []byte("operator's file"), 0o644); err != nil { t.Fatal(err) } if err := e.Unpoint("demo"); err != nil { t.Fatal(err) } if _, err := os.Stat(regular); err != nil { t.Fatalf("Unpoint deleted a regular file: %v", err) } outside := filepath.Join(t.TempDir(), "elsewhere") if err := os.MkdirAll(outside, 0o755); err != nil { t.Fatal(err) } foreign := filepath.Join(e.dir, "~other") if err := os.Symlink(outside, foreign); err != nil { t.Fatal(err) } if err := e.Unpoint("other"); err != nil { t.Fatal(err) } if _, err := os.Lstat(foreign); err != nil { t.Fatalf("Unpoint deleted a symlink pointing outside the deployments dir: %v", err) } } func TestReconcile(t *testing.T) { e := newEnv(t) a := e.deployment(t, "1/dpl_a") b := e.deployment(t, "2/dpl_b") c := e.deployment(t, "3/dpl_c") if err := e.Point("stale", a); err != nil { t.Fatal(err) } if err := e.Point("moved", a); err != nil { t.Fatal(err) } want := map[string]string{"moved": b, "fresh": c} if err := e.Reconcile(want); err != nil { t.Fatal(err) } if _, err := os.Lstat(filepath.Join(e.dir, "~stale")); !errors.Is(err, fs.ErrNotExist) { t.Errorf("~stale survived Reconcile: %v", err) } if got := e.readlink(t, "~moved"); got != b { t.Errorf("~moved -> %q, want %q", got, b) } if got := e.readlink(t, "~fresh"); got != c { t.Errorf("~fresh -> %q, want %q", got, c) } } // The guarantee that makes Reconcile safe to run on a shared webroot: it // deletes only symlinks of its own that point inside the deployments directory. func TestReconcileLeavesForeignEntriesAlone(t *testing.T) { e := newEnv(t) elsewhere := t.TempDir() keep := []string{ "index.html", // a file the operator serves directly "~notes.txt", // a file whose name happens to start with ~ "~static", // a directory under a name we would use "~elsewhere", // a symlink out of our control "~dangling", // a symlink to nothing "other-thing", // anything else } if err := os.WriteFile(filepath.Join(e.dir, "index.html"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(e.dir, "~notes.txt"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(e.dir, "~static"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(elsewhere, filepath.Join(e.dir, "~elsewhere")); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(elsewhere, "nope"), filepath.Join(e.dir, "~dangling")); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(e.dir, "other-thing"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } // An empty want set is the harshest case: every project is gone, so // anything Reconcile is willing to delete, it deletes now. if err := e.Reconcile(nil); err != nil { t.Fatal(err) } for _, name := range keep { if _, err := os.Lstat(filepath.Join(e.dir, name)); err != nil { t.Errorf("Reconcile removed %s: %v", name, err) } } } // A symlink pointing at the deployments directory itself is not one of ours: // nothing here ever creates one, so removing it would be removing an // operator's entry. func TestReconcileIgnoresALinkToTheDeploymentsRoot(t *testing.T) { e := newEnv(t) link := filepath.Join(e.dir, "~all") if err := os.Symlink(e.deployDir, link); err != nil { t.Fatal(err) } if err := e.Reconcile(nil); err != nil { t.Fatal(err) } if _, err := os.Lstat(link); err != nil { t.Errorf("Reconcile removed a link to the deployments root: %v", err) } } // A prefix match on the string would treat /deployments-old as inside // /deployments. It is a sibling directory and must be left alone. func TestReconcileIgnoresASiblingDirectoryWithASharedPrefix(t *testing.T) { e := newEnv(t) sibling := e.deployDir + "-old" if err := os.MkdirAll(filepath.Join(sibling, "dpl_x"), 0o755); err != nil { t.Fatal(err) } link := filepath.Join(e.dir, "~archived") if err := os.Symlink(filepath.Join(sibling, "dpl_x"), link); err != nil { t.Fatal(err) } if err := e.Reconcile(nil); err != nil { t.Fatal(err) } if _, err := os.Lstat(link); err != nil { t.Errorf("Reconcile removed a link into a sibling directory: %v", err) } } // A crash between symlink() and rename() leaves ~name.tmp. behind. It is // ours by construction, so Reconcile sweeps it. func TestReconcileSweepsAbandonedTemporaryLinks(t *testing.T) { e := newEnv(t) target := e.deployment(t, "1/dpl_a") leftover := filepath.Join(e.dir, "~demo"+tmpMarker+"0123456789ab") if err := os.Symlink(target, leftover); err != nil { t.Fatal(err) } if err := e.Reconcile(map[string]string{"demo": target}); err != nil { t.Fatal(err) } if _, err := os.Lstat(leftover); !errors.Is(err, fs.ErrNotExist) { t.Errorf("an abandoned temporary link survived Reconcile: %v", err) } if got := e.readlink(t, "~demo"); got != target { t.Errorf("~demo -> %q, want %q", got, target) } } // A relative target is still ours if it lands inside the deployments directory. // Reconcile resolves it against the webroot before deciding. func TestReconcileUnderstandsRelativeTargets(t *testing.T) { e := newEnv(t) e.deployment(t, "1/dpl_a") rel, err := filepath.Rel(e.dir, filepath.Join(e.deployDir, "1", "dpl_a")) if err != nil { t.Fatal(err) } link := filepath.Join(e.dir, "~gone") if err := os.Symlink(rel, link); err != nil { t.Fatal(err) } if err := e.Reconcile(nil); err != nil { t.Fatal(err) } if _, err := os.Lstat(link); !errors.Is(err, fs.ErrNotExist) { t.Error("a relative link into the deployments dir survived Reconcile") } } // Reconcile reports what failed but keeps going: one bad project must not stop // the rest of the webroot from converging. func TestReconcileContinuesAfterAFailure(t *testing.T) { e := newEnv(t) good := e.deployment(t, "1/dpl_a") if err := e.Reconcile(map[string]string{"good": good, "Bad Name": good}); err == nil { t.Fatal("Reconcile hid a failure") } if got := e.readlink(t, "~good"); got != good { t.Errorf("~good -> %q, want %q", got, good) } } func TestReconcileIsIdempotent(t *testing.T) { e := newEnv(t) want := map[string]string{ "a": e.deployment(t, "1/dpl_a"), "b": e.deployment(t, "2/dpl_b"), } for range 3 { if err := e.Reconcile(want); err != nil { t.Fatal(err) } } names := e.names(t) if len(names) != 2 { t.Fatalf("webroot contains %v, want two links", names) } for project, target := range want { if got := e.readlink(t, "~"+project); got != target { t.Errorf("~%s -> %q, want %q", project, got, target) } } }