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 default rollback journal, or WAL,
562    // which is stored and can roll back.
563    check_owner(db)?;
564    Store::open_existing(db, Access::Write)
565}
566
567const LIVE: &str = "The live database";
568const BACKUP: &str = "The backup";
569
570/// Refuses a key that is not stored exactly as given, suggesting keys that
571/// look like it. Suggesting is as far as it goes: nothing here ever acts on
572/// a key it was not given character for character.
573fn require_key(store: &Store, key: &str, what: &str) -> Result<()> {
574    if !store.rows(key)?.is_empty() {
575        return Ok(());
576    }
577    let near = similar_keys(&store.summaries()?, key);
578    let hint = if near.is_empty() && what == BACKUP {
579        "`recall-server admin list --backup <file>` shows every key it holds.".to_string()
580    } else if near.is_empty() {
581        "`recall-server admin list` shows every key.".to_string()
582    } else {
583        let near: Vec<String> = near.iter().map(|k| format!("{k:?}")).collect();
584        format!("Keys that look similar: {}.", near.join(", "))
585    };
586    bail!(
587        "{what} holds no project key that is exactly {key:?}. Keys are matched exactly, never \
588         by prefix or pattern. {hint}"
589    )
590}
591
592fn similar_keys(summaries: &[Summary], key: &str) -> Vec<String> {
593    let needle = key.trim().to_lowercase();
594    if needle.is_empty() {
595        return Vec::new();
596    }
597    summaries
598        .iter()
599        .map(|s| &s.project_key)
600        .filter(|k| {
601            let k = k.to_lowercase();
602            k.contains(&needle) || needle.contains(&k)
603        })
604        .take(10)
605        .cloned()
606        .collect()
607}
608
609/// The part every change shares: show it, check it, confirm it, back up,
610/// check the backup, apply it, and check it held. `again` is the command
611/// line that makes the same change, for when that check says to; `source`
612/// is the backup a restore restores from.
613fn carry_out(
614    ctx: &mut Ctx,
615    store: &Store,
616    plan: &Plan,
617    again: &str,
618    source: Option<&Path>,
619    opts: Opts,
620) -> Result<()> {
621    writeln!(ctx.out, "Database: {}", ctx.db.display())?;
622    describe(ctx.out, plan)?;
623    if let Some(why) = plan.refusal() {
624        bail!("refusing: {why}");
625    }
626    if let Plan::Remove { key, .. } = plan {
627        warn_unique(ctx.out, store, key)?;
628    }
629    if plan.expected_changes() == 0 {
630        writeln!(ctx.out, "Nothing to change.")?;
631        return Ok(());
632    }
633    if opts.dry_run {
634        writeln!(
635            ctx.out,
636            "Dry run: nothing was changed, and no backup was taken."
637        )?;
638        return Ok(());
639    }
640
641    // Asked before the confirmation, so nobody types a key only to be told
642    // there is nowhere to put the backup.
643    let Some(backup_dir) = ctx.backup_dir.clone() else {
644        bail!(
645            "RECALL_BACKUP_DIR is not set, so there is nowhere to take the backup every change \
646             takes first. Set it to where the server's own snapshots go (/backups in the \
647             Docker setup)"
648        );
649    };
650    confirm(ctx, plan, opts.yes)?;
651
652    // Its own directory, because the server prunes `recall-*.db` in the
653    // backup directory itself down to RECALL_BACKUP_KEEP: a snapshot taken
654    // here must neither push out one of those nor be pushed out by them.
655    // `usize::MAX` keeps every one, and `backup-offbox.sh` copies them too,
656    // since rclone's include pattern matches at any depth.
657    let snapshot = store
658        .backup(backup_dir.join("admin"), usize::MAX)
659        .context("taking the backup that every change takes first")?;
660    writeln!(ctx.out, "Backup written: {}", snapshot.display())?;
661
662    let checked = Store::open_existing(&snapshot, Access::Read)
663        .and_then(|taken| plan.snapshot_mismatch(&taken));
664    match checked {
665        Ok(None) => {}
666        Ok(Some(why)) => {
667            discard(ctx.out, &snapshot);
668            bail!(
669                "the backup just taken does not hold the rows shown above: {why}. Either a \
670                 push arrived since they were shown, or the backup is incomplete. Run the \
671                 command again to see them as they are now"
672            );
673        }
674        Err(err) => {
675            discard(ctx.out, &snapshot);
676            return Err(err.context("reading back the backup just taken"));
677        }
678    }
679
680    // The leaf names the backup by its file name: where it is, the
681    // directory every change's backup goes to, is this host's business,
682    // and what it holds is exactly what the log must never carry.
683    let backup_name = file_name(&snapshot);
684    let source_name = source.map(file_name).unwrap_or_default();
685    let mut appended = None;
686    let applied = match store.apply(plan, |seq, at, applied| {
687        appended = Some(seq);
688        audit_leaf(seq, at, plan, applied, &backup_name, &source_name)
689    }) {
690        Ok(applied) => applied,
691        Err(err) => {
692            // A change abandoned before it began leaves the database as
693            // it was, so its backup would only pile up, one per retry, next
694            // to the one the retry takes. Any other failure keeps its
695            // backup: something was attempted, and the backup is what shows
696            // the state it was attempted on.
697            if err.is::<Abandoned>() {
698                discard(ctx.out, &snapshot);
699            }
700            return Err(err);
701        }
702    };
703
704    // Committed. From here, every error must be an AfterCommit, or main
705    // would claim the database was not changed.
706    let _ = writeln!(ctx.out, "{}", done(plan, &applied));
707    if let Some(seq) = appended {
708        let _ = writeln!(
709            ctx.out,
710            "Recorded in the audit log as leaf {seq} ({}), in the same transaction.",
711            change_of(plan, &applied, &source_name).action()
712        );
713    }
714    settle(ctx, store, plan, again)
715}
716
717/// A path's last component, as the audit leaf names a backup.
718fn file_name(path: &Path) -> String {
719    path.file_name()
720        .map(|n| n.to_string_lossy().into_owned())
721        .unwrap_or_else(|| path.to_string_lossy().into_owned())
722}
723
724/// What `plan` did, as its audit leaf says it.
725fn change_of<'a>(plan: &'a Plan, applied: &Applied, source: &'a str) -> leaf::AdminChange<'a> {
726    match plan {
727        Plan::Rename { from, to, .. } => leaf::AdminChange::Rename {
728            from,
729            to,
730            rows: applied.rows,
731        },
732        Plan::Remove { key, .. } => leaf::AdminChange::Remove {
733            project_key: key,
734            rows: applied.rows,
735        },
736        Plan::Restore(r) => leaf::AdminChange::Restore {
737            project_key: &r.key,
738            source,
739            added: r.add.len(),
740            overwritten: r.overwrite.len(),
741            deleted: r.applied_deletions().len(),
742        },
743    }
744}
745
746/// The leaf a committed change appends: the host acting, signing nothing.
747fn audit_leaf(
748    seq: u64,
749    at: &str,
750    plan: &Plan,
751    applied: &Applied,
752    backup: &str,
753    source: &str,
754) -> Vec<u8> {
755    let change = change_of(plan, applied, source);
756    leaf::encode(
757        seq,
758        at,
759        change.action(),
760        &leaf::Actor::Host,
761        leaf::subject_admin(&change, &applied.jobs, backup),
762        None,
763    )
764}
765
766/// Deletes the backup of a change that did not happen, and says so.
767fn discard(out: &mut dyn Write, snapshot: &Path) {
768    let _ = match std::fs::remove_file(snapshot) {
769        Ok(()) => writeln!(
770            out,
771            "Deleted that backup again: the change it was taken for did not happen."
772        ),
773        Err(err) => writeln!(
774            out,
775            "The change that backup was taken for did not happen, and deleting it failed \
776             ({err}); delete it by hand."
777        ),
778    };
779}
780
781/// Warns, before the confirmation, that removing `key` removes content
782/// that is under no other key.
783///
784/// The case it is for: a key being folded into another, where the owner
785/// believes its notes already arrived there. If they did, this is silent.
786fn warn_unique(out: &mut dyn Write, store: &Store, key: &str) -> Result<()> {
787    let unique = store.unique_live_paths(key)?;
788    if unique.is_empty() {
789        return Ok(());
790    }
791    writeln!(
792        out,
793        "Warning: {} of these file(s) hold content that no live file under any other key has, \
794         so after this it exists only in the backup this command takes:",
795        unique.len()
796    )?;
797    for path in &unique {
798        writeln!(out, "  {}", shown(path))?;
799    }
800    writeln!(
801        out,
802        "If {key:?} is being folded into another key, rename it to an archive key such as {:?} \
803         instead, and bring the content across by hand (\"Folding one key into another\" in \
804         deploy/README.md).",
805        archive_key(key)
806    )?;
807    Ok(())
808}
809
810/// Waits until a push that was in flight when `plan` committed has had time
811/// to land, then checks the change still stands.
812///
813/// Only a change that replaced or emptied existing rows needs it: a push in
814/// flight read one of those rows, and when it writes, it writes the old key
815/// or a merge of the old row. A restore that only added rows replaced
816/// nothing a push could have read.
817fn settle(ctx: &mut Ctx, store: &Store, plan: &Plan, again: &str) -> Result<()> {
818    let checks = match plan {
819        Plan::Rename { .. } | Plan::Remove { .. } => true,
820        Plan::Restore(r) => r.replaces_live_rows(),
821    };
822    if !checks {
823        return Ok(());
824    }
825    let _ = writeln!(
826        ctx.out,
827        "Waiting {:.1}s before checking that the change held. The server reads a file before \
828         merging a push into it and writes the result up to RECALL_MERGE_TIMEOUT_MS later, \
829         holding no lock in between, so a push already being merged as this committed can land \
830         after it and partly undo it. The change is committed; interrupting now only skips the \
831         check.",
832        ctx.settle.as_secs_f64()
833    );
834    let _ = ctx.out.flush();
835    std::thread::sleep(ctx.settle);
836
837    let undone = plan.undone(store).map_err(|err| {
838        AfterCommit(format!(
839            "the change was committed, but checking afterwards that it held failed: {err:#}. \
840             Look with `recall-server admin list`"
841        ))
842    })?;
843    if undone.is_empty() {
844        let _ = writeln!(ctx.out, "Checked: the change held.");
845        return Ok(());
846    }
847    // A job open for these rows again was queued by a push that landed
848    // after the commit, which also wrote its row: said beside the paths
849    // rather than instead of them, since its result will merge there too.
850    let jobs = if undone.jobs.is_empty() {
851        String::new()
852    } else {
853        format!(
854            "\n{} merge job(s) were queued for them since, and will merge there:\n{}",
855            undone.jobs.len(),
856            job_lines(&undone.jobs)
857        )
858    };
859    let listed: Vec<String> = undone
860        .paths
861        .iter()
862        .map(|p| format!("  {}", shown(p)))
863        .collect();
864    let paths = listed.join("\n") + &jobs;
865    let n = undone.paths.len();
866    Err(AfterCommit(match plan {
867        Plan::Rename { from, to, .. } => format!(
868            "the rename was committed, but {n} row(s) are under {from:?} again:\n{paths}\n\
869             Either a push already being merged when it committed landed afterwards, or a \
870             machine is still syncing under {from:?}. Those rows are newer than what moved to \
871             {to:?}, and nothing is lost: they are in the live database. Set \
872             RECALL_PROJECT_KEY={to} on any machine still using {from:?}. Then the rename \
873             cannot simply be run again, since {to:?} now holds rows: rename {from:?} to an \
874             archive key such as {:?} and bring what those rows add into {to:?} by hand, as \
875             \"Folding one key into another\" in deploy/README.md describes.",
876            archive_key(from)
877        ),
878        Plan::Remove { key, .. } => format!(
879            "the remove was committed, but {n} row(s) are under {key:?} again:\n{paths}\n\
880             Either a push already being merged when it committed landed afterwards, or a \
881             machine is still syncing under {key:?}. Set RECALL_PROJECT_KEY on any machine \
882             still using {key:?}, then run `{again}` again; it shows these rows first and takes \
883             a backup of them."
884        ),
885        Plan::Restore(r) => format!(
886            "the restore was committed, but {n} restored row(s) under {:?} are no longer what \
887             it wrote:\n{paths}\nEither a push already being merged when it committed wrote \
888             over them (a merge made from the version the restore replaced), or a machine has \
889             edited them since. To put the backup's version back, run `{again}` again; it \
890             shows each difference first.",
891            r.key
892        ),
893    })
894    .into())
895}
896
897/// Asks the owner to type back every key the change names: a rename's
898/// target as well as its source, since a typo there strands the project
899/// where no machine will ever pull it.
900fn confirm(ctx: &mut Ctx, plan: &Plan, yes: bool) -> Result<()> {
901    let keys: Vec<(&str, &str)> = match plan {
902        Plan::Rename { from, to, .. } => vec![
903            ("the project key to rename", from),
904            ("the key to rename it to", to),
905        ],
906        Plan::Remove { key, .. } => vec![("the project key", key)],
907        Plan::Restore(r) => vec![("the project key", &r.key)],
908    };
909    if yes {
910        let named: Vec<String> = keys
911            .iter()
912            .map(|(what, key)| format!("{} {key:?}", what.trim_start_matches("the ")))
913            .collect();
914        writeln!(
915            ctx.out,
916            "Confirmed with --yes for {}.",
917            named.join(", and ")
918        )?;
919        return Ok(());
920    }
921    for (i, (what, key)) in keys.iter().enumerate() {
922        let lead = if i == 0 {
923            "To go ahead, type"
924        } else {
925            "Then type"
926        };
927        write!(
928            ctx.out,
929            "{lead} {what}, {key:?}, exactly, without the quotes: "
930        )?;
931        ctx.out.flush()?;
932        let mut line = String::new();
933        if ctx
934            .input
935            .read_line(&mut line)
936            .context("reading the confirmation")?
937            == 0
938        {
939            bail!("nothing was typed to confirm. Pass --yes to confirm without a prompt");
940        }
941        if !ctx.terminal {
942            writeln!(ctx.out)?;
943        }
944        let typed = line.strip_suffix('\n').unwrap_or(&line);
945        let typed = typed.strip_suffix('\r').unwrap_or(typed);
946        if typed != *key {
947            bail!("{typed:?} is not {key:?}, so the change was not confirmed");
948        }
949    }
950    Ok(())
951}
952
953fn describe(out: &mut dyn Write, plan: &Plan) -> io::Result<()> {
954    match plan {
955        Plan::Rename {
956            from,
957            to,
958            rows,
959            occupied,
960            ..
961        } => {
962            writeln!(
963                out,
964                "Rename {from:?} to {to:?}: {} row(s) move ({}).",
965                rows.len(),
966                counts(rows)
967            )?;
968            for r in rows {
969                writeln!(out, "  {}", path_line(r))?;
970            }
971            if !occupied.is_empty() {
972                writeln!(
973                    out,
974                    "{to:?} already holds {} row(s) ({}):",
975                    occupied.len(),
976                    counts(occupied)
977                )?;
978                for r in occupied {
979                    writeln!(out, "  {}", path_line(r))?;
980                }
981            }
982        }
983        Plan::Remove { key, rows, .. } => {
984            writeln!(
985                out,
986                "Remove {key:?}: {} row(s) are deleted, content included ({}).",
987                rows.len(),
988                counts(rows)
989            )?;
990            for r in rows {
991                writeln!(out, "  {}", path_line(r))?;
992            }
993        }
994        Plan::Restore(r) => describe_restore(out, r)?,
995    }
996    describe_jobs(out, plan)
997}
998
999/// How many open merge jobs the change closes, and which: always said,
1000/// none included, since a dry run is where the owner learns it.
1001fn describe_jobs(out: &mut dyn Write, plan: &Plan) -> io::Result<()> {
1002    let jobs = plan.jobs();
1003    let why = match plan {
1004        Plan::Rename { from, .. } => {
1005            format!("their versions are {from:?}'s, and a result must not land under either key")
1006        }
1007        Plan::Remove { .. } => "a result must not bring the removed notes back".to_string(),
1008        Plan::Restore(_) => {
1009            "a result, a merge of the versions the restore replaces, must not land over it"
1010                .to_string()
1011        }
1012    };
1013    if jobs.is_empty() {
1014        return writeln!(out, "Open merge jobs for these rows: none.");
1015    }
1016    writeln!(
1017        out,
1018        "Open merge jobs for these rows: {}, closed with the change ({why}):",
1019        jobs.len()
1020    )?;
1021    writeln!(out, "{}", job_lines(jobs))
1022}
1023
1024/// One line per job: its id, its state and its file.
1025fn job_lines(jobs: &[OpenJob]) -> String {
1026    jobs.iter()
1027        .map(|j| format!("  {}  {:<6}  {}", j.id, j.state, shown(&j.file_path)))
1028        .collect::<Vec<_>>()
1029        .join("\n")
1030}
1031
1032fn describe_restore(out: &mut dyn Write, r: &Restore) -> io::Result<()> {
1033    let deleting = r.applied_deletions().len();
1034    writeln!(
1035        out,
1036        "Restore {:?} from the backup: {} row(s) would change, {} added and {} overwritten{}.",
1037        r.key,
1038        r.add.len() + r.overwrite.len() + deleting,
1039        r.add.len(),
1040        r.overwrite.len(),
1041        if deleting > 0 {
1042            format!(", and {deleting} live file(s) deleted")
1043        } else {
1044            String::new()
1045        }
1046    )?;
1047    let width = r
1048        .backup
1049        .iter()
1050        .map(|b| shown(&b.file_path).chars().count())
1051        .chain(r.live_only.iter().map(|p| shown(p).chars().count()))
1052        .max()
1053        .unwrap_or(0);
1054    for row in &r.add {
1055        writeln!(
1056            out,
1057            "  add        {:<width$}  {}, {} bytes, updated {}, source {}",
1058            shown(&row.file_path),
1059            state(row),
1060            row.content.len(),
1061            row.updated_at,
1062            source(row)
1063        )?;
1064    }
1065    for (live, backup) in &r.overwrite {
1066        writeln!(
1067            out,
1068            "  overwrite  {:<width$}  {}",
1069            shown(&live.file_path),
1070            differences(live, backup)
1071        )?;
1072    }
1073    for (live, backup) in r.applied_deletions() {
1074        writeln!(
1075            out,
1076            "  delete     {:<width$}  {}; deleted from every machine at its next pull",
1077            shown(&live.file_path),
1078            differences(live, backup)
1079        )?;
1080    }
1081    for (live, _) in r.skipped_deletions() {
1082        writeln!(
1083            out,
1084            "  skipped    {:<width$}  a file now, deleted in the backup; left as it is \
1085             (--overwrite --restore-deletions would delete it on every machine)",
1086            shown(&live.file_path)
1087        )?;
1088    }
1089    for path in &r.unchanged {
1090        writeln!(out, "  unchanged  {}", shown(path))?;
1091    }
1092    for path in &r.live_only {
1093        writeln!(
1094            out,
1095            "  untouched  {:<width$}  not in the backup, so it stays as it is",
1096            shown(path)
1097        )?;
1098    }
1099    Ok(())
1100}
1101
1102/// Every field an overwrite would change, live value first.
1103fn differences(live: &Row, backup: &Row) -> String {
1104    let mut parts = Vec::new();
1105    if live.deleted != backup.deleted {
1106        parts.push(format!("{} -> {}", state(live), state(backup)));
1107    }
1108    if live.content != backup.content {
1109        parts.push(if live.content.len() == backup.content.len() {
1110            format!("content differs, {} bytes each", live.content.len())
1111        } else {
1112            format!(
1113                "content {} -> {} bytes",
1114                live.content.len(),
1115                backup.content.len()
1116            )
1117        });
1118    }
1119    if live.updated_at != backup.updated_at {
1120        parts.push(format!(
1121            "updated {} -> {}",
1122            live.updated_at, backup.updated_at
1123        ));
1124    }
1125    if live.source_env != backup.source_env {
1126        parts.push(format!("source {} -> {}", source(live), source(backup)));
1127    }
1128    parts.join("; ")
1129}
1130
1131fn done(plan: &Plan, applied: &Applied) -> String {
1132    let said = done_rows(plan, applied.rows);
1133    if applied.jobs.is_empty() {
1134        return said;
1135    }
1136    format!(
1137        "{said}\nClosed {} open merge job(s), so no result lands on these rows: {}.",
1138        applied.jobs.len(),
1139        applied.jobs.join(", ")
1140    )
1141}
1142
1143fn done_rows(plan: &Plan, changed: usize) -> String {
1144    match plan {
1145        Plan::Rename { from, to, .. } => format!(
1146            "Done: moved {changed} row(s) from {from:?} to {to:?}. A machine still syncing under \
1147             {from:?} will write to it again on its next push; set RECALL_PROJECT_KEY={to} there."
1148        ),
1149        Plan::Remove { key, .. } => format!(
1150            "Done: removed {changed} row(s) under {key:?}. A machine still syncing under it \
1151             will create it again on its next push."
1152        ),
1153        Plan::Restore(r) => {
1154            let mut said = format!(
1155                "Done: restored {changed} row(s) under {:?} ({} added, {} overwritten",
1156                r.key,
1157                r.add.len(),
1158                r.overwrite.len()
1159            );
1160            let deleted = r.applied_deletions().len();
1161            if deleted > 0 {
1162                said += &format!(
1163                    ", {deleted} live file(s) turned into tombstones, which deletes them on \
1164                     every machine at its next pull"
1165                );
1166            }
1167            said += ").";
1168            let skipped = r.skipped_deletions().len();
1169            if skipped > 0 {
1170                said += &format!(
1171                    " {skipped} live file(s) the backup has as deleted were left as they are."
1172                );
1173            }
1174            said + " A machine that edits a restored file before it next pulls pushes its own \
1175                    version over the restored one."
1176        }
1177    }
1178}
1179
1180fn counts(rows: &[Row]) -> String {
1181    let tombstones = rows.iter().filter(|r| r.deleted).count();
1182    format!(
1183        "{} file(s), {tombstones} tombstone(s)",
1184        rows.len() - tombstones
1185    )
1186}
1187
1188fn path_line(row: &Row) -> String {
1189    if row.deleted {
1190        format!("{}  (tombstone)", shown(&row.file_path))
1191    } else {
1192        shown(&row.file_path).into_owned()
1193    }
1194}
1195
1196fn state(row: &Row) -> &'static str {
1197    if row.deleted {
1198        "tombstone"
1199    } else {
1200        "file"
1201    }
1202}
1203
1204/// The machine a row came from. NULL and `''` are told apart, because a
1205/// restore compares them and would otherwise report a change it cannot show.
1206fn source(row: &Row) -> &str {
1207    match row.source_env.as_deref() {
1208        None => "(none)",
1209        Some("") => "\"\"",
1210        Some(s) => s,
1211    }
1212}
1213
1214/// A key or path as printed: as is, unless it would be ambiguous on a
1215/// terminal, in which case it is quoted and escaped, so the exact characters
1216/// are always visible.
1217///
1218/// Ambiguous means empty, starting with a dash, holding whitespace, or
1219/// holding anything `{:?}` would escape: a control character, a quote or
1220/// backslash, or one of the characters Rust treats as unprintable, which
1221/// include zero-width and bidi format characters. On top of those, anything
1222/// [`invisible`] names, since a few (fillers, variation selectors) are
1223/// printable to Rust and print as nothing on a terminal all the same.
1224fn shown(s: &str) -> Cow<'_, str> {
1225    // An apostrophe is left alone: inside double quotes it is unambiguous,
1226    // and `char::escape_debug` would escape it where `{:?}` on a string
1227    // does not.
1228    let escaped = |c: char| c != '\'' && c.escape_debug().ne(std::iter::once(c));
1229    if s.is_empty()
1230        || s.starts_with('-')
1231        || s.chars()
1232            .any(|c| c.is_whitespace() || escaped(c) || invisible(c))
1233    {
1234        let mut quoted = String::from("\"");
1235        for c in s.chars() {
1236            if escaped(c) {
1237                quoted.extend(c.escape_debug());
1238            } else if invisible(c) {
1239                quoted += &format!("\\u{{{:x}}}", c as u32);
1240            } else {
1241                quoted.push(c);
1242            }
1243        }
1244        quoted.push('"');
1245        Cow::Owned(quoted)
1246    } else {
1247        Cow::Borrowed(s)
1248    }
1249}
1250
1251/// Refuses to change the database as a user other than its owner.
1252///
1253/// `docker exec` runs as root unless told otherwise, while the server runs
1254/// as `node`. A change made as root leaves root-owned files behind: a
1255/// backup, and after a crash mid-transaction a hot journal, which the
1256/// server cannot open to roll back, so it cannot read the database until
1257/// something fixes the ownership.
1258#[cfg(target_os = "linux")]
1259fn check_owner(db: &Path) -> Result<()> {
1260    use std::os::unix::fs::MetadataExt;
1261    // A database that is not there is `open_existing`'s to report, with the
1262    // message written for exactly that.
1263    let Ok(file) = std::fs::metadata(db) else {
1264        return Ok(());
1265    };
1266    // /proc/self is owned by the process's effective uid: the one way to
1267    // learn it from std alone, without a libc dependency for one call.
1268    let me = std::fs::metadata("/proc/self").ok().map(|m| m.uid());
1269    match owner_refusal(me, file.uid(), db) {
1270        Some(why) => bail!("{why}"),
1271        None => Ok(()),
1272    }
1273}
1274
1275/// Why a process running as `me` must not change the database at `db`,
1276/// owned by `owner`, if it must not. `None` for `me` is a process that
1277/// could not tell who it runs as.
1278///
1279/// That case refuses rather than letting the change through. The check
1280/// exists for the one mistake that is easy to make (forgetting `-u node`)
1281/// and costly to find out about (a server that cannot open its database
1282/// after a crash); a check that passes whenever it cannot look would pass
1283/// in the very containers most likely to be unusual. A container without
1284/// /proc is rare enough that refusing there costs little, and the message
1285/// says what is wrong.
1286#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1287fn owner_refusal(me: Option<u32>, owner: u32, db: &Path) -> Option<String> {
1288    let run_as = "Run it as the database's owner, which in the Docker setup is: \
1289                  docker exec -it -u node recall-server recall-server admin ...";
1290    match me {
1291        None => Some(format!(
1292            "cannot tell which user this runs as (/proc/self cannot be read), so cannot check \
1293             that it is {}'s owner, uid {owner}. A change made as another user can leave files \
1294             the server cannot open. {run_as}",
1295            db.display()
1296        )),
1297        Some(me) if me != owner => Some(format!(
1298            "this runs as uid {me} but {} belongs to uid {owner}. A change made as another user \
1299             can leave files the server cannot open. {run_as}",
1300            db.display()
1301        )),
1302        Some(_) => None,
1303    }
1304}
1305
1306#[cfg(not(target_os = "linux"))]
1307fn check_owner(_db: &Path) -> Result<()> {
1308    Ok(())
1309}
1310
1311/// Whether two paths are the same file, hard links and symlinks included.
1312fn same_file(a: &Path, b: &Path) -> bool {
1313    #[cfg(unix)]
1314    {
1315        use std::os::unix::fs::MetadataExt;
1316        if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
1317            return x.dev() == y.dev() && x.ino() == y.ino();
1318        }
1319    }
1320    matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y)
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325    use super::*;
1326
1327    fn args(list: &[&str]) -> Vec<String> {
1328        list.iter().map(|s| s.to_string()).collect()
1329    }
1330
1331    #[test]
1332    fn parses_every_command() {
1333        assert_eq!(
1334            parse(&args(&["rename", "a", "b", "--dry-run"])),
1335            Ok(Command::Rename {
1336                from: "a".into(),
1337                to: "b".into(),
1338                opts: Opts {
1339                    dry_run: true,
1340                    yes: false
1341                }
1342            })
1343        );
1344        assert_eq!(
1345            parse(&args(&["restore", "--yes", "f.db", "k", "--overwrite"])),
1346            Ok(Command::Restore {
1347                backup: "f.db".into(),
1348                key: "k".into(),
1349                overwrite: true,
1350                deletions: false,
1351                opts: Opts {
1352                    dry_run: false,
1353                    yes: true
1354                }
1355            })
1356        );
1357        assert_eq!(
1358            parse(&args(&["list", "--backup=b.db", "k"])),
1359            Ok(Command::List {
1360                backup: Some("b.db".into()),
1361                key: Some("k".into())
1362            })
1363        );
1364        assert_eq!(parse(&args(&["remove", "--help"])), Ok(Command::Help));
1365    }
1366
1367    /// `--` is how a key that starts with a dash is named, and after it
1368    /// nothing is an option, `--yes` included.
1369    #[test]
1370    fn everything_after_a_double_dash_is_a_key() {
1371        assert_eq!(
1372            parse(&args(&["remove", "--", "-odd"])),
1373            Ok(Command::Remove {
1374                key: "-odd".into(),
1375                opts: Opts::default()
1376            })
1377        );
1378        assert!(parse(&args(&["remove", "k", "--", "--yes"])).is_err());
1379    }
1380
1381    #[test]
1382    fn refuses_what_it_does_not_understand() {
1383        for bad in [
1384            &[][..],
1385            &["serve"],
1386            &["remove"],
1387            &["remove", "a", "b"],
1388            &["rename", "a"],
1389            &["remove", "k", "--force"],
1390            &["remove", "k", "--overwrite"],
1391            &["list", "--yes"],
1392            &["list", "--backup"],
1393            &["restore", "k"],
1394        ] {
1395            assert!(
1396                parse(&args(bad)).is_err(),
1397                "{bad:?} should be a usage error"
1398            );
1399        }
1400    }
1401
1402    #[test]
1403    fn ambiguous_names_are_printed_quoted() {
1404        assert_eq!(shown("acme/app"), "acme/app");
1405        assert_eq!(shown("local:-Users-me-thing"), "local:-Users-me-thing");
1406        assert_eq!(shown("me/thing "), "\"me/thing \"");
1407        assert_eq!(shown(""), "\"\"");
1408        assert_eq!(shown("-x"), "\"-x\"");
1409        assert_eq!(shown("a\u{7}b"), "\"a\\u{7}b\"");
1410        assert_eq!(shown("it's"), "it's");
1411        assert_eq!(shown("a\"b"), "\"a\\\"b\"");
1412    }
1413
1414    /// A key that differs from another only by a character that prints as
1415    /// nothing must not print the same as it.
1416    #[test]
1417    fn invisible_characters_are_printed_escaped() {
1418        for (raw, printed) in [
1419            ("me/\u{200B}thing", "\"me/\\u{200b}thing\""),
1420            ("\u{FEFF}me/thing", "\"\\u{feff}me/thing\""),
1421            ("me/thing\u{2060}", "\"me/thing\\u{2060}\""),
1422            ("me/\u{202E}thing", "\"me/\\u{202e}thing\""),
1423            ("me/\u{00AD}thing", "\"me/\\u{ad}thing\""),
1424            // Printable to Rust, invisible on a terminal all the same.
1425            ("me/\u{3164}thing", "\"me/\\u{3164}thing\""),
1426            ("me/thing\u{FE0F}", "\"me/thing\\u{fe0f}\""),
1427        ] {
1428            assert_eq!(shown(raw), printed, "{raw:?}");
1429            assert_ne!(shown(raw), shown("me/thing"));
1430        }
1431    }
1432
1433    #[test]
1434    fn restore_deletions_comes_only_with_overwrite() {
1435        assert!(parse(&args(&["restore", "f.db", "k", "--restore-deletions"])).is_err());
1436        assert!(parse(&args(&["remove", "k", "--restore-deletions"])).is_err());
1437        assert_eq!(
1438            parse(&args(&[
1439                "restore",
1440                "f.db",
1441                "k",
1442                "--overwrite",
1443                "--restore-deletions"
1444            ])),
1445            Ok(Command::Restore {
1446                backup: "f.db".into(),
1447                key: "k".into(),
1448                overwrite: true,
1449                deletions: true,
1450                opts: Opts::default()
1451            })
1452        );
1453    }
1454
1455    /// The decision alone, so it is tested wherever the tests run, not only
1456    /// as root, which is the one place a test can make a file someone
1457    /// else's.
1458    #[test]
1459    fn only_the_databases_owner_may_change_it() {
1460        let db = Path::new("/data/recall.db");
1461        assert_eq!(owner_refusal(Some(1000), 1000, db), None);
1462        let root = owner_refusal(Some(0), 1000, db).unwrap();
1463        assert!(root.contains("uid 0"), "{root}");
1464        assert!(root.contains("uid 1000"), "{root}");
1465        assert!(root.contains("-u node"), "{root}");
1466        // Not knowing who this runs as is a refusal, never a pass.
1467        let blind = owner_refusal(None, 1000, db).unwrap();
1468        assert!(blind.contains("/proc/self"), "{blind}");
1469        assert!(blind.contains("-u node"), "{blind}");
1470        assert!(owner_refusal(None, 0, db).is_some(), "not even for root");
1471    }
1472
1473    /// Read the way the server reads it, or the wait would end before the
1474    /// push it exists for.
1475    #[test]
1476    fn the_wait_after_a_change_follows_the_servers_merge_timeout() {
1477        let with = |pairs: &'static [(&'static str, &'static str)]| {
1478            settle_window(&move |k: &str| {
1479                pairs
1480                    .iter()
1481                    .find(|(key, _)| *key == k)
1482                    .map(|(_, v)| v.to_string())
1483            })
1484        };
1485        assert_eq!(with(&[]), Duration::from_millis(45_000) + SETTLE_MARGIN);
1486        assert_eq!(
1487            with(&[("RECALL_MERGE_TIMEOUT_MS", "1234")]),
1488            Duration::from_millis(1234) + SETTLE_MARGIN
1489        );
1490        // The server's fallbacks: zero and nonsense both mean 45 seconds.
1491        for bad in ["0", "soon", "-5"] {
1492            let pairs: &'static [(&str, &str)] =
1493                Box::leak(Box::new([("RECALL_MERGE_TIMEOUT_MS", bad)]));
1494            assert_eq!(with(pairs), Duration::from_millis(45_000) + SETTLE_MARGIN);
1495        }
1496        // Only a literal "false" turns merge off, as in the server.
1497        assert_eq!(with(&[("RECALL_MERGE_ENABLED", "false")]), SETTLE_MARGIN);
1498        assert_eq!(
1499            with(&[
1500                ("RECALL_MERGE_ENABLED", "no"),
1501                ("RECALL_MERGE_TIMEOUT_MS", "10")
1502            ]),
1503            Duration::from_millis(10) + SETTLE_MARGIN
1504        );
1505    }
1506
1507    #[test]
1508    fn a_command_to_run_again_is_quoted_for_the_shell() {
1509        assert_eq!(
1510            sh_args(&["me/thing", "local:-Users-me"]),
1511            "me/thing local:-Users-me"
1512        );
1513        assert_eq!(sh_args(&["my project"]), "'my project'");
1514        assert_eq!(sh_args(&["it's"]), r"'it'\''s'");
1515        assert_eq!(sh_args(&["-odd", "x"]), "-- -odd x");
1516        assert_eq!(sh_args(&[""]), "''");
1517    }
1518
1519    #[test]
1520    fn similar_keys_are_hints_by_substring_either_way() {
1521        let s = |k: &str| Summary {
1522            project_key: k.into(),
1523            files: 1,
1524            tombstones: 0,
1525            last_updated_at: String::new(),
1526        };
1527        let all = [s("acme/app"), s("acme/api"), s("other")];
1528        assert_eq!(similar_keys(&all, "ACME"), vec!["acme/app", "acme/api"]);
1529        assert_eq!(similar_keys(&all, "acme/app-2"), vec!["acme/app"]);
1530        assert!(similar_keys(&all, " ").is_empty());
1531    }
1532}