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::project as proj;
const FAILED: i32 = 1;
const UNUSABLE: i32 = 2;
#[derive(Subcommand)]
pub enum Cmd {
Export {
#[arg(long, short, value_name = "FILE")]
output: Option<PathBuf>,
},
Verify {
file: Option<PathBuf>,
#[arg(long = "checkpoint", value_name = "SIZE:ROOT")]
checkpoints: Vec<String>,
},
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);
}
if tree.root() != current.root {
eprintln!(
"recall audit: FAIL: the leaves the server sent do not hash to its own checkpoint \
({}): the export was written, and recall audit verify will say the same",
current.header()
);
return FAILED;
}
let said = match witness.witness_export(&tree, current) {
Ok(Witnessed::Extends { proved, .. }) => match proved {
0 => "no checkpoint was saved here to hold it to; this one now is".to_string(),
n => format!("it extends the {n} checkpoint(s) saved here"),
},
Ok(Witnessed::Inconsistent { finding, unsaved }) => {
eprintln!(
"recall audit: wrote {} leaves to {}",
current.size,
describe(output)
);
report_inconsistency(&finding, unsaved.as_deref());
return FAILED;
}
Err(e) => {
eprintln!(
"recall audit: wrote {} leaves at checkpoint {} to {}, but the checkpoints \
saved here could not be checked against it: {e}",
current.size,
current.header(),
describe(output)
);
return UNUSABLE;
}
};
eprintln!(
"recall audit: wrote {} leaves at checkpoint {} to {}; {said}.",
current.size,
current.header(),
describe(output)
);
if let Some(path) = output {
eprintln!(
" Check it offline with: recall audit verify {}",
path.display()
);
}
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);
if !verdict.ok() {
for problem in &verdict.problems {
eprintln!("FAIL: {problem}");
}
return FAILED;
}
let mut held_to = Vec::new();
if let Some(origin) = &origin {
if !covered.is_empty() {
held_to.push(format!(
"the {} checkpoint(s) saved here for {origin}",
covered.len()
));
}
}
if !given.is_empty() {
held_to.push(format!("the {} given with --checkpoint", given.len()));
}
let held_to = match held_to.is_empty() {
true => "no saved checkpoint to hold it to".to_string(),
false => format!("it extends {}", held_to.join(" and ")),
};
println!(
"OK: checkpoint {}; {} leaves, {} signed, every signature checked; {held_to}",
verdict.checkpoint, verdict.leaves, verdict.signed
);
if !newer.is_empty() {
println!(
" {} checkpoint(s) saved here are newer than this export; recall audit verify, \
with no file, checks those against the server",
newer.len()
);
}
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);
println!(
"OK: the log at {} has {} leaves (root {}) and extends every checkpoint saved \
here: {proved} proven now, {kept} kept",
witness.origin(),
current.size,
checkpoint_root(¤t)
);
exit::OK
}
Ok(Witnessed::Inconsistent { finding, unsaved }) => {
report_inconsistency(&finding, unsaved.as_deref());
FAILED
}
Err(e) if e.no_log() && saved > 0 => 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) => {
eprintln!(
"FAIL: the server did not prove its log extends the checkpoints saved here: {e}"
);
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()
}
pub(crate) fn report_inconsistency(found: &Inconsistency, unsaved: Option<&str>) {
eprintln!(
"FAIL: the server's audit log no longer extends a checkpoint this machine saved: {}",
found.detail
);
eprintln!(" found {}", found.found_at);
eprintln!(" saved {}", found.saved_header());
eprintln!(" seen {}", found.seen_header());
if let Some(why) = unsaved {
eprintln!(" NOT SAVED to audit.json ({why}): keep this output");
}
eprintln!(" {}", AFTER_A_REWRITE);
}
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() {
println!("Nothing is saved here for {}.", 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(_) => {
println!(
"Forgot {what} for {}. The next pull saves a first checkpoint again.",
witness.origin()
);
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 = format!("{n} checkpoint(s)");
if let Some(found) = &held.inconsistent {
what.push_str(&format!(" and the rewrite found on {}", 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() => lost_log(saved.all().len()),
Ok(_) => no_log(),
Err(e) => unreadable(&e.to_string()),
}
}
fn lost_log(saved: usize) -> i32 {
eprintln!(
"FAIL: the server keeps no audit log, and this machine saved {saved} checkpoint(s) of \
one: a server that went back to before 0.4.2 lost it"
);
eprintln!(" {AFTER_A_REWRITE}");
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
}