1use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use clap::{Parser, ValueEnum};
8use mkit_core::hash::{Hash, from_hex, to_hex};
9use mkit_core::pack_key;
10use mkit_core::store::{ObjectStore, StoreError};
11use mkit_core::verify::{
12 ClosureReport, export_closure, verify_closure, verify_closure_manifest, verify_closure_store,
13};
14use mkit_core::{ClosureMode, reachable_objects, reachable_snapshot};
15
16use super::revspec;
17use crate::clap_shim;
18use crate::exit;
19use crate::format::{self, JsonObject};
20
21#[derive(Debug, Clone, Copy, ValueEnum)]
22enum ClosureFormat {
23 Default,
24 Json,
25}
26
27#[derive(Debug, Parser)]
28#[command(
29 name = "mkit closure",
30 about = "Export or verify a commit's object-set closure (full disclosure)."
31)]
32enum Cmd {
33 Export(ExportArgs),
35 Verify(VerifyArgs),
37}
38
39#[derive(Debug, Parser)]
40struct ExportArgs {
41 revision: String,
43 #[arg(long)]
45 history: bool,
46 #[arg(short = 'o', long = "output", value_name = "DIR")]
48 output: Option<PathBuf>,
49 #[arg(long)]
51 force: bool,
52 #[arg(long, value_enum, default_value = "default")]
53 format: ClosureFormat,
54}
55
56#[derive(Debug, Parser)]
57struct VerifyArgs {
58 #[arg(value_name = "COMMIT-ID")]
60 commit_id: String,
61 #[arg(long, value_name = "DIR")]
63 from: Option<PathBuf>,
64 #[arg(long)]
67 history: bool,
68 #[arg(long)]
73 show_unreferenced: bool,
74 #[arg(long, value_enum, default_value = "default")]
75 format: ClosureFormat,
76}
77
78#[must_use]
79pub fn run(args: &[String]) -> u8 {
80 let cmd = match clap_shim::parse::<Cmd>("mkit closure", args) {
81 Ok(c) => c,
82 Err(code) => return code,
83 };
84 match cmd {
85 Cmd::Export(opts) => run_export(&opts),
86 Cmd::Verify(opts) => run_verify(&opts),
87 }
88}
89
90fn run_export(opts: &ExportArgs) -> u8 {
91 let json = matches!(opts.format, ClosureFormat::Json);
92 let cwd = match std::env::current_dir() {
93 Ok(p) => p,
94 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
95 };
96 let layout = match super::resolve_layout(&cwd) {
97 Ok(layout) => layout,
98 Err(code) => return code,
99 };
100 let store = match ObjectStore::open(&layout) {
101 Ok(s) => s,
102 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
103 };
104 let root = match revspec::resolve_revision(&store, &layout, &opts.revision) {
105 Ok(h) => h,
106 Err(e) => return emit_err(&format!("{e}"), exit::DATAERR),
107 };
108 let mode = if opts.history {
109 ClosureMode::History
110 } else {
111 ClosureMode::Snapshot
112 };
113 let export = match export_closure(&store, &root, mode) {
114 Ok(e) => e,
115 Err(e) => return emit_err(&e.to_string(), super::prove::map_prove_error(&e)),
116 };
117 let dir = opts.output.clone().unwrap_or_else(|| {
118 PathBuf::from(format!(
119 "{}.closure",
120 format::short_hash(&root, format::SUMMARY_ABBREV)
121 ))
122 });
123 if let Err((msg, code)) = prepare_dir(&dir, opts.force) {
124 return emit_err(&msg, code);
125 }
126 let manifest_name = "MANIFEST.mkcl";
127 if let Err(e) = fs::write(dir.join(manifest_name), &export.manifest) {
128 return emit_err(
129 &format!("write {}: {e}", dir.join(manifest_name).display()),
130 exit::CANTCREAT,
131 );
132 }
133 let mut pack_infos = Vec::new();
134 let mut total = export.manifest.len() as u64;
135 for pack in &export.packs {
136 let key = pack_key(pack);
137 let hex = to_hex(&key);
138 let file = format!("{hex}.pack");
139 if let Err(e) = fs::write(dir.join(&file), pack) {
140 return emit_err(
141 &format!("write {}: {e}", dir.join(&file).display()),
142 exit::CANTCREAT,
143 );
144 }
145 total += pack.len() as u64;
146 pack_infos.push((file, key, pack.len() as u64));
147 }
148 let n_objects = match object_count(&store, &root, mode) {
149 Ok(n) => n,
150 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
151 };
152 let mode_str = mode_name(mode);
153 if json {
154 emit_export_json(&root, mode_str, &pack_infos, n_objects, manifest_name);
155 } else {
156 let mut stdout = std::io::stdout().lock();
157 let _ = writeln!(
158 stdout,
159 "closure: {mode_str}, {n_objects} objects in {} pack(s), {total} B -> {}",
160 pack_infos.len(),
161 dir.display()
162 );
163 }
164 exit::OK
165}
166
167fn run_verify(opts: &VerifyArgs) -> u8 {
168 let json = matches!(opts.format, ClosureFormat::Json);
169 if let Some(dir) = opts.from.as_deref() {
170 run_verify_from(opts, dir, json)
171 } else {
172 run_verify_local(opts, json)
173 }
174}
175
176fn run_verify_from(opts: &VerifyArgs, dir: &Path, json: bool) -> u8 {
177 let root = match from_hex(&opts.commit_id) {
178 Ok(h) => h,
179 Err(e) => {
180 return emit_err(
181 &format!(
182 "with --from, commit-id must be a trusted 64-hex id (not a revision): {e}"
183 ),
184 exit::DATAERR,
185 );
186 }
187 };
188 let manifest_path = dir.join("MANIFEST.mkcl");
189 let manifest = match fs::read(&manifest_path) {
190 Ok(b) => b,
191 Err(e) => {
192 return emit_err(
193 &format!("read {}: {e}", manifest_path.display()),
194 exit::NOINPUT,
195 );
196 }
197 };
198 let decoded = match mkit_core::verify::ClosureManifest::decode(&manifest) {
199 Ok(m) => m,
200 Err(e) => return emit_err(&e.to_string(), exit::DATAERR),
201 };
202 if opts.history && decoded.mode != ClosureMode::History {
203 return emit_err(
204 "manifest mode is snapshot; omit --history or re-export with --history",
205 exit::DATAERR,
206 );
207 }
208 let mut packs = Vec::new();
209 for key in &decoded.packs {
210 let file = dir.join(format!("{}.pack", to_hex(key)));
211 match fs::read(&file) {
212 Ok(b) => packs.push(b),
213 Err(e) => {
214 return emit_err(&format!("read {}: {e}", file.display()), exit::NOINPUT);
215 }
216 }
217 }
218 let refs: Vec<&[u8]> = packs.iter().map(Vec::as_slice).collect();
219 let report = match verify_closure_manifest(&root, &manifest, &refs) {
220 Ok(r) => r,
221 Err(e) => return emit_err(&e.to_string(), exit::DATAERR),
222 };
223 emit_report(&report, json, true)
224}
225
226fn run_verify_local(opts: &VerifyArgs, json: bool) -> u8 {
227 let cwd = match std::env::current_dir() {
228 Ok(p) => p,
229 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
230 };
231 let layout = match super::resolve_layout(&cwd) {
232 Ok(layout) => layout,
233 Err(code) => return code,
234 };
235 let store = match ObjectStore::open(&layout) {
236 Ok(s) => s,
237 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
238 };
239 let root = match revspec::resolve_revision(&store, &layout, &opts.commit_id) {
240 Ok(h) => h,
241 Err(e) => return emit_err(&format!("{e}"), exit::DATAERR),
242 };
243 let mode = if opts.history {
244 ClosureMode::History
245 } else {
246 ClosureMode::Snapshot
247 };
248 let report = if opts.show_unreferenced {
249 let hashes = match store.iter_object_hashes() {
250 Ok(h) => h,
251 Err(e) => return emit_err(&format!("enumerate objects: {e}"), exit::GENERAL_ERROR),
252 };
253 let mut objects = Vec::new();
254 let mut local_corrupt: Vec<(Hash, String)> = Vec::new();
260 for h in hashes {
261 match store.read(&h) {
262 Ok(b) => objects.push(b),
263 Err(StoreError::ObjectNotFound(_)) => {}
264 Err(e @ StoreError::HashMismatch { .. }) => local_corrupt.push((h, e.to_string())),
265 Err(e) => return emit_err(&format!("read {}: {e}", to_hex(&h)), exit::DATAERR),
266 }
267 }
268 let mut report = match verify_closure(&root, mode, objects.iter().map(Vec::as_slice)) {
269 Ok(r) => r,
270 Err(e) => return emit_err(&e.to_string(), exit::DATAERR),
271 };
272 for (h, reason) in local_corrupt {
273 report.missing.retain(|m| *m != h);
274 report.corrupt.push((h, reason));
275 }
276 report.corrupt.sort_by_key(|(id, _)| *id);
277 report
278 } else {
279 match verify_closure_store(&store, &root, mode) {
280 Ok(r) => r,
281 Err(e) => return emit_err(&e.to_string(), exit::DATAERR),
282 }
283 };
284 emit_report(&report, json, opts.show_unreferenced)
285}
286
287fn emit_report(report: &ClosureReport, json: bool, show_unreferenced: bool) -> u8 {
288 if json {
289 emit_report_json(report, show_unreferenced);
290 } else {
291 emit_report_text(report, show_unreferenced);
292 }
293 if report.is_complete() {
294 exit::OK
295 } else {
296 exit::DATAERR
297 }
298}
299
300fn emit_report_text(report: &ClosureReport, show_unreferenced: bool) {
301 let mut stdout = std::io::stdout().lock();
302 let mode = mode_name(report.mode);
303 if report.is_complete() {
304 let _ = writeln!(
305 stdout,
306 "ok: closure complete ({} objects, {mode})",
307 report.verified
308 );
309 } else {
310 let _ = writeln!(
311 stdout,
312 "bad: closure incomplete: {} missing, {} corrupt",
313 report.missing.len(),
314 report.corrupt.len()
315 );
316 print_id_list(&mut stdout, &report.missing, None);
317 let corrupt_ids: Vec<Hash> = report.corrupt.iter().map(|(id, _)| *id).collect();
318 print_id_list(&mut stdout, &corrupt_ids, Some(&report.corrupt));
319 }
320 if !show_unreferenced && !report.unreferenced_checked {
321 let _ = writeln!(
322 stdout,
323 "note: unreferenced not checked (use --show-unreferenced)"
324 );
325 } else if show_unreferenced && !report.unreferenced.is_empty() {
326 let _ = writeln!(stdout, "note: {} unreferenced", report.unreferenced.len());
327 print_id_list(&mut stdout, &report.unreferenced, None);
328 }
329}
330
331fn print_id_list(stdout: &mut impl Write, ids: &[Hash], corrupt: Option<&[(Hash, String)]>) {
332 let show = ids.len().min(10);
333 for id in ids.iter().take(show) {
334 if let Some(pairs) = corrupt {
335 let reason = pairs
336 .iter()
337 .find(|(h, _)| h == id)
338 .map_or("", |(_, r)| r.as_str());
339 let _ = writeln!(stdout, "{} ({reason})", to_hex(id));
340 } else {
341 let _ = writeln!(stdout, "{}", to_hex(id));
342 }
343 }
344 if ids.len() > 10 {
345 let _ = writeln!(stdout, "... and {} more", ids.len() - 10);
346 }
347}
348
349fn emit_report_json(report: &ClosureReport, show_unreferenced: bool) {
350 let missing: Vec<String> = report.missing.iter().map(to_hex).collect();
351 let unreferenced: Vec<String> = if show_unreferenced {
352 report.unreferenced.iter().map(to_hex).collect()
353 } else {
354 Vec::new()
355 };
356 let mut corrupt_items = Vec::new();
357 for (id, reason) in &report.corrupt {
358 let mut obj = JsonObject::new();
359 obj.field_hash("id", id).field_str("reason", reason);
360 corrupt_items.push(obj.finish());
361 }
362 let mut top = JsonObject::new();
363 top.field_hash("root", &report.root)
364 .field_str("mode", mode_name(report.mode))
365 .field_u64("verified", report.verified as u64)
366 .field_bool("complete", report.is_complete())
367 .field_bool("unreferenced_checked", report.unreferenced_checked)
368 .field_raw("missing", &format::json_string_array(&missing))
369 .field_raw("corrupt", &format!("[{}]", corrupt_items.join(",")))
370 .field_raw("unreferenced", &format::json_string_array(&unreferenced));
371 let mut stdout = std::io::stdout().lock();
372 let _ = writeln!(stdout, "{}", top.finish());
373}
374
375fn emit_export_json(
376 root: &Hash,
377 mode: &str,
378 packs: &[(String, Hash, u64)],
379 objects: usize,
380 manifest: &str,
381) {
382 let mut pack_items = Vec::new();
383 for (file, key, bytes) in packs {
384 let mut obj = JsonObject::new();
385 obj.field_str("file", file)
386 .field_hash("blake3", key)
387 .field_u64("bytes", *bytes);
388 pack_items.push(obj.finish());
389 }
390 let mut top = JsonObject::new();
391 top.field_hash("root", root)
392 .field_str("mode", mode)
393 .field_raw("packs", &format!("[{}]", pack_items.join(",")))
394 .field_u64("objects", objects as u64)
395 .field_str("manifest", manifest);
396 let mut stdout = std::io::stdout().lock();
397 let _ = writeln!(stdout, "{}", top.finish());
398}
399
400fn prepare_dir(dir: &Path, force: bool) -> Result<(), (String, u8)> {
401 if dir.exists() {
402 if dir.is_file() {
403 return Err((
404 format!("{} exists and is not a directory", dir.display()),
405 exit::CANTCREAT,
406 ));
407 }
408 let empty = fs::read_dir(dir)
409 .map_err(|e| (format!("read {}: {e}", dir.display()), exit::NOINPUT))?
410 .next()
411 .is_none();
412 if !empty && !force {
413 return Err((
414 format!("{} is not empty; pass --force to overwrite", dir.display()),
415 exit::CANTCREAT,
416 ));
417 }
418 } else {
419 fs::create_dir_all(dir)
420 .map_err(|e| (format!("create {}: {e}", dir.display()), exit::CANTCREAT))?;
421 }
422 Ok(())
423}
424
425fn object_count(store: &ObjectStore, root: &Hash, mode: ClosureMode) -> Result<usize, String> {
426 let set = match mode {
427 ClosureMode::Snapshot => reachable_snapshot(store, root),
428 ClosureMode::History => reachable_objects(store, root),
429 }
430 .map_err(|e| format!("count objects: {e}"))?;
431 Ok(set.len())
432}
433
434fn mode_name(mode: ClosureMode) -> &'static str {
435 match mode {
436 ClosureMode::Snapshot => "snapshot",
437 ClosureMode::History => "history",
438 }
439}
440
441use super::error as emit_err;
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn parse_args(args: &[String]) -> Result<Cmd, clap::Error> {
448 let mut full: Vec<String> = vec!["mkit closure".into()];
449 full.extend_from_slice(args);
450 Cmd::try_parse_from(full)
451 }
452
453 #[test]
454 fn parse_export_defaults() {
455 let Cmd::Export(p) = parse_args(&["export".into(), "HEAD".into()]).unwrap() else {
456 panic!("expected export");
457 };
458 assert_eq!(p.revision, "HEAD");
459 assert!(!p.history);
460 assert!(!p.force);
461 }
462
463 #[test]
464 fn parse_verify_from() {
465 let Cmd::Verify(p) = parse_args(&[
466 "verify".into(),
467 "aa".repeat(32),
468 "--from".into(),
469 "/tmp/c".into(),
470 ])
471 .unwrap() else {
472 panic!("expected verify");
473 };
474 assert!(p.from.is_some());
475 assert!(!p.history);
476 assert!(!p.show_unreferenced);
477 }
478
479 #[test]
480 fn parse_verify_show_unreferenced() {
481 let Cmd::Verify(p) =
482 parse_args(&["verify".into(), "HEAD".into(), "--show-unreferenced".into()]).unwrap()
483 else {
484 panic!("expected verify");
485 };
486 assert!(p.show_unreferenced);
487 }
488}