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    },
208
209    /// Extract a TBZ archive via the TIBET Airlock
210    ///
211    /// Auto-detects v1 vs v2 from magic bytes. For v2 sealed archives,
212    /// pass --as <privkey-path> to decrypt as the named receiver.
213    #[command(alias = "x")]
214    Unpack {
215        /// Path to the TBZ archive
216        archive: String,
217        /// Output directory
218        #[arg(short, long, default_value = ".")]
219        output: String,
220        /// Receiver's Ed25519 private key file (hex). Required for v2 sealed archives.
221        #[arg(long = "as", value_name = "PRIVKEY_PATH")]
222        as_key: Option<String>,
223    },
224
225    /// Validate a TBZ archive without extracting
226    #[command(alias = "v")]
227    Verify {
228        /// Path to the TBZ archive
229        archive: String,
230    },
231
232    /// Show manifest and block information
233    #[command(alias = "i")]
234    Inspect {
235        /// Path to the TBZ archive
236        archive: String,
237    },
238
239    /// Generate .jis.json for the current repository
240    Init {
241        /// Platform (github, gitlab, etc.)
242        #[arg(long, default_value = "github")]
243        platform: String,
244        /// Account name
245        #[arg(long)]
246        account: Option<String>,
247        /// Repository name
248        #[arg(long)]
249        repo: Option<String>,
250    },
251
252    /// Generate an Ed25519 keypair for v2 sealed archives
253    ///
254    /// Writes <output>.priv (32-byte hex, mode 0600) and <output>.pub (32-byte hex).
255    Keygen {
256        /// Output basename — produces <output>.priv and <output>.pub
257        #[arg(short, long, default_value = "tbz-key")]
258        output: String,
259    },
260}
261
262pub fn run() -> anyhow::Result<()> {
263    let _ = tracing_subscriber::fmt::try_init();
264
265    let cli = Cli::parse();
266
267    // Resolve mirror URL once (None = disabled, opt-in only)
268    let mirror_url: Option<&str> = if cli.mirror {
269        Some(&cli.mirror_url)
270    } else {
271        None
272    };
273
274    // If a subcommand was given, use it directly
275    if let Some(command) = cli.command {
276        return match command {
277            Commands::Pack { path, output, jis_level, seal, to, from } => {
278                if seal {
279                    let to_hex = to.ok_or_else(|| anyhow::anyhow!(
280                        "--seal requires --to <pubkey-hex> (64 hex chars)"))?;
281                    cmd_pack_sealed(&path, &output, jis_level, mirror_url, &to_hex, from.as_deref())
282                } else {
283                    cmd_pack(&path, &output, jis_level, mirror_url)
284                }
285            }
286            Commands::Unpack { archive, output, as_key } => {
287                cmd_unpack_dispatch(&archive, &output, as_key.as_deref())
288            }
289            Commands::Verify { archive } => cmd_verify(&archive, mirror_url),
290            Commands::Inspect { archive } => cmd_inspect(&archive),
291            Commands::Init { platform, account, repo } => cmd_init(&platform, account, repo),
292            Commands::Keygen { output } => cmd_keygen(&output),
293        };
294    }
295
296    // Smart auto-detection: tbz <path>
297    //
298    // v1.0.2: magic-bytes-FIRST. We read the first 4 bytes and check
299    // for the TBZ magic (0x54 0x42 0x5A 0x01 / TBZ\x01) BEFORE looking
300    // at the file extension. This prevents accidental double-wrap when
301    // a sealed envelope was renamed for human navigation
302    // (e.g. `vergadering-dinsdag.pdf`) — `tbz <file>` will now correctly
303    // route to unpack instead of re-packing the sealed bundle inside a
304    // new TBZ container.
305    //
306    // Bug reported by Jasper in cross-host vloedtest 12 mei 2026:
307    //   tbz superbelangrijk-doc-LEES-DIT-EERST.pdf
308    //   → Auto-detected: file → pack to ...tza    (= WRONG: re-wrapping a TBZ)
309    if let Some(path) = cli.path {
310        let p = Path::new(&path);
311
312        // Magic-bytes precheck (= content is truth, name is hint)
313        let is_tbz_by_magic = if p.is_file() {
314            match std::fs::File::open(p) {
315                Ok(mut f) => {
316                    use std::io::Read;
317                    let mut buf = [0u8; 4];
318                    matches!(f.read(&mut buf), Ok(n) if n == 4)
319                        && buf == [0x54, 0x42, 0x5A, 0x01]
320                }
321                Err(_) => false,
322            }
323        } else {
324            false
325        };
326
327        if is_tbz_by_magic {
328            // Sealed envelope identified by magic bytes — route to unpack
329            // regardless of extension. Plus warn the operator if the
330            // filename doesn't carry the typical .tza/.tbz suffix, so
331            // they know we detected a rename-recovered bundle.
332            let extension_matches =
333                path.ends_with(".tza") || path.ends_with(".tbz");
334            if !extension_matches {
335                println!(
336                    "✓ TBZ magic bytes detected — treating as sealed bundle"
337                );
338                println!(
339                    "  (filename does not carry .tza/.tbz suffix; this may be"
340                );
341                println!(
342                    "   an operator-renamed bundle. Content is truth, name is hint.)"
343                );
344            }
345            println!("Auto-detected: TBZ envelope → unpack (with airlock verification)\n");
346            let out_dir = p.file_stem()
347                .map(|s| s.to_string_lossy().to_string())
348                .unwrap_or_else(|| "tbz_out".to_string());
349            cmd_unpack(&path, &out_dir)?;
350            return Ok(());
351        }
352
353        if (path.ends_with(".tza") || path.ends_with(".tbz")) && p.is_file() {
354            // Has TBZ-style extension but NO magic match. Could be a
355            // truncated/corrupt bundle, or a non-TBZ file with a
356            // misleading extension. Fail loudly.
357            anyhow::bail!(
358                "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.",
359                path, path
360            );
361        } else if p.is_dir() {
362            // Directory → pack
363            let dir_name = p.file_name()
364                .map(|s| s.to_string_lossy().to_string())
365                .unwrap_or_else(|| "output".to_string());
366            let output = format!("{}.tza", dir_name);
367            println!("Auto-detected: directory → pack to {}\n", output);
368            cmd_pack(&path, &output, 0, mirror_url)?;
369            return Ok(());
370        } else if p.is_file() {
371            // Single file → pack
372            let file_name = p.file_stem()
373                .map(|s| s.to_string_lossy().to_string())
374                .unwrap_or_else(|| "output".to_string());
375            let output = format!("{}.tza", file_name);
376            println!("Auto-detected: file → pack to {}\n", output);
377            cmd_pack(&path, &output, 0, mirror_url)?;
378            return Ok(());
379        } else {
380            anyhow::bail!("Path not found: {}", path);
381        }
382    }
383
384    // No subcommand and no path — show help
385    Cli::parse_from(["tbz", "--help"]);
386    Ok(())
387}
388
389/// Pack files into a TBZ archive
390fn cmd_pack(path: &str, output: &str, default_jis_level: u8, mirror_url: Option<&str>) -> anyhow::Result<()> {
391    let source = Path::new(path);
392    if !source.exists() {
393        anyhow::bail!("Source path does not exist: {}", path);
394    }
395
396    // Collect files to pack
397    let files = collect_files(source)?;
398    println!("TBZ pack: {} file(s) from {}", files.len(), path);
399
400    // Check for .jis.json
401    let jis_manifest = tbz_jis::JisManifest::load(Path::new(".")).ok();
402    if let Some(ref jis) = jis_manifest {
403        println!("  .jis.json found: {}", jis.repo_identifier());
404    }
405
406    // Generate signing keypair for this archive
407    let (signing_key, verifying_key) = signature::generate_keypair();
408
409    // Build manifest
410    let mut manifest = Manifest::new();
411    for (i, (file_path, data)) in files.iter().enumerate() {
412        let jis_level = jis_manifest
413            .as_ref()
414            .map(|j| j.jis_level_for_path(file_path))
415            .unwrap_or(default_jis_level);
416
417        manifest.add_block(BlockEntry {
418            index: (i + 1) as u32,
419            block_type: "data".to_string(),
420            compressed_size: 0, // filled after compression
421            uncompressed_size: data.len() as u64,
422            jis_level,
423            description: file_path.clone(),
424            path: Some(file_path.clone()),
425        });
426    }
427
428    // Embed verifying key in manifest
429    manifest.set_signing_key(&verifying_key);
430
431    // Write TBZ archive
432    let out_file = fs::File::create(output)?;
433    let mut writer = TbzWriter::new(BufWriter::new(out_file), signing_key);
434
435    // Block 0: manifest
436    writer.write_manifest(&manifest)?;
437    println!("  [0] manifest ({} block entries)", manifest.blocks.len());
438
439    // Block 1..N: data
440    for (file_path, data) in &files {
441        let jis_level = jis_manifest
442            .as_ref()
443            .map(|j| j.jis_level_for_path(file_path))
444            .unwrap_or(default_jis_level);
445
446        let envelope = TibetEnvelope::new(
447            signature::sha256_hash(data),
448            "data",
449            mime_for_path(file_path),
450            "tbz-cli",
451            &format!("Pack file: {}", file_path),
452            vec!["block:0".to_string()],
453        );
454
455        let envelope = if let Some(ref jis) = jis_manifest {
456            envelope.with_source_repo(&jis.repo_identifier())
457        } else {
458            envelope
459        };
460
461        writer.write_data_block(data, jis_level, &envelope)?;
462        println!(
463            "  [{}] {} ({} bytes, JIS level {})",
464            writer.block_count() - 1,
465            file_path,
466            data.len(),
467            jis_level,
468        );
469    }
470
471    let total_blocks = writer.block_count();
472    writer.finish();
473
474    // Show public key (for verification)
475    let vk_hex = hex_encode(&verifying_key.to_bytes());
476
477    println!("\nArchive written: {}", output);
478    println!("  Blocks: {}", total_blocks);
479    println!("  Signing key (Ed25519 public): {}", vk_hex);
480    println!("  Format: TBZ v{}", tbz_core::VERSION);
481
482    // --- Transparency Mirror registration (best-effort) ---
483    if let Some(url) = mirror_url {
484        let archive_hash = hash_file(Path::new(output))?;
485        println!("\n  Mirror: registering {} ...", archive_hash);
486
487        let jis_id = jis_manifest.as_ref().map(|_| {
488            format!("jis:ed25519:{}", &vk_hex[..16])
489        });
490        let source_repo = jis_manifest.as_ref().map(|j| j.repo_identifier());
491
492        let payload = mirror_client::RegisterPayload {
493            content_hash: archive_hash,
494            signing_key: vk_hex.clone(),
495            jis_id,
496            source_repo,
497            block_count: total_blocks as u32,
498            total_size: fs::metadata(output).map(|m| m.len()).unwrap_or(0),
499        };
500
501        match mirror_client::register(url, &payload) {
502            Ok(resp) => println!("  Mirror: {} ({})", resp.status, url),
503            Err(e) => println!("  Mirror: WARNING — {}", e),
504        }
505    }
506
507    Ok(())
508}
509
510/// Inspect a TBZ archive
511fn cmd_inspect(archive: &str) -> anyhow::Result<()> {
512    // Format detection: route to TIBET-ZIP handler if Desktop format
513    match detect_format(archive)? {
514        ArchiveFormat::TibetZip => return tibet_zip::inspect(archive),
515        ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
516        ArchiveFormat::TbzBlock => {} // continue with block format below
517    }
518
519    let file = fs::File::open(archive)?;
520    let mut reader = TbzReader::new(std::io::BufReader::new(file));
521
522    println!("TBZ inspect: {}\n", archive);
523    println!("  Magic: 0x54425A (TBZ)");
524    println!("  Format: v{}\n", tbz_core::VERSION);
525
526    let mut block_idx = 0;
527    while let Some(block) = reader.read_block()? {
528        let type_str = match block.header.block_type {
529            BlockType::Manifest => "MANIFEST",
530            BlockType::Data => "DATA",
531            BlockType::Nested => "NESTED",
532        };
533
534        println!("  Block {} [{}]", block.header.block_index, type_str);
535        println!("    JIS level:         {}", block.header.jis_level);
536        println!("    Compressed:        {} bytes", block.header.compressed_size);
537        println!("    Uncompressed:      {} bytes", block.header.uncompressed_size);
538        println!("    TIBET ERIN hash:   {}", block.envelope.erin.content_hash);
539        println!("    TIBET ERACHTER:    {}", block.envelope.erachter);
540
541        if let Some(ref repo) = block.envelope.eromheen.source_repo {
542            println!("    Source repo:       {}", repo);
543        }
544
545        // For manifest block, show the parsed manifest
546        if block.header.block_type == BlockType::Manifest {
547            if let Ok(decompressed) = block.decompress() {
548                if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
549                    println!("    --- Manifest ---");
550                    println!("    Total blocks:      {}", manifest.block_count);
551                    println!("    Total uncompressed: {} bytes", manifest.total_uncompressed_size);
552                    println!("    Max JIS level:     {}", manifest.max_jis_level());
553                    for entry in &manifest.blocks {
554                        println!(
555                            "      [{:>3}] {} — {} bytes, JIS {}",
556                            entry.index,
557                            entry.path.as_deref().unwrap_or(&entry.description),
558                            entry.uncompressed_size,
559                            entry.jis_level,
560                        );
561                    }
562                }
563            }
564        }
565
566        // Signature present?
567        let sig_nonzero = block.signature.iter().any(|&b| b != 0);
568        println!("    Signature:         {}", if sig_nonzero { "Ed25519 (present)" } else { "none" });
569        println!();
570
571        block_idx += 1;
572    }
573
574    println!("  Total: {} blocks", block_idx);
575    Ok(())
576}
577
578/// Unpack a TBZ archive through the Airlock
579///
580/// AIRLOCK GATE: Runs full verification BEFORE extraction.
581/// Corrupt or tampered archives are BLOCKED.
582fn cmd_unpack(archive: &str, output_dir: &str) -> anyhow::Result<()> {
583    // Format detection: route to TIBET-ZIP handler if Desktop format
584    match detect_format(archive)? {
585        ArchiveFormat::TibetZip => return tibet_zip::unpack(archive, output_dir),
586        ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
587        ArchiveFormat::TbzBlock => {} // continue with block format below
588    }
589
590    // =========================================================================
591    // AIRLOCK GATE — Verify BEFORE extraction. No exceptions.
592    // =========================================================================
593    println!("TBZ unpack: {} → {}\n", archive, output_dir);
594    println!("  Airlock pre-check: verifying archive integrity...\n");
595
596    {
597        let vfile = fs::File::open(archive)?;
598        let mut vreader = TbzReader::new(std::io::BufReader::new(vfile));
599        let mut errors = 0u32;
600        let mut block_count = 0u32;
601        let mut verifying_key: Option<tbz_core::VerifyingKey> = None;
602
603        while let Some(block) = vreader.read_block()? {
604            if let Err(_) = block.validate() {
605                errors += 1;
606                block_count += 1;
607                continue;
608            }
609
610            if block.header.block_type == BlockType::Manifest {
611                if let Ok(decompressed) = block.decompress() {
612                    if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
613                        verifying_key = manifest.get_verifying_key();
614                    }
615                }
616            }
617
618            // Verify signature
619            if let Some(ref vk) = verifying_key {
620                if block.verify_signature(vk).is_err() {
621                    errors += 1;
622                }
623            }
624
625            // Verify content hash
626            match block.decompress() {
627                Ok(decompressed) => {
628                    let actual_hash = signature::sha256_hash(&decompressed);
629                    if actual_hash != block.envelope.erin.content_hash {
630                        errors += 1;
631                    }
632                }
633                Err(_) => { errors += 1; }
634            }
635
636            block_count += 1;
637        }
638
639        if errors > 0 {
640            anyhow::bail!(
641                "AIRLOCK BREACH BLOCKED — archive corrupt: {} ({} block errors in {} blocks). \
642                 Use `tbz verify` to inspect, or fix the archive.",
643                archive, errors, block_count
644            );
645        }
646
647        println!("  Airlock pre-check: {} blocks verified ✓\n", block_count);
648    }
649
650    // =========================================================================
651    // Extraction — only reached if all blocks verified
652    // =========================================================================
653    let file = fs::File::open(archive)?;
654    let mut reader = TbzReader::new(std::io::BufReader::new(file));
655
656    // Create Airlock
657    let mut airlock = tbz_airlock::Airlock::new(256 * 1024 * 1024, 30);
658    println!("  Airlock mode: {:?}\n", airlock.mode());
659
660    fs::create_dir_all(output_dir)?;
661
662    let mut block_idx = 0;
663    let mut manifest: Option<Manifest> = None;
664
665    while let Some(block) = reader.read_block()? {
666        match block.header.block_type {
667            BlockType::Manifest => {
668                let decompressed = block.decompress()?;
669                manifest = Some(serde_json::from_slice(&decompressed)
670                    .map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?);
671                println!("  [0] Manifest parsed ({} entries)", manifest.as_ref().unwrap().blocks.len());
672            }
673            BlockType::Data => {
674                // Decompress into Airlock
675                let decompressed = block.decompress()?;
676                airlock.allocate(decompressed.len() as u64)?;
677                airlock.receive(&decompressed)?;
678
679                // Determine output path from manifest
680                let file_path = manifest
681                    .as_ref()
682                    .and_then(|m| {
683                        m.blocks.iter()
684                            .find(|e| e.index == block.header.block_index)
685                            .and_then(|e| e.path.clone())
686                    })
687                    .unwrap_or_else(|| format!("block_{}", block.header.block_index));
688
689                // Write from Airlock to filesystem
690                let out_path = Path::new(output_dir).join(&file_path);
691                if let Some(parent) = out_path.parent() {
692                    fs::create_dir_all(parent)?;
693                }
694
695                let data = airlock.release(); // returns data + wipes buffer
696                fs::write(&out_path, &data)?;
697
698                println!(
699                    "  [{}] {} ({} bytes) ✓",
700                    block.header.block_index,
701                    file_path,
702                    data.len(),
703                );
704            }
705            BlockType::Nested => {
706                println!("  [{}] Nested TBZ (not yet supported)", block.header.block_index);
707            }
708        }
709        block_idx += 1;
710    }
711
712    println!("\n  Extracted {} blocks via Airlock", block_idx);
713    println!("  Airlock buffer: wiped (0x00)");
714    Ok(())
715}
716
717/// Verify a TBZ archive without extracting
718fn cmd_verify(archive: &str, mirror_url: Option<&str>) -> anyhow::Result<()> {
719    // Format detection: route to TIBET-ZIP handler if Desktop format
720    match detect_format(archive)? {
721        ArchiveFormat::TibetZip => return tibet_zip::verify(archive),
722        ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
723        ArchiveFormat::TbzBlock => {} // continue with block format below
724    }
725
726    let file = fs::File::open(archive)?;
727    let mut reader = TbzReader::new(std::io::BufReader::new(file));
728
729    println!("TBZ verify: {}\n", archive);
730
731    let mut errors = 0;
732    let mut block_idx = 0;
733    let mut verifying_key: Option<tbz_core::VerifyingKey> = None;
734
735    while let Some(block) = reader.read_block()? {
736        // Validate header
737        if let Err(e) = block.validate() {
738            println!("  [{}] FAIL header: {}", block.header.block_index, e);
739            errors += 1;
740            block_idx += 1;
741            continue;
742        }
743
744        // Extract verifying key from manifest (block 0)
745        if block.header.block_type == BlockType::Manifest {
746            if let Ok(decompressed) = block.decompress() {
747                if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
748                    verifying_key = manifest.get_verifying_key();
749                    if let Some(ref vk) = verifying_key {
750                        let vk_hex = hex_encode(&vk.to_bytes());
751                        println!("  Signing key: Ed25519 {}", &vk_hex[..16]);
752                        println!();
753                    } else {
754                        println!("  WARNING: No signing key in manifest — signature checks skipped\n");
755                    }
756                }
757            }
758        }
759
760        // 1. Verify Ed25519 signature (cryptographic proof of block integrity)
761        let sig_ok = if let Some(ref vk) = verifying_key {
762            match block.verify_signature(vk) {
763                Ok(()) => true,
764                Err(e) => {
765                    println!("  [{}] FAIL signature: {}", block.header.block_index, e);
766                    errors += 1;
767                    false
768                }
769            }
770        } else {
771            true // no key available, skip
772        };
773
774        // 2. Verify content hash matches TIBET ERIN
775        match block.decompress() {
776            Ok(decompressed) => {
777                let actual_hash = signature::sha256_hash(&decompressed);
778                if actual_hash == block.envelope.erin.content_hash {
779                    let sig_status = if verifying_key.is_some() && sig_ok {
780                        "hash + signature"
781                    } else if verifying_key.is_some() {
782                        "hash only (sig FAILED)"
783                    } else {
784                        "hash only (no key)"
785                    };
786                    println!("  [{}] OK — {} verified", block.header.block_index, sig_status);
787                } else {
788                    println!(
789                        "  [{}] FAIL — hash mismatch\n    expected: {}\n    actual:   {}",
790                        block.header.block_index,
791                        block.envelope.erin.content_hash,
792                        actual_hash,
793                    );
794                    errors += 1;
795                }
796            }
797            Err(e) => {
798                println!("  [{}] FAIL — decompress error: {}", block.header.block_index, e);
799                errors += 1;
800            }
801        }
802
803        block_idx += 1;
804    }
805
806    println!();
807    if errors == 0 {
808        if verifying_key.is_some() {
809            println!("  Result: ALL {} BLOCKS VERIFIED (hash + Ed25519) ✓", block_idx);
810        } else {
811            println!("  Result: ALL {} BLOCKS VERIFIED (hash only, no signing key) ✓", block_idx);
812        }
813    } else {
814        println!("  Result: {} ERRORS in {} blocks ✗", errors, block_idx);
815    }
816
817    // --- Transparency Mirror lookup (best-effort) ---
818    if let Some(url) = mirror_url {
819        let archive_hash = hash_file(Path::new(archive))?;
820        match mirror_client::lookup(url, &archive_hash) {
821            Ok(Some(entry)) => {
822                let verdicts: Vec<&str> = entry.attestations.iter()
823                    .map(|a| a.verdict.as_str())
824                    .collect();
825                println!("\n  Mirror: KNOWN");
826                println!("    Hash:         {}", entry.content_hash);
827                println!("    First seen:   {}", entry.first_seen);
828                println!(
829                    "    Attestations: {} ({})",
830                    entry.attestations.len(),
831                    if verdicts.is_empty() { "none".to_string() } else { verdicts.join(", ") },
832                );
833            }
834            Ok(None) => {
835                println!("\n  Mirror: UNKNOWN — not registered in Transparency Mirror");
836            }
837            Err(e) => {
838                println!("\n  Mirror: WARNING — {}", e);
839            }
840        }
841    }
842
843    Ok(())
844}
845
846/// Generate .jis.json and Ed25519 keypair for current repo
847fn cmd_init(platform: &str, account: Option<String>, repo: Option<String>) -> anyhow::Result<()> {
848    let account = account.unwrap_or_else(|| "<your-account>".to_string());
849    let repo = repo.unwrap_or_else(|| {
850        std::env::current_dir()
851            .ok()
852            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
853            .unwrap_or_else(|| "<repo>".to_string())
854    });
855
856    // Check if .tbz/ already exists
857    let tbz_dir = Path::new(".tbz");
858    let key_path = tbz_dir.join("signing.key");
859    let pub_path = tbz_dir.join("signing.pub");
860
861    let (signing_key, verifying_key) = if key_path.exists() {
862        // Load existing keypair
863        let sk_hex = fs::read_to_string(&key_path)?;
864        let sk_bytes: Vec<u8> = (0..sk_hex.trim().len())
865            .step_by(2)
866            .filter_map(|i| u8::from_str_radix(&sk_hex.trim()[i..i + 2], 16).ok())
867            .collect();
868        if sk_bytes.len() != 32 {
869            anyhow::bail!("Invalid signing key in .tbz/signing.key");
870        }
871        let mut key_array = [0u8; 32];
872        key_array.copy_from_slice(&sk_bytes);
873        let sk = tbz_core::SigningKey::from_bytes(&key_array);
874        let vk = sk.verifying_key();
875        println!("Using existing keypair from .tbz/");
876        (sk, vk)
877    } else {
878        // Generate new keypair
879        let (sk, vk) = signature::generate_keypair();
880
881        fs::create_dir_all(tbz_dir)?;
882        fs::write(&key_path, hex_encode(&sk.to_bytes()))?;
883        fs::write(&pub_path, hex_encode(&vk.to_bytes()))?;
884
885        println!("Generated Ed25519 keypair:");
886        println!("  Private: .tbz/signing.key (KEEP SECRET — add to .gitignore!)");
887        println!("  Public:  .tbz/signing.pub");
888        (sk, vk)
889    };
890
891    let vk_hex = hex_encode(&verifying_key.to_bytes());
892    let jis_id = format!("jis:ed25519:{}", &vk_hex[..16]);
893
894    // Sign the JIS identity claim
895    let claim_data = format!("{}:{}:{}:{}", platform, account, repo, vk_hex);
896    let claim_sig = signature::sign(claim_data.as_bytes(), &signing_key);
897
898    let jis_json = serde_json::json!({
899        "tbz": "1.0",
900        "jis_id": jis_id,
901        "signing_key": vk_hex,
902        "claim": {
903            "platform": platform,
904            "account": account,
905            "repo": repo,
906            "intent": "official_releases",
907            "sectors": {
908                "src/*": { "jis_level": 0, "description": "Public source code" },
909                "keys/*": { "jis_level": 2, "description": "Signing keys" }
910            }
911        },
912        "tibet": {
913            "erin": "Repository identity binding",
914            "eraan": [&jis_id],
915            "erachter": format!("Provenance root for TBZ packages from {}/{}", account, repo)
916        },
917        "signature": hex_encode(&claim_sig),
918        "timestamp": chrono_now()
919    });
920
921    let output = serde_json::to_string_pretty(&jis_json)?;
922    fs::write(".jis.json", &output)?;
923
924    // Ensure .tbz/signing.key is in .gitignore
925    let gitignore = Path::new(".gitignore");
926    if gitignore.exists() {
927        let content = fs::read_to_string(gitignore)?;
928        if !content.contains(".tbz/signing.key") {
929            fs::write(gitignore, format!("{}\n# TBZ signing key (NEVER commit!)\n.tbz/signing.key\n", content.trim_end()))?;
930            println!("\n  Added .tbz/signing.key to .gitignore");
931        }
932    }
933
934    println!("\nGenerated .jis.json:");
935    println!("  JIS ID: {}", jis_id);
936    println!("  Claim: {}/{}/{}", platform, account, repo);
937    println!("  Signature: Ed25519 (signed)");
938
939    Ok(())
940}
941
942/// Collect files from a path (file or directory, recursive)
943fn collect_files(path: &Path) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
944    let mut files = Vec::new();
945
946    if path.is_file() {
947        let data = fs::read(path)?;
948        let name = path.file_name()
949            .map(|n| n.to_string_lossy().to_string())
950            .unwrap_or_else(|| "file".to_string());
951        files.push((name, data));
952    } else if path.is_dir() {
953        collect_dir_recursive(path, path, &mut files)?;
954    }
955
956    Ok(files)
957}
958
959fn collect_dir_recursive(
960    base: &Path,
961    current: &Path,
962    files: &mut Vec<(String, Vec<u8>)>,
963) -> anyhow::Result<()> {
964    let mut entries: Vec<_> = fs::read_dir(current)?.collect::<Result<_, _>>()?;
965    entries.sort_by_key(|e| e.file_name());
966
967    for entry in entries {
968        let path = entry.path();
969        // Skip hidden files and common non-essential dirs
970        let name = entry.file_name().to_string_lossy().to_string();
971        if name.starts_with('.') || name == "target" || name == "node_modules" {
972            continue;
973        }
974
975        if path.is_file() {
976            let rel = path.strip_prefix(base)
977                .map(|p| p.to_string_lossy().to_string())
978                .unwrap_or_else(|_| name);
979            let data = fs::read(&path)?;
980            files.push((rel, data));
981        } else if path.is_dir() {
982            collect_dir_recursive(base, &path, files)?;
983        }
984    }
985    Ok(())
986}
987
988/// Simple MIME type detection
989fn mime_for_path(path: &str) -> &str {
990    match path.rsplit('.').next() {
991        Some("rs") => "text/x-rust",
992        Some("toml") => "application/toml",
993        Some("json") => "application/json",
994        Some("md") => "text/markdown",
995        Some("txt") => "text/plain",
996        Some("py") => "text/x-python",
997        Some("js") => "text/javascript",
998        Some("html") => "text/html",
999        Some("css") => "text/css",
1000        Some("png") => "image/png",
1001        Some("jpg") | Some("jpeg") => "image/jpeg",
1002        Some("bin") => "application/octet-stream",
1003        _ => "application/octet-stream",
1004    }
1005}
1006
1007fn hex_encode(bytes: &[u8]) -> String {
1008    bytes.iter().map(|b| format!("{:02x}", b)).collect()
1009}
1010
1011fn hex_decode_32(s: &str) -> anyhow::Result<[u8; 32]> {
1012    let s = s.trim();
1013    if s.len() != 64 {
1014        anyhow::bail!("expected 64 hex characters, got {}", s.len());
1015    }
1016    let mut out = [0u8; 32];
1017    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
1018        let pair = std::str::from_utf8(chunk).map_err(|_| anyhow::anyhow!("invalid utf8"))?;
1019        out[i] = u8::from_str_radix(pair, 16)
1020            .map_err(|e| anyhow::anyhow!("invalid hex pair '{}': {}", pair, e))?;
1021    }
1022    Ok(out)
1023}
1024
1025fn read_signing_key_from_file(path: &str) -> anyhow::Result<ed25519_dalek::SigningKey> {
1026    let raw = fs::read_to_string(path)
1027        .map_err(|e| anyhow::anyhow!("cannot read key file {}: {}", path, e))?;
1028    let bytes = hex_decode_32(&raw)?;
1029    Ok(ed25519_dalek::SigningKey::from_bytes(&bytes))
1030}
1031
1032fn chrono_now() -> String {
1033    use std::time::SystemTime;
1034    let duration = SystemTime::now()
1035        .duration_since(SystemTime::UNIX_EPOCH)
1036        .unwrap_or_default();
1037    format!("{}Z", duration.as_secs())
1038}
1039
1040// ---------------------------------------------------------------------------
1041// v2.1.0 NEW SUBCOMMANDS — Keygen, Pack --seal, Unpack --as
1042// ---------------------------------------------------------------------------
1043
1044fn cmd_keygen(output: &str) -> anyhow::Result<()> {
1045    use ed25519_dalek::SigningKey;
1046    use rand::rngs::OsRng;
1047
1048    let signing_key = SigningKey::generate(&mut OsRng);
1049    let verifying_key = signing_key.verifying_key();
1050
1051    let priv_path = format!("{}.priv", output);
1052    let pub_path = format!("{}.pub", output);
1053
1054    // Write private key (hex) with 0600 permissions
1055    let priv_hex = hex_encode(&signing_key.to_bytes());
1056    fs::write(&priv_path, &priv_hex)?;
1057    #[cfg(unix)]
1058    {
1059        use std::os::unix::fs::PermissionsExt;
1060        let perms = fs::Permissions::from_mode(0o600);
1061        fs::set_permissions(&priv_path, perms)?;
1062    }
1063
1064    let pub_hex = hex_encode(&verifying_key.to_bytes());
1065    fs::write(&pub_path, &pub_hex)?;
1066
1067    println!("TBZ keygen: Ed25519 keypair generated\n");
1068    println!("  Private: {} (mode 0600)", priv_path);
1069    println!("  Public:  {}", pub_path);
1070    println!("\n  Pubkey (share this): {}", pub_hex);
1071    println!("\n  Use with:");
1072    println!("    tibet-zip pack <dir> -o sealed.tza --seal --to {} --from {}", pub_hex, priv_path);
1073    println!("    tibet-zip unpack sealed.tza -o out/ --as {}", priv_path);
1074    Ok(())
1075}
1076
1077fn cmd_pack_sealed(
1078    path: &str,
1079    output: &str,
1080    default_jis_level: u8,
1081    mirror_url: Option<&str>,
1082    receiver_hex: &str,
1083    sender_priv_path: Option<&str>,
1084) -> anyhow::Result<()> {
1085    let source = Path::new(path);
1086    if !source.exists() {
1087        anyhow::bail!("Source path does not exist: {}", path);
1088    }
1089    let receiver_pubkey = hex_decode_32(receiver_hex)
1090        .map_err(|e| anyhow::anyhow!("--to pubkey: {}", e))?;
1091
1092    // Sender key: from --from or ephemeral
1093    let sender_key = match sender_priv_path {
1094        Some(p) => read_signing_key_from_file(p)?,
1095        None => {
1096            use rand::rngs::OsRng;
1097            ed25519_dalek::SigningKey::generate(&mut OsRng)
1098        }
1099    };
1100
1101    println!("TBZ pack (sealed v2): {} → {}", path, output);
1102    println!("  Sender pubkey:   {}", hex_encode(&sender_key.verifying_key().to_bytes()));
1103    println!("  Receiver pubkey: {}", receiver_hex);
1104
1105    // Build the v1 archive in memory (Vec<u8> buffer)
1106    let v1_bytes = build_v1_archive_bytes(source, default_jis_level, mirror_url)?;
1107    println!("\n  Inner v1 archive: {} bytes", v1_bytes.len());
1108
1109    // Wrap in v2 sealed container
1110    let container = v2::write_sealed_container(&sender_key, &receiver_pubkey, &v1_bytes)
1111        .map_err(|e| anyhow::anyhow!("v2 seal failed: {}", e))?;
1112
1113    fs::write(output, &container)?;
1114    println!("  Outer v2 envelope: {} bytes (overhead: {} bytes)", container.len(), container.len() - v1_bytes.len());
1115    println!("\n  Sealed: {} ✓", output);
1116    println!("  Format: TBZ v2 (AES-256-GCM, Ed25519-signed)");
1117    Ok(())
1118}
1119
1120/// Build a v1 archive in memory as Vec<u8>. Refactored from cmd_pack so we
1121/// can wrap the bytes in a v2 sealed envelope.
1122fn build_v1_archive_bytes(
1123    source: &Path,
1124    default_jis_level: u8,
1125    _mirror_url: Option<&str>,
1126) -> anyhow::Result<Vec<u8>> {
1127    let files = collect_files(source)?;
1128    let jis_manifest = tbz_jis::JisManifest::load(Path::new(".")).ok();
1129
1130    let (signing_key, verifying_key) = signature::generate_keypair();
1131
1132    let mut manifest = Manifest::new();
1133    for (i, (file_path, data)) in files.iter().enumerate() {
1134        let jis_level = jis_manifest
1135            .as_ref()
1136            .map(|j| j.jis_level_for_path(file_path))
1137            .unwrap_or(default_jis_level);
1138
1139        manifest.add_block(BlockEntry {
1140            index: (i + 1) as u32,
1141            block_type: "data".to_string(),
1142            compressed_size: 0,
1143            uncompressed_size: data.len() as u64,
1144            jis_level,
1145            description: file_path.clone(),
1146            path: Some(file_path.clone()),
1147        });
1148    }
1149    manifest.set_signing_key(&verifying_key);
1150
1151    let mut buf: Vec<u8> = Vec::new();
1152    {
1153        let mut writer = TbzWriter::new(&mut buf, signing_key);
1154        writer.write_manifest(&manifest)?;
1155        for (file_path, data) in &files {
1156            let jis_level = jis_manifest
1157                .as_ref()
1158                .map(|j| j.jis_level_for_path(file_path))
1159                .unwrap_or(default_jis_level);
1160            let envelope = TibetEnvelope::new(
1161                signature::sha256_hash(data),
1162                "data",
1163                mime_for_path(file_path),
1164                "tbz-cli",
1165                &format!("Pack file: {}", file_path),
1166                vec!["block:0".to_string()],
1167            );
1168            let envelope = if let Some(ref jis) = jis_manifest {
1169                envelope.with_source_repo(&jis.repo_identifier())
1170            } else {
1171                envelope
1172            };
1173            writer.write_data_block(data, jis_level, &envelope)?;
1174        }
1175        writer.finish();
1176    }
1177    Ok(buf)
1178}
1179
1180/// Dispatch unpack: detect v1 vs v2 and route accordingly.
1181fn cmd_unpack_dispatch(archive: &str, output_dir: &str, as_key: Option<&str>) -> anyhow::Result<()> {
1182    // Peek first 32 bytes to detect version
1183    let mut head = [0u8; 32];
1184    let n = {
1185        let mut f = fs::File::open(archive)?;
1186        std::io::Read::read(&mut f, &mut head)?
1187    };
1188    let version = if n >= 7 { v2::detect_version(&head[..n]) } else { 0 };
1189
1190    if version == 2 {
1191        let priv_path = as_key.ok_or_else(|| anyhow::anyhow!(
1192            "{} is a v2 sealed archive — pass --as <privkey-path> to decrypt", archive))?;
1193        let receiver_key = read_signing_key_from_file(priv_path)?;
1194        println!("TBZ unpack (v2 sealed): {} → {}", archive, output_dir);
1195        let container = fs::read(archive)?;
1196        let (env, plain) = v2::read_sealed_container(&container, &receiver_key)
1197            .map_err(|e| anyhow::anyhow!("v2 unseal failed: {}", e))?;
1198        println!("  Sender:   {}", hex_encode(&env.sender_pubkey));
1199        println!("  Receiver: {} ✓", hex_encode(&env.receiver_pubkey));
1200        println!("  Inner v1 archive: {} bytes\n", plain.len());
1201
1202        // Write to temp file, then call cmd_unpack with that file
1203        let tmp = std::env::temp_dir().join(format!("tbz-v2-inner-{}.tza", std::process::id()));
1204        fs::write(&tmp, &plain)?;
1205        let result = cmd_unpack(tmp.to_str().unwrap(), output_dir);
1206        let _ = fs::remove_file(&tmp);
1207        result
1208    } else {
1209        cmd_unpack(archive, output_dir)
1210    }
1211}