use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use clap::{Parser, ValueEnum};
use mkit_core::hash::{Hash, hash};
use mkit_core::store::ObjectStore;
use mkit_core::verify::{Selector, VerifyError, build_disclosure};
use super::revspec;
use crate::clap_shim;
use crate::exit;
use crate::format::{self, JsonObject};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ProveFormat {
Default,
Json,
}
#[derive(Debug, Parser)]
#[command(
name = "mkit prove",
about = "Build a disclosure bundle proving a path, chunk, or byte range belongs to a commit."
)]
struct ProveOpts {
revision: String,
path: Option<String>,
#[arg(long, value_name = "N", conflicts_with = "range")]
chunk: Option<u32>,
#[arg(
long,
value_name = "OFFSET:LEN",
conflicts_with = "chunk",
value_parser = parse_range_arg
)]
range: Option<(u64, u64)>,
#[arg(long, requires = "range")]
with_offsets: bool,
#[arg(short = 'o', long = "output", value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long, value_enum, default_value = "default")]
format: ProveFormat,
}
fn parse_range_arg(s: &str) -> Result<(u64, u64), String> {
let (off, len) = s
.split_once(':')
.ok_or_else(|| "range must be OFFSET:LEN (decimal bytes)".to_string())?;
let offset: u64 = off
.parse()
.map_err(|_| format!("invalid range offset '{off}'"))?;
let length: u64 = len
.parse()
.map_err(|_| format!("invalid range length '{len}'"))?;
if length == 0 {
return Err("range length must be greater than 0".into());
}
Ok((offset, length))
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<ProveOpts>("mkit prove", args) {
Ok(o) => o,
Err(code) => return code,
};
let json = matches!(opts.format, ProveFormat::Json);
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let store = match ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
let commit_id = match revspec::resolve_revision(&store, &layout, &opts.revision) {
Ok(h) => h,
Err(e) => return emit_err(&format!("{e}"), exit::DATAERR),
};
let path_owned = match split_repo_path(opts.path.as_deref().unwrap_or("")) {
Ok(p) => p,
Err(msg) => return emit_err(&msg, exit::USAGE),
};
let path_refs: Vec<&[u8]> = path_owned.iter().map(Vec::as_slice).collect();
let selector = match (&opts.chunk, opts.range, opts.with_offsets) {
(None, None, false) => Selector::Object,
(Some(n), None, false) => Selector::Chunk(*n),
(None, Some((offset, len)), with_offsets) => Selector::Range {
offset,
len,
with_offsets,
},
_ => return emit_err("invalid selector combination", exit::USAGE),
};
let bundle = match build_disclosure(&store, &commit_id, &path_refs, selector) {
Ok(b) => b,
Err(e) => return emit_err(&e.to_string(), map_prove_error(&e)),
};
let dest = match write_bundle(&bundle, opts.output.as_deref()) {
Ok(d) => d,
Err((msg, code)) => return emit_err(&msg, code),
};
let path_display = display_path(opts.path.as_deref());
let kind = selector_kind(selector);
let status_to_stderr = opts.output.is_none();
if json {
emit_prove_json(
&commit_id,
&path_display,
selector,
bundle.len(),
&hash(&bundle),
&dest,
status_to_stderr,
);
} else {
let line = format!(
"proof: {} B for {} @ {} ({kind})",
bundle.len(),
path_display,
format::short_hash(&commit_id, format::SUMMARY_ABBREV),
);
emit_status(&line, status_to_stderr);
}
exit::OK
}
fn write_bundle(bundle: &[u8], output: Option<&Path>) -> Result<String, (String, u8)> {
if let Some(path) = output {
fs_write(path, bundle)?;
return Ok(path.display().to_string());
}
if io::stdout().is_terminal() {
return Err((
"refusing to write a binary proof to a TTY; pass -o FILE or redirect stdout".into(),
exit::USAGE,
));
}
io::stdout()
.write_all(bundle)
.map_err(|e| (format!("write stdout: {e}"), exit::CANTCREAT))?;
Ok("-".into())
}
fn fs_write(path: &Path, bytes: &[u8]) -> Result<(), (String, u8)> {
std::fs::write(path, bytes)
.map_err(|e| (format!("write {}: {e}", path.display()), exit::CANTCREAT))
}
fn emit_status(line: &str, to_stderr: bool) {
if to_stderr {
let mut stderr = io::stderr().lock();
let _ = writeln!(stderr, "{line}");
} else {
let mut stdout = io::stdout().lock();
let _ = writeln!(stdout, "{line}");
}
}
fn emit_prove_json(
commit_id: &Hash,
path: &str,
selector: Selector,
bundle_bytes: usize,
bundle_blake3: &Hash,
output: &str,
to_stderr: bool,
) {
let mut sel = JsonObject::new();
match selector {
Selector::Object => {
sel.field_str("kind", "object");
}
Selector::Chunk(index) => {
sel.field_str("kind", "chunk")
.field_u64("index", u64::from(index));
}
Selector::Range {
offset,
len,
with_offsets,
} => {
sel.field_str("kind", "range")
.field_u64("offset", offset)
.field_u64("len", len)
.field_bool("with_offsets", with_offsets);
}
}
let mut top = JsonObject::new();
top.field_hash("commit_id", commit_id)
.field_str("path", path)
.field_raw("selector", &sel.finish())
.field_u64("bundle_bytes", bundle_bytes as u64)
.field_hash("bundle_blake3", bundle_blake3)
.field_str("output", output);
let line = top.finish();
emit_status(&line, to_stderr);
}
fn selector_kind(selector: Selector) -> &'static str {
match selector {
Selector::Object => "object",
Selector::Chunk(_) => "chunk",
Selector::Range { .. } => "range",
}
}
pub(crate) fn split_repo_path(path: &str) -> Result<Vec<Vec<u8>>, String> {
if path.is_empty() || path == "/" || path == "." {
return Ok(Vec::new());
}
let trimmed = path.trim_matches('/');
if trimmed.is_empty() {
return Ok(Vec::new());
}
let mut parts = Vec::new();
for p in trimmed.split('/') {
if p.is_empty() || p == "." {
continue;
}
if p == ".." {
return Err("path must be repository-relative without '..'".into());
}
if p.len() > 255 {
return Err("path component exceeds 255 bytes".into());
}
parts.push(p.as_bytes().to_vec());
}
Ok(parts)
}
pub(crate) fn display_path(path: Option<&str>) -> String {
match path {
None | Some("" | "/" | ".") => "/".into(),
Some(p) => p.trim_matches('/').to_owned(),
}
}
pub(crate) fn map_prove_error(e: &VerifyError) -> u8 {
match e {
VerifyError::PathNotFound(_)
| VerifyError::PathThroughNonTree
| VerifyError::Store(mkit_core::store::StoreError::ObjectNotFound(_)) => exit::NOINPUT,
VerifyError::RangeCrossesChunkBoundary
| VerifyError::SelectorLeafMismatch
| VerifyError::RangeOutOfBounds
| VerifyError::ZeroLengthRange
| VerifyError::ChunkIndexOutOfRange { .. }
| VerifyError::NotACommitOrRemix(_) => exit::DATAERR,
_ => exit::GENERAL_ERROR,
}
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
use super::*;
fn parse_args(args: &[String]) -> Result<ProveOpts, clap::Error> {
let mut full: Vec<String> = vec!["mkit prove".into()];
full.extend_from_slice(args);
ProveOpts::try_parse_from(full)
}
#[test]
fn parse_revision_only() {
let p = parse_args(&["HEAD".into()]).unwrap();
assert_eq!(p.revision, "HEAD");
assert!(p.path.is_none());
assert!(p.chunk.is_none());
assert!(p.range.is_none());
assert!(!p.with_offsets);
}
#[test]
fn parse_chunk_and_range_conflict() {
let err = parse_args(&[
"HEAD".into(),
"a.txt".into(),
"--chunk".into(),
"0".into(),
"--range".into(),
"0:10".into(),
]);
assert!(err.is_err());
}
#[test]
fn parse_with_offsets_requires_range() {
let err = parse_args(&["HEAD".into(), "a.txt".into(), "--with-offsets".into()]);
assert!(err.is_err());
}
#[test]
fn parse_range_rejects_zero_len() {
let err = parse_args(&[
"HEAD".into(),
"a.txt".into(),
"--range".into(),
"0:0".into(),
]);
assert!(err.is_err());
}
#[test]
fn split_path_nested() {
let p = split_repo_path("src/lib.rs").unwrap();
assert_eq!(p, vec![b"src".to_vec(), b"lib.rs".to_vec()]);
}
}