Skip to main content

tibet_zip_cli/
lib.rs

1//! tibet-zip CLI library.
2//!
3//! The entire CLI implementation lives here as `pub fn run()`, so the
4//! canonical `tibet-zip-cli` binary and the short-name `tbz-cli` alias
5//! both delegate to a single source of truth — no drift possible.
6//!
7//! Usage (when installed):
8//!   tbz pack <path> -o output.tza    Create a TBZ archive
9//!   tbz unpack <archive.tza>         Extract via TIBET Airlock
10//!   tbz verify <archive.tza>         Validate without extracting
11//!   tbz inspect <archive.tza>        Show manifest and block info
12//!   tbz keygen -o <name>             Generate Ed25519 keypair (v2)
13//!   tbz init                         Generate .jis.json for current repo
14//!
15//! Short aliases (because life is too short for tar -xvf):
16//!   tbz p  = tbz pack
17//!   tbz x  = tbz unpack    (x for eXtract, like tar)
18//!   tbz v  = tbz verify
19//!   tbz i  = tbz inspect
20//!
21//! Smart defaults:
22//!   tbz archive.tza          → auto-detects: verify + unpack
23//!   tbz ./src                → auto-detects: pack
24
25use clap::{Parser, Subcommand};
26use std::fs;
27use std::io::{BufReader, BufWriter, Read as _};
28use std::path::Path;
29
30use sha2::{Digest, Sha256};
31use tbz_core::envelope::TibetEnvelope;
32use tbz_core::manifest::{BlockEntry, Manifest};
33use tbz_core::stream::{TbzReader, TbzWriter};
34use tbz_core::v2;
35use tbz_core::{signature, BlockType};
36
37pub mod tibet_zip;
38
39// ---------------------------------------------------------------------------
40// Format detection: TBZ block-format vs TIBET-ZIP (Desktop ZIP+MANIFEST)
41// ---------------------------------------------------------------------------
42
43/// Archive format detected by magic bytes
44#[derive(Debug, Clone, Copy, PartialEq)]
45enum ArchiveFormat {
46    /// CLI block format: magic 0x54425A ("TBZ"), zstd+Ed25519
47    TbzBlock,
48    /// Desktop ZIP format: magic 0x504B0304 (PK), MANIFEST.json with SHA-256
49    TibetZip,
50    /// Unknown format
51    Unknown,
52}
53
54/// Detect archive format by reading the first 4 bytes
55fn detect_format(path: &str) -> anyhow::Result<ArchiveFormat> {
56    let mut file = fs::File::open(path)?;
57    let mut magic = [0u8; 4];
58    let n = std::io::Read::read(&mut file, &mut magic)?;
59    if n < 3 {
60        return Ok(ArchiveFormat::Unknown);
61    }
62    if magic[0..3] == [0x54, 0x42, 0x5A] {
63        Ok(ArchiveFormat::TbzBlock)
64    } else if magic == [0x50, 0x4B, 0x03, 0x04] {
65        Ok(ArchiveFormat::TibetZip)
66    } else {
67        Ok(ArchiveFormat::Unknown)
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Transparency Mirror client (best-effort HTTP, never a hard error)
73// ---------------------------------------------------------------------------
74mod mirror_client {
75    use serde::{Deserialize, Serialize};
76
77    const TIMEOUT_SECS: u64 = 5;
78
79    #[derive(Serialize)]
80    pub struct RegisterPayload {
81        pub content_hash: String,
82        pub signing_key: String,
83        pub jis_id: Option<String>,
84        pub source_repo: Option<String>,
85        pub block_count: u32,
86        pub total_size: u64,
87    }
88
89    #[derive(Deserialize)]
90    pub struct RegisterResponse {
91        pub status: String, // "registered" | "already_registered"
92    }
93
94    #[derive(Deserialize)]
95    pub struct LookupEntry {
96        pub content_hash: String,
97        pub first_seen: String,
98        pub attestations: Vec<LookupAttestation>,
99    }
100
101    #[derive(Deserialize)]
102    pub struct LookupAttestation {
103        pub verdict: String,
104    }
105
106    pub fn register(base_url: &str, payload: &RegisterPayload) -> Result<RegisterResponse, String> {
107        let url = format!("{}/api/tbz-mirror/register", base_url.trim_end_matches('/'));
108        let resp = ureq::post(&url)
109            .timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
110            .send_json(serde_json::json!({
111                "content_hash": payload.content_hash,
112                "signing_key": payload.signing_key,
113                "jis_id": payload.jis_id,
114                "source_repo": payload.source_repo,
115                "block_count": payload.block_count,
116                "total_size": payload.total_size,
117            }))
118            .map_err(|e| e.to_string())?;
119
120        resp.into_json::<RegisterResponse>().map_err(|e| e.to_string())
121    }
122
123    pub fn lookup(base_url: &str, hash: &str) -> Result<Option<LookupEntry>, String> {
124        let url = format!(
125            "{}/api/tbz-mirror/lookup/{}",
126            base_url.trim_end_matches('/'),
127            hash,
128        );
129        let resp = ureq::get(&url)
130            .timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
131            .call();
132
133        match resp {
134            Ok(r) => {
135                let entry = r.into_json::<LookupEntry>().map_err(|e| e.to_string())?;
136                Ok(Some(entry))
137            }
138            Err(ureq::Error::Status(404, _)) => Ok(None),
139            Err(e) => Err(e.to_string()),
140        }
141    }
142}
143
144/// Compute SHA-256 of a file on disk (streaming, 8 KB chunks).
145fn hash_file(path: &Path) -> anyhow::Result<String> {
146    let file = fs::File::open(path)?;
147    let mut reader = BufReader::new(file);
148    let mut hasher = Sha256::new();
149    let mut buf = [0u8; 8192];
150    loop {
151        let n = reader.read(&mut buf)?;
152        if n == 0 {
153            break;
154        }
155        hasher.update(&buf[..n]);
156    }
157    Ok(format!("sha256:{:x}", hasher.finalize()))
158}
159
160#[derive(Parser)]
161#[command(name = "tbz")]
162#[command(about = "TBZ (TIBET-zip) — Block-level authenticated compression")]
163#[command(version)]
164struct Cli {
165    #[command(subcommand)]
166    command: Option<Commands>,
167
168    /// Smart mode: pass a .tza file to verify+unpack, or a directory to pack
169    #[arg(global = false)]
170    path: Option<String>,
171
172    /// Enable Transparency Mirror registration/lookups (opt-in)
173    #[arg(long, global = true, default_value_t = false)]
174    mirror: bool,
175
176    /// Transparency Mirror base URL (also via TBZ_MIRROR_URL env)
177    #[arg(long, global = true, env = "TBZ_MIRROR_URL",
178          default_value = "https://brein.jaspervandemeent.nl")]
179    mirror_url: String,
180}
181
182#[derive(Subcommand)]
183enum Commands {
184    /// Create a TBZ archive from a file or directory
185    ///
186    /// Default = v1 transparent archive. Pass --seal --to <pubkey-hex> to
187    /// produce a v2 sealed envelope (AES-256-GCM, identity-bound).
188    #[command(alias = "p")]
189    Pack {
190        /// Path to file or directory to archive
191        path: String,
192        /// Output file path
193        #[arg(short, long, default_value = "output.tza")]
194        output: String,
195        /// JIS authorization level for all blocks (default: 0)
196        #[arg(long, default_value = "0")]
197        jis_level: u8,
198        /// Seal the archive (= v2): wrap in an AES-256-GCM envelope.
199        #[arg(long)]
200        seal: bool,
201        /// Receiver's Ed25519 public key (hex, 64 chars). Required with --seal.
202        #[arg(long, value_name = "PUBKEY_HEX")]
203        to: Option<String>,
204        /// Sender's Ed25519 private key file (hex). Optional; ephemeral if absent.
205        #[arg(long, value_name = "PRIVKEY_PATH")]
206        from: Option<String>,
207        /// Declared payload class for v2 envelopes (= L2 semantic typing).
208        /// Values: identity | code | document | command | receipt.
209        /// Aliases: id / exec / doc / cmd / ack. Default: unspecified.
210        #[arg(long = "type", value_name = "CLASS")]
211        payload_type: Option<String>,
212    },
213
214    /// Extract a TBZ archive via the TIBET Airlock
215    ///
216    /// Auto-detects v1 vs v2 from magic bytes. For v2 sealed archives,
217    /// pass --as <privkey-path> to decrypt as the named receiver.
218    #[command(alias = "x")]
219    Unpack {
220        /// Path to the TBZ archive
221        archive: String,
222        /// Output directory
223        #[arg(short, long, default_value = ".")]
224        output: String,
225        /// Receiver's Ed25519 private key file (hex). Required for v2 sealed archives.
226        #[arg(long = "as", value_name = "PRIVKEY_PATH")]
227        as_key: Option<String>,
228        /// Skip the inner-manifest preview shown before extraction.
229        #[arg(long)]
230        no_preview: bool,
231        /// Make payload-class / extension mismatches fatal (default = warn only).
232        #[arg(long)]
233        strict_type: bool,
234    },
235
236    /// Validate a TBZ archive without extracting
237    #[command(alias = "v")]
238    Verify {
239        /// Path to the TBZ archive
240        archive: String,
241    },
242
243    /// Show manifest and block information
244    #[command(alias = "i")]
245    Inspect {
246        /// Path to the TBZ archive
247        archive: String,
248    },
249
250    /// Generate .jis.json for the current repository
251    Init {
252        /// Platform (github, gitlab, etc.)
253        #[arg(long, default_value = "github")]
254        platform: String,
255        /// Account name
256        #[arg(long)]
257        account: Option<String>,
258        /// Repository name
259        #[arg(long)]
260        repo: Option<String>,
261    },
262
263    /// Generate an Ed25519 keypair for v2 sealed archives
264    ///
265    /// Writes <output>.priv (32-byte hex, mode 0600) and <output>.pub (32-byte hex).
266    Keygen {
267        /// Output basename — produces <output>.priv and <output>.pub
268        #[arg(short, long, default_value = "tbz-key")]
269        output: String,
270    },
271}
272
273pub fn run() -> anyhow::Result<()> {
274    let _ = tracing_subscriber::fmt::try_init();
275
276    let cli = Cli::parse();
277
278    // Resolve mirror URL once (None = disabled, opt-in only)
279    let mirror_url: Option<&str> = if cli.mirror {
280        Some(&cli.mirror_url)
281    } else {
282        None
283    };
284
285    // If a subcommand was given, use it directly
286    if let Some(command) = cli.command {
287        return match command {
288            Commands::Pack { path, output, jis_level, seal, to, from, payload_type } => {
289                if seal {
290                    let to_hex = to.ok_or_else(|| anyhow::anyhow!(
291                        "--seal requires --to <pubkey-hex> (64 hex chars)"))?;
292                    let class = match payload_type.as_deref() {
293                        Some(s) => v2::PayloadClass::from_label(s).ok_or_else(|| {
294                            anyhow::anyhow!(
295                                "--type '{}' unknown — use one of: identity, code, document, command, receipt",
296                                s
297                            )
298                        })?,
299                        None => v2::PayloadClass::Unspecified,
300                    };
301                    cmd_pack_sealed(
302                        &path, &output, jis_level, mirror_url,
303                        &to_hex, from.as_deref(), class,
304                    )
305                } else {
306                    if payload_type.is_some() {
307                        anyhow::bail!("--type only applies to sealed v2 archives (combine with --seal)");
308                    }
309                    cmd_pack(&path, &output, jis_level, mirror_url)
310                }
311            }
312            Commands::Unpack { archive, output, as_key, no_preview, strict_type } => {
313                cmd_unpack_dispatch(&archive, &output, as_key.as_deref(), !no_preview, strict_type)
314            }
315            Commands::Verify { archive } => cmd_verify(&archive, mirror_url),
316            Commands::Inspect { archive } => cmd_inspect(&archive),
317            Commands::Init { platform, account, repo } => cmd_init(&platform, account, repo),
318            Commands::Keygen { output } => cmd_keygen(&output),
319        };
320    }
321
322    // Smart auto-detection: tbz <path>
323    //
324    // v1.0.2: magic-bytes-FIRST. We read the first 4 bytes and check
325    // for the TBZ magic (0x54 0x42 0x5A 0x01 / TBZ\x01) BEFORE looking
326    // at the file extension. This prevents accidental double-wrap when
327    // a sealed envelope was renamed for human navigation
328    // (e.g. `vergadering-dinsdag.pdf`) — `tbz <file>` will now correctly
329    // route to unpack instead of re-packing the sealed bundle inside a
330    // new TBZ container.
331    //
332    // Bug reported by Jasper in cross-host vloedtest 12 mei 2026:
333    //   tbz superbelangrijk-doc-LEES-DIT-EERST.pdf
334    //   → Auto-detected: file → pack to ...tza    (= WRONG: re-wrapping a TBZ)
335    if let Some(path) = cli.path {
336        let p = Path::new(&path);
337
338        // Magic-bytes precheck (= content is truth, name is hint)
339        let is_tbz_by_magic = if p.is_file() {
340            match std::fs::File::open(p) {
341                Ok(mut f) => {
342                    use std::io::Read;
343                    let mut buf = [0u8; 4];
344                    matches!(f.read(&mut buf), Ok(n) if n == 4)
345                        && buf == [0x54, 0x42, 0x5A, 0x01]
346                }
347                Err(_) => false,
348            }
349        } else {
350            false
351        };
352
353        if is_tbz_by_magic {
354            // Sealed envelope identified by magic bytes — route to unpack
355            // regardless of extension. Plus warn the operator if the
356            // filename doesn't carry the typical .tza/.tbz suffix, so
357            // they know we detected a rename-recovered bundle.
358            let extension_matches =
359                path.ends_with(".tza") || path.ends_with(".tbz");
360            if !extension_matches {
361                println!(
362                    "✓ TBZ magic bytes detected — treating as sealed bundle"
363                );
364                println!(
365                    "  (filename does not carry .tza/.tbz suffix; this may be"
366                );
367                println!(
368                    "   an operator-renamed bundle. Content is truth, name is hint.)"
369                );
370            }
371            println!("Auto-detected: TBZ envelope → unpack (with airlock verification)\n");
372            let out_dir = p.file_stem()
373                .map(|s| s.to_string_lossy().to_string())
374                .unwrap_or_else(|| "tbz_out".to_string());
375            cmd_unpack(&path, &out_dir)?;
376            return Ok(());
377        }
378
379        if (path.ends_with(".tza") || path.ends_with(".tbz")) && p.is_file() {
380            // Has TBZ-style extension but NO magic match. Could be a
381            // truncated/corrupt bundle, or a non-TBZ file with a
382            // misleading extension. Fail loudly.
383            anyhow::bail!(
384                "File '{}' has .tza/.tbz extension but does NOT carry the TBZ magic bytes. \n  Refusing to treat as a sealed archive. Use `tbz inspect {}` for details.",
385                path, path
386            );
387        } else if p.is_dir() {
388            // Directory → pack
389            let dir_name = p.file_name()
390                .map(|s| s.to_string_lossy().to_string())
391                .unwrap_or_else(|| "output".to_string());
392            let output = format!("{}.tza", dir_name);
393            println!("Auto-detected: directory → pack to {}\n", output);
394            cmd_pack(&path, &output, 0, mirror_url)?;
395            return Ok(());
396        } else if p.is_file() {
397            // Single file → pack
398            let file_name = p.file_stem()
399                .map(|s| s.to_string_lossy().to_string())
400                .unwrap_or_else(|| "output".to_string());
401            let output = format!("{}.tza", file_name);
402            println!("Auto-detected: file → pack to {}\n", output);
403            cmd_pack(&path, &output, 0, mirror_url)?;
404            return Ok(());
405        } else {
406            anyhow::bail!("Path not found: {}", path);
407        }
408    }
409
410    // No subcommand and no path — show help
411    Cli::parse_from(["tbz", "--help"]);
412    Ok(())
413}
414
415/// Pack files into a TBZ archive
416fn cmd_pack(path: &str, output: &str, default_jis_level: u8, mirror_url: Option<&str>) -> anyhow::Result<()> {
417    let source = Path::new(path);
418    if !source.exists() {
419        anyhow::bail!("Source path does not exist: {}", path);
420    }
421
422    // Collect files to pack
423    let files = collect_files(source)?;
424    println!("TBZ pack: {} file(s) from {}", files.len(), path);
425
426    // Check for .jis.json
427    let jis_manifest = tbz_jis::JisManifest::load(Path::new(".")).ok();
428    if let Some(ref jis) = jis_manifest {
429        println!("  .jis.json found: {}", jis.repo_identifier());
430    }
431
432    // Generate signing keypair for this archive
433    let (signing_key, verifying_key) = signature::generate_keypair();
434
435    // Build manifest
436    let mut manifest = Manifest::new();
437    for (i, (file_path, data)) in files.iter().enumerate() {
438        let jis_level = jis_manifest
439            .as_ref()
440            .map(|j| j.jis_level_for_path(file_path))
441            .unwrap_or(default_jis_level);
442
443        manifest.add_block(BlockEntry {
444            index: (i + 1) as u32,
445            block_type: "data".to_string(),
446            compressed_size: 0, // filled after compression
447            uncompressed_size: data.len() as u64,
448            jis_level,
449            description: file_path.clone(),
450            path: Some(file_path.clone()),
451        });
452    }
453
454    // Embed verifying key in manifest
455    manifest.set_signing_key(&verifying_key);
456
457    // Write TBZ archive
458    let out_file = fs::File::create(output)?;
459    let mut writer = TbzWriter::new(BufWriter::new(out_file), signing_key);
460
461    // Block 0: manifest
462    writer.write_manifest(&manifest)?;
463    println!("  [0] manifest ({} block entries)", manifest.blocks.len());
464
465    // Block 1..N: data
466    for (file_path, data) in &files {
467        let jis_level = jis_manifest
468            .as_ref()
469            .map(|j| j.jis_level_for_path(file_path))
470            .unwrap_or(default_jis_level);
471
472        let envelope = TibetEnvelope::new(
473            signature::sha256_hash(data),
474            "data",
475            mime_for_path(file_path),
476            "tbz-cli",
477            &format!("Pack file: {}", file_path),
478            vec!["block:0".to_string()],
479        );
480
481        let envelope = if let Some(ref jis) = jis_manifest {
482            envelope.with_source_repo(&jis.repo_identifier())
483        } else {
484            envelope
485        };
486
487        writer.write_data_block(data, jis_level, &envelope)?;
488        println!(
489            "  [{}] {} ({} bytes, JIS level {})",
490            writer.block_count() - 1,
491            file_path,
492            data.len(),
493            jis_level,
494        );
495    }
496
497    let total_blocks = writer.block_count();
498    writer.finish();
499
500    // Show public key (for verification)
501    let vk_hex = hex_encode(&verifying_key.to_bytes());
502
503    println!("\nArchive written: {}", output);
504    println!("  Blocks: {}", total_blocks);
505    println!("  Signing key (Ed25519 public): {}", vk_hex);
506    println!("  Format: TBZ v{}", tbz_core::VERSION);
507
508    // --- Transparency Mirror registration (best-effort) ---
509    if let Some(url) = mirror_url {
510        let archive_hash = hash_file(Path::new(output))?;
511        println!("\n  Mirror: registering {} ...", archive_hash);
512
513        let jis_id = jis_manifest.as_ref().map(|_| {
514            format!("jis:ed25519:{}", &vk_hex[..16])
515        });
516        let source_repo = jis_manifest.as_ref().map(|j| j.repo_identifier());
517
518        let payload = mirror_client::RegisterPayload {
519            content_hash: archive_hash,
520            signing_key: vk_hex.clone(),
521            jis_id,
522            source_repo,
523            block_count: total_blocks as u32,
524            total_size: fs::metadata(output).map(|m| m.len()).unwrap_or(0),
525        };
526
527        match mirror_client::register(url, &payload) {
528            Ok(resp) => println!("  Mirror: {} ({})", resp.status, url),
529            Err(e) => println!("  Mirror: WARNING — {}", e),
530        }
531    }
532
533    Ok(())
534}
535
536/// Inspect a TBZ archive
537fn cmd_inspect(archive: &str) -> anyhow::Result<()> {
538    // Format detection: route to TIBET-ZIP handler if Desktop format
539    match detect_format(archive)? {
540        ArchiveFormat::TibetZip => return tibet_zip::inspect(archive),
541        ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
542        ArchiveFormat::TbzBlock => {} // continue with block format below
543    }
544
545    let file = fs::File::open(archive)?;
546    let mut reader = TbzReader::new(std::io::BufReader::new(file));
547
548    println!("TBZ inspect: {}\n", archive);
549    println!("  Magic: 0x54425A (TBZ)");
550    println!("  Format: v{}\n", tbz_core::VERSION);
551
552    let mut block_idx = 0;
553    while let Some(block) = reader.read_block()? {
554        let type_str = match block.header.block_type {
555            BlockType::Manifest => "MANIFEST",
556            BlockType::Data => "DATA",
557            BlockType::Nested => "NESTED",
558        };
559
560        println!("  Block {} [{}]", block.header.block_index, type_str);
561        println!("    JIS level:         {}", block.header.jis_level);
562        println!("    Compressed:        {} bytes", block.header.compressed_size);
563        println!("    Uncompressed:      {} bytes", block.header.uncompressed_size);
564        println!("    TIBET ERIN hash:   {}", block.envelope.erin.content_hash);
565        println!("    TIBET ERACHTER:    {}", block.envelope.erachter);
566
567        if let Some(ref repo) = block.envelope.eromheen.source_repo {
568            println!("    Source repo:       {}", repo);
569        }
570
571        // For manifest block, show the parsed manifest
572        if block.header.block_type == BlockType::Manifest {
573            if let Ok(decompressed) = block.decompress() {
574                if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
575                    println!("    --- Manifest ---");
576                    println!("    Total blocks:      {}", manifest.block_count);
577                    println!("    Total uncompressed: {} bytes", manifest.total_uncompressed_size);
578                    println!("    Max JIS level:     {}", manifest.max_jis_level());
579                    for entry in &manifest.blocks {
580                        println!(
581                            "      [{:>3}] {} — {} bytes, JIS {}",
582                            entry.index,
583                            entry.path.as_deref().unwrap_or(&entry.description),
584                            entry.uncompressed_size,
585                            entry.jis_level,
586                        );
587                    }
588                }
589            }
590        }
591
592        // Signature present?
593        let sig_nonzero = block.signature.iter().any(|&b| b != 0);
594        println!("    Signature:         {}", if sig_nonzero { "Ed25519 (present)" } else { "none" });
595        println!();
596
597        block_idx += 1;
598    }
599
600    println!("  Total: {} blocks", block_idx);
601    Ok(())
602}
603
604/// Unpack a TBZ archive through the Airlock
605///
606/// AIRLOCK GATE: Runs full verification BEFORE extraction.
607/// Corrupt or tampered archives are BLOCKED.
608fn cmd_unpack(archive: &str, output_dir: &str) -> anyhow::Result<()> {
609    // Format detection: route to TIBET-ZIP handler if Desktop format
610    match detect_format(archive)? {
611        ArchiveFormat::TibetZip => return tibet_zip::unpack(archive, output_dir),
612        ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
613        ArchiveFormat::TbzBlock => {} // continue with block format below
614    }
615
616    // =========================================================================
617    // AIRLOCK GATE — Verify BEFORE extraction. No exceptions.
618    // =========================================================================
619    println!("TBZ unpack: {} → {}\n", archive, output_dir);
620    println!("  Airlock pre-check: verifying archive integrity...\n");
621
622    {
623        let vfile = fs::File::open(archive)?;
624        let mut vreader = TbzReader::new(std::io::BufReader::new(vfile));
625        let mut errors = 0u32;
626        let mut block_count = 0u32;
627        let mut verifying_key: Option<tbz_core::VerifyingKey> = None;
628
629        while let Some(block) = vreader.read_block()? {
630            if let Err(_) = block.validate() {
631                errors += 1;
632                block_count += 1;
633                continue;
634            }
635
636            if block.header.block_type == BlockType::Manifest {
637                if let Ok(decompressed) = block.decompress() {
638                    if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
639                        verifying_key = manifest.get_verifying_key();
640                    }
641                }
642            }
643
644            // Verify signature
645            if let Some(ref vk) = verifying_key {
646                if block.verify_signature(vk).is_err() {
647                    errors += 1;
648                }
649            }
650
651            // Verify content hash
652            match block.decompress() {
653                Ok(decompressed) => {
654                    let actual_hash = signature::sha256_hash(&decompressed);
655                    if actual_hash != block.envelope.erin.content_hash {
656                        errors += 1;
657                    }
658                }
659                Err(_) => { errors += 1; }
660            }
661
662            block_count += 1;
663        }
664
665        if errors > 0 {
666            anyhow::bail!(
667                "AIRLOCK BREACH BLOCKED — archive corrupt: {} ({} block errors in {} blocks). \
668                 Use `tbz verify` to inspect, or fix the archive.",
669                archive, errors, block_count
670            );
671        }
672
673        println!("  Airlock pre-check: {} blocks verified ✓\n", block_count);
674    }
675
676    // =========================================================================
677    // Extraction — only reached if all blocks verified
678    // =========================================================================
679    let file = fs::File::open(archive)?;
680    let mut reader = TbzReader::new(std::io::BufReader::new(file));
681
682    // Create Airlock
683    let mut airlock = tbz_airlock::Airlock::new(256 * 1024 * 1024, 30);
684    println!("  Airlock mode: {:?}\n", airlock.mode());
685
686    fs::create_dir_all(output_dir)?;
687
688    let mut block_idx = 0;
689    let mut manifest: Option<Manifest> = None;
690
691    while let Some(block) = reader.read_block()? {
692        match block.header.block_type {
693            BlockType::Manifest => {
694                let decompressed = block.decompress()?;
695                manifest = Some(serde_json::from_slice(&decompressed)
696                    .map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?);
697                println!("  [0] Manifest parsed ({} entries)", manifest.as_ref().unwrap().blocks.len());
698            }
699            BlockType::Data => {
700                // Decompress into Airlock
701                let decompressed = block.decompress()?;
702                airlock.allocate(decompressed.len() as u64)?;
703                airlock.receive(&decompressed)?;
704
705                // Determine output path from manifest
706                let file_path = manifest
707                    .as_ref()
708                    .and_then(|m| {
709                        m.blocks.iter()
710                            .find(|e| e.index == block.header.block_index)
711                            .and_then(|e| e.path.clone())
712                    })
713                    .unwrap_or_else(|| format!("block_{}", block.header.block_index));
714
715                // Write from Airlock to filesystem
716                let out_path = Path::new(output_dir).join(&file_path);
717                if let Some(parent) = out_path.parent() {
718                    fs::create_dir_all(parent)?;
719                }
720
721                let data = airlock.release(); // returns data + wipes buffer
722                fs::write(&out_path, &data)?;
723
724                println!(
725                    "  [{}] {} ({} bytes) ✓",
726                    block.header.block_index,
727                    file_path,
728                    data.len(),
729                );
730            }
731            BlockType::Nested => {
732                println!("  [{}] Nested TBZ (not yet supported)", block.header.block_index);
733            }
734        }
735        block_idx += 1;
736    }
737
738    println!("\n  Extracted {} blocks via Airlock", block_idx);
739    println!("  Airlock buffer: wiped (0x00)");
740    Ok(())
741}
742
743/// Verify a TBZ archive without extracting
744fn cmd_verify(archive: &str, mirror_url: Option<&str>) -> anyhow::Result<()> {
745    // Format detection: route to TIBET-ZIP handler if Desktop format
746    match detect_format(archive)? {
747        ArchiveFormat::TibetZip => return tibet_zip::verify(archive),
748        ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
749        ArchiveFormat::TbzBlock => {} // continue with block format below
750    }
751
752    let file = fs::File::open(archive)?;
753    let mut reader = TbzReader::new(std::io::BufReader::new(file));
754
755    println!("TBZ verify: {}\n", archive);
756
757    let mut errors = 0;
758    let mut block_idx = 0;
759    let mut verifying_key: Option<tbz_core::VerifyingKey> = None;
760
761    while let Some(block) = reader.read_block()? {
762        // Validate header
763        if let Err(e) = block.validate() {
764            println!("  [{}] FAIL header: {}", block.header.block_index, e);
765            errors += 1;
766            block_idx += 1;
767            continue;
768        }
769
770        // Extract verifying key from manifest (block 0)
771        if block.header.block_type == BlockType::Manifest {
772            if let Ok(decompressed) = block.decompress() {
773                if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
774                    verifying_key = manifest.get_verifying_key();
775                    if let Some(ref vk) = verifying_key {
776                        let vk_hex = hex_encode(&vk.to_bytes());
777                        println!("  Signing key: Ed25519 {}", &vk_hex[..16]);
778                        println!();
779                    } else {
780                        println!("  WARNING: No signing key in manifest — signature checks skipped\n");
781                    }
782                }
783            }
784        }
785
786        // 1. Verify Ed25519 signature (cryptographic proof of block integrity)
787        let sig_ok = if let Some(ref vk) = verifying_key {
788            match block.verify_signature(vk) {
789                Ok(()) => true,
790                Err(e) => {
791                    println!("  [{}] FAIL signature: {}", block.header.block_index, e);
792                    errors += 1;
793                    false
794                }
795            }
796        } else {
797            true // no key available, skip
798        };
799
800        // 2. Verify content hash matches TIBET ERIN
801        match block.decompress() {
802            Ok(decompressed) => {
803                let actual_hash = signature::sha256_hash(&decompressed);
804                if actual_hash == block.envelope.erin.content_hash {
805                    let sig_status = if verifying_key.is_some() && sig_ok {
806                        "hash + signature"
807                    } else if verifying_key.is_some() {
808                        "hash only (sig FAILED)"
809                    } else {
810                        "hash only (no key)"
811                    };
812                    println!("  [{}] OK — {} verified", block.header.block_index, sig_status);
813                } else {
814                    println!(
815                        "  [{}] FAIL — hash mismatch\n    expected: {}\n    actual:   {}",
816                        block.header.block_index,
817                        block.envelope.erin.content_hash,
818                        actual_hash,
819                    );
820                    errors += 1;
821                }
822            }
823            Err(e) => {
824                println!("  [{}] FAIL — decompress error: {}", block.header.block_index, e);
825                errors += 1;
826            }
827        }
828
829        block_idx += 1;
830    }
831
832    println!();
833    if errors == 0 {
834        if verifying_key.is_some() {
835            println!("  Result: ALL {} BLOCKS VERIFIED (hash + Ed25519) ✓", block_idx);
836        } else {
837            println!("  Result: ALL {} BLOCKS VERIFIED (hash only, no signing key) ✓", block_idx);
838        }
839    } else {
840        println!("  Result: {} ERRORS in {} blocks ✗", errors, block_idx);
841    }
842
843    // --- Transparency Mirror lookup (best-effort) ---
844    if let Some(url) = mirror_url {
845        let archive_hash = hash_file(Path::new(archive))?;
846        match mirror_client::lookup(url, &archive_hash) {
847            Ok(Some(entry)) => {
848                let verdicts: Vec<&str> = entry.attestations.iter()
849                    .map(|a| a.verdict.as_str())
850                    .collect();
851                println!("\n  Mirror: KNOWN");
852                println!("    Hash:         {}", entry.content_hash);
853                println!("    First seen:   {}", entry.first_seen);
854                println!(
855                    "    Attestations: {} ({})",
856                    entry.attestations.len(),
857                    if verdicts.is_empty() { "none".to_string() } else { verdicts.join(", ") },
858                );
859            }
860            Ok(None) => {
861                println!("\n  Mirror: UNKNOWN — not registered in Transparency Mirror");
862            }
863            Err(e) => {
864                println!("\n  Mirror: WARNING — {}", e);
865            }
866        }
867    }
868
869    Ok(())
870}
871
872/// Generate .jis.json and Ed25519 keypair for current repo
873fn cmd_init(platform: &str, account: Option<String>, repo: Option<String>) -> anyhow::Result<()> {
874    let account = account.unwrap_or_else(|| "<your-account>".to_string());
875    let repo = repo.unwrap_or_else(|| {
876        std::env::current_dir()
877            .ok()
878            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
879            .unwrap_or_else(|| "<repo>".to_string())
880    });
881
882    // Check if .tbz/ already exists
883    let tbz_dir = Path::new(".tbz");
884    let key_path = tbz_dir.join("signing.key");
885    let pub_path = tbz_dir.join("signing.pub");
886
887    let (signing_key, verifying_key) = if key_path.exists() {
888        // Load existing keypair
889        let sk_hex = fs::read_to_string(&key_path)?;
890        let sk_bytes: Vec<u8> = (0..sk_hex.trim().len())
891            .step_by(2)
892            .filter_map(|i| u8::from_str_radix(&sk_hex.trim()[i..i + 2], 16).ok())
893            .collect();
894        if sk_bytes.len() != 32 {
895            anyhow::bail!("Invalid signing key in .tbz/signing.key");
896        }
897        let mut key_array = [0u8; 32];
898        key_array.copy_from_slice(&sk_bytes);
899        let sk = tbz_core::SigningKey::from_bytes(&key_array);
900        let vk = sk.verifying_key();
901        println!("Using existing keypair from .tbz/");
902        (sk, vk)
903    } else {
904        // Generate new keypair
905        let (sk, vk) = signature::generate_keypair();
906
907        fs::create_dir_all(tbz_dir)?;
908        fs::write(&key_path, hex_encode(&sk.to_bytes()))?;
909        fs::write(&pub_path, hex_encode(&vk.to_bytes()))?;
910
911        println!("Generated Ed25519 keypair:");
912        println!("  Private: .tbz/signing.key (KEEP SECRET — add to .gitignore!)");
913        println!("  Public:  .tbz/signing.pub");
914        (sk, vk)
915    };
916
917    let vk_hex = hex_encode(&verifying_key.to_bytes());
918    let jis_id = format!("jis:ed25519:{}", &vk_hex[..16]);
919
920    // Sign the JIS identity claim
921    let claim_data = format!("{}:{}:{}:{}", platform, account, repo, vk_hex);
922    let claim_sig = signature::sign(claim_data.as_bytes(), &signing_key);
923
924    let jis_json = serde_json::json!({
925        "tbz": "1.0",
926        "jis_id": jis_id,
927        "signing_key": vk_hex,
928        "claim": {
929            "platform": platform,
930            "account": account,
931            "repo": repo,
932            "intent": "official_releases",
933            "sectors": {
934                "src/*": { "jis_level": 0, "description": "Public source code" },
935                "keys/*": { "jis_level": 2, "description": "Signing keys" }
936            }
937        },
938        "tibet": {
939            "erin": "Repository identity binding",
940            "eraan": [&jis_id],
941            "erachter": format!("Provenance root for TBZ packages from {}/{}", account, repo)
942        },
943        "signature": hex_encode(&claim_sig),
944        "timestamp": chrono_now()
945    });
946
947    let output = serde_json::to_string_pretty(&jis_json)?;
948    fs::write(".jis.json", &output)?;
949
950    // Ensure .tbz/signing.key is in .gitignore
951    let gitignore = Path::new(".gitignore");
952    if gitignore.exists() {
953        let content = fs::read_to_string(gitignore)?;
954        if !content.contains(".tbz/signing.key") {
955            fs::write(gitignore, format!("{}\n# TBZ signing key (NEVER commit!)\n.tbz/signing.key\n", content.trim_end()))?;
956            println!("\n  Added .tbz/signing.key to .gitignore");
957        }
958    }
959
960    println!("\nGenerated .jis.json:");
961    println!("  JIS ID: {}", jis_id);
962    println!("  Claim: {}/{}/{}", platform, account, repo);
963    println!("  Signature: Ed25519 (signed)");
964
965    Ok(())
966}
967
968/// Collect files from a path (file or directory, recursive)
969fn collect_files(path: &Path) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
970    let mut files = Vec::new();
971
972    if path.is_file() {
973        let data = fs::read(path)?;
974        let name = path.file_name()
975            .map(|n| n.to_string_lossy().to_string())
976            .unwrap_or_else(|| "file".to_string());
977        files.push((name, data));
978    } else if path.is_dir() {
979        collect_dir_recursive(path, path, &mut files)?;
980    }
981
982    Ok(files)
983}
984
985fn collect_dir_recursive(
986    base: &Path,
987    current: &Path,
988    files: &mut Vec<(String, Vec<u8>)>,
989) -> anyhow::Result<()> {
990    let mut entries: Vec<_> = fs::read_dir(current)?.collect::<Result<_, _>>()?;
991    entries.sort_by_key(|e| e.file_name());
992
993    for entry in entries {
994        let path = entry.path();
995        // Skip hidden files and common non-essential dirs
996        let name = entry.file_name().to_string_lossy().to_string();
997        if name.starts_with('.') || name == "target" || name == "node_modules" {
998            continue;
999        }
1000
1001        if path.is_file() {
1002            let rel = path.strip_prefix(base)
1003                .map(|p| p.to_string_lossy().to_string())
1004                .unwrap_or_else(|_| name);
1005            let data = fs::read(&path)?;
1006            files.push((rel, data));
1007        } else if path.is_dir() {
1008            collect_dir_recursive(base, &path, files)?;
1009        }
1010    }
1011    Ok(())
1012}
1013
1014/// Simple MIME type detection
1015fn mime_for_path(path: &str) -> &str {
1016    match path.rsplit('.').next() {
1017        Some("rs") => "text/x-rust",
1018        Some("toml") => "application/toml",
1019        Some("json") => "application/json",
1020        Some("md") => "text/markdown",
1021        Some("txt") => "text/plain",
1022        Some("py") => "text/x-python",
1023        Some("js") => "text/javascript",
1024        Some("html") => "text/html",
1025        Some("css") => "text/css",
1026        Some("png") => "image/png",
1027        Some("jpg") | Some("jpeg") => "image/jpeg",
1028        Some("bin") => "application/octet-stream",
1029        _ => "application/octet-stream",
1030    }
1031}
1032
1033fn hex_encode(bytes: &[u8]) -> String {
1034    bytes.iter().map(|b| format!("{:02x}", b)).collect()
1035}
1036
1037fn hex_decode_32(s: &str) -> anyhow::Result<[u8; 32]> {
1038    let s = s.trim();
1039    if s.len() != 64 {
1040        anyhow::bail!("expected 64 hex characters, got {}", s.len());
1041    }
1042    let mut out = [0u8; 32];
1043    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
1044        let pair = std::str::from_utf8(chunk).map_err(|_| anyhow::anyhow!("invalid utf8"))?;
1045        out[i] = u8::from_str_radix(pair, 16)
1046            .map_err(|e| anyhow::anyhow!("invalid hex pair '{}': {}", pair, e))?;
1047    }
1048    Ok(out)
1049}
1050
1051fn read_signing_key_from_file(path: &str) -> anyhow::Result<ed25519_dalek::SigningKey> {
1052    let raw = fs::read_to_string(path)
1053        .map_err(|e| anyhow::anyhow!("cannot read key file {}: {}", path, e))?;
1054    let bytes = hex_decode_32(&raw)?;
1055    Ok(ed25519_dalek::SigningKey::from_bytes(&bytes))
1056}
1057
1058fn chrono_now() -> String {
1059    use std::time::SystemTime;
1060    let duration = SystemTime::now()
1061        .duration_since(SystemTime::UNIX_EPOCH)
1062        .unwrap_or_default();
1063    format!("{}Z", duration.as_secs())
1064}
1065
1066// ---------------------------------------------------------------------------
1067// v2.1.0 NEW SUBCOMMANDS — Keygen, Pack --seal, Unpack --as
1068// ---------------------------------------------------------------------------
1069
1070fn cmd_keygen(output: &str) -> anyhow::Result<()> {
1071    use ed25519_dalek::SigningKey;
1072    use rand::rngs::OsRng;
1073
1074    let signing_key = SigningKey::generate(&mut OsRng);
1075    let verifying_key = signing_key.verifying_key();
1076
1077    let priv_path = format!("{}.priv", output);
1078    let pub_path = format!("{}.pub", output);
1079
1080    // Write private key (hex) with 0600 permissions
1081    let priv_hex = hex_encode(&signing_key.to_bytes());
1082    fs::write(&priv_path, &priv_hex)?;
1083    #[cfg(unix)]
1084    {
1085        use std::os::unix::fs::PermissionsExt;
1086        let perms = fs::Permissions::from_mode(0o600);
1087        fs::set_permissions(&priv_path, perms)?;
1088    }
1089
1090    let pub_hex = hex_encode(&verifying_key.to_bytes());
1091    fs::write(&pub_path, &pub_hex)?;
1092
1093    println!("TBZ keygen: Ed25519 keypair generated\n");
1094    println!("  Private: {} (mode 0600)", priv_path);
1095    println!("  Public:  {}", pub_path);
1096    println!("\n  Pubkey (share this): {}", pub_hex);
1097    println!("\n  Use with:");
1098    println!("    tibet-zip pack <dir> -o sealed.tza --seal --to {} --from {}", pub_hex, priv_path);
1099    println!("    tibet-zip unpack sealed.tza -o out/ --as {}", priv_path);
1100    Ok(())
1101}
1102
1103fn cmd_pack_sealed(
1104    path: &str,
1105    output: &str,
1106    default_jis_level: u8,
1107    mirror_url: Option<&str>,
1108    receiver_hex: &str,
1109    sender_priv_path: Option<&str>,
1110    payload_class: v2::PayloadClass,
1111) -> anyhow::Result<()> {
1112    let source = Path::new(path);
1113    if !source.exists() {
1114        anyhow::bail!("Source path does not exist: {}", path);
1115    }
1116    let receiver_pubkey = hex_decode_32(receiver_hex)
1117        .map_err(|e| anyhow::anyhow!("--to pubkey: {}", e))?;
1118
1119    // Sender key: from --from or ephemeral
1120    let sender_key = match sender_priv_path {
1121        Some(p) => read_signing_key_from_file(p)?,
1122        None => {
1123            use rand::rngs::OsRng;
1124            ed25519_dalek::SigningKey::generate(&mut OsRng)
1125        }
1126    };
1127
1128    println!("TBZ pack (sealed v2): {} → {}", path, output);
1129    println!("  Sender pubkey:   {}", hex_encode(&sender_key.verifying_key().to_bytes()));
1130    println!("  Receiver pubkey: {}", receiver_hex);
1131    println!("  Payload class:   {}", payload_class.label());
1132
1133    // Build the v1 archive in memory (Vec<u8> buffer)
1134    let v1_bytes = build_v1_archive_bytes(source, default_jis_level, mirror_url)?;
1135    println!("\n  Inner v1 archive: {} bytes", v1_bytes.len());
1136
1137    // Wrap in v2 sealed container with declared class
1138    let container = v2::write_sealed_container_with_class(
1139        &sender_key,
1140        &receiver_pubkey,
1141        &v1_bytes,
1142        payload_class,
1143    )
1144    .map_err(|e| anyhow::anyhow!("v2 seal failed: {}", e))?;
1145
1146    fs::write(output, &container)?;
1147    println!("  Outer v2 envelope: {} bytes (overhead: {} bytes)", container.len(), container.len() - v1_bytes.len());
1148    println!("\n  Sealed: {} ✓", output);
1149    println!("  Format: TBZ v2 (AES-256-GCM, Ed25519-signed)");
1150    Ok(())
1151}
1152
1153/// Build a v1 archive in memory as Vec<u8>. Refactored from cmd_pack so we
1154/// can wrap the bytes in a v2 sealed envelope.
1155fn build_v1_archive_bytes(
1156    source: &Path,
1157    default_jis_level: u8,
1158    _mirror_url: Option<&str>,
1159) -> anyhow::Result<Vec<u8>> {
1160    let files = collect_files(source)?;
1161    let jis_manifest = tbz_jis::JisManifest::load(Path::new(".")).ok();
1162
1163    let (signing_key, verifying_key) = signature::generate_keypair();
1164
1165    let mut manifest = Manifest::new();
1166    for (i, (file_path, data)) in files.iter().enumerate() {
1167        let jis_level = jis_manifest
1168            .as_ref()
1169            .map(|j| j.jis_level_for_path(file_path))
1170            .unwrap_or(default_jis_level);
1171
1172        manifest.add_block(BlockEntry {
1173            index: (i + 1) as u32,
1174            block_type: "data".to_string(),
1175            compressed_size: 0,
1176            uncompressed_size: data.len() as u64,
1177            jis_level,
1178            description: file_path.clone(),
1179            path: Some(file_path.clone()),
1180        });
1181    }
1182    manifest.set_signing_key(&verifying_key);
1183
1184    let mut buf: Vec<u8> = Vec::new();
1185    {
1186        let mut writer = TbzWriter::new(&mut buf, signing_key);
1187        writer.write_manifest(&manifest)?;
1188        for (file_path, data) in &files {
1189            let jis_level = jis_manifest
1190                .as_ref()
1191                .map(|j| j.jis_level_for_path(file_path))
1192                .unwrap_or(default_jis_level);
1193            let envelope = TibetEnvelope::new(
1194                signature::sha256_hash(data),
1195                "data",
1196                mime_for_path(file_path),
1197                "tbz-cli",
1198                &format!("Pack file: {}", file_path),
1199                vec!["block:0".to_string()],
1200            );
1201            let envelope = if let Some(ref jis) = jis_manifest {
1202                envelope.with_source_repo(&jis.repo_identifier())
1203            } else {
1204                envelope
1205            };
1206            writer.write_data_block(data, jis_level, &envelope)?;
1207        }
1208        writer.finish();
1209    }
1210    Ok(buf)
1211}
1212
1213/// Dispatch unpack: detect v1 vs v2 and route accordingly.
1214///
1215/// `preview` (= default true) shows the inner manifest before extraction.
1216/// `strict_type` (= default false) escalates payload-class/extension
1217/// mismatches from warnings to errors.
1218fn cmd_unpack_dispatch(
1219    archive: &str,
1220    output_dir: &str,
1221    as_key: Option<&str>,
1222    preview: bool,
1223    strict_type: bool,
1224) -> anyhow::Result<()> {
1225    // Peek first 32 bytes to detect version
1226    let mut head = [0u8; 32];
1227    let n = {
1228        let mut f = fs::File::open(archive)?;
1229        std::io::Read::read(&mut f, &mut head)?
1230    };
1231    let version = if n >= 7 { v2::detect_version(&head[..n]) } else { 0 };
1232
1233    if version == 2 {
1234        let priv_path = as_key.ok_or_else(|| anyhow::anyhow!(
1235            "{} is a v2 sealed archive — pass --as <privkey-path> to decrypt", archive))?;
1236        let receiver_key = read_signing_key_from_file(priv_path)?;
1237        println!("TBZ unpack (v2 sealed): {} → {}", archive, output_dir);
1238        let container = fs::read(archive)?;
1239        let result = v2::read_sealed_container_full(&container, &receiver_key);
1240        let (env, plain, payload_class) = match result {
1241            Ok(t) => t,
1242            Err(e) => {
1243                // Audit the failure before bubbling up
1244                // Extract whatever metadata we can (sender/receiver/uuid in plain header)
1245                let (sender_hex, receiver_hex, uuid_hex) = peek_v2_envelope_metadata(&container);
1246                emit_unseal_audit(
1247                    &sender_hex,
1248                    &receiver_hex,
1249                    &uuid_hex,
1250                    "unknown",
1251                    0,
1252                    &format!("error:{:?}", e).replace('"', "'"),
1253                );
1254                return Err(anyhow::anyhow!("v2 unseal failed: {}", e));
1255            }
1256        };
1257        println!("  Sender:   {}", hex_encode(&env.sender_pubkey));
1258        println!("  Receiver: {} ✓", hex_encode(&env.receiver_pubkey));
1259        println!("  Declared payload class: {}", payload_class.label());
1260        println!("  Inner v1 archive: {} bytes\n", plain.len());
1261
1262        // Check outer filename extension against declared class
1263        match check_class_vs_extension(archive, payload_class) {
1264            ClassCheck::Ok => {}
1265            ClassCheck::Warn(msg) | ClassCheck::Mismatch(msg) => {
1266                if strict_type {
1267                    anyhow::bail!("payload-class/extension mismatch (strict mode): {}", msg);
1268                } else {
1269                    println!("  ⚠ payload-class hint: {}", msg);
1270                    println!("    (use --strict-type to make this fatal)\n");
1271                }
1272            }
1273        }
1274
1275        // Inner manifest preview (= shown before disk extraction)
1276        if preview {
1277            preview_inner_manifest(&plain, payload_class, strict_type)?;
1278        }
1279
1280        // Write to temp file, then call cmd_unpack with that file
1281        let tmp = std::env::temp_dir().join(format!("tbz-v2-inner-{}.tza", std::process::id()));
1282        fs::write(&tmp, &plain)?;
1283        let result = cmd_unpack(tmp.to_str().unwrap(), output_dir);
1284        let _ = fs::remove_file(&tmp);
1285
1286        // Emit unseal audit record (= soft-fail, never blocks)
1287        let outcome = if result.is_ok() { "success" } else { "extract-failed" };
1288        emit_unseal_audit(
1289            &hex_encode(&env.sender_pubkey),
1290            &hex_encode(&env.receiver_pubkey),
1291            &hex_encode(&env.archive_uuid),
1292            payload_class.label(),
1293            plain.len(),
1294            outcome,
1295        );
1296
1297        result
1298    } else {
1299        cmd_unpack(archive, output_dir)
1300    }
1301}
1302
1303/// Best-effort metadata peek without decryption (for audit on failure paths).
1304fn peek_v2_envelope_metadata(container: &[u8]) -> (String, String, String) {
1305    let min = 3 + 4 + 32 + 32 + 16;
1306    if container.len() < min { return (String::new(), String::new(), String::new()); }
1307    let mut off = 3 + 4;
1308    let sender = &container[off..off + 32]; off += 32;
1309    let receiver = &container[off..off + 32]; off += 32;
1310    let uuid = &container[off..off + 16];
1311    (hex_encode(sender), hex_encode(receiver), hex_encode(uuid))
1312}
1313
1314/// Result of checking outer filename extension against declared payload class.
1315enum ClassCheck {
1316    Ok,
1317    Warn(String),
1318    Mismatch(String),
1319}
1320
1321fn check_class_vs_extension(archive: &str, class: v2::PayloadClass) -> ClassCheck {
1322    // Take the LAST extension component
1323    let ext = Path::new(archive)
1324        .extension()
1325        .and_then(|e| e.to_str())
1326        .map(|s| s.to_ascii_lowercase());
1327
1328    use v2::PayloadClass::*;
1329    let hint = match (class, ext.as_deref()) {
1330        (Unspecified, _) => return ClassCheck::Ok,
1331        (Identity, Some("aint")) | (Identity, Some("id")) | (Identity, Some("tza")) => return ClassCheck::Ok,
1332        (Code, Some("tza")) | (Code, Some("bin")) | (Code, Some("exec")) => return ClassCheck::Ok,
1333        (Document, Some("tza")) | (Document, Some("doc")) | (Document, Some("pdf"))
1334            | (Document, Some("txt")) | (Document, Some("md")) => return ClassCheck::Ok,
1335        (Command, Some("tza")) | (Command, Some("cmd")) | (Command, Some("req")) => return ClassCheck::Ok,
1336        (Receipt, Some("tza")) | (Receipt, Some("ack")) | (Receipt, Some("rcpt")) => return ClassCheck::Ok,
1337
1338        (Identity, Some(e)) => format!("outer .{} but declared class = identity", e),
1339        (Code,     Some(e)) => format!("outer .{} but declared class = code", e),
1340        (Document, Some(e)) => format!("outer .{} but declared class = document", e),
1341        (Command,  Some(e)) => format!("outer .{} but declared class = command", e),
1342        (Receipt,  Some(e)) => format!("outer .{} but declared class = receipt", e),
1343        (_, None) => format!("no extension on outer file but declared class = {}", class.label()),
1344    };
1345
1346    ClassCheck::Mismatch(hint)
1347}
1348
1349/// Emit a tbz-unseal.v1 audit record to the audit JSONL file.
1350///
1351/// Soft-fail: if the audit destination is not writable we silently skip
1352/// — auditability is opt-in and never blocks a legitimate unseal.
1353///
1354/// Destination, in order of preference:
1355/// 1. `$TBZ_UNSEAL_AUDIT_LOG` (= explicit override)
1356/// 2. `/var/log/tibet/tbz-unseal.jsonl` (= system mode)
1357/// 3. `$XDG_STATE_HOME/tbz/audit.jsonl` or `$HOME/.local/state/tbz/audit.jsonl`
1358fn emit_unseal_audit(
1359    sender_pubkey_hex: &str,
1360    receiver_pubkey_hex: &str,
1361    archive_uuid_hex: &str,
1362    payload_class: &str,
1363    payload_size: usize,
1364    outcome: &str,
1365) {
1366    use std::io::Write as _;
1367    let timestamp = chrono_now();
1368    let event_id = format!("tbz-unseal-{}-{}", std::process::id(), timestamp);
1369    let record = format!(
1370        "{{\"event\":\"tbz-unseal.v1\",\"event_id\":\"{}\",\"timestamp\":\"{}\",\"sender_pubkey\":\"{}\",\"receiver_pubkey\":\"{}\",\"archive_uuid\":\"{}\",\"payload_class\":\"{}\",\"payload_size\":{},\"outcome\":\"{}\",\"_emitter\":\"tibet-zip-cli@{}\"}}\n",
1371        event_id,
1372        timestamp,
1373        sender_pubkey_hex,
1374        receiver_pubkey_hex,
1375        archive_uuid_hex,
1376        payload_class,
1377        payload_size,
1378        outcome,
1379        env!("CARGO_PKG_VERSION"),
1380    );
1381
1382    let candidates: Vec<std::path::PathBuf> = {
1383        let mut v = Vec::new();
1384        if let Ok(p) = std::env::var("TBZ_UNSEAL_AUDIT_LOG") {
1385            v.push(std::path::PathBuf::from(p));
1386        }
1387        v.push(std::path::PathBuf::from("/var/log/tibet/tbz-unseal.jsonl"));
1388        let xdg = std::env::var("XDG_STATE_HOME")
1389            .ok()
1390            .or_else(|| {
1391                std::env::var("HOME")
1392                    .ok()
1393                    .map(|h| format!("{}/.local/state", h))
1394            });
1395        if let Some(base) = xdg {
1396            v.push(std::path::PathBuf::from(format!("{}/tbz/audit.jsonl", base)));
1397        }
1398        v
1399    };
1400
1401    for path in candidates {
1402        if let Some(parent) = path.parent() {
1403            let _ = fs::create_dir_all(parent);
1404        }
1405        if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&path) {
1406            if f.write_all(record.as_bytes()).is_ok() {
1407                return; // success — first writable destination wins
1408            }
1409        }
1410    }
1411    // All destinations failed → silent skip (soft-fail)
1412}
1413
1414/// Decode the inner v1 archive enough to show what's inside BEFORE extraction.
1415/// Optionally warn on executable file types when payload_class != code.
1416fn preview_inner_manifest(
1417    inner: &[u8],
1418    class: v2::PayloadClass,
1419    strict_type: bool,
1420) -> anyhow::Result<()> {
1421    use tbz_core::stream::TbzReader;
1422    let mut reader = TbzReader::new(std::io::Cursor::new(inner));
1423    let mut shown = 0u32;
1424    let mut warn_files: Vec<String> = Vec::new();
1425
1426    println!("  Preview (= no disk write yet):");
1427    while let Some(block) = reader.read_block()? {
1428        if block.header.block_type == BlockType::Manifest {
1429            if let Ok(json) = block.decompress() {
1430                if let Ok(manifest) = serde_json::from_slice::<Manifest>(&json) {
1431                    for entry in &manifest.blocks {
1432                        let path = entry.path.as_deref().unwrap_or(&entry.description);
1433                        println!(
1434                            "    [{:>3}] {:<40} {:>10} bytes  JIS {}",
1435                            entry.index, path, entry.uncompressed_size, entry.jis_level
1436                        );
1437                        shown += 1;
1438                        // Warn on executable file types when class != code
1439                        if class != v2::PayloadClass::Code {
1440                            let lower = path.to_ascii_lowercase();
1441                            let exec = lower.ends_with(".exe")
1442                                || lower.ends_with(".bat")
1443                                || lower.ends_with(".cmd")
1444                                || lower.ends_with(".sh")
1445                                || lower.ends_with(".ps1")
1446                                || lower.ends_with(".vbs");
1447                            if exec {
1448                                warn_files.push(path.to_string());
1449                            }
1450                        }
1451                    }
1452                }
1453            }
1454            break; // manifest is block 0, we're done previewing
1455        }
1456    }
1457    if shown == 0 {
1458        println!("    (no manifest entries found)");
1459    }
1460    if !warn_files.is_empty() {
1461        let msg = format!(
1462            "executable file(s) found inside a non-code envelope: {}",
1463            warn_files.join(", ")
1464        );
1465        if strict_type {
1466            anyhow::bail!("strict-type: {}", msg);
1467        } else {
1468            println!("\n  ⚠ {}", msg);
1469            println!("    (declared class = {} — pass --strict-type to refuse)\n",
1470                class.label());
1471        }
1472    } else {
1473        println!();
1474    }
1475    Ok(())
1476}