Skip to main content

auths_cli/commands/
log.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use anyhow::{Context, Result, bail};
5use auths_infra_http::HttpRegistryClient;
6use auths_sdk::ports::RegistryClient;
7use auths_transparency::SignedCheckpoint;
8use clap::{Args, Subcommand};
9use serde::Serialize;
10
11use super::executable::ExecutableCommand;
12use crate::config::CliConfig;
13use crate::ux::format::{JsonResponse, is_json_mode};
14
15#[derive(Args, Debug, Clone)]
16#[command(about = "Inspect, verify, and operate the transparency log")]
17pub struct LogCommand {
18    #[command(subcommand)]
19    pub command: LogSubcommand,
20}
21
22#[derive(Subcommand, Debug, Clone)]
23pub enum LogSubcommand {
24    /// Fetch and display a log entry by sequence number
25    Inspect(InspectArgs),
26    /// Verify log consistency from the cached checkpoint
27    Verify(VerifyArgs),
28    /// Append an artifact digest to a local tile-backed transparency log
29    Append(AppendArgs),
30    /// Emit offline inclusion evidence for an appended artifact digest
31    Prove(ProveArgs),
32}
33
34#[derive(Args, Debug, Clone)]
35pub struct InspectArgs {
36    /// Sequence number of the entry to inspect
37    pub sequence: u128,
38
39    /// Registry URL to fetch from
40    #[clap(long, default_value = "https://public.auths.dev")]
41    pub registry: String,
42}
43
44#[derive(Args, Debug, Clone)]
45pub struct VerifyArgs {
46    /// Registry URL to verify against
47    #[clap(long, default_value = "https://public.auths.dev")]
48    pub registry: String,
49}
50
51#[derive(Args, Debug, Clone)]
52pub struct AppendArgs {
53    /// Artifact digest to log (sha256:<64 hex>)
54    #[clap(long)]
55    pub artifact: String,
56
57    /// Directory holding the local log (tiles, checkpoint, signing key)
58    #[clap(long)]
59    pub log_dir: PathBuf,
60
61    /// The log's origin line (its identity in every checkpoint)
62    #[clap(long, default_value = "auths.local/log")]
63    pub origin: String,
64}
65
66#[derive(Args, Debug, Clone)]
67pub struct ProveArgs {
68    /// Artifact digest to prove (sha256:<64 hex>)
69    #[clap(long)]
70    pub artifact: String,
71
72    /// Directory holding the local log (tiles, checkpoint, signing key)
73    #[clap(long)]
74    pub log_dir: PathBuf,
75
76    /// The log's origin line (must match the log's checkpoints)
77    #[clap(long, default_value = "auths.local/log")]
78    pub origin: String,
79
80    /// Write the inclusion-evidence JSON to this file instead of stdout
81    #[clap(long)]
82    pub out: Option<PathBuf>,
83}
84
85#[derive(Serialize)]
86struct VerifyResult {
87    consistent: bool,
88    cached_size: u64,
89    latest_size: u64,
90    cached_root: String,
91    latest_root: String,
92}
93
94#[derive(Serialize)]
95struct AppendResult {
96    artifact_digest: String,
97    leaf_hash: String,
98    index: u64,
99    size: u64,
100    root: String,
101    origin: String,
102}
103
104impl ExecutableCommand for LogCommand {
105    fn execute(&self, _ctx: &CliConfig) -> Result<()> {
106        let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
107        rt.block_on(async {
108            match &self.command {
109                LogSubcommand::Inspect(args) => handle_inspect(args).await,
110                LogSubcommand::Verify(args) => handle_verify(args).await,
111                LogSubcommand::Append(args) => handle_append(args).await,
112                LogSubcommand::Prove(args) => handle_prove(args).await,
113            }
114        })
115    }
116}
117
118async fn handle_inspect(args: &InspectArgs) -> Result<()> {
119    let registry_url = args.registry.trim_end_matches('/');
120    let client =
121        HttpRegistryClient::new_with_timeouts(Duration::from_secs(30), Duration::from_secs(60));
122
123    let path = format!("v1/log/entries/{}", args.sequence);
124    let response_bytes = client
125        .fetch_registry_data(registry_url, &path)
126        .await
127        .context("Failed to fetch log entry")?;
128
129    let entry: serde_json::Value =
130        serde_json::from_slice(&response_bytes).context("Failed to parse log entry response")?;
131
132    if is_json_mode() {
133        println!(
134            "{}",
135            serde_json::to_string_pretty(&entry).context("Failed to serialize entry")?
136        );
137    } else {
138        let entry_type = entry
139            .get("content")
140            .and_then(|c| c.get("entry_type"))
141            .and_then(|t| t.as_str())
142            .unwrap_or("unknown");
143        let actor = entry
144            .get("content")
145            .and_then(|c| c.get("actor_did"))
146            .and_then(|a| a.as_str())
147            .unwrap_or("unknown");
148        let timestamp = entry
149            .get("timestamp")
150            .and_then(|t| t.as_str())
151            .unwrap_or("unknown");
152        let sequence: u128 = entry
153            .get("sequence")
154            .and_then(|s| s.as_u64())
155            .map(u128::from)
156            .unwrap_or(args.sequence);
157
158        println!("Log Entry #{sequence}");
159        println!("  Type:      {entry_type}");
160        println!("  Actor:     {actor}");
161        println!("  Timestamp: {timestamp}");
162
163        if let Some(body) = entry.get("content").and_then(|c| c.get("body")) {
164            println!(
165                "  Body:      {}",
166                serde_json::to_string_pretty(body).unwrap_or_default()
167            );
168        }
169    }
170
171    Ok(())
172}
173
174#[allow(clippy::disallowed_methods)] // CLI is the presentation boundary
175async fn handle_verify(args: &VerifyArgs) -> Result<()> {
176    let cache_path = dirs::home_dir()
177        .map(|h| h.join(".auths").join("log_checkpoint.json"))
178        .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
179
180    let cached_checkpoint: SignedCheckpoint = match std::fs::read_to_string(&cache_path) {
181        Ok(json) => serde_json::from_str(&json).context("Failed to parse cached checkpoint")?,
182        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
183            if is_json_mode() {
184                let result = VerifyResult {
185                    consistent: false,
186                    cached_size: 0,
187                    latest_size: 0,
188                    cached_root: String::new(),
189                    latest_root: String::new(),
190                };
191                JsonResponse::success("log verify", result).print()?;
192            } else {
193                println!("No cached checkpoint found at {}", cache_path.display());
194                println!("Run 'auths artifact verify' with a bundle to establish initial trust.");
195            }
196            return Ok(());
197        }
198        Err(e) => return Err(e).context("Failed to read cached checkpoint"),
199    };
200
201    let registry_url = args.registry.trim_end_matches('/');
202    let client =
203        HttpRegistryClient::new_with_timeouts(Duration::from_secs(30), Duration::from_secs(60));
204
205    let response_bytes = client
206        .fetch_registry_data(registry_url, "v1/log/checkpoint")
207        .await
208        .context("Failed to fetch latest checkpoint from registry")?;
209
210    let latest_checkpoint: SignedCheckpoint =
211        serde_json::from_slice(&response_bytes).context("Failed to parse latest checkpoint")?;
212
213    let report = auths_sdk::workflows::transparency::try_cache_checkpoint(
214        &crate::adapters::config_store::FileConfigStore,
215        &cache_path,
216        &latest_checkpoint,
217        None,
218    );
219
220    match report {
221        Ok(consistency) => {
222            if is_json_mode() {
223                let result = VerifyResult {
224                    consistent: consistency.consistent,
225                    cached_size: consistency.old_size,
226                    latest_size: consistency.new_size,
227                    cached_root: hex::encode(cached_checkpoint.checkpoint.root.as_bytes()),
228                    latest_root: hex::encode(latest_checkpoint.checkpoint.root.as_bytes()),
229                };
230                JsonResponse::success("log verify", result).print()?;
231            } else {
232                println!("Log Consistency: verified");
233                println!(
234                    "  Cached:  size={}, root={}",
235                    consistency.old_size,
236                    hex::encode(cached_checkpoint.checkpoint.root.as_bytes())
237                );
238                println!(
239                    "  Latest:  size={}, root={}",
240                    consistency.new_size,
241                    hex::encode(latest_checkpoint.checkpoint.root.as_bytes())
242                );
243                println!("  Checkpoint updated.");
244            }
245        }
246        Err(e) => {
247            if is_json_mode() {
248                let result = VerifyResult {
249                    consistent: false,
250                    cached_size: cached_checkpoint.checkpoint.size,
251                    latest_size: latest_checkpoint.checkpoint.size,
252                    cached_root: hex::encode(cached_checkpoint.checkpoint.root.as_bytes()),
253                    latest_root: hex::encode(latest_checkpoint.checkpoint.root.as_bytes()),
254                };
255                let resp: JsonResponse<VerifyResult> = JsonResponse {
256                    success: false,
257                    command: "log verify".into(),
258                    data: Some(result),
259                    error: Some(e.to_string()),
260                };
261                println!(
262                    "{}",
263                    serde_json::to_string(&resp).context("Failed to serialize error response")?
264                );
265            } else {
266                eprintln!("Log Consistency: FAILED");
267                eprintln!("  Error: {e}");
268                eprintln!("  This may indicate a split-view attack.");
269            }
270            bail!("Log consistency verification failed: {e}");
271        }
272    }
273
274    Ok(())
275}
276
277#[allow(clippy::disallowed_methods)] // CLI is the presentation boundary (checkpoint timestamp)
278async fn handle_append(args: &AppendArgs) -> Result<()> {
279    let appended = auths_sdk::workflows::transparency::append_artifact_digest(
280        &args.log_dir,
281        &args.origin,
282        &args.artifact,
283        chrono::Utc::now(),
284    )
285    .await
286    .context("Failed to append artifact to transparency log")?;
287
288    let checkpoint = &appended.signed_checkpoint.checkpoint;
289    let result = AppendResult {
290        artifact_digest: appended.artifact_digest,
291        leaf_hash: hex::encode(appended.leaf_hash.as_bytes()),
292        index: appended.index,
293        size: checkpoint.size,
294        root: hex::encode(checkpoint.root.as_bytes()),
295        origin: checkpoint.origin.to_string(),
296    };
297
298    if is_json_mode() {
299        JsonResponse::success("log append", result).print()?;
300    } else {
301        println!("Appended to transparency log");
302        println!("  Artifact: {}", result.artifact_digest);
303        println!("  Leaf:     {}", result.leaf_hash);
304        println!("  Index:    {}", result.index);
305        println!("  Size:     {}", result.size);
306        println!("  Root:     {}", result.root);
307        println!("  Origin:   {}", result.origin);
308    }
309    Ok(())
310}
311
312async fn handle_prove(args: &ProveArgs) -> Result<()> {
313    let inclusion = auths_sdk::workflows::transparency::prove_artifact_digest(
314        &args.log_dir,
315        &args.origin,
316        &args.artifact,
317    )
318    .await
319    .context("Failed to prove artifact inclusion")?;
320
321    if let Some(out) = &args.out {
322        let json = serde_json::to_string_pretty(&inclusion)
323            .context("Failed to serialize inclusion evidence")?;
324        std::fs::write(out, json).with_context(|| format!("Failed to write {}", out.display()))?;
325        if is_json_mode() {
326            JsonResponse::success("log prove", &inclusion).print()?;
327        } else {
328            println!("Inclusion evidence written");
329            println!("  Artifact: {}", args.artifact);
330            println!("  Index:    {}", inclusion.inclusion_proof.index);
331            println!("  Size:     {}", inclusion.inclusion_proof.size);
332            println!("  Out:      {}", out.display());
333        }
334    } else if is_json_mode() {
335        JsonResponse::success("log prove", &inclusion).print()?;
336    } else {
337        println!(
338            "{}",
339            serde_json::to_string_pretty(&inclusion)
340                .context("Failed to serialize inclusion evidence")?
341        );
342    }
343    Ok(())
344}