Skip to main content

recall_server/
admin.rs

1//! `recall-server admin`: listing, renaming, removing and restoring the
2//! memory stored under a project key, from a shell on the server's host.
3//!
4//! ```text
5//! docker exec -it -u node recall-server recall-server admin list
6//! ```
7//!
8//! It is a subcommand rather than a route on purpose. The HTTP API has no
9//! way to delete or move a project, so a leaked token cannot destroy history
10//! through it, and this keeps that true: these commands open no listener of
11//! any kind, not even one bound to `127.0.0.1`. Running them takes a shell
12//! inside the server's container, which is the same access that could
13//! already open the database file directly, so they give no attacker
14//! anything new. "Admin commands" in `ARCHITECTURE.md` has the reasoning.
15//!
16//! What they add over the file is the procedure a human used to have to
17//! remember. Every change:
18//!
19//! 1. names its keys exactly (never a prefix or a pattern) and is confirmed
20//!    by typing each key back, a rename's target included, or by `--yes`,
21//!    which prints them instead;
22//! 2. takes a fresh backup first, with [`Store::backup`], into an `admin/`
23//!    directory the server's rotation never prunes, checks that the backup
24//!    holds the rows it was shown, and prints its path;
25//! 3. runs in one transaction, re-checks inside it that nothing changed
26//!    since it was shown, closes the merge jobs still open for the rows it
27//!    touches, so no worker's late result lands on them, appends one audit
28//!    leaf saying what it did, as the host, and commits only if `changes()`
29//!    matches;
30//! 4. waits out the server's merge window after committing, and checks that
31//!    no push that was already in flight has partly undone it, or queued a
32//!    job for its rows again;
33//! 5. can be previewed with `--dry-run`, which changes nothing and takes no
34//!    backup.
35//!
36//! It runs beside a running server. Both wait up to 5 seconds on a lock the
37//! other holds, and a change's transaction takes the write lock before it
38//! re-reads anything, so neither can fail the other half way. A lock cannot
39//! order a change against a whole push, though, which reads, merges with no
40//! lock held, and then writes: step 4 is there for the push that read before
41//! the change and writes after it. The store's admin module has the detail.
42//!
43//! [`reset_passkeys`] is here for the same reasons: it is the way back into
44//! `/admin` for an owner who lost every passkey, so it must take a shell on
45//! the host, and it is held to the same owner check and lock wait.
46
47use std::borrow::Cow;
48use std::io::{self, BufRead, IsTerminal, Write};
49use std::path::{Path, PathBuf};
50use std::process::ExitCode;
51use std::time::Duration;
52
53use anyhow::{bail, Context, Result};
54
55use crate::audit::leaf;
56use crate::store::admin::{
57    archive_key, invisible, Abandoned, Access, Applied, OpenJob, Plan, Restore, Row, Summary,
58};
59use crate::Store;
60
61const USAGE: &str = "\
62Changes the memory a Recall server stores, from a shell on its host.
63
64Usage:
65  recall-server admin list [--backup <file>] [<key>]
66  recall-server admin rename <from> <to> [--dry-run] [--yes]
67  recall-server admin remove <key> [--dry-run] [--yes]
68  recall-server admin restore <backup-file> <key> [--overwrite [--restore-deletions]]
69                              [--dry-run] [--yes]
70
71  list      Every project key with its files, tombstones and last update.
72            With a key, that key's files. With --backup, what a backup holds.
73  rename    Move every row of one key to another key that holds none.
74  remove    Delete every row of one key.
75  restore   Copy one key's rows from a backup into the live database.
76
77Options:
78  --dry-run            Print exactly what would change, and change nothing.
79  --yes                Confirm without typing the keys. They are printed
80                       instead.
81  --overwrite          Let restore replace live rows that differ from the
82                       backup, except a live file the backup has deleted.
83  --restore-deletions  With --overwrite, let restore turn such a live file
84                       into a tombstone too, which deletes it on every
85                       machine at its next pull.
86  --backup             Make list read a backup file instead of the live
87                       database.
88
89Keys are matched exactly, never by prefix or pattern. Put -- before a key
90that starts with a dash.
91
92Every change takes a backup first, into $RECALL_BACKUP_DIR/admin/, which the
93server's rotation never prunes. It runs in one transaction, which also closes
94the merge jobs still open for its rows and appends its leaf to the audit log,
95and commits only if exactly the rows and jobs it showed changed. Then it
96waits out the server's merge window (RECALL_MERGE_TIMEOUT_MS, plus a second)
97and checks that no push already in flight has partly undone it. The database
98is RECALL_DB_PATH.
99
100Exit status: 0 done, or nothing to do; 1 refused or failed, and nothing was
101changed; 2 a usage error; 3 the change was made, but something needs a look
102(the message says what).
103
104In the Docker setup, run it as the user the server runs as:
105  docker exec -it -u node recall-server recall-server admin list";
106
107/// Runs `recall-server admin`, given the arguments after `admin`.
108///
109/// Exits 0 on success, including a dry run and a change with nothing to do;
110/// 1 when a command is refused or fails, having changed nothing; 2 for a
111/// usage error; 3 when the change committed but the check after it found
112/// something the owner has to act on.
113pub fn main(args: &[String]) -> ExitCode {
114    let env = |key: &str| std::env::var(key).ok().filter(|v| !v.is_empty());
115    let stdin = io::stdin();
116    let stdout = io::stdout();
117    let terminal = stdin.is_terminal();
118    let command = match parse(args) {
119        Ok(command) => command,
120        Err(msg) => {
121            eprintln!("recall-server admin: {msg}\n\n{USAGE}");
122            return ExitCode::from(2);
123        }
124    };
125    let changes = command.changes_memory();
126    let io = Io {
127        input: &mut stdin.lock(),
128        terminal,
129        out: &mut stdout.lock(),
130    };
131    match execute(command, &env, io) {
132        Ok(()) => ExitCode::SUCCESS,
133        Err(err) if err.is::<AfterCommit>() => {
134            eprintln!("recall-server admin: {err:#}");
135            ExitCode::from(3)
136        }
137        Err(err) => {
138            eprintln!("recall-server admin: {err:#}");
139            // True of every other error a change can return: after COMMIT
140            // there is only printing, whose errors are ignored, and the
141            // check after the wait, whose every error is an AfterCommit.
142            if changes {
143                eprintln!("The database was not changed.");
144            }
145            ExitCode::FAILURE
146        }
147    }
148}
149
150/// A failure after the change committed: the one kind of error after which
151/// "the database was not changed" would be a lie.
152#[derive(Debug)]
153struct AfterCommit(String);
154
155impl std::fmt::Display for AfterCommit {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.write_str(&self.0)
158    }
159}
160
161impl std::error::Error for AfterCommit {}
162
163/// How long after the server's merge timeout a push that timed out can
164/// still take to write: the fallback write itself, and scheduling. Generous,
165/// because waiting a second too long costs nothing, and checking a second
166/// too early misses the very push this wait is for.
167const SETTLE_MARGIN: Duration = Duration::from_secs(1);
168
169/// How long a push that was in flight when a change committed can take to
170/// land: the server's merge timeout, read the way the server reads it, plus
171/// [`SETTLE_MARGIN`].
172///
173/// The command runs inside the server's container, so it sees the server's
174/// own environment. With merge turned off a push reads and writes with
175/// nothing to wait for in between, so only the margin is left.
176fn settle_window(env: &dyn Fn(&str) -> Option<String>) -> Duration {
177    // Both as `Config::from_lookup` has them: only a literal "false" turns
178    // merge off, and a timeout that is unparseable or zero means 45 seconds.
179    let merging = env("RECALL_MERGE_ENABLED").as_deref() != Some("false");
180    let timeout_ms = env("RECALL_MERGE_TIMEOUT_MS")
181        .and_then(|v| v.parse::<u64>().ok())
182        .filter(|ms| *ms > 0)
183        .unwrap_or(45_000);
184    if merging {
185        Duration::from_millis(timeout_ms) + SETTLE_MARGIN
186    } else {
187        SETTLE_MARGIN
188    }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
192struct Opts {
193    dry_run: bool,
194    yes: bool,
195}
196
197#[derive(Debug, PartialEq, Eq)]
198enum Command {
199    Help,
200    List {
201        backup: Option<PathBuf>,
202        key: Option<String>,
203    },
204    Rename {
205        from: String,
206        to: String,
207        opts: Opts,
208    },
209    Remove {
210        key: String,
211        opts: Opts,
212    },
213    Restore {
214        backup: PathBuf,
215        key: String,
216        overwrite: bool,
217        deletions: bool,
218        opts: Opts,
219    },
220}
221
222impl Command {
223    fn changes_memory(&self) -> bool {
224        matches!(
225            self,
226            Command::Rename { .. } | Command::Remove { .. } | Command::Restore { .. }
227        )
228    }
229}
230
231fn parse(args: &[String]) -> Result<Command, String> {
232    let Some((name, rest)) = args.split_first() else {
233        return Err("which command? list, rename, remove or restore".into());
234    };
235    let allowed: &[&str] = match name.as_str() {
236        "help" => &[],
237        "list" => &["--backup"],
238        "rename" | "remove" => &["--dry-run", "--yes"],
239        "restore" => &["--overwrite", "--restore-deletions", "--dry-run", "--yes"],
240        other => return Err(format!("unknown command {other:?}")),
241    };
242
243    let mut positional = Vec::new();
244    let mut flags = Vec::new();
245    let mut backup = None;
246    let mut rest = rest.iter();
247    let mut options_done = false;
248    while let Some(arg) = rest.next() {
249        if options_done || !arg.starts_with('-') || arg == "-" {
250            positional.push(arg.clone());
251            continue;
252        }
253        match arg.as_str() {
254            "--" => options_done = true,
255            "-h" | "--help" => return Ok(Command::Help),
256            "--dry-run" | "--yes" | "--overwrite" | "--restore-deletions" => {
257                flags.push(arg.as_str())
258            }
259            "--backup" => {
260                backup = Some(rest.next().ok_or("--backup needs a file")?.clone());
261                flags.push("--backup");
262            }
263            other => match other.strip_prefix("--backup=") {
264                Some(file) => {
265                    backup = Some(file.to_owned());
266                    flags.push("--backup");
267                }
268                None => return Err(format!("unknown option {other}")),
269            },
270        }
271    }
272    if let Some(flag) = flags.iter().find(|f| !allowed.contains(f)) {
273        return Err(format!("{flag} is not an option of {name}"));
274    }
275    let has = |flag: &str| flags.contains(&flag);
276    // Turning a live file into a tombstone is replacing a live row, so it
277    // is asked for on top of permission to replace rows at all, never
278    // instead of it.
279    if has("--restore-deletions") && !has("--overwrite") {
280        return Err("--restore-deletions needs --overwrite as well".into());
281    }
282    let opts = Opts {
283        dry_run: has("--dry-run"),
284        yes: has("--yes"),
285    };
286
287    let count = positional.len();
288    let mut positional = positional.into_iter();
289    let mut next = || positional.next().unwrap_or_default();
290    match (name.as_str(), count) {
291        ("help", 0) => Ok(Command::Help),
292        ("list", 0) => Ok(Command::List {
293            backup: backup.map(PathBuf::from),
294            key: None,
295        }),
296        ("list", 1) => Ok(Command::List {
297            backup: backup.map(PathBuf::from),
298            key: Some(next()),
299        }),
300        ("rename", 2) => Ok(Command::Rename {
301            from: next(),
302            to: next(),
303            opts,
304        }),
305        ("remove", 1) => Ok(Command::Remove { key: next(), opts }),
306        ("restore", 2) => Ok(Command::Restore {
307            backup: PathBuf::from(next()),
308            key: next(),
309            overwrite: has("--overwrite"),
310            deletions: has("--restore-deletions"),
311            opts,
312        }),
313        ("help", _) => Err("help takes no arguments".into()),
314        ("list", _) => Err("list takes at most one key".into()),
315        ("rename", _) => Err("rename takes two keys: <from> <to>".into()),
316        ("remove", _) => Err("remove takes one key".into()),
317        _ => Err("restore takes a backup file and a key: <backup-file> <key>".into()),
318    }
319}
320
321/// How a command talks to the person running it.
322struct Io<'a> {
323    input: &'a mut dyn BufRead,
324    /// Whether `input` is a terminal, which echoes the newline a typed
325    /// confirmation ends with. Piped input does not, so one is printed.
326    terminal: bool,
327    out: &'a mut dyn Write,
328}
329
330/// Where a command reads and writes, and how it talks to the person
331/// running it.
332struct Ctx<'a> {
333    db: PathBuf,
334    backup_dir: Option<PathBuf>,
335    /// How long to wait after a commit before checking it held; see
336    /// [`settle_window`].
337    settle: Duration,
338    input: &'a mut dyn BufRead,
339    terminal: bool,
340    out: &'a mut dyn Write,
341}
342
343fn execute(command: Command, env: &dyn Fn(&str) -> Option<String>, io: Io) -> Result<()> {
344    let mut ctx = Ctx {
345        // The server's own default, so the two cannot disagree about which
346        // file is meant. Nothing is created if it is missing.
347        db: PathBuf::from(env("RECALL_DB_PATH").unwrap_or_else(|| "data/recall.db".into())),
348        backup_dir: env("RECALL_BACKUP_DIR").map(PathBuf::from),
349        settle: settle_window(env),
350        input: io.input,
351        terminal: io.terminal,
352        out: io.out,
353    };
354    match command {
355        Command::Help => {
356            writeln!(ctx.out, "{USAGE}")?;
357            Ok(())
358        }
359        Command::List { backup, key } => list(&mut ctx, backup.as_deref(), key.as_deref()),
360        Command::Rename { from, to, opts } => {
361            let store = open_live(&ctx.db, opts)?;
362            require_key(&store, &from, LIVE)?;
363            let plan = store.plan_rename(&from, &to)?;
364            let again = format!("recall-server admin rename {}", sh_args(&[&from, &to]));
365            carry_out(&mut ctx, &store, &plan, &again, None, opts)
366        }
367        Command::Remove { key, opts } => {
368            let store = open_live(&ctx.db, opts)?;
369            require_key(&store, &key, LIVE)?;
370            let plan = store.plan_remove(&key)?;
371            let again = format!("recall-server admin remove {}", sh_args(&[&key]));
372            carry_out(&mut ctx, &store, &plan, &again, None, opts)
373        }
374        Command::Restore {
375            backup,
376            key,
377            overwrite,
378            deletions,
379            opts,
380        } => {
381            if same_file(&backup, &ctx.db) {
382                bail!(
383                    "{} is the live database itself. Name a backup file: `ls` the backup \
384                     directory, or see `recall-server admin list --backup <file>`",
385                    backup.display()
386                );
387            }
388            let source = Store::open_existing(&backup, Access::Read)?;
389            require_key(&source, &key, BACKUP)?;
390            let rows = source.rows(&key)?;
391            let store = open_live(&ctx.db, opts)?;
392            writeln!(ctx.out, "Backup: {}", backup.display())?;
393            let plan = store.plan_restore(&key, rows, overwrite, deletions)?;
394            let again = format!(
395                "recall-server admin restore --overwrite{} {}",
396                if deletions {
397                    " --restore-deletions"
398                } else {
399                    ""
400                },
401                sh_args(&[&backup.to_string_lossy(), &key])
402            );
403            carry_out(&mut ctx, &store, &plan, &again, Some(&backup), opts)
404        }
405    }
406}
407
408/// Positional arguments as shell words, for a command line the owner is
409/// told to run: each as is when that is unambiguous and single-quoted
410/// otherwise, all after `--` when one starts with a dash (see USAGE).
411fn sh_args(args: &[&str]) -> String {
412    let words: Vec<String> = args
413        .iter()
414        .map(|s| {
415            let plain = !s.is_empty()
416                && s.chars()
417                    .all(|c| c.is_ascii_alphanumeric() || "-_./:@%+=,".contains(c));
418            if plain {
419                s.to_string()
420            } else {
421                format!("'{}'", s.replace('\'', r"'\''"))
422            }
423        })
424        .collect();
425    let dash = if args.iter().any(|s| s.starts_with('-')) {
426        "-- "
427    } else {
428        ""
429    };
430    format!("{dash}{}", words.join(" "))
431}
432
433fn list(ctx: &mut Ctx, backup: Option<&Path>, key: Option<&str>) -> Result<()> {
434    let (label, path) = match backup {
435        Some(file) => ("Backup", file),
436        None => ("Database", ctx.db.as_path()),
437    };
438    let store = Store::open_existing(path, Access::Read)?;
439    let out = &mut *ctx.out;
440    writeln!(out, "{label}: {}", path.display())?;
441
442    let Some(key) = key else {
443        let summaries = store.summaries()?;
444        if summaries.is_empty() {
445            writeln!(out, "No project keys.")?;
446            return Ok(());
447        }
448        writeln!(
449            out,
450            "{:>7}  {:>10}  {:<24}  PROJECT KEY",
451            "FILES", "TOMBSTONES", "LAST UPDATE"
452        )?;
453        for s in &summaries {
454            writeln!(
455                out,
456                "{:>7}  {:>10}  {:<24}  {}",
457                s.files,
458                s.tombstones,
459                s.last_updated_at,
460                shown(&s.project_key)
461            )?;
462        }
463        let files: i64 = summaries.iter().map(|s| s.files).sum();
464        let tombstones: i64 = summaries.iter().map(|s| s.tombstones).sum();
465        writeln!(
466            out,
467            "{} project key(s), {files} file(s), {tombstones} tombstone(s).",
468            summaries.len()
469        )?;
470        return Ok(());
471    };
472
473    let what = if backup.is_some() { BACKUP } else { LIVE };
474    require_key(&store, key, what)?;
475    let rows = store.rows(key)?;
476    let tombstones = rows.iter().filter(|r| r.deleted).count();
477    writeln!(
478        out,
479        "Project key {key:?}: {} file(s), {tombstones} tombstone(s).",
480        rows.len() - tombstones
481    )?;
482    let source_width = rows
483        .iter()
484        .map(|r| source(r).chars().count())
485        .max()
486        .unwrap_or(0)
487        .max("SOURCE".len());
488    writeln!(
489        out,
490        "{:<9}  {:>8}  {:<24}  {:<source_width$}  PATH",
491        "STATE", "BYTES", "UPDATED", "SOURCE"
492    )?;
493    for r in &rows {
494        writeln!(
495            out,
496            "{:<9}  {:>8}  {:<24}  {:<source_width$}  {}",
497            state(r),
498            r.content.len(),
499            r.updated_at,
500            source(r),
501            shown(&r.file_path)
502        )?;
503    }
504    Ok(())
505}
506
507/// Runs `recall-server reset-passkeys`: removes every passkey and admin
508/// session from the database at `RECALL_DB_PATH`, and prints a new
509/// bootstrap code, which with `RECALL_TOKEN` registers a first passkey
510/// again.
511///
512/// A command rather than a route on purpose: the one way back in after the
513/// last passkey is lost must not be something `RECALL_TOKEN` can do over the
514/// network, or a leaked token could replace the owner's passkeys. It is
515/// held to what a change made by `admin` is held to: it runs as the
516/// database's owner, and waits out a server mid-write rather than failing.
517/// Exits 0 when done, 1 when refused or failed.
518pub fn reset_passkeys() -> ExitCode {
519    let db = PathBuf::from(
520        std::env::var("RECALL_DB_PATH")
521            .ok()
522            .filter(|p| !p.is_empty())
523            .unwrap_or_else(|| "data/recall.db".into()),
524    );
525    match reset_passkeys_at(&db) {
526        Ok(said) => {
527            println!("{said}");
528            ExitCode::SUCCESS
529        }
530        Err(err) => {
531            eprintln!("recall-server reset-passkeys: {err:#}");
532            ExitCode::FAILURE
533        }
534    }
535}
536
537fn reset_passkeys_at(db: &Path) -> Result<String> {
538    check_owner(db)?;
539    // Not `Store::open`, which would create a database at a mistyped path.
540    // `open_existing` also sets the same busy timeout the other commands
541    // wait on a running server with.
542    let store = Store::open_existing(db, Access::Write)?;
543    let (removed, code) = crate::bootstrap::reset(&store, time::OffsetDateTime::now_utc())
544        .with_context(|| format!("resetting the passkeys in {}", db.display()))?;
545    Ok(format!(
546        "Removed {removed} passkey(s) and every admin session from {}.\n\n{}",
547        db.display(),
548        code.instructions(None)
549    ))
550}
551
552/// Opens the live database: read-only for a dry run, which then provably
553/// changes nothing, and otherwise only after the checks a change needs.
554fn open_live(db: &Path, opts: Opts) -> Result<Store> {
555    if opts.dry_run {
556        return Store::open_existing(db, Access::Read);
557    }
558    // There is no journal mode to check. `off` and `memory`, the two under
559    // which a transaction cannot be rolled back, are settings of the
560    // connection that asks for them and are never stored in the file, so
561    // this connection always has the file's own: WAL, which the server
562    // sets and the file keeps, or the rollback journal of a file no current
563    // server has opened yet. Both roll back.
564    check_owner(db)?;
565    Store::open_existing(db, Access::Write)
566}
567
568const LIVE: &str = "The live database";
569const BACKUP: &str = "The backup";
570
571/// Refuses a key that is not stored exactly as given, suggesting keys that
572/// look like it. Suggesting is as far as it goes: nothing here ever acts on
573/// a key it was not given character for character.
574fn require_key(store: &Store, key: &str, what: &str) -> Result<()> {
575    if !store.rows(key)?.is_empty() {
576        return Ok(());
577    }
578    let near = similar_keys(&store.summaries()?, key);
579    let hint = if near.is_empty() && what == BACKUP {
580        "`recall-server admin list --backup <file>` shows every key it holds.".to_string()
581    } else if near.is_empty() {
582        "`recall-server admin list` shows every key.".to_string()
583    } else {
584        let near: Vec<String> = near.iter().map(|k| format!("{k:?}")).collect();
585        format!("Keys that look similar: {}.", near.join(", "))
586    };
587    bail!(
588        "{what} holds no project key that is exactly {key:?}. Keys are matched exactly, never \
589         by prefix or pattern. {hint}"
590    )
591}
592
593fn similar_keys(summaries: &[Summary], key: &str) -> Vec<String> {
594    let needle = key.trim().to_lowercase();
595    if needle.is_empty() {
596        return Vec::new();
597    }
598    summaries
599        .iter()
600        .map(|s| &s.project_key)
601        .filter(|k| {
602            let k = k.to_lowercase();
603            k.contains(&needle) || needle.contains(&k)
604        })
605        .take(10)
606        .cloned()
607        .collect()
608}
609
610/// The part every change shares: show it, check it, confirm it, back up,
611/// check the backup, apply it, and check it held. `again` is the command
612/// line that makes the same change, for when that check says to; `source`
613/// is the backup a restore restores from.
614fn carry_out(
615    ctx: &mut Ctx,
616    store: &Store,
617    plan: &Plan,
618    again: &str,
619    source: Option<&Path>,
620    opts: Opts,
621) -> Result<()> {
622    writeln!(ctx.out, "Database: {}", ctx.db.display())?;
623    describe(ctx.out, plan)?;
624    if let Some(why) = plan.refusal() {
625        bail!("refusing: {why}");
626    }
627    if let Plan::Remove { key, .. } = plan {
628        warn_unique(ctx.out, store, key)?;
629    }
630    if plan.expected_changes() == 0 {
631        writeln!(ctx.out, "Nothing to change.")?;
632        return Ok(());
633    }
634    if opts.dry_run {
635        writeln!(
636            ctx.out,
637            "Dry run: nothing was changed, and no backup was taken."
638        )?;
639        return Ok(());
640    }
641
642    // Asked before the confirmation, so nobody types a key only to be told
643    // there is nowhere to put the backup.
644    let Some(backup_dir) = ctx.backup_dir.clone() else {
645        bail!(
646            "RECALL_BACKUP_DIR is not set, so there is nowhere to take the backup every change \
647             takes first. Set it to where the server's own snapshots go (/backups in the \
648             Docker setup)"
649        );
650    };
651    confirm(ctx, plan, opts.yes)?;
652
653    // Its own directory, because the server prunes `recall-*.db` in the
654    // backup directory itself down to RECALL_BACKUP_KEEP: a snapshot taken
655    // here must neither push out one of those nor be pushed out by them.
656    // `usize::MAX` keeps every one, and `backup-offbox.sh` copies them too,
657    // since rclone's include pattern matches at any depth.
658    let snapshot = store
659        .backup(backup_dir.join("admin"), usize::MAX)
660        .context("taking the backup that every change takes first")?;
661    writeln!(ctx.out, "Backup written: {}", snapshot.display())?;
662
663    let checked = Store::open_existing(&snapshot, Access::Read)
664        .and_then(|taken| plan.snapshot_mismatch(&taken));
665    match checked {
666        Ok(None) => {}
667        Ok(Some(why)) => {
668            discard(ctx.out, &snapshot);
669            bail!(
670                "the backup just taken does not hold the rows shown above: {why}. Either a \
671                 push arrived since they were shown, or the backup is incomplete. Run the \
672                 command again to see them as they are now"
673            );
674        }
675        Err(err) => {
676            discard(ctx.out, &snapshot);
677            return Err(err.context("reading back the backup just taken"));
678        }
679    }
680
681    // The leaf names the backup by its file name: where it is, the
682    // directory every change's backup goes to, is this host's business,
683    // and what it holds is exactly what the log must never carry.
684    let backup_name = file_name(&snapshot);
685    let source_name = source.map(file_name).unwrap_or_default();
686    let mut appended = None;
687    let applied = match store.apply(plan, |seq, at, applied| {
688        appended = Some(seq);
689        audit_leaf(seq, at, plan, applied, &backup_name, &source_name)
690    }) {
691        Ok(applied) => applied,
692        Err(err) => {
693            // A change abandoned before it began leaves the database as
694            // it was, so its backup would only pile up, one per retry, next
695            // to the one the retry takes. Any other failure keeps its
696            // backup: something was attempted, and the backup is what shows
697            // the state it was attempted on.
698            if err.is::<Abandoned>() {
699                discard(ctx.out, &snapshot);
700            }
701            return Err(err);
702        }
703    };
704
705    // Committed. From here, every error must be an AfterCommit, or main
706    // would claim the database was not changed.
707    let _ = writeln!(ctx.out, "{}", done(plan, &applied));
708    if let Some(seq) = appended {
709        let _ = writeln!(
710            ctx.out,
711            "Recorded in the audit log as leaf {seq} ({}), in the same transaction.",
712            change_of(plan, &applied, &source_name).action()
713        );
714    }
715    settle(ctx, store, plan, again)
716}
717
718/// A path's last component, as the audit leaf names a backup.
719fn file_name(path: &Path) -> String {
720    path.file_name()
721        .map(|n| n.to_string_lossy().into_owned())
722        .unwrap_or_else(|| path.to_string_lossy().into_owned())
723}
724
725/// What `plan` did, as its audit leaf says it.
726fn change_of<'a>(plan: &'a Plan, applied: &Applied, source: &'a str) -> leaf::AdminChange<'a> {
727    match plan {
728        Plan::Rename { from, to, .. } => leaf::AdminChange::Rename {
729            from,
730            to,
731            rows: applied.rows,
732        },
733        Plan::Remove { key, .. } => leaf::AdminChange::Remove {
734            project_key: key,
735            rows: applied.rows,
736        },
737        Plan::Restore(r) => leaf::AdminChange::Restore {
738            project_key: &r.key,
739            source,
740            added: r.add.len(),
741            overwritten: r.overwrite.len(),
742            deleted: r.applied_deletions().len(),
743        },
744    }
745}
746
747/// The leaf a committed change appends: the host acting, signing nothing.
748fn audit_leaf(
749    seq: u64,
750    at: &str,
751    plan: &Plan,
752    applied: &Applied,
753    backup: &str,
754    source: &str,
755) -> Vec<u8> {
756    let change = change_of(plan, applied, source);
757    leaf::encode(
758        seq,
759        at,
760        change.action(),
761        &leaf::Actor::Host,
762        leaf::subject_admin(&change, &applied.jobs, backup),
763        None,
764    )
765}
766
767/// Deletes the backup of a change that did not happen, and says so.
768fn discard(out: &mut dyn Write, snapshot: &Path) {
769    let _ = match std::fs::remove_file(snapshot) {
770        Ok(()) => writeln!(
771            out,
772            "Deleted that backup again: the change it was taken for did not happen."
773        ),
774        Err(err) => writeln!(
775            out,
776            "The change that backup was taken for did not happen, and deleting it failed \
777             ({err}); delete it by hand."
778        ),
779    };
780}
781
782/// Warns, before the confirmation, that removing `key` removes content
783/// that is under no other key.
784///
785/// The case it is for: a key being folded into another, where the owner
786/// believes its notes already arrived there. If they did, this is silent.
787fn warn_unique(out: &mut dyn Write, store: &Store, key: &str) -> Result<()> {
788    let unique = store.unique_live_paths(key)?;
789    if unique.is_empty() {
790        return Ok(());
791    }
792    writeln!(
793        out,
794        "Warning: {} of these file(s) hold content that no live file under any other key has, \
795         so after this it exists only in the backup this command takes:",
796        unique.len()
797    )?;
798    for path in &unique {
799        writeln!(out, "  {}", shown(path))?;
800    }
801    writeln!(
802        out,
803        "If {key:?} is being folded into another key, rename it to an archive key such as {:?} \
804         instead, and bring the content across by hand (\"Folding one key into another\" in \
805         deploy/README.md).",
806        archive_key(key)
807    )?;
808    Ok(())
809}
810
811/// Waits until a push that was in flight when `plan` committed has had time
812/// to land, then checks the change still stands.
813///
814/// Only a change that replaced or emptied existing rows needs it: a push in
815/// flight read one of those rows, and when it writes, it writes the old key
816/// or a merge of the old row. A restore that only added rows replaced
817/// nothing a push could have read.
818fn settle(ctx: &mut Ctx, store: &Store, plan: &Plan, again: &str) -> Result<()> {
819    let checks = match plan {
820        Plan::Rename { .. } | Plan::Remove { .. } => true,
821        Plan::Restore(r) => r.replaces_live_rows(),
822    };
823    if !checks {
824        return Ok(());
825    }
826    let _ = writeln!(
827        ctx.out,
828        "Waiting {:.1}s before checking that the change held. The server reads a file before \
829         merging a push into it and writes the result up to RECALL_MERGE_TIMEOUT_MS later, \
830         holding no lock in between, so a push already being merged as this committed can land \
831         after it and partly undo it. The change is committed; interrupting now only skips the \
832         check.",
833        ctx.settle.as_secs_f64()
834    );
835    let _ = ctx.out.flush();
836    std::thread::sleep(ctx.settle);
837
838    let undone = plan.undone(store).map_err(|err| {
839        AfterCommit(format!(
840            "the change was committed, but checking afterwards that it held failed: {err:#}. \
841             Look with `recall-server admin list`"
842        ))
843    })?;
844    if undone.is_empty() {
845        let _ = writeln!(ctx.out, "Checked: the change held.");
846        return Ok(());
847    }
848    // A job open for these rows again was queued by a push that landed
849    // after the commit, which also wrote its row: said beside the paths
850    // rather than instead of them, since its result will merge there too.
851    let jobs = if undone.jobs.is_empty() {
852        String::new()
853    } else {
854        format!(
855            "\n{} merge job(s) were queued for them since, and will merge there:\n{}",
856            undone.jobs.len(),
857            job_lines(&undone.jobs)
858        )
859    };
860    let listed: Vec<String> = undone
861        .paths
862        .iter()
863        .map(|p| format!("  {}", shown(p)))
864        .collect();
865    let paths = listed.join("\n") + &jobs;
866    let n = undone.paths.len();
867    Err(AfterCommit(match plan {
868        Plan::Rename { from, to, .. } => format!(
869            "the rename was committed, but {n} row(s) are under {from:?} again:\n{paths}\n\
870             Either a push already being merged when it committed landed afterwards, or a \
871             machine is still syncing under {from:?}. Those rows are newer than what moved to \
872             {to:?}, and nothing is lost: they are in the live database. Set \
873             RECALL_PROJECT_KEY={to} on any machine still using {from:?}. Then the rename \
874             cannot simply be run again, since {to:?} now holds rows: rename {from:?} to an \
875             archive key such as {:?} and bring what those rows add into {to:?} by hand, as \
876             \"Folding one key into another\" in deploy/README.md describes.",
877            archive_key(from)
878        ),
879        Plan::Remove { key, .. } => format!(
880            "the remove was committed, but {n} row(s) are under {key:?} again:\n{paths}\n\
881             Either a push already being merged when it committed landed afterwards, or a \
882             machine is still syncing under {key:?}. Set RECALL_PROJECT_KEY on any machine \
883             still using {key:?}, then run `{again}` again; it shows these rows first and takes \
884             a backup of them."
885        ),
886        Plan::Restore(r) => format!(
887            "the restore was committed, but {n} restored row(s) under {:?} are no longer what \
888             it wrote:\n{paths}\nEither a push already being merged when it committed wrote \
889             over them (a merge made from the version the restore replaced), or a machine has \
890             edited them since. To put the backup's version back, run `{again}` again; it \
891             shows each difference first.",
892            r.key
893        ),
894    })
895    .into())
896}
897
898/// Asks the owner to type back every key the change names: a rename's
899/// target as well as its source, since a typo there strands the project
900/// where no machine will ever pull it.
901fn confirm(ctx: &mut Ctx, plan: &Plan, yes: bool) -> Result<()> {
902    let keys: Vec<(&str, &str)> = match plan {
903        Plan::Rename { from, to, .. } => vec![
904            ("the project key to rename", from),
905            ("the key to rename it to", to),
906        ],
907        Plan::Remove { key, .. } => vec![("the project key", key)],
908        Plan::Restore(r) => vec![("the project key", &r.key)],
909    };
910    if yes {
911        let named: Vec<String> = keys
912            .iter()
913            .map(|(what, key)| format!("{} {key:?}", what.trim_start_matches("the ")))
914            .collect();
915        writeln!(
916            ctx.out,
917            "Confirmed with --yes for {}.",
918            named.join(", and ")
919        )?;
920        return Ok(());
921    }
922    for (i, (what, key)) in keys.iter().enumerate() {
923        let lead = if i == 0 {
924            "To go ahead, type"
925        } else {
926            "Then type"
927        };
928        write!(
929            ctx.out,
930            "{lead} {what}, {key:?}, exactly, without the quotes: "
931        )?;
932        ctx.out.flush()?;
933        let mut line = String::new();
934        if ctx
935            .input
936            .read_line(&mut line)
937            .context("reading the confirmation")?
938            == 0
939        {
940            bail!("nothing was typed to confirm. Pass --yes to confirm without a prompt");
941        }
942        if !ctx.terminal {
943            writeln!(ctx.out)?;
944        }
945        let typed = line.strip_suffix('\n').unwrap_or(&line);
946        let typed = typed.strip_suffix('\r').unwrap_or(typed);
947        if typed != *key {
948            bail!("{typed:?} is not {key:?}, so the change was not confirmed");
949        }
950    }
951    Ok(())
952}
953
954fn describe(out: &mut dyn Write, plan: &Plan) -> io::Result<()> {
955    match plan {
956        Plan::Rename {
957            from,
958            to,
959            rows,
960            occupied,
961            ..
962        } => {
963            writeln!(
964                out,
965                "Rename {from:?} to {to:?}: {} row(s) move ({}).",
966                rows.len(),
967                counts(rows)
968            )?;
969            for r in rows {
970                writeln!(out, "  {}", path_line(r))?;
971            }
972            if !occupied.is_empty() {
973                writeln!(
974                    out,
975                    "{to:?} already holds {} row(s) ({}):",
976                    occupied.len(),
977                    counts(occupied)
978                )?;
979                for r in occupied {
980                    writeln!(out, "  {}", path_line(r))?;
981                }
982            }
983        }
984        Plan::Remove { key, rows, .. } => {
985            writeln!(
986                out,
987                "Remove {key:?}: {} row(s) are deleted, content included ({}).",
988                rows.len(),
989                counts(rows)
990            )?;
991            for r in rows {
992                writeln!(out, "  {}", path_line(r))?;
993            }
994        }
995        Plan::Restore(r) => describe_restore(out, r)?,
996    }
997    describe_jobs(out, plan)
998}
999
1000/// How many open merge jobs the change closes, and which: always said,
1001/// none included, since a dry run is where the owner learns it.
1002fn describe_jobs(out: &mut dyn Write, plan: &Plan) -> io::Result<()> {
1003    let jobs = plan.jobs();
1004    let why = match plan {
1005        Plan::Rename { from, .. } => {
1006            format!("their versions are {from:?}'s, and a result must not land under either key")
1007        }
1008        Plan::Remove { .. } => "a result must not bring the removed notes back".to_string(),
1009        Plan::Restore(_) => {
1010            "a result, a merge of the versions the restore replaces, must not land over it"
1011                .to_string()
1012        }
1013    };
1014    if jobs.is_empty() {
1015        return writeln!(out, "Open merge jobs for these rows: none.");
1016    }
1017    writeln!(
1018        out,
1019        "Open merge jobs for these rows: {}, closed with the change ({why}):",
1020        jobs.len()
1021    )?;
1022    writeln!(out, "{}", job_lines(jobs))
1023}
1024
1025/// One line per job: its id, its state and its file.
1026fn job_lines(jobs: &[OpenJob]) -> String {
1027    jobs.iter()
1028        .map(|j| format!("  {}  {:<6}  {}", j.id, j.state, shown(&j.file_path)))
1029        .collect::<Vec<_>>()
1030        .join("\n")
1031}
1032
1033fn describe_restore(out: &mut dyn Write, r: &Restore) -> io::Result<()> {
1034    let deleting = r.applied_deletions().len();
1035    writeln!(
1036        out,
1037        "Restore {:?} from the backup: {} row(s) would change, {} added and {} overwritten{}.",
1038        r.key,
1039        r.add.len() + r.overwrite.len() + deleting,
1040        r.add.len(),
1041        r.overwrite.len(),
1042        if deleting > 0 {
1043            format!(", and {deleting} live file(s) deleted")
1044        } else {
1045            String::new()
1046        }
1047    )?;
1048    let width = r
1049        .backup
1050        .iter()
1051        .map(|b| shown(&b.file_path).chars().count())
1052        .chain(r.live_only.iter().map(|p| shown(p).chars().count()))
1053        .max()
1054        .unwrap_or(0);
1055    for row in &r.add {
1056        writeln!(
1057            out,
1058            "  add        {:<width$}  {}, {} bytes, updated {}, source {}",
1059            shown(&row.file_path),
1060            state(row),
1061            row.content.len(),
1062            row.updated_at,
1063            source(row)
1064        )?;
1065    }
1066    for (live, backup) in &r.overwrite {
1067        writeln!(
1068            out,
1069            "  overwrite  {:<width$}  {}",
1070            shown(&live.file_path),
1071            differences(live, backup)
1072        )?;
1073    }
1074    for (live, backup) in r.applied_deletions() {
1075        writeln!(
1076            out,
1077            "  delete     {:<width$}  {}; deleted from every machine at its next pull",
1078            shown(&live.file_path),
1079            differences(live, backup)
1080        )?;
1081    }
1082    for (live, _) in r.skipped_deletions() {
1083        writeln!(
1084            out,
1085            "  skipped    {:<width$}  a file now, deleted in the backup; left as it is \
1086             (--overwrite --restore-deletions would delete it on every machine)",
1087            shown(&live.file_path)
1088        )?;
1089    }
1090    for path in &r.unchanged {
1091        writeln!(out, "  unchanged  {}", shown(path))?;
1092    }
1093    for path in &r.live_only {
1094        writeln!(
1095            out,
1096            "  untouched  {:<width$}  not in the backup, so it stays as it is",
1097            shown(path)
1098        )?;
1099    }
1100    Ok(())
1101}
1102
1103/// Every field an overwrite would change, live value first.
1104fn differences(live: &Row, backup: &Row) -> String {
1105    let mut parts = Vec::new();
1106    if live.deleted != backup.deleted {
1107        parts.push(format!("{} -> {}", state(live), state(backup)));
1108    }
1109    if live.content != backup.content {
1110        parts.push(if live.content.len() == backup.content.len() {
1111            format!("content differs, {} bytes each", live.content.len())
1112        } else {
1113            format!(
1114                "content {} -> {} bytes",
1115                live.content.len(),
1116                backup.content.len()
1117            )
1118        });
1119    }
1120    if live.updated_at != backup.updated_at {
1121        parts.push(format!(
1122            "updated {} -> {}",
1123            live.updated_at, backup.updated_at
1124        ));
1125    }
1126    if live.source_env != backup.source_env {
1127        parts.push(format!("source {} -> {}", source(live), source(backup)));
1128    }
1129    parts.join("; ")
1130}
1131
1132fn done(plan: &Plan, applied: &Applied) -> String {
1133    let said = done_rows(plan, applied.rows);
1134    if applied.jobs.is_empty() {
1135        return said;
1136    }
1137    format!(
1138        "{said}\nClosed {} open merge job(s), so no result lands on these rows: {}.",
1139        applied.jobs.len(),
1140        applied.jobs.join(", ")
1141    )
1142}
1143
1144fn done_rows(plan: &Plan, changed: usize) -> String {
1145    match plan {
1146        Plan::Rename { from, to, .. } => format!(
1147            "Done: moved {changed} row(s) from {from:?} to {to:?}. A machine still syncing under \
1148             {from:?} will write to it again on its next push; set RECALL_PROJECT_KEY={to} there."
1149        ),
1150        Plan::Remove { key, .. } => format!(
1151            "Done: removed {changed} row(s) under {key:?}. A machine still syncing under it \
1152             will create it again on its next push."
1153        ),
1154        Plan::Restore(r) => {
1155            let mut said = format!(
1156                "Done: restored {changed} row(s) under {:?} ({} added, {} overwritten",
1157                r.key,
1158                r.add.len(),
1159                r.overwrite.len()
1160            );
1161            let deleted = r.applied_deletions().len();
1162            if deleted > 0 {
1163                said += &format!(
1164                    ", {deleted} live file(s) turned into tombstones, which deletes them on \
1165                     every machine at its next pull"
1166                );
1167            }
1168            said += ").";
1169            let skipped = r.skipped_deletions().len();
1170            if skipped > 0 {
1171                said += &format!(
1172                    " {skipped} live file(s) the backup has as deleted were left as they are."
1173                );
1174            }
1175            said + " A machine that edits a restored file before it next pulls pushes its own \
1176                    version over the restored one."
1177        }
1178    }
1179}
1180
1181fn counts(rows: &[Row]) -> String {
1182    let tombstones = rows.iter().filter(|r| r.deleted).count();
1183    format!(
1184        "{} file(s), {tombstones} tombstone(s)",
1185        rows.len() - tombstones
1186    )
1187}
1188
1189fn path_line(row: &Row) -> String {
1190    if row.deleted {
1191        format!("{}  (tombstone)", shown(&row.file_path))
1192    } else {
1193        shown(&row.file_path).into_owned()
1194    }
1195}
1196
1197fn state(row: &Row) -> &'static str {
1198    if row.deleted {
1199        "tombstone"
1200    } else {
1201        "file"
1202    }
1203}
1204
1205/// The machine a row came from. NULL and `''` are told apart, because a
1206/// restore compares them and would otherwise report a change it cannot show.
1207fn source(row: &Row) -> &str {
1208    match row.source_env.as_deref() {
1209        None => "(none)",
1210        Some("") => "\"\"",
1211        Some(s) => s,
1212    }
1213}
1214
1215/// A key or path as printed: as is, unless it would be ambiguous on a
1216/// terminal, in which case it is quoted and escaped, so the exact characters
1217/// are always visible.
1218///
1219/// Ambiguous means empty, starting with a dash, holding whitespace, or
1220/// holding anything `{:?}` would escape: a control character, a quote or
1221/// backslash, or one of the characters Rust treats as unprintable, which
1222/// include zero-width and bidi format characters. On top of those, anything
1223/// [`invisible`] names, since a few (fillers, variation selectors) are
1224/// printable to Rust and print as nothing on a terminal all the same.
1225fn shown(s: &str) -> Cow<'_, str> {
1226    // An apostrophe is left alone: inside double quotes it is unambiguous,
1227    // and `char::escape_debug` would escape it where `{:?}` on a string
1228    // does not.
1229    let escaped = |c: char| c != '\'' && c.escape_debug().ne(std::iter::once(c));
1230    if s.is_empty()
1231        || s.starts_with('-')
1232        || s.chars()
1233            .any(|c| c.is_whitespace() || escaped(c) || invisible(c))
1234    {
1235        let mut quoted = String::from("\"");
1236        for c in s.chars() {
1237            if escaped(c) {
1238                quoted.extend(c.escape_debug());
1239            } else if invisible(c) {
1240                quoted += &format!("\\u{{{:x}}}", c as u32);
1241            } else {
1242                quoted.push(c);
1243            }
1244        }
1245        quoted.push('"');
1246        Cow::Owned(quoted)
1247    } else {
1248        Cow::Borrowed(s)
1249    }
1250}
1251
1252/// Refuses to change the database as a user other than its owner.
1253///
1254/// `docker exec` runs as root unless told otherwise, while the server runs
1255/// as `node`. A change made as root leaves root-owned files behind: a
1256/// backup, and after a crash mid-transaction a hot journal, which the
1257/// server cannot open to roll back, so it cannot read the database until
1258/// something fixes the ownership.
1259#[cfg(target_os = "linux")]
1260fn check_owner(db: &Path) -> Result<()> {
1261    use std::os::unix::fs::MetadataExt;
1262    // A database that is not there is `open_existing`'s to report, with the
1263    // message written for exactly that.
1264    let Ok(file) = std::fs::metadata(db) else {
1265        return Ok(());
1266    };
1267    // /proc/self is owned by the process's effective uid: the one way to
1268    // learn it from std alone, without a libc dependency for one call.
1269    let me = std::fs::metadata("/proc/self").ok().map(|m| m.uid());
1270    match owner_refusal(me, file.uid(), db) {
1271        Some(why) => bail!("{why}"),
1272        None => Ok(()),
1273    }
1274}
1275
1276/// Why a process running as `me` must not change the database at `db`,
1277/// owned by `owner`, if it must not. `None` for `me` is a process that
1278/// could not tell who it runs as.
1279///
1280/// That case refuses rather than letting the change through. The check
1281/// exists for the one mistake that is easy to make (forgetting `-u node`)
1282/// and costly to find out about (a server that cannot open its database
1283/// after a crash); a check that passes whenever it cannot look would pass
1284/// in the very containers most likely to be unusual. A container without
1285/// /proc is rare enough that refusing there costs little, and the message
1286/// says what is wrong.
1287#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1288fn owner_refusal(me: Option<u32>, owner: u32, db: &Path) -> Option<String> {
1289    let run_as = "Run it as the database's owner, which in the Docker setup is: \
1290                  docker exec -it -u node recall-server recall-server admin ...";
1291    match me {
1292        None => Some(format!(
1293            "cannot tell which user this runs as (/proc/self cannot be read), so cannot check \
1294             that it is {}'s owner, uid {owner}. A change made as another user can leave files \
1295             the server cannot open. {run_as}",
1296            db.display()
1297        )),
1298        Some(me) if me != owner => Some(format!(
1299            "this runs as uid {me} but {} belongs to uid {owner}. A change made as another user \
1300             can leave files the server cannot open. {run_as}",
1301            db.display()
1302        )),
1303        Some(_) => None,
1304    }
1305}
1306
1307#[cfg(not(target_os = "linux"))]
1308fn check_owner(_db: &Path) -> Result<()> {
1309    Ok(())
1310}
1311
1312/// Whether two paths are the same file, hard links and symlinks included.
1313fn same_file(a: &Path, b: &Path) -> bool {
1314    #[cfg(unix)]
1315    {
1316        use std::os::unix::fs::MetadataExt;
1317        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1318            return x.dev() == y.dev() && x.ino() == y.ino();
1319        }
1320    }
1321    matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y)
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::*;
1327
1328    fn args(list: &[&str]) -> Vec<String> {
1329        list.iter().map(|s| s.to_string()).collect()
1330    }
1331
1332    #[test]
1333    fn parses_every_command() {
1334        assert_eq!(
1335            parse(&args(&["rename", "a", "b", "--dry-run"])),
1336            Ok(Command::Rename {
1337                from: "a".into(),
1338                to: "b".into(),
1339                opts: Opts {
1340                    dry_run: true,
1341                    yes: false
1342                }
1343            })
1344        );
1345        assert_eq!(
1346            parse(&args(&["restore", "--yes", "f.db", "k", "--overwrite"])),
1347            Ok(Command::Restore {
1348                backup: "f.db".into(),
1349                key: "k".into(),
1350                overwrite: true,
1351                deletions: false,
1352                opts: Opts {
1353                    dry_run: false,
1354                    yes: true
1355                }
1356            })
1357        );
1358        assert_eq!(
1359            parse(&args(&["list", "--backup=b.db", "k"])),
1360            Ok(Command::List {
1361                backup: Some("b.db".into()),
1362                key: Some("k".into())
1363            })
1364        );
1365        assert_eq!(parse(&args(&["remove", "--help"])), Ok(Command::Help));
1366    }
1367
1368    /// `--` is how a key that starts with a dash is named, and after it
1369    /// nothing is an option, `--yes` included.
1370    #[test]
1371    fn everything_after_a_double_dash_is_a_key() {
1372        assert_eq!(
1373            parse(&args(&["remove", "--", "-odd"])),
1374            Ok(Command::Remove {
1375                key: "-odd".into(),
1376                opts: Opts::default()
1377            })
1378        );
1379        assert!(parse(&args(&["remove", "k", "--", "--yes"])).is_err());
1380    }
1381
1382    #[test]
1383    fn refuses_what_it_does_not_understand() {
1384        for bad in [
1385            &[][..],
1386            &["serve"],
1387            &["remove"],
1388            &["remove", "a", "b"],
1389            &["rename", "a"],
1390            &["remove", "k", "--force"],
1391            &["remove", "k", "--overwrite"],
1392            &["list", "--yes"],
1393            &["list", "--backup"],
1394            &["restore", "k"],
1395        ] {
1396            assert!(
1397                parse(&args(bad)).is_err(),
1398                "{bad:?} should be a usage error"
1399            );
1400        }
1401    }
1402
1403    #[test]
1404    fn ambiguous_names_are_printed_quoted() {
1405        assert_eq!(shown("acme/app"), "acme/app");
1406        assert_eq!(shown("local:-Users-me-thing"), "local:-Users-me-thing");
1407        assert_eq!(shown("me/thing "), "\"me/thing \"");
1408        assert_eq!(shown(""), "\"\"");
1409        assert_eq!(shown("-x"), "\"-x\"");
1410        assert_eq!(shown("a\u{7}b"), "\"a\\u{7}b\"");
1411        assert_eq!(shown("it's"), "it's");
1412        assert_eq!(shown("a\"b"), "\"a\\\"b\"");
1413    }
1414
1415    /// A key that differs from another only by a character that prints as
1416    /// nothing must not print the same as it.
1417    #[test]
1418    fn invisible_characters_are_printed_escaped() {
1419        for (raw, printed) in [
1420            ("me/\u{200B}thing", "\"me/\\u{200b}thing\""),
1421            ("\u{FEFF}me/thing", "\"\\u{feff}me/thing\""),
1422            ("me/thing\u{2060}", "\"me/thing\\u{2060}\""),
1423            ("me/\u{202E}thing", "\"me/\\u{202e}thing\""),
1424            ("me/\u{00AD}thing", "\"me/\\u{ad}thing\""),
1425            // Printable to Rust, invisible on a terminal all the same.
1426            ("me/\u{3164}thing", "\"me/\\u{3164}thing\""),
1427            ("me/thing\u{FE0F}", "\"me/thing\\u{fe0f}\""),
1428        ] {
1429            assert_eq!(shown(raw), printed, "{raw:?}");
1430            assert_ne!(shown(raw), shown("me/thing"));
1431        }
1432    }
1433
1434    #[test]
1435    fn restore_deletions_comes_only_with_overwrite() {
1436        assert!(parse(&args(&["restore", "f.db", "k", "--restore-deletions"])).is_err());
1437        assert!(parse(&args(&["remove", "k", "--restore-deletions"])).is_err());
1438        assert_eq!(
1439            parse(&args(&[
1440                "restore",
1441                "f.db",
1442                "k",
1443                "--overwrite",
1444                "--restore-deletions"
1445            ])),
1446            Ok(Command::Restore {
1447                backup: "f.db".into(),
1448                key: "k".into(),
1449                overwrite: true,
1450                deletions: true,
1451                opts: Opts::default()
1452            })
1453        );
1454    }
1455
1456    /// The decision alone, so it is tested wherever the tests run, not only
1457    /// as root, which is the one place a test can make a file someone
1458    /// else's.
1459    #[test]
1460    fn only_the_databases_owner_may_change_it() {
1461        let db = Path::new("/data/recall.db");
1462        assert_eq!(owner_refusal(Some(1000), 1000, db), None);
1463        let root = owner_refusal(Some(0), 1000, db).unwrap();
1464        assert!(root.contains("uid 0"), "{root}");
1465        assert!(root.contains("uid 1000"), "{root}");
1466        assert!(root.contains("-u node"), "{root}");
1467        // Not knowing who this runs as is a refusal, never a pass.
1468        let blind = owner_refusal(None, 1000, db).unwrap();
1469        assert!(blind.contains("/proc/self"), "{blind}");
1470        assert!(blind.contains("-u node"), "{blind}");
1471        assert!(owner_refusal(None, 0, db).is_some(), "not even for root");
1472    }
1473
1474    /// Read the way the server reads it, or the wait would end before the
1475    /// push it exists for.
1476    #[test]
1477    fn the_wait_after_a_change_follows_the_servers_merge_timeout() {
1478        let with = |pairs: &'static [(&'static str, &'static str)]| {
1479            settle_window(&move |k: &str| {
1480                pairs
1481                    .iter()
1482                    .find(|(key, _)| *key == k)
1483                    .map(|(_, v)| v.to_string())
1484            })
1485        };
1486        assert_eq!(with(&[]), Duration::from_millis(45_000) + SETTLE_MARGIN);
1487        assert_eq!(
1488            with(&[("RECALL_MERGE_TIMEOUT_MS", "1234")]),
1489            Duration::from_millis(1234) + SETTLE_MARGIN
1490        );
1491        // The server's fallbacks: zero and nonsense both mean 45 seconds.
1492        for bad in ["0", "soon", "-5"] {
1493            let pairs: &'static [(&str, &str)] =
1494                Box::leak(Box::new([("RECALL_MERGE_TIMEOUT_MS", bad)]));
1495            assert_eq!(with(pairs), Duration::from_millis(45_000) + SETTLE_MARGIN);
1496        }
1497        // Only a literal "false" turns merge off, as in the server.
1498        assert_eq!(with(&[("RECALL_MERGE_ENABLED", "false")]), SETTLE_MARGIN);
1499        assert_eq!(
1500            with(&[
1501                ("RECALL_MERGE_ENABLED", "no"),
1502                ("RECALL_MERGE_TIMEOUT_MS", "10")
1503            ]),
1504            Duration::from_millis(10) + SETTLE_MARGIN
1505        );
1506    }
1507
1508    #[test]
1509    fn a_command_to_run_again_is_quoted_for_the_shell() {
1510        assert_eq!(
1511            sh_args(&["me/thing", "local:-Users-me"]),
1512            "me/thing local:-Users-me"
1513        );
1514        assert_eq!(sh_args(&["my project"]), "'my project'");
1515        assert_eq!(sh_args(&["it's"]), r"'it'\''s'");
1516        assert_eq!(sh_args(&["-odd", "x"]), "-- -odd x");
1517        assert_eq!(sh_args(&[""]), "''");
1518    }
1519
1520    #[test]
1521    fn similar_keys_are_hints_by_substring_either_way() {
1522        let s = |k: &str| Summary {
1523            project_key: k.into(),
1524            files: 1,
1525            tombstones: 0,
1526            last_updated_at: String::new(),
1527        };
1528        let all = [s("acme/app"), s("acme/api"), s("other")];
1529        assert_eq!(similar_keys(&all, "ACME"), vec!["acme/app", "acme/api"]);
1530        assert_eq!(similar_keys(&all, "acme/app-2"), vec!["acme/app"]);
1531        assert!(similar_keys(&all, " ").is_empty());
1532    }
1533}