package runner
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// backup.go: the restore-point channel — `tmctl backup` run as a direct child, like the other $0
// commands (engine.go, build.go, bankapply.go).
//
// ⚠ WHY THE ENGINE MAKES THE COPY AND NOT THIS PLATFORM. A book's paid work lives in the engine's
// SQLite, and D39.85 forbids this side to open it. That is not only a boundary rule here, it is
// also the correct engineering: a live SQLite copied byte-wise while a run is writing it is a
// silently torn file, and "we have a backup" would then be a belief rather than a mechanism. The
// engine's verb runs `PRAGMA integrity_check` and then `VACUUM INTO`, which is SQLite's own
// consistent-snapshot statement (backend/internal/store/backup.go, BackupSQLite) — so what this
// platform copies is a file nobody is writing.
//
// ⚠ THE PATH IS READ OFF THE VERB'S OWN OUTPUT AND NEVER DERIVED. The engine writes its restore
// point into a directory of its own choosing (`backups/` beside the project database) and takes no
// flag for it, so the one lawful way to learn where it landed is to read what the verb printed:
// deriving `
/backups/.db` here would be this side re-implementing another zone's
// convention, which 17-seam-inbound-law п.1 forbids and which artifacts.go already had to undo once.
//
// ⚠ AND THAT IS A DEBT, NAMED RATHER THAN HIDDEN: the line is prose, not a versioned document, so it
// is the one channel of this seam that carries no version (17-seam-inbound-law п.3). The durable
// cure is an engine-side `--out` or `--json` on `backup`; until then this parse is deliberately
// STRICT — a line it does not recognise is an error and never a guess — and it is pinned against the
// real binary rather than against a string this file made up (backup_live_test.go).
// BackupArgs is the verb's argv. $0 and key-less: it reads the project database and writes a copy,
// no provider is called, so no `--keys-file` (D20.4).
func BackupArgs(workdir string) []string {
return []string{"backup", "--config", filepath.Join(workdir, ConfigFile)}
}
// maxBackupOutput bounds what is read back. The verb prints ONE line; the cap refuses a process at
// the configured path that is not the engine.
const maxBackupOutput = 1 << 20
// backupStopGrace is how long the verb gets after SIGTERM before it is killed outright.
//
// It is the same 30 s as the build door's and for a nearer reason: `VACUUM INTO` writes a whole
// database file, and a kill in the middle of one leaves a PARTIAL file behind, in the engine's own
// `backups/` directory, under a name this platform never learned — so nothing here or there ever
// removes it. (⚠ An earlier edition said such a leftover would block the next backup of that book:
// it would not. The engine stamps to the second and refuses only a collision with that same second,
// which the next pass an interval later never has.) The grace is what gives a nearly-done vacuum the
// chance to finish and leave a whole file instead.
const backupStopGrace = 30 * time.Second
// backupLinePrefix and backupLineSuffix bracket the path in the verb's success line
// (backend/cmd/tmctl/backup.go, backupCmd: `backup OK: %s (integrity_check green, VACUUM INTO)`).
// Both halves are matched, so a line that merely begins the same way is not read as a path.
const (
backupLinePrefix = "backup OK: "
backupLineSuffix = " (integrity_check green, VACUUM INTO)"
)
// BackupOutcome is what running the verb produced.
type BackupOutcome struct {
// Path is the restore point the engine wrote, as the engine named it. Empty when the verb did
// not report one.
Path string
// ExitCode is the engine's own exit, valid when Exited.
ExitCode int
Exited bool
// Stderr is the first line of the engine's stderr — for the operator's log, never for the wire.
Stderr string
}
// Backup asks the engine for a consistent copy of a book's project database and returns where it
// put it.
//
// A non-zero exit is NOT an error here, for the same reason it is not one in BankApply: it is an
// answer, and the caller has to be able to read it.
//
// ⚠ BUT THE CODE ITSELF SAYS NOTHING, and the caller must not pretend otherwise. `tmctl backup`
// classifies none of its refusals: "no database yet", a red `PRAGMA integrity_check`, a collision
// with an existing restore point and a failed `VACUUM INTO` all return a plain error and all leave
// through the default arm as exit **1** (backend/cmd/tmctl/backup.go, backend/cmd/tmctl/main.go) —
// unlike `preflightBackup`, which does classify. So the exit code separates "the verb refused" from
// "the verb worked" and nothing finer; deciding WHICH refusal it was belongs to the caller's own
// facts about the book, never to this number and never to the message beside it.
func (r *Runner) Backup(ctx context.Context, binary, workdir string) (BackupOutcome, error) {
cmd := exec.CommandContext(ctx, binary, BackupArgs(workdir)...)
cmd.Dir = workdir
var out, errOut bytes.Buffer
cmd.Stdout = &limitedBuffer{buf: &out, limit: maxBackupOutput}
cmd.Stderr = &errOut
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = backupStopGrace
err := cmd.Run()
res := BackupOutcome{Stderr: string(firstLine(errOut.Bytes()))}
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
res.ExitCode, res.Exited = cmd.ProcessState.ExitCode(), true
}
if err != nil && !res.Exited {
return res, errors.Join(fmt.Errorf("runner: tmctl backup: %w: %s", err, res.Stderr), ctx.Err())
}
if res.Exited && res.ExitCode == 0 {
path, perr := backupPathIn(out.String(), workdir)
if perr != nil {
// A zero exit whose output cannot be read is worse than a failure: the copy exists
// somewhere and this side would report a success it cannot point at.
return res, perr
}
res.Path = path
}
return res, nil
}
// backupPathIn finds the restore point in the verb's output and refuses anything else.
//
// The guard on the directory is not ceremony. This value becomes a source path that gets copied into
// the deployment's backup store, so a line that named something outside the book's own directory
// would make the parse of another zone's prose into a file-read primitive. Inside the workdir it can
// only ever name the engine's own artifact.
func backupPathIn(stdout, workdir string) (string, error) {
for line := range strings.SplitSeq(stdout, "\n") {
line = strings.TrimRight(line, "\r")
if !strings.HasPrefix(line, backupLinePrefix) || !strings.HasSuffix(line, backupLineSuffix) {
continue
}
path := line[len(backupLinePrefix) : len(line)-len(backupLineSuffix)]
if path == "" {
break
}
if !filepath.IsAbs(path) {
path = filepath.Join(workdir, path)
}
clean := filepath.Clean(path)
if rel, err := filepath.Rel(workdir, clean); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("runner: tmctl backup reported a restore point outside the book's own directory: %q", clean)
}
return clean, nil
}
return "", errors.New("runner: tmctl backup exited 0 without naming the restore point it wrote; this build's output is not the one this platform can read")
}