use std::fs::File;
use std::io::{self, BufWriter, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::Subcommand;
use recall_hooks::audit::{CheckError, Checkpoint, Inconsistency, Saved, Witness, Witnessed};
use recall_hooks::client::{self, Client};
use recall_hooks::{exit, ClientConfig};
use recall_wire::audit::merkle::{self, Tree};
use recall_wire::audit::verify;
use crate::devices::{count, done, next_line, relative, short_hash, title_on_stderr, wrap};
use crate::edit::printable;
use crate::project as proj;
use crate::ui::{self, Tone};
const FAILED: i32 = 1;
const UNUSABLE: i32 = 2;
#[derive(Subcommand)]
pub enum Cmd {
#[command(verbatim_doc_comment)]
Export {
#[arg(long, short, value_name = "FILE")]
output: Option<PathBuf>,
},
#[command(verbatim_doc_comment)]
Verify {
file: Option<PathBuf>,
#[arg(long = "checkpoint", value_name = "SIZE:ROOT")]
checkpoints: Vec<String>,
},
#[command(verbatim_doc_comment)]
Reset {
#[arg(long, short)]
yes: bool,
},
}
pub async fn run(cmd: Cmd) -> anyhow::Result<i32> {
let cfg = proj::resolve().config();
Ok(match cmd {
Cmd::Export { output } => export(&cfg, output.as_deref()).await,
Cmd::Verify {
file: Some(file),
checkpoints,
} => verify_file(&cfg, &file, &checkpoints),
Cmd::Verify {
file: None,
checkpoints,
} if !checkpoints.is_empty() => refuse(
"--checkpoint holds an export to a checkpoint, and no export was named.",
"recall audit verify FILE --checkpoint SIZE:ROOT",
),
Cmd::Verify { file: None, .. } => check(&cfg).await,
Cmd::Reset { yes } => reset(&cfg, yes),
})
}
fn witness(cfg: &ClientConfig) -> Result<Witness, i32> {
if cfg.url.is_empty() {
return Err(refuse(
"no server: run recall connect first, or set RECALL_URL.",
"",
));
}
match &cfg.audit_file {
Some(file) => Ok(Witness::new(file, &cfg.url)),
None => Err(refuse(
"nowhere to keep checkpoints: neither RECALL_HOME nor HOME is set.",
"",
)),
}
}
const RATE_LIMIT_RETRIES: usize = 13;
const RATE_LIMIT_WAIT: Duration = Duration::from_secs(5);
async fn export(cfg: &ClientConfig, output: Option<&Path>) -> i32 {
let witness = match witness(cfg) {
Ok(w) => w,
Err(code) => return code,
};
let client = match crate::devices::admin_client(cfg) {
Ok(client) => client,
Err(why) => return refuse(&why, ""),
};
let capability = match client.discover().await {
Ok(Some(doc)) => doc.audit(),
Ok(None) => None,
Err(e) => return server_error(&e),
};
let Some(capability) = capability else {
return no_log_to_export(&witness);
};
let answer = match client.audit_checkpoint().await {
Ok(answer) => answer,
Err(client::Error::Status { code: 404, .. }) => return no_log_to_export(&witness),
Err(e) => return server_error(&e),
};
let Some(current) = Checkpoint::from_wire(&answer) else {
eprintln!(
"recall audit: the server's checkpoint is not a size and a root: {}",
answer.to_header_value()
);
return FAILED;
};
let mut sink = match Sink::open(output) {
Ok(sink) => sink,
Err(e) => {
eprintln!("recall audit: cannot write {}: {e}", describe(output));
return UNUSABLE;
}
};
let mut tree = Tree::new();
let fetched = fetch(&client, current, capability.max_page, &mut sink, &mut tree).await;
let written = fetched.and_then(|()| sink.finish().map_err(|e| Fetch::Write(e.to_string())));
if let Err(stop) = written {
return stop.report(output, cfg);
}
let wrote = format!(
"wrote {} to {}",
count(current.size as usize, "leaf", "leaves"),
describe(output)
);
let at = format!("at {}", describe_checkpoint(¤t));
if tree.root() != current.root {
title_on_stderr("recall audit export", witness.origin());
eprintln!();
mark_line(Tone::Good, &wrote);
detail(&at);
mark_line(
Tone::Bad,
"the leaves the server sent do not hash to its own checkpoint",
);
detail("recall audit verify says the same of the file.");
return FAILED;
}
let held = match witness.witness_export(&tree, current) {
Ok(Witnessed::Extends { proved, .. }) => proved,
Ok(Witnessed::Inconsistent { finding, unsaved }) => {
title_on_stderr("recall audit export", witness.origin());
eprintln!();
mark_line(Tone::Good, &wrote);
detail(&at);
let kept = describe(output);
report_inconsistency(&finding, unsaved.as_deref(), Some(&kept));
return FAILED;
}
Err(e) => {
eprintln!(
"recall audit: {wrote}, {at}, but the checkpoints saved here could not be \
checked against it: {e}"
);
return UNUSABLE;
}
};
title_on_stderr("recall audit export", witness.origin());
eprintln!();
mark_line(Tone::Good, &wrote);
detail(&at);
match held {
0 => mark_line(
Tone::Quiet,
"no checkpoint was saved here to hold it to; this one now is",
),
n => mark_line(
Tone::Good,
&format!(
"it extends the {} saved here",
count(n, "checkpoint", "checkpoints")
),
),
}
if let Some(path) = output {
next_on_stderr(
&format!("recall audit verify {}", path.display()),
"checks it offline",
);
}
exit::OK
}
enum Fetch {
Server(client::Error),
Page(String),
Write(String),
}
impl Fetch {
fn report(self, output: Option<&Path>, cfg: &ClientConfig) -> i32 {
match self {
Fetch::Server(client::Error::Status { code: 403, .. }) => {
let what = match cfg.device.as_ref() {
Some(d) => format!("this machine is enrolled as a {} device", d.scope),
None => "the credential this machine sent is not one".to_string(),
};
refuse(
&format!(
"reading the log's leaves needs an admin device or the server's \
RECALL_TOKEN, and {what}."
),
"Run this on an admin device, or with RECALL_TOKEN set. recall audit \
verify, with no file, needs neither.",
);
UNUSABLE
}
Fetch::Server(e) => server_error(&e),
Fetch::Page(why) => {
eprintln!("recall audit: the server answered a page that is not one: {why}");
FAILED
}
Fetch::Write(why) => {
eprintln!("recall audit: cannot write {}: {why}", describe(output));
UNUSABLE
}
}
}
}
async fn fetch(
client: &Client,
current: Checkpoint,
max_page: u32,
sink: &mut Sink,
tree: &mut Tree,
) -> Result<(), Fetch> {
let write = |sink: &mut Sink, bytes: &[u8]| {
sink.writer()
.write_all(bytes)
.map_err(|e| Fetch::Write(e.to_string()))
};
let header = format!("{}\n", current.header());
if current.size == 0 {
write(sink, header.as_bytes())?;
}
let page = u64::from(max_page.max(1));
let mut start = 0;
while start < current.size {
let end = (start + page).min(current.size);
let got = page_of(client, start, end).await.map_err(Fetch::Server)?;
if start == 0 {
write(sink, header.as_bytes())?;
}
let count = got.entries.len() as u64;
if got.start != start || got.end != start + count || count == 0 || got.end > end {
return Err(Fetch::Page(format!(
"asked for {start} to {end}, got {} to {} holding {count}",
got.start, got.end
)));
}
for leaf in &got.entries {
if leaf.contains('\n') {
return Err(Fetch::Page(format!(
"leaf {} holds a line break, which no leaf the server writes does",
tree.size()
)));
}
tree.append(merkle::hash_leaf(leaf.as_bytes()));
write(sink, leaf.as_bytes())?;
write(sink, b"\n")?;
}
start = got.end;
}
Ok(())
}
async fn page_of(
client: &Client,
start: u64,
end: u64,
) -> Result<recall_wire::AuditEntriesResponse, client::Error> {
let mut waited = 0;
loop {
match client.audit_entries(start, end).await {
Err(client::Error::Status { code: 429, .. }) if waited < RATE_LIMIT_RETRIES => {
if waited == 0 {
eprintln!("recall audit: the server asked to wait (its rate limit); waiting");
}
waited += 1;
tokio::time::sleep(RATE_LIMIT_WAIT).await;
}
other => return other,
}
}
}
enum Sink {
Stdout(BufWriter<io::Stdout>),
File {
writer: Option<BufWriter<File>>,
partial: PathBuf,
path: PathBuf,
},
}
impl Sink {
fn open(output: Option<&Path>) -> io::Result<Self> {
Ok(match output {
None => Sink::Stdout(BufWriter::new(io::stdout())),
Some(path) => {
let mut partial = path.as_os_str().to_owned();
partial.push(".partial");
let partial = PathBuf::from(partial);
let create = || {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&partial)
};
let file = match create() {
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
std::fs::remove_file(&partial)?;
create()?
}
other => other?,
};
Sink::File {
writer: Some(BufWriter::new(file)),
partial,
path: path.to_path_buf(),
}
}
})
}
fn writer(&mut self) -> &mut dyn Write {
match self {
Sink::Stdout(w) => w,
Sink::File { writer, .. } => writer.as_mut().expect("open until finished"),
}
}
fn finish(&mut self) -> io::Result<()> {
match self {
Sink::Stdout(w) => w.flush(),
Sink::File {
writer,
partial,
path,
} => {
let file = writer
.take()
.expect("finished once")
.into_inner()
.map_err(|e| e.into_error())?;
file.sync_all()?;
drop(file);
std::fs::rename(&*partial, &*path)
}
}
}
}
impl Drop for Sink {
fn drop(&mut self) {
if let Sink::File {
writer, partial, ..
} = self
{
drop(writer.take());
let _ = std::fs::remove_file(partial);
}
}
}
fn describe(output: Option<&Path>) -> String {
output.map_or_else(
|| "standard output".to_string(),
|p| p.display().to_string(),
)
}
fn verify_file(cfg: &ClientConfig, file: &Path, args: &[String]) -> i32 {
let mut given = Vec::new();
for arg in args {
match verify::parse_checkpoint_arg(arg) {
Some(cp) => given.push(cp),
None => {
eprintln!(
"recall audit: --checkpoint {arg:?} is not SIZE:ROOT, a size and a root in \
standard base64"
);
return UNUSABLE;
}
}
}
let export = match std::fs::read(file) {
Ok(bytes) => bytes,
Err(e) => {
eprintln!("recall audit: cannot read {}: {e}", file.display());
return UNUSABLE;
}
};
let leaves = leaf_lines(&export);
let (held, origin) = match (&cfg.audit_file, cfg.url.is_empty()) {
(Some(path), false) => {
let witness = Witness::new(path, &cfg.url);
match witness.load() {
Ok(saved) => (saved.all(), Some(witness.origin().to_string())),
Err(e) => {
eprintln!("recall audit: {e}, so the checkpoints saved here cannot be checked");
return UNUSABLE;
}
}
}
_ => (Vec::new(), None),
};
let (covered, newer): (Vec<Checkpoint>, Vec<Checkpoint>) =
held.iter().partition(|c| c.size <= leaves);
let saved: Vec<(u64, merkle::Hash)> = covered
.iter()
.map(|c| (c.size, c.root))
.chain(given.iter().copied())
.collect();
let verdict = verify::verify_export(&export, &saved);
let file_name = file.display().to_string();
if !verdict.ok() {
title_on_stderr("recall audit verify", &file_name);
eprintln!();
for problem in &verdict.problems {
mark_line(Tone::Bad, &printable(&shorten_hashes(problem)));
}
verdict_on_stderr(
Tone::Bad,
&format!(
"The export does not check out: {}.",
count(verdict.problems.len(), "problem", "problems")
),
);
return FAILED;
}
let mut held_to = Vec::new();
if let Some(origin) = &origin {
if !covered.is_empty() {
held_to.push(format!(
"the {} saved here for {origin}",
count(covered.len(), "checkpoint", "checkpoints")
));
}
}
if !given.is_empty() {
held_to.push(format!("the {} given with --checkpoint", given.len()));
}
ui::title("recall audit verify", &file_name);
anstream::println!();
let root = Checkpoint::from_header(&verdict.checkpoint)
.map(|cp| checkpoint_root(&cp))
.unwrap_or_else(|| verdict.checkpoint.clone());
ui::check(
Tone::Good,
"leaves",
LABEL_WIDTH,
&format!(
"{}, whose root is the checkpoint's: {}",
verdict.leaves,
short_hash(&root)
),
None,
);
ui::check(
Tone::Good,
"signatures",
LABEL_WIDTH,
&format!(
"every one checked, on {}",
count(verdict.signed as usize, "signed leaf", "signed leaves")
),
None,
);
if held_to.is_empty() {
ui::check(
Tone::Quiet,
"checkpoints",
LABEL_WIDTH,
"none saved here or given to hold it to",
None,
);
} else {
ui::check(
Tone::Good,
"checkpoints",
LABEL_WIDTH,
&format!("it extends {}", held_to.join(" and ")),
None,
);
}
if !newer.is_empty() {
ui::check(
Tone::Quiet,
"newer",
LABEL_WIDTH,
&format!(
"{} saved here {} newer than this export",
count(newer.len(), "checkpoint", "checkpoints"),
if newer.len() == 1 { "is" } else { "are" }
),
Some("recall audit verify (no file) checks them against the server"),
);
}
ui::verdict(Tone::Good, "The export checks out.");
exit::OK
}
fn leaf_lines(export: &[u8]) -> u64 {
let body = export.strip_suffix(b"\n").unwrap_or(export);
if body.is_empty() {
return 0;
}
body.iter().filter(|&&b| b == b'\n').count() as u64
}
async fn check(cfg: &ClientConfig) -> i32 {
let witness = match witness(cfg) {
Ok(w) => w,
Err(code) => return code,
};
let client = match cfg.client() {
Ok(client) => client,
Err(e) => return refuse(&e.to_string(), ""),
};
let saved = match witness.load() {
Ok(saved) => saved.all().len(),
Err(e) => return unreadable(&e.to_string()),
};
match witness.check(&client, CHECK_DEADLINE).await {
Ok(Witnessed::Extends { current, proved }) => {
let kept = witness.load().map(|s| s.checkpoints.len()).unwrap_or(0);
ui::title("recall audit verify", witness.origin());
anstream::println!();
anstream::println!(
" {} The server's log extends every checkpoint saved here.",
ui::toned(Tone::Good, Tone::Good.mark())
);
anstream::println!(
" {}",
ui::dim(&format!(
"{proved} proven now, {kept} kept · the log now: {}",
describe_checkpoint(¤t)
))
);
exit::OK
}
Ok(Witnessed::Inconsistent { finding, unsaved }) => {
title_on_stderr("recall audit verify", witness.origin());
eprintln!();
report_inconsistency(&finding, unsaved.as_deref(), None);
FAILED
}
Err(e) if e.no_log() && saved > 0 => {
title_on_stderr("recall audit verify", witness.origin());
eprintln!();
lost_log(saved)
}
Err(e) if e.no_log() => no_log(),
Err(e) if e.unreadable() => unreadable(&e.to_string()),
Err(e) if e.refused() => {
eprintln!("recall audit: the server refused this machine's credential: {e}");
eprintln!(
" The device may have been revoked, or not be allowed the audit routes: \
recall status says which, and recall connect enrols it again."
);
UNUSABLE
}
Err(e) if e.unanswered() => {
eprintln!("recall audit: {e}; what was proven before then is kept");
UNUSABLE
}
Err(e @ (CheckError::File(_) | CheckError::Moved)) => {
eprintln!("recall audit: {e}");
UNUSABLE
}
Err(e) => {
title_on_stderr("recall audit verify", witness.origin());
eprintln!();
mark_line(
Tone::Bad,
"the server did not prove its log extends the checkpoints saved here",
);
detail(&e.to_string());
FAILED
}
}
}
const CHECK_DEADLINE: Duration = Duration::from_secs(90);
fn unreadable(why: &str) -> i32 {
eprintln!(
"recall audit: {why}; it may hold the only record of a rewrite, so nothing was checked \
or saved"
);
eprintln!(" Look at it first; move it aside only once you know what it held.");
UNUSABLE
}
fn checkpoint_root(cp: &Checkpoint) -> String {
cp.header()
.split_once(' ')
.map(|(_, root)| root.to_string())
.unwrap_or_default()
}
fn describe_checkpoint(cp: &Checkpoint) -> String {
format!(
"checkpoint {} · root {}",
cp.size,
short_hash(&checkpoint_root(cp))
)
}
pub(crate) fn report_inconsistency(
found: &Inconsistency,
unsaved: Option<&str>,
kept: Option<&str>,
) {
mark_line(
Tone::Bad,
"the server's audit log no longer extends a checkpoint this machine saved",
);
detail(&found.detail);
let at = format!("{} ({})", relative(&found.found_at), found.found_at);
for (label, value) in [
("found", at),
("saved", found.saved_header()),
("seen", found.seen_header()),
] {
anstream::eprintln!(" {} {value}", ui::dim(&format!("{label:<5}")));
}
if let Some(why) = unsaved {
mark_line(
Tone::Warn,
&format!("NOT SAVED to audit.json ({why}): keep this output"),
);
}
after_a_rewrite(kept);
}
fn after_a_rewrite(kept: Option<&str>) {
eprintln!();
detail("If the server was restored from a backup, that is why:");
next_on_stderr("recall audit reset", "starts again from the log as it is");
match kept {
Some(kept) => detail(&format!(
"If not, its history was rewritten: keep {kept}, the evidence."
)),
None => {
detail("If not, its history was rewritten. Keep the evidence first:");
next_on_stderr("recall audit export -o audit-evidence.jsonl", "");
}
}
}
pub(crate) const AFTER_A_REWRITE: &str =
"If the server was restored from a backup, that is why: recall audit reset, and it starts \
again from the log as it is. If not, its history was rewritten: keep the evidence first, \
recall audit export -o audit-evidence.jsonl";
fn reset(cfg: &ClientConfig, yes: bool) -> i32 {
let witness = match witness(cfg) {
Ok(w) => w,
Err(code) => return code,
};
let held = match witness.load() {
Ok(held) => held,
Err(e) => return unreadable(&e.to_string()),
};
if held.is_empty() {
anstream::println!(
"{} Nothing to forget: nothing is saved here for {}.",
ui::toned(Tone::Quiet, Tone::Quiet.mark()),
witness.origin()
);
return exit::OK;
}
let what = describe_saved(&held);
if !yes {
if !(io::stdin().is_terminal() && io::stderr().is_terminal()) {
return refuse("needs a terminal to ask first.", "In a script, pass --yes.");
}
let question = format!(
"Forget {what} for {}? Do this once you know why the log changed.",
witness.origin()
);
match cliclack::confirm(question).initial_value(false).interact() {
Ok(true) => {}
_ => return FAILED,
}
}
match witness.reset() {
Ok(_) => {
done(&format!("Forgot {what}, for {}.", witness.origin()));
anstream::println!(" The next pull saves a first checkpoint again.");
exit::OK
}
Err(e) => refuse(&e.to_string(), ""),
}
}
fn describe_saved(held: &Saved) -> String {
let n = held.checkpoints.len() + held.unchecked.len();
let mut what = count(n, "checkpoint", "checkpoints");
if let Some(found) = &held.inconsistent {
what.push_str(&format!(
" and the rewrite found {}",
relative(&found.found_at)
));
}
what
}
fn refuse(what: &str, then: &str) -> i32 {
eprintln!("recall audit: {what}");
if !then.is_empty() {
eprintln!(" {then}");
}
UNUSABLE
}
fn no_log() -> i32 {
eprintln!("recall audit: the server keeps no audit log: it is older than 0.4.2.");
UNUSABLE
}
fn no_log_to_export(witness: &Witness) -> i32 {
match witness.load() {
Ok(saved) if !saved.all().is_empty() => {
title_on_stderr("recall audit export", witness.origin());
eprintln!();
lost_log(saved.all().len())
}
Ok(_) => no_log(),
Err(e) => unreadable(&e.to_string()),
}
}
fn lost_log(saved: usize) -> i32 {
mark_line(
Tone::Bad,
&format!(
"the server keeps no audit log, and this machine saved {} of one",
count(saved, "checkpoint", "checkpoints")
),
);
detail("A server that went back to before 0.4.2 lost it.");
after_a_rewrite(None);
FAILED
}
fn server_error(e: &client::Error) -> i32 {
eprintln!("recall audit: {}", e.reason());
if e.device_gone() {
eprintln!(" Run recall connect to enrol this machine again.");
} else if matches!(e, client::Error::Transport(_)) {
eprintln!(" Check the server is up: recall doctor");
}
UNUSABLE
}
const WIDTH: usize = 100;
const LABEL_WIDTH: usize = 11;
fn mark_line(tone: Tone, text: &str) {
for (i, line) in wrap(text, WIDTH - 4).iter().enumerate() {
match i {
0 => anstream::eprintln!(" {} {line}", ui::toned(tone, tone.mark())),
_ => anstream::eprintln!(" {line}"),
}
}
}
fn detail(text: &str) {
for line in wrap(text, WIDTH - 4) {
anstream::eprintln!(" {}", ui::dim(&line));
}
}
fn next_on_stderr(command: &str, what: &str) {
anstream::eprintln!("{}", next_line(command, what));
}
fn verdict_on_stderr(tone: Tone, text: &str) {
anstream::eprintln!();
anstream::eprintln!("{} {}", ui::toned(tone, tone.mark()), ui::bold(text));
}
fn shorten_hashes(text: &str) -> String {
let is_b64 = |c: char| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=');
let mut out = String::with_capacity(text.len());
let mut word = String::new();
let flush = |word: &mut String, out: &mut String| {
if word.len() == 44 && word.ends_with('=') && verify::root_hash(word).is_some() {
out.push_str(&short_hash(word));
} else {
out.push_str(word);
}
word.clear();
};
for c in text.chars() {
if is_b64(c) {
word.push(c);
} else {
flush(&mut word, &mut out);
out.push(c);
}
}
flush(&mut word, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_problems_roots_are_cut_short_and_nothing_else() {
let problem = "the root over the first 16 leaves is \
A9eynh+FB8idl91nn/0ibY0DxDqTpcrMv7JstDslA+g=, the saved checkpoint says \
gBzXgBzaYCPdFUGxq2R1UtJhM9rS104gua1Tw/Xp/Q8=: the log does not extend it";
assert_eq!(
shorten_hashes(problem),
"the root over the first 16 leaves is A9eynh+F…, the saved checkpoint says \
gBzXgBza…: the log does not extend it"
);
let other = "leaf 3: signature x4bsQ2 does not verify";
assert_eq!(shorten_hashes(other), other);
}
}