1use 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
107pub 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 if changes {
143 eprintln!("The database was not changed.");
144 }
145 ExitCode::FAILURE
146 }
147 }
148}
149
150#[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
163const SETTLE_MARGIN: Duration = Duration::from_secs(1);
168
169fn settle_window(env: &dyn Fn(&str) -> Option<String>) -> Duration {
177 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 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
321struct Io<'a> {
323 input: &'a mut dyn BufRead,
324 terminal: bool,
327 out: &'a mut dyn Write,
328}
329
330struct Ctx<'a> {
333 db: PathBuf,
334 backup_dir: Option<PathBuf>,
335 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 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
408fn 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
507pub 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 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
552fn open_live(db: &Path, opts: Opts) -> Result<Store> {
555 if opts.dry_run {
556 return Store::open_existing(db, Access::Read);
557 }
558 check_owner(db)?;
565 Store::open_existing(db, Access::Write)
566}
567
568const LIVE: &str = "The live database";
569const BACKUP: &str = "The backup";
570
571fn 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
610fn 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 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 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 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 if err.is::<Abandoned>() {
699 discard(ctx.out, &snapshot);
700 }
701 return Err(err);
702 }
703 };
704
705 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
718fn 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
725fn 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
747fn 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
767fn 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
782fn 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
811fn 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 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
898fn 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
1000fn 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
1025fn 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
1103fn 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
1205fn source(row: &Row) -> &str {
1208 match row.source_env.as_deref() {
1209 None => "(none)",
1210 Some("") => "\"\"",
1211 Some(s) => s,
1212 }
1213}
1214
1215fn shown(s: &str) -> Cow<'_, str> {
1226 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#[cfg(target_os = "linux")]
1260fn check_owner(db: &Path) -> Result<()> {
1261 use std::os::unix::fs::MetadataExt;
1262 let Ok(file) = std::fs::metadata(db) else {
1265 return Ok(());
1266 };
1267 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#[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
1312fn 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 #[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 #[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 ("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 #[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 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 #[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 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 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}