Skip to main content

mkit_cli/commands/
prove.rs

1//! `mkit prove <revision> [<path>]` — build a disclosure bundle for a
2//! path, chunk, or byte range under a commit (or remix).
3
4use std::io::{self, IsTerminal, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Parser, ValueEnum};
8use mkit_core::hash::{Hash, hash};
9use mkit_core::store::ObjectStore;
10use mkit_core::verify::{Selector, VerifyError, build_disclosure};
11
12use super::revspec;
13use crate::clap_shim;
14use crate::exit;
15use crate::format::{self, JsonObject};
16
17#[derive(Debug, Clone, Copy, ValueEnum)]
18enum ProveFormat {
19    Default,
20    Json,
21}
22
23#[derive(Debug, Parser)]
24#[command(
25    name = "mkit prove",
26    about = "Build a disclosure bundle proving a path, chunk, or byte range belongs to a commit."
27)]
28struct ProveOpts {
29    /// Revision: an object hash, a branch/tag name, or `HEAD`.
30    revision: String,
31    /// Repository-relative path, split on `/`. Omitted: disclose the root tree.
32    path: Option<String>,
33    /// Disclose chunk N of a `ChunkedBlob` (0-based data-chunk index).
34    #[arg(long, value_name = "N", conflicts_with = "range")]
35    chunk: Option<u32>,
36    /// Disclose OFFSET:LEN decimal bytes of the file (LEN must be > 0).
37    #[arg(
38        long,
39        value_name = "OFFSET:LEN",
40        conflicts_with = "chunk",
41        value_parser = parse_range_arg
42    )]
43    range: Option<(u64, u64)>,
44    /// Include per-preceding-chunk length proofs so the range's absolute
45    /// file offset is authenticated. Only valid with `--range`.
46    #[arg(long, requires = "range")]
47    with_offsets: bool,
48    /// Write the bundle to FILE instead of stdout.
49    #[arg(short = 'o', long = "output", value_name = "FILE")]
50    output: Option<PathBuf>,
51    /// Emit a JSON summary. The bundle still goes to `-o` or (non-TTY) stdout.
52    #[arg(long, value_enum, default_value = "default")]
53    format: ProveFormat,
54}
55
56fn parse_range_arg(s: &str) -> Result<(u64, u64), String> {
57    let (off, len) = s
58        .split_once(':')
59        .ok_or_else(|| "range must be OFFSET:LEN (decimal bytes)".to_string())?;
60    let offset: u64 = off
61        .parse()
62        .map_err(|_| format!("invalid range offset '{off}'"))?;
63    let length: u64 = len
64        .parse()
65        .map_err(|_| format!("invalid range length '{len}'"))?;
66    if length == 0 {
67        return Err("range length must be greater than 0".into());
68    }
69    Ok((offset, length))
70}
71
72#[must_use]
73pub fn run(args: &[String]) -> u8 {
74    let opts = match clap_shim::parse::<ProveOpts>("mkit prove", args) {
75        Ok(o) => o,
76        Err(code) => return code,
77    };
78    let json = matches!(opts.format, ProveFormat::Json);
79    let cwd = match std::env::current_dir() {
80        Ok(p) => p,
81        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
82    };
83    let layout = match super::resolve_layout(&cwd) {
84        Ok(layout) => layout,
85        Err(code) => return code,
86    };
87    let store = match ObjectStore::open(&layout) {
88        Ok(s) => s,
89        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
90    };
91    let commit_id = match revspec::resolve_revision(&store, &layout, &opts.revision) {
92        Ok(h) => h,
93        Err(e) => return emit_err(&format!("{e}"), exit::DATAERR),
94    };
95    let path_owned = match split_repo_path(opts.path.as_deref().unwrap_or("")) {
96        Ok(p) => p,
97        Err(msg) => return emit_err(&msg, exit::USAGE),
98    };
99    let path_refs: Vec<&[u8]> = path_owned.iter().map(Vec::as_slice).collect();
100    let selector = match (&opts.chunk, opts.range, opts.with_offsets) {
101        (None, None, false) => Selector::Object,
102        (Some(n), None, false) => Selector::Chunk(*n),
103        (None, Some((offset, len)), with_offsets) => Selector::Range {
104            offset,
105            len,
106            with_offsets,
107        },
108        _ => return emit_err("invalid selector combination", exit::USAGE),
109    };
110    let bundle = match build_disclosure(&store, &commit_id, &path_refs, selector) {
111        Ok(b) => b,
112        Err(e) => return emit_err(&e.to_string(), map_prove_error(&e)),
113    };
114    let dest = match write_bundle(&bundle, opts.output.as_deref()) {
115        Ok(d) => d,
116        Err((msg, code)) => return emit_err(&msg, code),
117    };
118    let path_display = display_path(opts.path.as_deref());
119    let kind = selector_kind(selector);
120    let status_to_stderr = opts.output.is_none();
121    if json {
122        emit_prove_json(
123            &commit_id,
124            &path_display,
125            selector,
126            bundle.len(),
127            &hash(&bundle),
128            &dest,
129            status_to_stderr,
130        );
131    } else {
132        let line = format!(
133            "proof: {} B for {} @ {} ({kind})",
134            bundle.len(),
135            path_display,
136            format::short_hash(&commit_id, format::SUMMARY_ABBREV),
137        );
138        emit_status(&line, status_to_stderr);
139    }
140    exit::OK
141}
142
143fn write_bundle(bundle: &[u8], output: Option<&Path>) -> Result<String, (String, u8)> {
144    if let Some(path) = output {
145        fs_write(path, bundle)?;
146        return Ok(path.display().to_string());
147    }
148    if io::stdout().is_terminal() {
149        return Err((
150            "refusing to write a binary proof to a TTY; pass -o FILE or redirect stdout".into(),
151            exit::USAGE,
152        ));
153    }
154    io::stdout()
155        .write_all(bundle)
156        .map_err(|e| (format!("write stdout: {e}"), exit::CANTCREAT))?;
157    Ok("-".into())
158}
159
160fn fs_write(path: &Path, bytes: &[u8]) -> Result<(), (String, u8)> {
161    std::fs::write(path, bytes)
162        .map_err(|e| (format!("write {}: {e}", path.display()), exit::CANTCREAT))
163}
164
165fn emit_status(line: &str, to_stderr: bool) {
166    if to_stderr {
167        let mut stderr = io::stderr().lock();
168        let _ = writeln!(stderr, "{line}");
169    } else {
170        let mut stdout = io::stdout().lock();
171        let _ = writeln!(stdout, "{line}");
172    }
173}
174
175fn emit_prove_json(
176    commit_id: &Hash,
177    path: &str,
178    selector: Selector,
179    bundle_bytes: usize,
180    bundle_blake3: &Hash,
181    output: &str,
182    to_stderr: bool,
183) {
184    let mut sel = JsonObject::new();
185    match selector {
186        Selector::Object => {
187            sel.field_str("kind", "object");
188        }
189        Selector::Chunk(index) => {
190            sel.field_str("kind", "chunk")
191                .field_u64("index", u64::from(index));
192        }
193        Selector::Range {
194            offset,
195            len,
196            with_offsets,
197        } => {
198            sel.field_str("kind", "range")
199                .field_u64("offset", offset)
200                .field_u64("len", len)
201                .field_bool("with_offsets", with_offsets);
202        }
203    }
204    let mut top = JsonObject::new();
205    top.field_hash("commit_id", commit_id)
206        .field_str("path", path)
207        .field_raw("selector", &sel.finish())
208        .field_u64("bundle_bytes", bundle_bytes as u64)
209        .field_hash("bundle_blake3", bundle_blake3)
210        .field_str("output", output);
211    let line = top.finish();
212    emit_status(&line, to_stderr);
213}
214
215fn selector_kind(selector: Selector) -> &'static str {
216    match selector {
217        Selector::Object => "object",
218        Selector::Chunk(_) => "chunk",
219        Selector::Range { .. } => "range",
220    }
221}
222
223pub(crate) fn split_repo_path(path: &str) -> Result<Vec<Vec<u8>>, String> {
224    if path.is_empty() || path == "/" || path == "." {
225        return Ok(Vec::new());
226    }
227    let trimmed = path.trim_matches('/');
228    if trimmed.is_empty() {
229        return Ok(Vec::new());
230    }
231    let mut parts = Vec::new();
232    for p in trimmed.split('/') {
233        if p.is_empty() || p == "." {
234            continue;
235        }
236        if p == ".." {
237            return Err("path must be repository-relative without '..'".into());
238        }
239        if p.len() > 255 {
240            return Err("path component exceeds 255 bytes".into());
241        }
242        parts.push(p.as_bytes().to_vec());
243    }
244    Ok(parts)
245}
246
247pub(crate) fn display_path(path: Option<&str>) -> String {
248    match path {
249        None | Some("" | "/" | ".") => "/".into(),
250        Some(p) => p.trim_matches('/').to_owned(),
251    }
252}
253
254pub(crate) fn map_prove_error(e: &VerifyError) -> u8 {
255    match e {
256        VerifyError::PathNotFound(_)
257        | VerifyError::PathThroughNonTree
258        | VerifyError::Store(mkit_core::store::StoreError::ObjectNotFound(_)) => exit::NOINPUT,
259        VerifyError::RangeCrossesChunkBoundary
260        | VerifyError::SelectorLeafMismatch
261        | VerifyError::RangeOutOfBounds
262        | VerifyError::ZeroLengthRange
263        | VerifyError::ChunkIndexOutOfRange { .. }
264        | VerifyError::NotACommitOrRemix(_) => exit::DATAERR,
265        _ => exit::GENERAL_ERROR,
266    }
267}
268
269use super::error as emit_err;
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn parse_args(args: &[String]) -> Result<ProveOpts, clap::Error> {
276        let mut full: Vec<String> = vec!["mkit prove".into()];
277        full.extend_from_slice(args);
278        ProveOpts::try_parse_from(full)
279    }
280
281    #[test]
282    fn parse_revision_only() {
283        let p = parse_args(&["HEAD".into()]).unwrap();
284        assert_eq!(p.revision, "HEAD");
285        assert!(p.path.is_none());
286        assert!(p.chunk.is_none());
287        assert!(p.range.is_none());
288        assert!(!p.with_offsets);
289    }
290
291    #[test]
292    fn parse_chunk_and_range_conflict() {
293        let err = parse_args(&[
294            "HEAD".into(),
295            "a.txt".into(),
296            "--chunk".into(),
297            "0".into(),
298            "--range".into(),
299            "0:10".into(),
300        ]);
301        assert!(err.is_err());
302    }
303
304    #[test]
305    fn parse_with_offsets_requires_range() {
306        let err = parse_args(&["HEAD".into(), "a.txt".into(), "--with-offsets".into()]);
307        assert!(err.is_err());
308    }
309
310    #[test]
311    fn parse_range_rejects_zero_len() {
312        let err = parse_args(&[
313            "HEAD".into(),
314            "a.txt".into(),
315            "--range".into(),
316            "0:0".into(),
317        ]);
318        assert!(err.is_err());
319    }
320
321    #[test]
322    fn split_path_nested() {
323        let p = split_repo_path("src/lib.rs").unwrap();
324        assert_eq!(p, vec![b"src".to_vec(), b"lib.rs".to_vec()]);
325    }
326}