oxy tlog verify: audit a hub's whole transparency log e59512f2f075
`oxy tlog verify [HUB]` (spec §14.8, oxygenhub#238) accepts the hub's checkpoint as a fetch does (its pinned log key, extending the last one seen, a fork failing), downloads every entry bundle and requires the entries to make the root the checkpoint signs, then lists this repository's entries, a key's (--key) or all (--all); --json too. The checkpoint rules are now shared with fetch (accept_checkpoint). Checked live: oxygenhub.io's 76 entries make its signed root; oxygen's 15 entries listed. Refs oxygenhub#238
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7a65d02804e1
- When
- · 2026-10-18 00:05 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
e59512f2f0756fc294add7dd36d39c558cfd18d5ec5d56c7904d640d62c81f70- Depends on
- 0ed150d62994 132b884b9a8d 135212de5370 4b9d06778581 4dee277c327a 61e3bce7ea5b 6caacfff1d4f 982864068a59 d42369abf180 ee06a6c557b7 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d ff1f62fee654
- Depended on by
- 284fe47d98b9 642da2636de2 028545dfa7a4 51d4862a16d8 b333a19976e4
- In bookmarks
- carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 18 atoms, 1 tree operation, 6 files
Browse the tree at this change
added src/cli/tlog.rs +120 −0
| 1 | + | //! `oxy tlog verify` (spec §14.8, oxygenhub#238): audit a hub's whole |
|
| 2 | + | //! transparency log and list the entries that concern you. |
|
| 3 | + | ||
| 4 | + | use super::json; |
|
| 5 | + | use super::open; |
|
| 6 | + | use super::ui; |
|
| 7 | + | use anyhow::{anyhow, Result}; |
|
| 8 | + | use oxy_core::hash::KeyId; |
|
| 9 | + | use std::path::Path; |
|
| 10 | + | ||
| 11 | + | pub fn verify(hub: Option<&str>, key: Option<&str>, all: bool, as_json: bool) -> Result<i32> { |
|
| 12 | + | let repo = open()?; |
|
| 13 | + | let remote = repo.default_remote(); |
|
| 14 | + | let url = match hub { |
|
| 15 | + | Some(h) if h.contains("://") => h.to_string(), |
|
| 16 | + | Some(h) => format!("https://{h}"), |
|
| 17 | + | None => repo.config.remotes.get(&remote).cloned().ok_or_else(|| { |
|
| 18 | + | anyhow!("name the hub to check (this checkout has no remote {remote})") |
|
| 19 | + | })?, |
|
| 20 | + | }; |
|
| 21 | + | let ca = repo |
|
| 22 | + | .config |
|
| 23 | + | .remote_cas |
|
| 24 | + | .get(&remote) |
|
| 25 | + | .map(Path::new) |
|
| 26 | + | .filter(|_| hub.is_none()); |
|
| 27 | + | let audit = repo.audit_log(&url, ca).map_err(|e| anyhow!("{e}"))?; |
|
| 28 | + | let key = match key { |
|
| 29 | + | Some(k) => Some( |
|
| 30 | + | KeyId::from_hex(k) |
|
| 31 | + | .ok_or_else(|| anyhow!("--key takes a full KeyId (64 hex digits)"))?, |
|
| 32 | + | ), |
|
| 33 | + | None => None, |
|
| 34 | + | }; |
|
| 35 | + | let rid = repo.id().ok(); |
|
| 36 | + | let listed: Vec<(usize, &oxy_core::tlog::Entry)> = audit |
|
| 37 | + | .entries |
|
| 38 | + | .iter() |
|
| 39 | + | .enumerate() |
|
| 40 | + | .filter(|(_, e)| match (&key, all) { |
|
| 41 | + | (_, true) => true, |
|
| 42 | + | (Some(k), _) => e.signers.contains(k), |
|
| 43 | + | (None, _) => Some(e.repo) == rid, |
|
| 44 | + | }) |
|
| 45 | + | .collect(); |
|
| 46 | + | let what = match (&key, all) { |
|
| 47 | + | (_, true) => "every entry".to_string(), |
|
| 48 | + | (Some(k), _) => format!("entries signed by {}", k.short(12)), |
|
| 49 | + | (None, _) => "this repository's entries".to_string(), |
|
| 50 | + | }; |
|
| 51 | + | if as_json { |
|
| 52 | + | println!( |
|
| 53 | + | "{}", |
|
| 54 | + | json::obj([ |
|
| 55 | + | ("hub", json::s(&audit.hub)), |
|
| 56 | + | ("origin", json::s(&audit.checkpoint.origin)), |
|
| 57 | + | ("size", json::n(audit.checkpoint.size)), |
|
| 58 | + | ( |
|
| 59 | + | "root", |
|
| 60 | + | json::s(&oxy_core::hash::ObjectRef(audit.checkpoint.root).to_hex()) |
|
| 61 | + | ), |
|
| 62 | + | ( |
|
| 63 | + | "entries", |
|
| 64 | + | json::arr(listed.iter().map(|(i, e)| { |
|
| 65 | + | json::obj([ |
|
| 66 | + | ("index", json::n(*i)), |
|
| 67 | + | ("kind", json::s(&e.kind)), |
|
| 68 | + | ( |
|
| 69 | + | "repo", |
|
| 70 | + | json::s( |
|
| 71 | + | &e.repo |
|
| 72 | + | .iter() |
|
| 73 | + | .map(|b| format!("{b:02x}")) |
|
| 74 | + | .collect::<String>(), |
|
| 75 | + | ), |
|
| 76 | + | ), |
|
| 77 | + | ("object", json::s(&e.object.to_hex())), |
|
| 78 | + | ( |
|
| 79 | + | "signers", |
|
| 80 | + | json::arr(e.signers.iter().map(|k| json::s(&k.to_hex()))), |
|
| 81 | + | ), |
|
| 82 | + | ("received_ms", json::n(e.received)), |
|
| 83 | + | ]) |
|
| 84 | + | })), |
|
| 85 | + | ), |
|
| 86 | + | ]) |
|
| 87 | + | ); |
|
| 88 | + | return Ok(0); |
|
| 89 | + | } |
|
| 90 | + | println!( |
|
| 91 | + | "{} {}: {} entries make the root its log key signed, and extend what you saw before", |
|
| 92 | + | ui::done("Verified"), |
|
| 93 | + | ui::path(&audit.checkpoint.origin), |
|
| 94 | + | audit.checkpoint.size |
|
| 95 | + | ); |
|
| 96 | + | if listed.is_empty() { |
|
| 97 | + | println!(" {} {}", ui::note(), ui::dim(format!("no {what} in it"))); |
|
| 98 | + | return Ok(0); |
|
| 99 | + | } |
|
| 100 | + | println!( |
|
| 101 | + | " {} {}", |
|
| 102 | + | ui::note(), |
|
| 103 | + | ui::dim(format!("{} of {what}:", listed.len())) |
|
| 104 | + | ); |
|
| 105 | + | for (i, e) in listed { |
|
| 106 | + | let signers: Vec<String> = e.signers.iter().map(|k| k.short(12)).collect(); |
|
| 107 | + | println!( |
|
| 108 | + | " #{i:<6} {:<11} {} {} {}", |
|
| 109 | + | e.kind, |
|
| 110 | + | ui::hash(e.object.short(12)), |
|
| 111 | + | ui::dim(if signers.is_empty() { |
|
| 112 | + | "unsigned".to_string() |
|
| 113 | + | } else { |
|
| 114 | + | format!("by {}", signers.join(", ")) |
|
| 115 | + | }), |
|
| 116 | + | ui::dim(ui::ago(e.received)) |
|
| 117 | + | ); |
|
| 118 | + | } |
|
| 119 | + | Ok(0) |
|
| 120 | + | } |
modified crates/oxy-core/src/repo/mod.rs +1 −0
| … | 158 unchanged lines |
||
| 159 | 159 | pub use subrepos::{SubrepoInfo, SubrepoSync}; |
|
| 160 | 160 | pub use sync::{FetchReport, PullReport, PushReport, RefUpdated, OBJECTS_RESULT}; |
|
| 161 | 161 | pub use tags::TagInfo; |
|
| 162 | + | pub use transparency::LogAudit; |
|
| 162 | 163 | pub use workspace::{FileLock, SnapshotLock, WorkingCopyLock, WorkspaceInfo}; |
|
| 163 | 164 | ||
| 164 | 165 | /// The file name under which per-bookmark or per-tag data is kept: `/` |
|
| … | 2526 unchanged lines |
modified crates/oxy-core/src/repo/transparency.rs +112 −27
| … | 104 unchanged lines |
||
| 105 | 105 | Ok(()) |
|
| 106 | 106 | } |
|
| 107 | 107 | ||
| 108 | - | /// Check what a refs answer from `url` says about its host's log. |
|
| 109 | - | /// Fails only when the host showed two histories (or a log key other |
|
| 110 | - | /// than the one it showed before); everything else is a note. |
|
| 111 | - | pub(crate) fn check_log( |
|
| 108 | + | /// Take a checkpoint note `text` from `hub` as the newest seen, once it |
|
| 109 | + | /// verifies with the hub's pinned log key and extends the last one |
|
| 110 | + | /// seen. `Err` when the hub showed two histories; `Ok(Err(why))` when |
|
| 111 | + | /// it could not be checked (the last one seen is kept). |
|
| 112 | + | pub(crate) fn accept_checkpoint( |
|
| 112 | 113 | &self, |
|
| 113 | - | url: &str, |
|
| 114 | - | ca: Option<&Path>, |
|
| 115 | - | resp: &RefsResponse, |
|
| 116 | - | ) -> Result<Vec<String>> { |
|
| 117 | - | let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else { |
|
| 118 | - | return Ok(vec![]); |
|
| 119 | - | }; |
|
| 120 | - | let mut notes = Vec::new(); |
|
| 121 | - | let unchecked = |why: String| { |
|
| 122 | - | Ok(vec![format!( |
|
| 123 | - | "{hub}'s transparency log was not checked: {why}" |
|
| 124 | - | )]) |
|
| 125 | - | }; |
|
| 114 | + | hub: &str, |
|
| 115 | + | text: &str, |
|
| 116 | + | options: &RemoteOptions, |
|
| 117 | + | ) -> Result<std::result::Result<Checkpoint, String>> { |
|
| 118 | + | let unchecked = |why: String| Ok(Err(why)); |
|
| 126 | 119 | let note = match Note::parse(text) { |
|
| 127 | 120 | Ok(n) => n, |
|
| 128 | 121 | Err(e) => return unchecked(e.to_string()), |
|
| … | 2 unchanged lines |
||
| 131 | 124 | Ok(c) => c, |
|
| 132 | 125 | Err(e) => return unchecked(e.to_string()), |
|
| 133 | 126 | }; |
|
| 134 | - | let options = RemoteOptions { |
|
| 135 | - | ca: ca.map(Path::to_path_buf), |
|
| 136 | - | ..RemoteOptions::default() |
|
| 137 | - | }; |
|
| 138 | - | let seen = self.seen_log(&hub); |
|
| 127 | + | let seen = self.seen_log(hub); |
|
| 139 | 128 | let identity = match &seen { |
|
| 140 | 129 | Some(s) => s.identity.clone(), |
|
| 141 | - | None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), &options) |
|
| 130 | + | None => match http_get(&format!("{hub}/.well-known/oxyhub-key"), options) |
|
| 142 | 131 | .and_then(|b| Ok(Identity::decode(&b)?)) |
|
| 143 | 132 | { |
|
| 144 | 133 | Ok(i) => i, |
|
| … | 4 unchanged lines |
||
| 149 | 138 | fail(format!( |
|
| 150 | 139 | "{hub} showed two histories: {why}. Do not trust what it serves; tell its operator. \ |
|
| 151 | 140 | (What this user saw of its log is in {}.)", |
|
| 152 | - | self.tlog_file(&hub).display() |
|
| 141 | + | self.tlog_file(hub).display() |
|
| 153 | 142 | )) |
|
| 154 | 143 | }; |
|
| 155 | 144 | if let Err(e) = note.verify(&cp.origin, &identity) { |
|
| … | 20 unchanged lines |
||
| 176 | 165 | } |
|
| 177 | 166 | if cp.size > old.size && old.size > 0 { |
|
| 178 | 167 | let tiles = Tiles { |
|
| 179 | - | hub: &hub, |
|
| 168 | + | hub, |
|
| 180 | 169 | size: cp.size, |
|
| 181 | - | options: &options, |
|
| 170 | + | options, |
|
| 182 | 171 | cache: RefCell::new(HashMap::new()), |
|
| 183 | 172 | }; |
|
| 184 | 173 | match tlog::prove_consistency(cp.size, old.size, &tiles).and_then(|p| { |
|
| … | 11 unchanged lines |
||
| 196 | 185 | } |
|
| 197 | 186 | } |
|
| 198 | 187 | } |
|
| 199 | - | self.remember_log(&hub, &identity, ¬e)?; |
|
| 188 | + | self.remember_log(hub, &identity, ¬e)?; |
|
| 189 | + | Ok(Ok(cp)) |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | /// Check what a refs answer from `url` says about its host's log. |
|
| 193 | + | /// Fails only when the host showed two histories (or a log key other |
|
| 194 | + | /// than the one it showed before); everything else is a note. |
|
| 195 | + | pub(crate) fn check_log( |
|
| 196 | + | &self, |
|
| 197 | + | url: &str, |
|
| 198 | + | ca: Option<&Path>, |
|
| 199 | + | resp: &RefsResponse, |
|
| 200 | + | ) -> Result<Vec<String>> { |
|
| 201 | + | let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else { |
|
| 202 | + | return Ok(vec![]); |
|
| 203 | + | }; |
|
| 204 | + | let options = RemoteOptions { |
|
| 205 | + | ca: ca.map(Path::to_path_buf), |
|
| 206 | + | ..RemoteOptions::default() |
|
| 207 | + | }; |
|
| 208 | + | let cp = match self.accept_checkpoint(&hub, text, &options)? { |
|
| 209 | + | Ok(cp) => cp, |
|
| 210 | + | Err(why) => { |
|
| 211 | + | return Ok(vec![format!( |
|
| 212 | + | "{hub}'s transparency log was not checked: {why}" |
|
| 213 | + | )]) |
|
| 214 | + | } |
|
| 215 | + | }; |
|
| 216 | + | let mut notes = Vec::new(); |
|
| 200 | 217 | let mut missing = 0; |
|
| 201 | 218 | for r in &resp.refs { |
|
| 202 | 219 | let Some(a) = &r.attestation else { continue }; |
|
| … | 28 unchanged lines |
||
| 231 | 248 | )); |
|
| 232 | 249 | } |
|
| 233 | 250 | Ok(notes) |
|
| 251 | + | } |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | /// A hub's whole log, checked (`oxy tlog verify`). |
|
| 255 | + | #[derive(Clone, Debug)] |
|
| 256 | + | pub struct LogAudit { |
|
| 257 | + | pub hub: String, |
|
| 258 | + | pub checkpoint: Checkpoint, |
|
| 259 | + | /// Every entry, numbered from 0. |
|
| 260 | + | pub entries: Vec<Entry>, |
|
| 261 | + | } |
|
| 262 | + | ||
| 263 | + | impl Repo { |
|
| 264 | + | /// Fetch the log of `url`'s hub whole: its checkpoint, accepted as a |
|
| 265 | + | /// fetch accepts one, and every entry bundle, whose entries must make |
|
| 266 | + | /// the root the checkpoint signs. |
|
| 267 | + | pub fn audit_log(&self, url: &str, ca: Option<&Path>) -> Result<LogAudit> { |
|
| 268 | + | let hub = |
|
| 269 | + | hub_of(url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub's address")))?; |
|
| 270 | + | let options = RemoteOptions { |
|
| 271 | + | ca: ca.map(Path::to_path_buf), |
|
| 272 | + | ..RemoteOptions::default() |
|
| 273 | + | }; |
|
| 274 | + | let tl = |e: TlogError| RepoError::Msg(e.to_string()); |
|
| 275 | + | let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?) |
|
| 276 | + | .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?; |
|
| 277 | + | let cp = match self.accept_checkpoint(&hub, &text, &options)? { |
|
| 278 | + | Ok(c) => c, |
|
| 279 | + | Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")), |
|
| 280 | + | }; |
|
| 281 | + | let mut entries = Vec::with_capacity(cp.size as usize); |
|
| 282 | + | let mut stored: Vec<Hash> = Vec::new(); |
|
| 283 | + | let mut n = 0u64; |
|
| 284 | + | while n < cp.size { |
|
| 285 | + | let t = Tile { |
|
| 286 | + | level: 0, |
|
| 287 | + | n: n >> tlog::TILE_HEIGHT, |
|
| 288 | + | width: (cp.size - n).min(tlog::TILE_WIDTH) as u32, |
|
| 289 | + | }; |
|
| 290 | + | let bytes = http_get(&format!("{hub}/log/{}", t.entries_path()), &options)?; |
|
| 291 | + | let bundle = tlog::decode_bundle(&bytes).map_err(tl)?; |
|
| 292 | + | if bundle.len() != t.width as usize { |
|
| 293 | + | return fail(format!( |
|
| 294 | + | "{hub}'s entry bundle {} holds {} entries, not {}", |
|
| 295 | + | t.entries_path(), |
|
| 296 | + | bundle.len(), |
|
| 297 | + | t.width |
|
| 298 | + | )); |
|
| 299 | + | } |
|
| 300 | + | for b in bundle { |
|
| 301 | + | let add = |
|
| 302 | + | tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&b), &stored).map_err(tl)?; |
|
| 303 | + | stored.extend(add); |
|
| 304 | + | entries.push(Entry::decode(&b).map_err(tl)?); |
|
| 305 | + | n += 1; |
|
| 306 | + | } |
|
| 307 | + | } |
|
| 308 | + | if tlog::tree_hash(cp.size, &stored).map_err(tl)? != cp.root { |
|
| 309 | + | return fail(format!( |
|
| 310 | + | "{hub} serves entries that do not make the root it signed for {} entries: its log is not the one it attests", |
|
| 311 | + | cp.size |
|
| 312 | + | )); |
|
| 313 | + | } |
|
| 314 | + | Ok(LogAudit { |
|
| 315 | + | hub, |
|
| 316 | + | checkpoint: cp, |
|
| 317 | + | entries, |
|
| 318 | + | }) |
|
| 234 | 319 | } |
|
| 235 | 320 | } |
modified docs/guide.md +10 −0
| … | 695 unchanged lines |
||
| 696 | 696 | ``` |
|
| 697 | 697 | ||
| 698 | 698 | The oh-my-bash plugin in `contrib/oh-my-bash` loads the bash completions itself. |
|
| 699 | + | ||
| 700 | + | ## Transparency logs |
|
| 701 | + | ||
| 702 | + | A hub that keeps a transparency log (OxygenHub does, at `/log`) logs every signed object it accepts. `oxy fetch` and `oxy pull` check it as they go. They remember the log's key and its last checkpoint, and fail if the hub ever shows a log that does not extend what you saw before. That is the sign of a hub showing different histories to different people. To audit a hub's whole log and see what it holds for this repository: |
|
| 703 | + | ||
| 704 | + | ``` |
|
| 705 | + | oxy tlog verify # this checkout's hub |
|
| 706 | + | oxy tlog verify https://oxygenhub.io --all |
|
| 707 | + | oxy tlog verify --key <KeyId> # what a key signed |
|
| 708 | + | ``` |
modified docs/spec.md +2 −0
| … | 787 unchanged lines |
||
| 788 | 788 | - requires every later checkpoint to verify with that key, keep its origin, and extend the last one seen: a smaller tree, the same size with another root, or a consistency proof that fails (computed from the host's tiles) means the host showed two histories, and the fetch fails; |
|
| 789 | 789 | - checks each bookmark's attestation against its entry (kind `ref`, the attestation's ObjectRef and repository id) and inclusion proof, reporting one that is missing or does not verify. While logs are new this is a warning, not a refusal. |
|
| 790 | 790 | ||
| 791 | + | **`oxy tlog verify [HUB]`** audits a whole log: it accepts the checkpoint as a fetch does, downloads every entry bundle, and requires the entries to make the root the checkpoint signs. It then lists this repository's entries (`--key KEYID` for a key's, `--all` for every one, `--json` for machines). |
|
| 792 | + | ||
| 791 | 793 | ## 15. Transport |
|
| 792 | 794 | ||
| 793 | 795 | Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel. |
|
| … | 343 unchanged lines |
modified src/cli/mod.rs +35 −0
| … | 7 unchanged lines |
||
| 8 | 8 | mod proposal; |
|
| 9 | 9 | mod receipt; |
|
| 10 | 10 | mod sync; |
|
| 11 | + | mod tlog; |
|
| 11 | 12 | pub mod ui; |
|
| 12 | 13 | mod workspace; |
|
| 13 | 14 | ||
| … | 358 unchanged lines |
||
| 372 | 373 | #[command(subcommand)] |
|
| 373 | 374 | cmd: SubmoduleCmd, |
|
| 374 | 375 | }, |
|
| 376 | + | /// Hubs' transparency logs: check that a hub shows everyone the same |
|
| 377 | + | /// history (spec §14.8) |
|
| 378 | + | Tlog { |
|
| 379 | + | #[command(subcommand)] |
|
| 380 | + | cmd: TlogCmd, |
|
| 381 | + | }, |
|
| 375 | 382 | /// Execution receipts: signed records of what a runner ran on a state |
|
| 376 | 383 | Receipt { |
|
| 377 | 384 | #[command(subcommand)] |
|
| … | 281 unchanged lines |
||
| 659 | 666 | /// versions of the files they touch |
|
| 660 | 667 | #[arg(long, conflicts_with = "discard")] |
|
| 661 | 668 | carry: bool, |
|
| 669 | + | }, |
|
| 670 | + | } |
|
| 671 | + | ||
| 672 | + | #[derive(Subcommand)] |
|
| 673 | + | enum TlogCmd { |
|
| 674 | + | /// Check a hub's whole log: its checkpoint against the log key and the |
|
| 675 | + | /// last checkpoint seen, and every entry against the root it signed; |
|
| 676 | + | /// then list this repository's entries (or a key's, or all) |
|
| 677 | + | Verify { |
|
| 678 | + | /// The hub, as https://oxygenhub.io (default: this checkout's remote) |
|
| 679 | + | hub: Option<String>, |
|
| 680 | + | /// List the entries a key signed (a KeyId) instead |
|
| 681 | + | #[arg(long, conflicts_with = "all")] |
|
| 682 | + | key: Option<String>, |
|
| 683 | + | /// List every entry |
|
| 684 | + | #[arg(long)] |
|
| 685 | + | all: bool, |
|
| 686 | + | /// Machine-readable output |
|
| 687 | + | #[arg(long)] |
|
| 688 | + | json: bool, |
|
| 662 | 689 | }, |
|
| 663 | 690 | } |
|
| 664 | 691 | ||
| … | 634 unchanged lines |
||
| 1299 | 1326 | SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths), |
|
| 1300 | 1327 | SubmoduleCmd::Status { json } => sync::submodule_status(json), |
|
| 1301 | 1328 | SubmoduleCmd::Remove { path } => sync::submodule_remove(&path), |
|
| 1329 | + | }, |
|
| 1330 | + | Cmd::Tlog { cmd } => match cmd { |
|
| 1331 | + | TlogCmd::Verify { |
|
| 1332 | + | hub, |
|
| 1333 | + | key, |
|
| 1334 | + | all, |
|
| 1335 | + | json, |
|
| 1336 | + | } => tlog::verify(hub.as_deref(), key.as_deref(), all, json), |
|
| 1302 | 1337 | }, |
|
| 1303 | 1338 | Cmd::Receipt { cmd } => match cmd { |
|
| 1304 | 1339 | ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json), |
|
| … | 206 unchanged lines |