package runner import ( "os" "os/exec" "path/filepath" "slices" "strings" "testing" ) // The verb's argv is `backup --config /book.yaml` and carries NO `--keys-file`. Pinned at // the argv because both halves are one-token regressions: a dropped `--config` backs up whatever // directory the process happens to sit in, and an added `--keys-file` makes a $0 command refuse on // every engine build (D20.4 — the read verbs must not demand keys). func TestTheBackupVerbAsksForTheBooksConfigAndNoKeys(t *testing.T) { args := BackupArgs("/srv/books/bk_1") if args[0] != "backup" { t.Errorf("argv does not start with the verb: %q", args) } if i := slices.Index(args, "--config"); i < 0 || args[i+1] != filepath.Join("/srv/books/bk_1", ConfigFile) { t.Errorf("the book's own configuration is not what is backed up: %q", args) } if slices.Contains(args, "--keys-file") { t.Errorf("a $0 verb is being handed provider keys: %q", args) } } // The path is READ off the engine's line and never derived, so the parse is what stands between this // platform and copying the wrong file. Every case below is a way the parse could quietly succeed on // something it should refuse. // // Mutation caught: matching only the prefix (a line that begins the same way is read as a path); // dropping the containment guard (a line naming any path on the host becomes a file-read primitive); // answering an empty path (the copier then opens the book's own directory). func TestTheBackupPathIsReadStrictlyAndStaysInsideTheBook(t *testing.T) { const workdir = "/srv/books/bk_1" good := "backup OK: /srv/books/bk_1/backups/20260905T120000Z.db (integrity_check green, VACUUM INTO)\n" got, err := backupPathIn(good, workdir) if err != nil || got != "/srv/books/bk_1/backups/20260905T120000Z.db" { t.Fatalf("the engine's own success line did not parse: %q, %v", got, err) } // A relative path is resolved against the book, because that is the directory the verb ran in. if got, err := backupPathIn("backup OK: backups/x.db (integrity_check green, VACUUM INTO)\n", workdir); err != nil || got != "/srv/books/bk_1/backups/x.db" { t.Errorf("a relative path was not resolved against the book: %q, %v", got, err) } // Noise before the line is fine: the engine prints other things. if _, err := backupPathIn("pre-flight: something\n"+good, workdir); err != nil { t.Errorf("a success line preceded by other output did not parse: %v", err) } for name, out := range map[string]string{ "prefix only": "backup OK: /srv/books/bk_1/x.db\n", "suffix only": "/srv/books/bk_1/x.db (integrity_check green, VACUUM INTO)\n", "empty path": "backup OK: (integrity_check green, VACUUM INTO)\n", "nothing at all": "", "another verb": "manifest OK: /srv/books/bk_1/x.json\n", "outside escape": "backup OK: /srv/books/bk_1/../bk_2/x.db (integrity_check green, VACUUM INTO)\n", "outside outright": "backup OK: /etc/shadow (integrity_check green, VACUUM INTO)\n", } { if got, err := backupPathIn(out, workdir); err == nil { t.Errorf("%s: parsed as %q instead of being refused", name, got) } } } // A zero exit whose output cannot be read is an ERROR and not a silent success. Without this the // caller would report a restore point it cannot point at, which is the exact shape of "we thought we // had backups". func TestABackupThatSucceedsWithoutNamingItsFileIsAnError(t *testing.T) { sh, err := exec.LookPath("sh") if err != nil { t.Skip("sh not on PATH: the fake engine cannot be built") } dir := t.TempDir() fake := filepath.Join(dir, "quiet-engine") if err := os.WriteFile(fake, []byte("#!"+sh+"\necho 'all done'\nexit 0\n"), 0o700); err != nil { t.Fatal(err) } out, err := New(nil).Backup(t.Context(), fake, dir) if err == nil { t.Fatalf("a silent success was accepted: %+v", out) } if !strings.Contains(err.Error(), "without naming the restore point") { t.Errorf("the error does not say what is wrong: %v", err) } } // A non-zero exit is DATA and not an error: a book that has never been cut has no project database, // and the engine says so rather than failing. Reading that as a broken deployment would stop the // whole backup pass over an ordinary state. func TestABookWithNoDatabaseComesBackAsAnExitCodeAndNotAnError(t *testing.T) { sh, err := exec.LookPath("sh") if err != nil { t.Skip("sh not on PATH: the fake engine cannot be built") } dir := t.TempDir() fake := filepath.Join(dir, "refusing-engine") script := "#!" + sh + "\necho 'tmctl backup: project database does not exist yet' >&2\nexit 1\n" if err := os.WriteFile(fake, []byte(script), 0o700); err != nil { t.Fatal(err) } out, err := New(nil).Backup(t.Context(), fake, dir) if err != nil { t.Fatalf("an ordinary refusal was raised as an error: %v", err) } if !out.Exited || out.ExitCode != 1 || out.Path != "" { t.Errorf("the refusal did not come back as an exit code: %+v", out) } if !strings.Contains(out.Stderr, "does not exist yet") { t.Errorf("the engine's own words were lost: %q", out.Stderr) } }