use std::collections::{BTreeMap, BTreeSet};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::time::SystemTime;
use clap::{Args, Subcommand};
use serde::Serialize;
use crate::error::{Error, Result};
use crate::etree::{self, ParseOps, TextNode};
use crate::output;
use super::{CommonArgs, apply_common, resolve_policy};
#[derive(Args, Debug, Clone)]
pub struct CasArgs {
#[command(subcommand)]
pub command: CasSubcmd,
}
#[derive(Subcommand, Debug, Clone)]
pub enum CasSubcmd {
Verify(CasVerifyArgs),
Gc(CasGcArgs),
}
#[derive(Args, Debug, Clone)]
pub struct CasVerifyArgs {
#[arg(value_name = "FILE", default_value = "-")]
pub files: Vec<String>,
}
#[derive(Args, Debug, Clone)]
pub struct CasGcArgs {
#[arg(value_name = "FILE", default_value = "-")]
pub files: Vec<String>,
#[arg(long, value_name = "SECONDS", default_value_t = 0)]
pub min_age: u64,
}
pub fn run(args: CasArgs, common: &CommonArgs) -> Result<()> {
match args.command {
CasSubcmd::Verify(a) => run_verify(a, common),
CasSubcmd::Gc(a) => run_gc(a, common),
}
}
struct HashRef {
hash: String,
kind: &'static str,
label: Option<String>,
file: String,
}
enum HashStatus {
Ok { bytes: usize },
Fail { reason: String },
}
impl HashStatus {
fn is_ok(&self) -> bool {
matches!(self, HashStatus::Ok { .. })
}
}
struct HashCheck {
hash: String,
kind: &'static str,
label: Option<String>,
file: String,
status: HashStatus,
}
impl HashCheck {
fn print_text(&self) {
match &self.status {
HashStatus::Ok { bytes } => {
let label = super::color::green("OK");
eprintln!(
"{label: <6} {} {} ({} bytes) [{}]",
self.hash,
self.kind,
bytes,
location(self.file.as_str(), self.label.as_deref()),
);
}
HashStatus::Fail { reason } => {
let label = super::color::red("FAIL");
eprintln!(
"{label: <6} {} {} — {} [{}]",
self.hash,
self.kind,
reason,
location(self.file.as_str(), self.label.as_deref()),
);
}
}
}
fn to_dto(&self) -> HashCheckDto<'_> {
match &self.status {
HashStatus::Ok { bytes } => HashCheckDto {
hash: &self.hash,
kind: self.kind,
label: self.label.as_deref(),
file: &self.file,
status: "ok",
bytes: Some(*bytes),
reason: None,
},
HashStatus::Fail { reason } => HashCheckDto {
hash: &self.hash,
kind: self.kind,
label: self.label.as_deref(),
file: &self.file,
status: "fail",
bytes: None,
reason: Some(reason.as_str()),
},
}
}
}
fn location(file: &str, label: Option<&str>) -> String {
match label {
Some(l) => format!("{}:{}", file, l),
None => file.to_string(),
}
}
fn open_reader(fname: &str) -> Result<Box<dyn BufRead>> {
if fname == "-" {
Ok(Box::new(BufReader::new(std::io::stdin())))
} else {
Ok(Box::new(BufReader::new(File::open(fname).map_err(
|e| {
Error::Io(std::io::Error::other(format!(
"Failed to open {fname}: {e}"
)))
},
)?)))
}
}
fn collect_all_refs(files: &[String], paops: &mut ParseOps) -> Result<Vec<HashRef>> {
let mut refs: Vec<HashRef> = Vec::new();
for fname in files {
let reader = open_reader(fname)?;
paops.runtime.fname = fname.clone();
let tree = etree::parse(reader, paops)
.map_err(|e| Error::Cas(format!("parse error in {fname}: {e}")))?;
for node in &tree {
collect_refs(node, fname, &mut refs);
}
}
Ok(refs)
}
fn run_verify(args: CasVerifyArgs, common: &CommonArgs) -> Result<()> {
let policy = resolve_policy(common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(common, &mut paops);
let refs = collect_all_refs(&args.files, &mut paops)?;
let mut unique: BTreeMap<String, &HashRef> = BTreeMap::new();
for r in &refs {
unique.entry(r.hash.clone()).or_insert(r);
}
let policy_ref: &dyn crate::crypto::CryptoPolicy = &*paops.crypto.policy;
let cas_store = paops.io.cas.as_ref();
let mut results: Vec<HashCheck> = Vec::with_capacity(unique.len());
for (hash, r) in &unique {
let status = match cas_store.load(hash, policy_ref) {
Ok(blob) => HashStatus::Ok { bytes: blob.len() },
Err(e) => HashStatus::Fail {
reason: e.to_string(),
},
};
results.push(HashCheck {
hash: hash.clone(),
kind: r.kind,
label: r.label.clone(),
file: r.file.clone(),
status,
});
}
results.sort_by(|a, b| a.hash.cmp(&b.hash));
let ok = results.iter().filter(|r| r.status.is_ok()).count();
let fail = results.len() - ok;
match common.format {
output::OutputFormat::Text => {
for r in &results {
r.print_text();
}
eprintln!("---");
eprintln!(
"{} OK, {} FAIL ({} unique hashes checked)",
ok,
fail,
results.len()
);
if results.is_empty() {
eprintln!("no CAS references found in input");
}
}
output::OutputFormat::Json => {
let payload = CasVerifyOutput {
checked: results.len(),
ok,
fail,
results: results.iter().map(HashCheck::to_dto).collect(),
};
println!("{}", output::to_json(&payload)?);
}
}
if fail > 0 {
std::process::exit(1);
}
Ok(())
}
fn run_gc(args: CasGcArgs, common: &CommonArgs) -> Result<()> {
let policy = resolve_policy(common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(common, &mut paops);
let referenced: BTreeSet<String> = collect_all_refs(&args.files, &mut paops)?
.into_iter()
.map(|r| r.hash)
.collect();
let cas_store = paops.io.cas.as_ref();
let all_hashes = cas_store.list()?;
let now = SystemTime::now();
let casdir = &paops.io.casdir;
let mut orphans: Vec<String> = Vec::new();
let mut kept = 0usize;
for hash in &all_hashes {
if referenced.contains(hash.as_str()) {
kept += 1;
continue;
}
if args.min_age > 0 {
let path = casdir.join(hash);
if let Ok(meta) = std::fs::metadata(&path)
&& let Ok(modified) = meta.modified()
&& let Ok(elapsed) = now.duration_since(modified)
&& elapsed.as_secs() < args.min_age
{
kept += 1;
continue;
}
}
orphans.push(hash.clone());
}
let dry_run = common.dry_run;
match common.format {
output::OutputFormat::Text => {
for hash in &orphans {
if dry_run {
let prefix = super::color::yellow("WOULD DEL");
eprintln!("{prefix} {hash}");
} else {
let prefix = super::color::red("DELETED ");
eprintln!("{prefix} {hash}");
}
}
eprintln!("---");
eprintln!(
"{} {}, {} kept ({} total blobs)",
if dry_run { "would delete" } else { "deleted" },
orphans.len(),
kept,
all_hashes.len(),
);
}
output::OutputFormat::Json => {
let payload = CasGcOutput {
total: all_hashes.len(),
kept,
deleted: orphans.len(),
dry_run,
orphans: orphans.iter().map(|h| h.as_str()).collect(),
};
println!("{}", output::to_json(&payload)?);
}
}
if !dry_run {
for hash in &orphans {
cas_store.delete(hash)?;
}
}
Ok(())
}
fn collect_refs(node: &TextNode, file: &str, out: &mut Vec<HashRef>) {
match node {
TextNode::Stored { keyw, cas } => {
out.push(HashRef {
hash: cas.clone(),
kind: "STORED",
label: Some(keyw.clone()),
file: file.to_string(),
});
}
TextNode::Include { hash } => {
out.push(HashRef {
hash: hash.clone(),
kind: "INCLUDE",
label: None,
file: file.to_string(),
});
}
TextNode::Muted { name, hash, .. } => {
out.push(HashRef {
hash: hash.clone(),
kind: "MUTED",
label: Some(name.clone()),
file: file.to_string(),
});
}
TextNode::Key { name, hash, .. } => {
out.push(HashRef {
hash: hash.clone(),
kind: "KEY",
label: Some(name.clone()),
file: file.to_string(),
});
}
TextNode::Cert { name, hash, .. } => {
out.push(HashRef {
hash: hash.clone(),
kind: "CERT",
label: Some(name.clone()),
file: file.to_string(),
});
}
TextNode::Encrypted { txt, .. } | TextNode::BeginEnd { txt, .. } => {
for child in txt {
collect_refs(child, file, out);
}
}
TextNode::Immutable { txt, .. } => {
for child in txt {
collect_refs(child, file, out);
}
}
TextNode::Conflict { ours, theirs, .. } => {
for child in ours {
collect_refs(child, file, out);
}
for child in theirs {
collect_refs(child, file, out);
}
}
_ => {}
}
}
#[derive(Serialize)]
struct CasVerifyOutput<'a> {
checked: usize,
ok: usize,
fail: usize,
results: Vec<HashCheckDto<'a>>,
}
#[derive(Serialize)]
struct HashCheckDto<'a> {
hash: &'a str,
kind: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<&'a str>,
file: &'a str,
status: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
bytes: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a str>,
}
#[derive(Serialize)]
struct CasGcOutput<'a> {
total: usize,
kept: usize,
deleted: usize,
dry_run: bool,
orphans: Vec<&'a str>,
}