1use 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#[derive(Debug, Clone, Copy, PartialEq)]
45enum ArchiveFormat {
46 TbzBlock,
48 TibetZip,
50 Unknown,
52}
53
54fn 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
71mod 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, }
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
144fn 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 #[arg(global = false)]
170 path: Option<String>,
171
172 #[arg(long, global = true, default_value_t = false)]
174 mirror: bool,
175
176 #[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 #[command(alias = "p")]
189 Pack {
190 path: String,
192 #[arg(short, long, default_value = "output.tza")]
194 output: String,
195 #[arg(long, default_value = "0")]
197 jis_level: u8,
198 #[arg(long)]
200 seal: bool,
201 #[arg(long, value_name = "PUBKEY_HEX")]
203 to: Option<String>,
204 #[arg(long, value_name = "PRIVKEY_PATH")]
206 from: Option<String>,
207 #[arg(long = "type", value_name = "CLASS")]
211 payload_type: Option<String>,
212 },
213
214 #[command(alias = "x")]
219 Unpack {
220 archive: String,
222 #[arg(short, long, default_value = ".")]
224 output: String,
225 #[arg(long = "as", value_name = "PRIVKEY_PATH")]
227 as_key: Option<String>,
228 #[arg(long)]
230 no_preview: bool,
231 #[arg(long)]
233 strict_type: bool,
234 },
235
236 #[command(alias = "v")]
238 Verify {
239 archive: String,
241 },
242
243 #[command(alias = "i")]
245 Inspect {
246 archive: String,
248 },
249
250 Init {
252 #[arg(long, default_value = "github")]
254 platform: String,
255 #[arg(long)]
257 account: Option<String>,
258 #[arg(long)]
260 repo: Option<String>,
261 },
262
263 Keygen {
267 #[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 let mirror_url: Option<&str> = if cli.mirror {
280 Some(&cli.mirror_url)
281 } else {
282 None
283 };
284
285 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 if let Some(path) = cli.path {
336 let p = Path::new(&path);
337
338 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 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 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 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 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 Cli::parse_from(["tbz", "--help"]);
412 Ok(())
413}
414
415fn 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 let files = collect_files(source)?;
424 println!("TBZ pack: {} file(s) from {}", files.len(), path);
425
426 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 let (signing_key, verifying_key) = signature::generate_keypair();
434
435 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, uncompressed_size: data.len() as u64,
448 jis_level,
449 description: file_path.clone(),
450 path: Some(file_path.clone()),
451 });
452 }
453
454 manifest.set_signing_key(&verifying_key);
456
457 let out_file = fs::File::create(output)?;
459 let mut writer = TbzWriter::new(BufWriter::new(out_file), signing_key);
460
461 writer.write_manifest(&manifest)?;
463 println!(" [0] manifest ({} block entries)", manifest.blocks.len());
464
465 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 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 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
536fn cmd_inspect(archive: &str) -> anyhow::Result<()> {
538 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 => {} }
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 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 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
604fn cmd_unpack(archive: &str, output_dir: &str) -> anyhow::Result<()> {
609 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 => {} }
615
616 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 if let Some(ref vk) = verifying_key {
646 if block.verify_signature(vk).is_err() {
647 errors += 1;
648 }
649 }
650
651 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 let file = fs::File::open(archive)?;
680 let mut reader = TbzReader::new(std::io::BufReader::new(file));
681
682 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 let decompressed = block.decompress()?;
702 airlock.allocate(decompressed.len() as u64)?;
703 airlock.receive(&decompressed)?;
704
705 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 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(); 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
743fn cmd_verify(archive: &str, mirror_url: Option<&str>) -> anyhow::Result<()> {
745 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 => {} }
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 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 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 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 };
799
800 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 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
872fn 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 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 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 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 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 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
968fn 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 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
1014fn 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
1066fn 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 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 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 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 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
1153fn 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
1213fn 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 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 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 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 if preview {
1277 preview_inner_manifest(&plain, payload_class, strict_type)?;
1278 }
1279
1280 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 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
1303fn 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
1314enum ClassCheck {
1316 Ok,
1317 Warn(String),
1318 Mismatch(String),
1319}
1320
1321fn check_class_vs_extension(archive: &str, class: v2::PayloadClass) -> ClassCheck {
1322 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
1349fn 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; }
1409 }
1410 }
1411 }
1413
1414fn 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 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; }
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}