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)?;
564 Store::open_existing(db, Access::Write)
565}
566
567const LIVE: &str = "The live database";
568const BACKUP: &str = "The backup";
569
570fn 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
609fn 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 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 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 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 if err.is::<Abandoned>() {
698 discard(ctx.out, &snapshot);
699 }
700 return Err(err);
701 }
702 };
703
704 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
717fn 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
724fn 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
746fn 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
766fn 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
781fn 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
810fn 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 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
897fn 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
999fn 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
1024fn 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
1102fn 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
1204fn source(row: &Row) -> &str {
1207 match row.source_env.as_deref() {
1208 None => "(none)",
1209 Some("") => "\"\"",
1210 Some(s) => s,
1211 }
1212}
1213
1214fn shown(s: &str) -> Cow<'_, str> {
1225 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#[cfg(target_os = "linux")]
1259fn check_owner(db: &Path) -> Result<()> {
1260 use std::os::unix::fs::MetadataExt;
1261 let Ok(file) = std::fs::metadata(db) else {
1264 return Ok(());
1265 };
1266 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#[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
1311fn 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 #[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 #[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 ("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 #[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 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 #[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 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 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}