1use std::io::{self, Read, Write};
5use std::path::PathBuf;
6
7use clap::{Parser, ValueEnum};
8use mkit_core::hash::{from_hex, hash, to_hex, to_hex_bytes};
9use mkit_core::object::EntryMode;
10use mkit_core::verify::{Disclosed, DisclosedPayload, MAX_BUNDLE_BYTES, verify_disclosure};
11
12use super::prove::display_path;
13use super::trust_roots;
14use crate::clap_shim;
15use crate::exit;
16use crate::format::{self, JsonObject};
17
18#[derive(Debug, Clone, Copy, ValueEnum)]
19enum VerifyProofFormat {
20 Default,
21 Json,
22}
23
24#[derive(Debug, Parser)]
25#[command(
26 name = "mkit verify-proof",
27 about = "Verify a disclosure bundle against a trusted 64-hex commit id."
28)]
29struct VerifyProofOpts {
30 #[arg(value_name = "COMMIT-ID")]
33 commit_id: String,
34 #[arg(value_name = "BUNDLE")]
36 bundle: String,
37 #[arg(long, value_name = "PATH")]
41 expect_path: Option<String>,
42 #[arg(long)]
45 trusted: bool,
46 #[arg(long, value_name = "PATH")]
48 trust_roots: Option<String>,
49 #[arg(long, value_enum, default_value = "default")]
51 format: VerifyProofFormat,
52 #[arg(long, value_name = "FILE")]
54 payload_out: Option<PathBuf>,
55}
56
57#[must_use]
58#[allow(clippy::too_many_lines)]
59pub fn run(args: &[String]) -> u8 {
60 let opts = match clap_shim::parse::<VerifyProofOpts>("mkit verify-proof", args) {
61 Ok(o) => o,
62 Err(code) => return code,
63 };
64 let json = matches!(opts.format, VerifyProofFormat::Json);
65 let commit_id = match from_hex(&opts.commit_id) {
66 Ok(h) => h,
67 Err(e) => {
68 return emit_err(
69 &format!("commit-id must be 64 hex characters: {e}"),
70 exit::DATAERR,
71 );
72 }
73 };
74 let bundle = match read_bundle(&opts.bundle) {
75 Ok(b) => b,
76 Err((msg, code)) => return emit_err(&msg, code),
77 };
78 let disclosed = match verify_disclosure(&commit_id, &bundle) {
79 Ok(d) => d,
80 Err(e) => {
81 emit_human(&format!("bad: {e}"), json);
82 return exit::DATAERR;
83 }
84 };
85 if let Some(expect) = opts.expect_path.as_deref() {
86 let got = authenticated_path(&disclosed.path);
87 let want = display_path(Some(expect));
88 if !paths_match(&got, &want) {
89 emit_human(
90 &format!("bad: authenticated path '{got}' does not match --expect-path '{want}'"),
91 json,
92 );
93 return exit::DATAERR;
94 }
95 }
96
97 let want_trust = opts.trusted || opts.trust_roots.is_some();
98 let mut signer_trusted: Option<bool> = None;
99 let mut trusted_keyid: Option<String> = None;
100 if want_trust {
101 let cwd = match std::env::current_dir() {
102 Ok(p) => p,
103 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
104 };
105 let layout = match super::resolve_layout(&cwd) {
106 Ok(layout) => layout,
107 Err(code) => return code,
108 };
109 let trust_path = opts
110 .trust_roots
111 .as_deref()
112 .map_or_else(trust_roots::default_trust_roots_path, PathBuf::from);
113 if let Err(code) = trust_roots::warn_if_unsafe_trust_roots(
114 &trust_path,
115 layout.common_dir(),
116 opts.trust_roots.is_some(),
117 ) {
118 return code;
119 }
120 trust_roots::note_if_missing(&trust_path);
121 let entries = match trust_roots::load_entries(&trust_path) {
122 Ok(e) => e,
123 Err((msg, code)) => return emit_err(&msg, code),
124 };
125 if let Some(keyid) = trust_roots::find_ed25519_signer(&entries, &disclosed.signer) {
126 signer_trusted = Some(true);
127 trusted_keyid = Some(trust_roots::short_keyid(keyid));
128 } else {
129 signer_trusted = Some(false);
130 }
131 if !disclosed.signature_valid {
132 emit_result(
133 &disclosed,
134 signer_trusted.as_ref(),
135 trusted_keyid.as_deref(),
136 json,
137 );
138 return exit::DATAERR;
139 }
140 if signer_trusted == Some(false) {
141 emit_result(
142 &disclosed,
143 signer_trusted.as_ref(),
144 trusted_keyid.as_deref(),
145 json,
146 );
147 return exit::DATAERR;
148 }
149 }
150
151 if let Some(path) = opts.payload_out.as_deref() {
152 let bytes = payload_bytes(&disclosed.payload);
153 if let Err(e) = std::fs::write(path, bytes) {
154 return emit_err(&format!("write {}: {e}", path.display()), exit::CANTCREAT);
155 }
156 }
157
158 emit_result(
159 &disclosed,
160 signer_trusted.as_ref(),
161 trusted_keyid.as_deref(),
162 json,
163 );
164 if want_trust && (signer_trusted != Some(true) || !disclosed.signature_valid) {
165 exit::DATAERR
166 } else {
167 exit::OK
168 }
169}
170
171fn read_bundle(spec: &str) -> Result<Vec<u8>, (String, u8)> {
172 if spec == "-" {
173 let mut buf = Vec::new();
174 io::stdin()
175 .take(MAX_BUNDLE_BYTES as u64 + 1)
176 .read_to_end(&mut buf)
177 .map_err(|e| (format!("read stdin: {e}"), exit::NOINPUT))?;
178 if buf.len() > MAX_BUNDLE_BYTES {
179 return Err((
180 format!("disclosure bundle exceeds the {MAX_BUNDLE_BYTES} byte cap"),
181 exit::DATAERR,
182 ));
183 }
184 return Ok(buf);
185 }
186 std::fs::read(spec).map_err(|e| (format!("read {spec}: {e}"), exit::NOINPUT))
187}
188
189fn emit_result(
190 d: &Disclosed,
191 signer_trusted: Option<&bool>,
192 trusted_keyid: Option<&str>,
193 json: bool,
194) {
195 let failed =
196 matches!(signer_trusted, Some(false)) || (signer_trusted.is_some() && !d.signature_valid);
197 if json {
198 emit_json(d, signer_trusted.copied());
199 emit_human(&human_line(d, trusted_keyid, failed), true);
200 } else {
201 emit_human(&human_line(d, trusted_keyid, failed), false);
202 }
203}
204
205fn emit_human(line: &str, to_stderr: bool) {
206 if to_stderr {
207 let mut stderr = io::stderr().lock();
208 let _ = writeln!(stderr, "{line}");
209 } else {
210 let mut stdout = io::stdout().lock();
211 let _ = writeln!(stdout, "{line}");
212 }
213}
214
215fn human_line(d: &Disclosed, trusted_keyid: Option<&str>, failed: bool) -> String {
216 let kind = payload_kind(&d.payload);
217 let path = authenticated_path(&d.path);
218 let short = format::short_hash(&d.commit_id, format::SUMMARY_ABBREV);
219 let nbytes = payload_bytes(&d.payload).len();
220 let keyid = trusted_keyid.map_or_else(
221 || {
222 let hex = to_hex_bytes(&d.signer);
223 if hex.len() > 16 {
224 format!("{}…", &hex[..16])
225 } else {
226 hex
227 }
228 },
229 ToOwned::to_owned,
230 );
231 let sig = if d.signature_valid {
232 "valid signature"
233 } else {
234 "INVALID signature"
235 };
236 let trust = if trusted_keyid.is_some() {
237 ", signer trusted"
238 } else {
239 ""
240 };
241 if failed && !d.signature_valid {
242 format!("bad: {kind} {path} @ {short}, {nbytes} B, signer {keyid} ({sig}){trust}")
243 } else if failed {
244 format!(
245 "bad: signature valid, but signer {} is not in the trust-roots registry",
246 to_hex_bytes(&d.signer)
247 )
248 } else {
249 format!("ok: {kind} {path} @ {short}, {nbytes} B, signer {keyid} ({sig}){trust}")
250 }
251}
252
253fn emit_json(d: &Disclosed, signer_trusted: Option<bool>) {
254 let mut top = JsonObject::new();
255 top.field_hash("commit_id", &d.commit_id)
256 .field_hash("tree_hash", &d.tree_hash)
257 .field_raw("path", &path_json(&d.path))
258 .field_hash("leaf_id", &d.leaf_id)
259 .field_str("signer", &to_hex_bytes(&d.signer))
260 .field_bool("signature_valid", d.signature_valid)
261 .field_raw("payload", &payload_json(&d.payload))
262 .field_raw("step_inner_roots", &hashes_json(&d.step_inner_roots))
263 .field_opt_hash("chunk_inner_root", d.chunk_inner_root.as_ref());
264 match signer_trusted {
265 Some(v) => {
266 top.field_bool("signer_trusted", v);
267 }
268 None => {
269 top.field_raw("signer_trusted", "null");
270 }
271 }
272 let mut stdout = io::stdout().lock();
273 let _ = writeln!(stdout, "{}", top.finish());
274}
275
276fn hashes_json(hashes: &[mkit_core::hash::Hash]) -> String {
277 let items: Vec<String> = hashes
278 .iter()
279 .map(|h| format!("\"{}\"", to_hex(h)))
280 .collect();
281 format!("[{}]", items.join(","))
282}
283
284fn path_json(path: &[(Vec<u8>, EntryMode)]) -> String {
285 let mut items = Vec::with_capacity(path.len());
286 for (name, mode) in path {
287 let mut obj = JsonObject::new();
288 match std::str::from_utf8(name) {
289 Ok(s) => {
290 obj.field_str("name", s);
291 }
292 Err(_) => {
293 obj.field_raw("name", "null");
294 }
295 }
296 obj.field_str("name_hex", &to_hex_bytes(name))
297 .field_str("mode", mode_str(*mode));
298 items.push(obj.finish());
299 }
300 format!("[{}]", items.join(","))
301}
302
303fn payload_json(payload: &DisclosedPayload) -> String {
304 let bytes = payload_bytes(payload);
305 let bytes_len = bytes.len() as u64;
306 let bytes_blake3 = to_hex(&hash(bytes));
307 let mut obj = JsonObject::new();
308 match payload {
309 DisclosedPayload::Object { .. } => {
310 obj.field_str("kind", "object")
311 .field_u64("bytes_len", bytes_len)
312 .field_str("bytes_blake3", &bytes_blake3);
313 }
314 DisclosedPayload::Chunk {
315 total_size,
316 chunk_size,
317 index,
318 ..
319 } => {
320 obj.field_str("kind", "chunk")
321 .field_u64("bytes_len", bytes_len)
322 .field_str("bytes_blake3", &bytes_blake3)
323 .field_u64("index", u64::from(*index))
324 .field_u64("total_size", *total_size)
325 .field_u64("chunk_size", u64::from(*chunk_size));
326 }
327 DisclosedPayload::Range {
328 blob_id,
329 chunk,
330 offset_in_blob,
331 absolute_offset,
332 ..
333 } => {
334 obj.field_str("kind", "range")
335 .field_u64("bytes_len", bytes_len)
336 .field_str("bytes_blake3", &bytes_blake3)
337 .field_hash("blob_id", blob_id);
338 match chunk {
339 None => {
340 obj.field_raw("chunk", "null");
341 }
342 Some((index, total_size, chunk_size)) => {
343 let mut c = JsonObject::new();
344 c.field_u64("index", u64::from(*index))
345 .field_u64("total_size", *total_size)
346 .field_u64("chunk_size", u64::from(*chunk_size));
347 obj.field_raw("chunk", &c.finish());
348 }
349 }
350 obj.field_u64("offset_in_blob", *offset_in_blob);
351 match absolute_offset {
352 Some(off) => {
353 obj.field_u64("absolute_offset", *off);
354 }
355 None => {
356 obj.field_raw("absolute_offset", "null");
357 }
358 }
359 }
360 }
361 obj.finish()
362}
363
364fn payload_bytes(payload: &DisclosedPayload) -> &[u8] {
365 match payload {
366 DisclosedPayload::Object { bytes }
367 | DisclosedPayload::Chunk { bytes, .. }
368 | DisclosedPayload::Range { bytes, .. } => bytes,
369 }
370}
371
372fn payload_kind(payload: &DisclosedPayload) -> &'static str {
373 match payload {
374 DisclosedPayload::Object { .. } => "object",
375 DisclosedPayload::Chunk { .. } => "chunk",
376 DisclosedPayload::Range { .. } => "range",
377 }
378}
379
380fn mode_str(mode: EntryMode) -> &'static str {
381 match mode {
382 EntryMode::Blob => "blob",
383 EntryMode::Tree => "tree",
384 EntryMode::Symlink => "symlink",
385 EntryMode::Executable => "exec",
386 }
387}
388
389fn authenticated_path(path: &[(Vec<u8>, EntryMode)]) -> String {
390 if path.is_empty() {
391 return "/".into();
392 }
393 path.iter()
394 .map(|(n, _)| String::from_utf8_lossy(n).into_owned())
395 .collect::<Vec<_>>()
396 .join("/")
397}
398
399fn paths_match(got: &str, want: &str) -> bool {
400 normalize_path(got) == normalize_path(want)
401}
402
403fn normalize_path(p: &str) -> String {
404 let t = p.trim_matches('/');
405 if t.is_empty() {
406 String::new()
407 } else {
408 t.to_owned()
409 }
410}
411
412use super::error as emit_err;
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 fn parse_args(args: &[String]) -> Result<VerifyProofOpts, clap::Error> {
419 let mut full: Vec<String> = vec!["mkit verify-proof".into()];
420 full.extend_from_slice(args);
421 VerifyProofOpts::try_parse_from(full)
422 }
423
424 #[test]
425 fn parse_positional() {
426 let p = parse_args(&["aa".repeat(32), "bundle.bin".into()]).unwrap();
427 assert_eq!(p.bundle, "bundle.bin");
428 assert!(p.expect_path.is_none());
429 assert!(!p.trusted);
430 }
431
432 #[test]
433 fn paths_match_root_aliases() {
434 assert!(paths_match("/", ""));
435 assert!(paths_match("/", "/"));
436 assert!(paths_match("src/a.txt", "/src/a.txt"));
437 assert!(!paths_match("a.txt", "b.txt"));
438 }
439}