1use 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: String,
31 path: Option<String>,
33 #[arg(long, value_name = "N", conflicts_with = "range")]
35 chunk: Option<u32>,
36 #[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 #[arg(long, requires = "range")]
47 with_offsets: bool,
48 #[arg(short = 'o', long = "output", value_name = "FILE")]
50 output: Option<PathBuf>,
51 #[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}