1use anyhow::{Context, Result, anyhow, bail};
7use base64::prelude::*;
8use chrono::Utc;
9use ring::rand::{SecureRandom, SystemRandom};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::BTreeMap;
13use std::fs::{self, File, OpenOptions};
14use std::io::{BufReader, BufWriter, Read, Write};
15use std::path::{Path, PathBuf};
16
17use super::archive_config::{ArchiveConfig, UnencryptedConfig};
18use super::docs::{DocLocation, GeneratedDoc};
19use super::encrypt::{EncryptionConfig, validate_supported_payload_format};
20
21const PAGES_ASSETS: &[(&str, &[u8])] = &[
23 ("index.html", include_bytes!("../pages_assets/index.html")),
24 ("styles.css", include_bytes!("../pages_assets/styles.css")),
25 ("auth.js", include_bytes!("../pages_assets/auth.js")),
26 (
27 "password-strength.js",
28 include_bytes!("../pages_assets/password-strength.js"),
29 ),
30 ("viewer.js", include_bytes!("../pages_assets/viewer.js")),
31 ("router.js", include_bytes!("../pages_assets/router.js")),
32 ("share.js", include_bytes!("../pages_assets/share.js")),
33 ("stats.js", include_bytes!("../pages_assets/stats.js")),
34 ("storage.js", include_bytes!("../pages_assets/storage.js")),
35 ("search.js", include_bytes!("../pages_assets/search.js")),
36 (
37 "conversation.js",
38 include_bytes!("../pages_assets/conversation.js"),
39 ),
40 ("database.js", include_bytes!("../pages_assets/database.js")),
41 ("session.js", include_bytes!("../pages_assets/session.js")),
42 ("sw.js", include_bytes!("../pages_assets/sw.js")),
43 (
44 "sw-register.js",
45 include_bytes!("../pages_assets/sw-register.js"),
46 ),
47 (
48 "crypto_worker.js",
49 include_bytes!("../pages_assets/crypto_worker.js"),
50 ),
51 (
52 "virtual-list.js",
53 include_bytes!("../pages_assets/virtual-list.js"),
54 ),
55 (
56 "coi-detector.js",
57 include_bytes!("../pages_assets/coi-detector.js"),
58 ),
59 (
60 "attachments.js",
61 include_bytes!("../pages_assets/attachments.js"),
62 ),
63 ("settings.js", include_bytes!("../pages_assets/settings.js")),
64];
65
66const MASTER_KEY_BACKUP_NOTE: &str =
67 "This file contains the wrapped DEK. Keep it with your recovery secret.";
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct IntegrityEntry {
72 pub sha256: String,
74 pub size: u64,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct IntegrityManifest {
81 pub version: u8,
83 pub generated_at: String,
85 pub files: BTreeMap<String, IntegrityEntry>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct SiteMetadata {
92 pub title: String,
93 pub description: String,
94 pub generated_at: String,
95 pub generator: String,
96 pub generator_version: String,
97}
98
99#[derive(Debug, Clone)]
101pub struct BundleConfig {
102 pub title: String,
104 pub description: String,
106 pub hide_metadata: bool,
108 pub recovery_secret: Option<Vec<u8>>,
110 pub generate_qr: bool,
112 pub generated_docs: Vec<GeneratedDoc>,
114}
115
116impl Default for BundleConfig {
117 fn default() -> Self {
118 Self {
119 title: "cass Archive".to_string(),
120 description: "Encrypted archive of AI coding agent conversations".to_string(),
121 hide_metadata: false,
122 recovery_secret: None,
123 generate_qr: false,
124 generated_docs: Vec::new(),
125 }
126 }
127}
128
129#[derive(Default)]
131pub struct BundleBuilder {
132 config: BundleConfig,
133}
134
135impl BundleBuilder {
136 pub fn new() -> Self {
138 Self {
139 config: BundleConfig::default(),
140 }
141 }
142
143 pub fn with_config(config: BundleConfig) -> Self {
145 Self { config }
146 }
147
148 pub fn title(mut self, title: impl Into<String>) -> Self {
150 self.config.title = title.into();
151 self
152 }
153
154 pub fn description(mut self, description: impl Into<String>) -> Self {
156 self.config.description = description.into();
157 self
158 }
159
160 pub fn hide_metadata(mut self, hide: bool) -> Self {
162 self.config.hide_metadata = hide;
163 self
164 }
165
166 pub fn recovery_secret(mut self, recovery_material: Option<Vec<u8>>) -> Self {
168 let recovery_slot = &mut self.config.recovery_secret;
170 *recovery_slot = recovery_material;
171 self
172 }
173
174 pub fn generate_qr(mut self, generate: bool) -> Self {
176 self.config.generate_qr = generate;
177 self
178 }
179
180 pub fn with_docs(mut self, docs: Vec<GeneratedDoc>) -> Self {
182 self.config.generated_docs = docs;
183 self
184 }
185
186 pub fn build<P: AsRef<Path>>(
193 &self,
194 encrypted_dir: P,
195 output_dir: P,
196 progress: impl Fn(&str, &str),
197 ) -> Result<BundleResult> {
198 let encrypted_dir = encrypted_dir.as_ref();
199 let output_dir = output_dir.as_ref();
200
201 ensure_replaceable_bundle_output_dir(output_dir)?;
202
203 let config_path = encrypted_dir.join("config.json");
205 let payload_dir = encrypted_dir.join("payload");
206
207 if !config_path.exists() {
208 bail!("Missing config.json in encrypted directory");
209 }
210 if !payload_dir.exists() {
211 bail!("Missing payload/ directory in encrypted directory");
212 }
213
214 let archive_config: ArchiveConfig = {
216 let file = File::open(&config_path).context("Failed to open config.json")?;
217 serde_json::from_reader(BufReader::new(file))?
218 };
219
220 let temp_output_dir = unique_bundle_dir(output_dir, "tmp")?;
221 let final_site_dir = output_dir.join("site");
222 let final_private_dir = output_dir.join("private");
223 let mut replace_attempted = false;
224 let result = (|| -> Result<BundleResult> {
225 progress("setup", "Creating directory structure...");
226
227 let site_dir = temp_output_dir.join("site");
229 let private_dir = temp_output_dir.join("private");
230
231 fs::create_dir_all(&site_dir).context("Failed to create site/ directory")?;
232 fs::create_dir_all(&private_dir).context("Failed to create private/ directory")?;
233
234 let site_payload_dir = site_dir.join("payload");
236 fs::create_dir_all(&site_payload_dir).context("Failed to create site/payload/")?;
237
238 progress("assets", "Copying web assets...");
239
240 for (name, content) in PAGES_ASSETS {
242 let dest_path = site_dir.join(name);
243 fs::write(&dest_path, content)
244 .with_context(|| format!("Failed to write {}", name))?;
245 }
246
247 let (chunk_count, is_encrypted) = match archive_config.as_encrypted() {
249 Some(enc_config) => {
250 progress("payload", "Copying encrypted payload...");
251 let count = copy_payload_chunks(
252 encrypted_dir,
253 &payload_dir,
254 &site_payload_dir,
255 enc_config,
256 )?;
257 (count, true)
258 }
259 None => {
260 progress("payload", "Copying unencrypted payload...");
261 let unenc_config = archive_config
262 .as_unencrypted()
263 .context("Unencrypted config missing")?;
264 let count = copy_payload_file(encrypted_dir, &site_dir, unenc_config)?;
265 (count, false)
266 }
267 };
268
269 let blobs_dir = encrypted_dir.join("blobs");
271 let attachment_count = if blobs_dir.exists() && blobs_dir.is_dir() {
272 progress("attachments", "Copying encrypted attachments...");
273 let site_blobs_dir = site_dir.join("blobs");
274 copy_blobs_directory(encrypted_dir, &blobs_dir, &site_blobs_dir)?
275 } else {
276 0
277 };
278
279 progress("config", "Writing configuration files...");
280
281 let site_config_path = site_dir.join("config.json");
283 let config_file = File::create(&site_config_path)?;
284 serde_json::to_writer_pretty(BufWriter::new(config_file), &archive_config)?;
285
286 let site_metadata = SiteMetadata {
288 title: self.config.title.clone(),
289 description: self.config.description.clone(),
290 generated_at: Utc::now().to_rfc3339(),
291 generator: "cass".to_string(),
292 generator_version: env!("CARGO_PKG_VERSION").to_string(),
293 };
294 let site_json_path = site_dir.join("site.json");
295 let site_json_file = File::create(&site_json_path)?;
296 serde_json::to_writer_pretty(BufWriter::new(site_json_file), &site_metadata)?;
297
298 progress("static", "Writing static files...");
299
300 let robots_content = "User-agent: *\nDisallow: /\n";
302 fs::write(site_dir.join("robots.txt"), robots_content)?;
303
304 fs::write(site_dir.join(".nojekyll"), "")?;
306
307 if !self.config.generated_docs.is_empty() {
309 progress("docs", "Writing generated documentation...");
310 for doc in &self.config.generated_docs {
311 let dest_path = resolve_generated_doc_path(&site_dir, doc)?;
312 fs::write(&dest_path, &doc.content)
313 .with_context(|| format!("Failed to write {}", doc.filename))?;
314 }
315 } else {
316 let public_readme = generate_public_readme(
318 &self.config.title,
319 &self.config.description,
320 is_encrypted,
321 );
322 fs::write(site_dir.join("README.md"), public_readme)?;
323 }
324
325 progress("integrity", "Generating integrity manifest...");
326
327 let integrity_manifest = generate_integrity_manifest(&site_dir)?;
329 let integrity_path = site_dir.join("integrity.json");
330 let integrity_file = File::create(&integrity_path)?;
331 serde_json::to_writer_pretty(BufWriter::new(integrity_file), &integrity_manifest)?;
332
333 let fingerprint = compute_fingerprint(&integrity_manifest);
335
336 progress("private", "Writing private artifacts...");
337
338 write_private_fingerprint(&private_dir, &fingerprint)?;
340 if is_encrypted {
341 let enc_config = archive_config
342 .as_encrypted()
343 .context("Encrypted config missing")?;
344 write_private_artifacts_encrypted(
345 &private_dir,
346 enc_config,
347 self.config.recovery_secret.as_deref(),
348 self.config.generate_qr,
349 true,
350 )?;
351 } else {
352 write_private_unencrypted_notice(&private_dir)?;
353 }
354
355 sync_tree(&temp_output_dir)?;
356 replace_attempted = true;
357 replace_dir_from_temp(&temp_output_dir, output_dir)
358 .context("Failed to install completed bundle")?;
359
360 progress("complete", "Bundle complete!");
361
362 Ok(BundleResult {
363 site_dir: final_site_dir,
364 private_dir: final_private_dir,
365 chunk_count,
366 attachment_count,
367 fingerprint,
368 total_files: integrity_manifest.files.len(),
369 })
370 })();
371
372 if result.is_err() && !replace_attempted {
373 let _ = fs::remove_dir_all(&temp_output_dir);
374 }
375
376 result
377 }
378}
379
380fn unique_bundle_dir(path: &Path, suffix: &str) -> Result<PathBuf> {
381 unique_bundle_sidecar_path(path, suffix, "pages_bundle")
382}
383
384fn unique_bundle_backup_dir(path: &Path) -> Result<PathBuf> {
385 unique_bundle_sidecar_path(path, "bak", "pages_bundle")
386}
387
388fn unique_bundle_sidecar_path(path: &Path, suffix: &str, fallback_name: &str) -> Result<PathBuf> {
389 static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
390
391 let random_nonce = bundle_sidecar_random_nonce()?;
392 let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
393 let file_name = path
394 .file_name()
395 .and_then(|name| name.to_str())
396 .unwrap_or(fallback_name);
397
398 Ok(path.with_file_name(format!(".{file_name}.{suffix}.{random_nonce:032x}.{nonce}")))
399}
400
401fn bundle_sidecar_random_nonce() -> Result<u128> {
402 let mut bytes = [0u8; 16];
403 SystemRandom::new()
404 .fill(&mut bytes)
405 .map_err(|_| anyhow!("failed to generate random bundle sidecar nonce"))?;
406 Ok(u128::from_le_bytes(bytes))
407}
408
409fn ensure_replaceable_bundle_output_dir(path: &Path) -> Result<bool> {
410 ensure_existing_parent_ancestors_are_real_dirs(path, "bundle output path")?;
411
412 match fs::symlink_metadata(path) {
413 Ok(metadata) => {
414 let file_type = metadata.file_type();
415 if file_type.is_symlink() {
416 bail!(
417 "bundle output path must not be a symlink: {}",
418 path.display()
419 );
420 }
421 if !file_type.is_dir() {
422 bail!(
423 "bundle output path points to a file, expected a directory: {}",
424 path.display()
425 );
426 }
427 Ok(true)
428 }
429 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
430 Err(err) => Err(err)
431 .with_context(|| format!("failed inspecting bundle output path {}", path.display())),
432 }
433}
434
435fn ensure_existing_parent_ancestors_are_real_dirs(path: &Path, label: &str) -> Result<()> {
436 let Some(parent) = path.parent() else {
437 return Ok(());
438 };
439
440 let mut ancestors = Vec::new();
441 let mut current = Some(parent);
442 while let Some(ancestor) = current {
443 if ancestor.as_os_str().is_empty() {
444 break;
445 }
446 ancestors.push(ancestor.to_path_buf());
447 current = ancestor.parent();
448 }
449 ancestors.reverse();
450
451 for ancestor in ancestors {
452 match fs::symlink_metadata(&ancestor) {
453 Ok(metadata) => {
454 let file_type = metadata.file_type();
455 if file_type.is_symlink() {
456 bail!(
457 "{label} parent must not contain symlinks: {}",
458 ancestor.display()
459 );
460 }
461 if !file_type.is_dir() {
462 bail!("{label} parent must be a directory: {}", ancestor.display());
463 }
464 }
465 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
466 Err(err) => {
467 return Err(err).with_context(|| {
468 format!("failed inspecting {label} parent {}", ancestor.display())
469 });
470 }
471 }
472 }
473
474 Ok(())
475}
476
477fn replace_dir_from_temp(temp_dir: &Path, final_dir: &Path) -> Result<()> {
478 if !ensure_replaceable_bundle_output_dir(final_dir)? {
479 fs::rename(temp_dir, final_dir).with_context(|| {
480 format!(
481 "failed renaming completed bundle {} into place at {}",
482 temp_dir.display(),
483 final_dir.display()
484 )
485 })?;
486 sync_parent_directory(final_dir)?;
487 return Ok(());
488 }
489
490 let backup_dir = unique_bundle_backup_dir(final_dir)?;
491 fs::rename(final_dir, &backup_dir).with_context(|| {
492 format!(
493 "failed preparing backup {} before replacing {}",
494 backup_dir.display(),
495 final_dir.display()
496 )
497 })?;
498
499 match fs::rename(temp_dir, final_dir) {
500 Ok(()) => {
501 sync_parent_directory(final_dir)?;
502 let _ = fs::remove_dir_all(&backup_dir);
503 sync_parent_directory(final_dir)?;
504 Ok(())
505 }
506 Err(second_err) => match fs::rename(&backup_dir, final_dir) {
507 Ok(()) => {
508 let _ = fs::remove_dir_all(temp_dir);
509 sync_parent_directory(final_dir)?;
510 bail!(
511 "failed replacing {} with {}: {}; restored original bundle",
512 final_dir.display(),
513 temp_dir.display(),
514 second_err
515 );
516 }
517 Err(restore_err) => {
518 bail!(
519 "failed replacing {} with {}: {}; restore error: {}; temp bundle retained at {}",
520 final_dir.display(),
521 temp_dir.display(),
522 second_err,
523 restore_err,
524 temp_dir.display()
525 );
526 }
527 },
528 }
529}
530
531#[cfg(not(windows))]
532fn sync_tree(path: &Path) -> Result<()> {
533 sync_tree_inner(path)?;
534 sync_parent_directory(path)
535}
536
537#[cfg(windows)]
538fn sync_tree(_path: &Path) -> Result<()> {
539 Ok(())
540}
541
542#[cfg(not(windows))]
543fn sync_tree_inner(path: &Path) -> Result<()> {
544 let metadata = fs::symlink_metadata(path)
545 .with_context(|| format!("failed reading metadata for {}", path.display()))?;
546 let file_type = metadata.file_type();
547 if file_type.is_symlink() {
548 return Ok(());
549 }
550 if file_type.is_file() {
551 File::open(path)
552 .with_context(|| format!("failed opening {} for sync", path.display()))?
553 .sync_all()
554 .with_context(|| format!("failed syncing {}", path.display()))?;
555 return Ok(());
556 }
557 if file_type.is_dir() {
558 for entry in
559 fs::read_dir(path).with_context(|| format!("failed reading {}", path.display()))?
560 {
561 let entry = entry.with_context(|| format!("failed walking {}", path.display()))?;
562 sync_tree_inner(&entry.path())?;
563 }
564 File::open(path)
565 .with_context(|| format!("failed opening directory {} for sync", path.display()))?
566 .sync_all()
567 .with_context(|| format!("failed syncing directory {}", path.display()))?;
568 }
569 Ok(())
570}
571
572#[cfg(not(windows))]
573fn sync_parent_directory(path: &Path) -> Result<()> {
574 let Some(parent) = path.parent() else {
575 return Ok(());
576 };
577 File::open(parent)
578 .with_context(|| format!("failed opening parent directory {}", parent.display()))?
579 .sync_all()
580 .with_context(|| format!("failed syncing parent directory {}", parent.display()))
581}
582
583#[cfg(windows)]
584fn sync_parent_directory(_path: &Path) -> Result<()> {
585 Ok(())
586}
587
588#[derive(Debug, Clone)]
590pub struct BundleResult {
591 pub site_dir: PathBuf,
593 pub private_dir: PathBuf,
595 pub chunk_count: usize,
597 pub attachment_count: usize,
599 pub fingerprint: String,
601 pub total_files: usize,
603}
604
605fn copy_payload_chunks(
610 src_root: &Path,
611 src_dir: &Path,
612 dest_dir: &Path,
613 config: &EncryptionConfig,
614) -> Result<usize> {
615 ensure_regular_copy_directory_under_root(src_root, src_dir, "Encrypted payload directory")?;
616 validate_supported_payload_format(config)?;
617
618 let mut count = 0;
619
620 for (idx, expected_file) in config.payload.files.iter().enumerate() {
621 let expected_path = format!("payload/chunk-{idx:05}.bin");
622 if expected_file != &expected_path {
623 bail!(
624 "Encrypted payload file entry {idx} must be {expected_path}, got {expected_file}"
625 );
626 }
627
628 let rel_path = Path::new(expected_file);
629 let src_path = src_root.join(rel_path);
630 let label = format!("Encrypted payload chunk {expected_file}");
631 ensure_regular_copy_source_under_root(src_root, &src_path, &label)?;
632
633 let Some(filename) = rel_path.file_name() else {
634 bail!("Encrypted payload chunk path has no file name: {expected_file}");
635 };
636 let dest_path = dest_dir.join(filename);
637 fs::copy(&src_path, &dest_path)?;
638 count += 1;
639 }
640
641 Ok(count)
642}
643
644fn copy_payload_file(
646 src_root: &Path,
647 site_dir: &Path,
648 config: &UnencryptedConfig,
649) -> Result<usize> {
650 let rel_path = Path::new(&config.payload.path);
651 if rel_path.is_absolute() {
652 bail!("Unencrypted payload path must be relative");
653 }
654 if rel_path
655 .components()
656 .any(|c| matches!(c, std::path::Component::ParentDir))
657 {
658 bail!("Unencrypted payload path must not contain '..'");
659 }
660 if !rel_path.starts_with("payload") {
661 bail!("Unencrypted payload path must reside under payload/");
662 }
663
664 let src_path = src_root.join(rel_path);
665 ensure_regular_copy_source_under_root(src_root, &src_path, "Unencrypted payload file")?;
666
667 let dest_path = site_dir.join(rel_path);
668 if let Some(parent) = dest_path.parent() {
669 fs::create_dir_all(parent)?;
670 }
671
672 fs::copy(&src_path, &dest_path)?;
673 Ok(1)
674}
675
676fn resolve_generated_doc_path(site_dir: &Path, doc: &GeneratedDoc) -> Result<PathBuf> {
677 if doc.filename.contains(['/', '\\']) {
678 bail!(
679 "Generated documentation filename must not contain path separators: {}",
680 doc.filename
681 );
682 }
683
684 let rel_path = Path::new(&doc.filename);
685 let mut components = rel_path.components();
686 let Some(std::path::Component::Normal(file_name)) = components.next() else {
687 bail!(
688 "Generated documentation filename must be a plain relative file name: {}",
689 doc.filename
690 );
691 };
692 if components.next().is_some() {
693 bail!(
694 "Generated documentation filename must not contain path separators: {}",
695 doc.filename
696 );
697 }
698
699 Ok(match doc.location {
700 DocLocation::RepoRoot | DocLocation::WebRoot => site_dir.join(file_name),
701 })
702}
703
704fn ensure_regular_copy_source_under_root(
705 src_root: &Path,
706 src_path: &Path,
707 label: &str,
708) -> Result<()> {
709 let metadata = fs::symlink_metadata(src_path)
710 .with_context(|| format!("{label} not found: {}", src_path.display()))?;
711 let file_type = metadata.file_type();
712 if file_type.is_symlink() {
713 bail!("{label} must not be a symlink: {}", src_path.display());
714 }
715 if !file_type.is_file() {
716 bail!("{label} must be a regular file: {}", src_path.display());
717 }
718
719 let canonical_root = src_root.canonicalize().with_context(|| {
720 format!(
721 "Failed to resolve bundle source directory {}",
722 src_root.display()
723 )
724 })?;
725 let canonical_source = src_path.canonicalize().with_context(|| {
726 format!(
727 "Failed to resolve {label} source path {}",
728 src_path.display()
729 )
730 })?;
731 if !canonical_source.starts_with(&canonical_root) {
732 bail!(
733 "{label} resolves outside bundle source directory: {}",
734 src_path.display()
735 );
736 }
737
738 Ok(())
739}
740
741fn ensure_regular_copy_directory_under_root(
742 src_root: &Path,
743 src_dir: &Path,
744 label: &str,
745) -> Result<()> {
746 let metadata = fs::symlink_metadata(src_dir)
747 .with_context(|| format!("{label} not found: {}", src_dir.display()))?;
748 let file_type = metadata.file_type();
749 if file_type.is_symlink() {
750 bail!("{label} must not be a symlink: {}", src_dir.display());
751 }
752 if !file_type.is_dir() {
753 bail!("{label} must be a directory: {}", src_dir.display());
754 }
755
756 let canonical_root = src_root.canonicalize().with_context(|| {
757 format!(
758 "Failed to resolve bundle source directory {}",
759 src_root.display()
760 )
761 })?;
762 let canonical_source = src_dir.canonicalize().with_context(|| {
763 format!(
764 "Failed to resolve {label} source directory {}",
765 src_dir.display()
766 )
767 })?;
768 if !canonical_source.starts_with(&canonical_root) {
769 bail!(
770 "{label} resolves outside bundle source directory: {}",
771 src_dir.display()
772 );
773 }
774
775 Ok(())
776}
777
778fn copy_blobs_directory(src_root: &Path, src_dir: &Path, dest_dir: &Path) -> Result<usize> {
780 ensure_regular_copy_directory_under_root(src_root, src_dir, "Attachment blobs directory")?;
781 fs::create_dir_all(dest_dir).context("Failed to create blobs directory")?;
782
783 let mut count = 0;
784
785 for entry in fs::read_dir(src_dir)? {
786 let entry = entry?;
787 let path = entry.path();
788 let metadata = fs::symlink_metadata(&path)?;
789 let file_type = metadata.file_type();
790
791 if file_type.is_file() {
792 let Some(filename) = path.file_name() else {
793 continue; };
795 let dest_path = dest_dir.join(filename);
796 fs::copy(&path, &dest_path)?;
797 count += 1;
798 }
799 }
800
801 Ok(count)
802}
803
804pub(crate) fn generate_integrity_manifest(dir: &Path) -> Result<IntegrityManifest> {
806 let mut files = BTreeMap::new();
807
808 collect_file_hashes(dir, dir, &mut files)?;
809
810 Ok(IntegrityManifest {
811 version: 1,
812 generated_at: Utc::now().to_rfc3339(),
813 files,
814 })
815}
816
817fn collect_file_hashes(
819 base_dir: &Path,
820 current_dir: &Path,
821 files: &mut BTreeMap<String, IntegrityEntry>,
822) -> Result<()> {
823 let canonical_base_dir = base_dir.canonicalize().with_context(|| {
824 format!(
825 "Failed to resolve site directory {} while generating integrity manifest",
826 base_dir.display()
827 )
828 })?;
829 collect_file_hashes_recursive(base_dir, current_dir, &canonical_base_dir, files)
830}
831
832fn collect_file_hashes_recursive(
833 base_dir: &Path,
834 current_dir: &Path,
835 canonical_base_dir: &Path,
836 files: &mut BTreeMap<String, IntegrityEntry>,
837) -> Result<()> {
838 for entry in fs::read_dir(current_dir)? {
839 let entry = entry?;
840 let path = entry.path();
841 let metadata = fs::symlink_metadata(&path)?;
842 let file_type = metadata.file_type();
843 let rel_path = path.strip_prefix(base_dir)?;
844 let rel_str = rel_path.to_string_lossy().replace('\\', "/");
845
846 if rel_str == "integrity.json" {
848 continue;
849 }
850
851 if file_type.is_dir() {
852 collect_file_hashes_recursive(base_dir, &path, canonical_base_dir, files)?;
853 } else if file_type.is_symlink() {
854 let canonical_target = path.canonicalize().with_context(|| {
855 format!(
856 "Failed to resolve symlink {} while generating integrity manifest",
857 rel_str
858 )
859 })?;
860 if !canonical_target.starts_with(canonical_base_dir) {
861 bail!(
862 "Refusing to include symlink outside site directory in integrity manifest: {}",
863 rel_str
864 );
865 }
866
867 let target_meta = fs::metadata(&path).with_context(|| {
868 format!(
869 "Failed to read symlink target metadata for {} while generating integrity manifest",
870 rel_str
871 )
872 })?;
873 if !target_meta.is_file() {
874 bail!(
875 "Refusing to include symlink that does not point to a regular file in integrity manifest: {}",
876 rel_str
877 );
878 }
879
880 files.insert(rel_str, build_integrity_entry(&path)?);
881 } else if file_type.is_file() {
882 files.insert(rel_str, build_integrity_entry(&path)?);
883 }
884 }
885
886 Ok(())
887}
888
889fn build_integrity_entry(path: &Path) -> Result<IntegrityEntry> {
890 let file = File::open(path)?;
891 let metadata = file.metadata()?;
892 let size = metadata.len();
893
894 let mut hasher = Sha256::new();
895 let mut reader = BufReader::new(file);
896 let mut buffer = [0u8; 8192];
897
898 loop {
899 let bytes_read = reader.read(&mut buffer)?;
900 if bytes_read == 0 {
901 break;
902 }
903 hasher.update(&buffer[..bytes_read]);
904 }
905
906 Ok(IntegrityEntry {
907 sha256: hex::encode(hasher.finalize()),
911 size,
912 })
913}
914
915pub(crate) fn compute_fingerprint(manifest: &IntegrityManifest) -> String {
917 let mut hasher = Sha256::new();
919
920 for (path, entry) in &manifest.files {
921 hasher.update(path.as_bytes());
922 hasher.update(entry.sha256.as_bytes());
923 }
924
925 let hash = hasher.finalize();
926
927 hex::encode(hash)[..16].to_string()
931}
932
933pub(crate) fn write_private_fingerprint(private_dir: &Path, fingerprint: &str) -> Result<()> {
935 let fingerprint_content = format!(
936 "Integrity Fingerprint: {}\n\n\
937 Generated: {}\n\n\
938 Verify this fingerprint matches the one displayed in the web viewer\n\
939 before proceeding. If it doesn't match, the archive may have been\n\
940 tampered with.\n",
941 fingerprint,
942 Utc::now().to_rfc3339()
943 );
944 write_private_artifact_file(
945 private_dir,
946 "integrity-fingerprint.txt",
947 fingerprint_content.as_bytes(),
948 )?;
949 Ok(())
950}
951
952fn ensure_private_artifact_dir(private_dir: &Path) -> Result<()> {
953 ensure_existing_parent_ancestors_are_real_dirs(private_dir, "private artifact directory")?;
954
955 match fs::symlink_metadata(private_dir) {
956 Ok(metadata) => {
957 let file_type = metadata.file_type();
958 if file_type.is_symlink() {
959 bail!(
960 "private artifact directory must not be a symlink: {}",
961 private_dir.display()
962 );
963 }
964 if !file_type.is_dir() {
965 bail!(
966 "private artifact path must be a directory: {}",
967 private_dir.display()
968 );
969 }
970 Ok(())
971 }
972 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
973 fs::create_dir_all(private_dir).with_context(|| {
974 format!(
975 "Failed to create private artifact directory {}",
976 private_dir.display()
977 )
978 })?;
979 ensure_private_artifact_dir(private_dir)
980 }
981 Err(err) => Err(err).with_context(|| {
982 format!(
983 "Failed to inspect private artifact directory {}",
984 private_dir.display()
985 )
986 }),
987 }
988}
989
990fn reject_symlinked_private_artifact(path: &Path) -> Result<()> {
991 match fs::symlink_metadata(path) {
992 Ok(metadata) => {
993 let file_type = metadata.file_type();
994 if file_type.is_symlink() {
995 bail!(
996 "private artifact file must not be a symlink: {}",
997 path.display()
998 );
999 }
1000 if file_type.is_dir() {
1001 bail!(
1002 "private artifact path must be a regular file, not a directory: {}",
1003 path.display()
1004 );
1005 }
1006 Ok(())
1007 }
1008 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1009 Err(err) => Err(err)
1010 .with_context(|| format!("Failed to inspect private artifact {}", path.display())),
1011 }
1012}
1013
1014fn write_private_artifact_file(private_dir: &Path, filename: &str, contents: &[u8]) -> Result<()> {
1015 if filename.contains(['/', '\\']) {
1016 bail!("private artifact filename must not contain path separators: {filename}");
1017 }
1018
1019 ensure_private_artifact_dir(private_dir)?;
1020 let final_path = private_dir.join(filename);
1021 reject_symlinked_private_artifact(&final_path)?;
1022 let temp_path = unique_bundle_sidecar_path(&final_path, "tmp", "private_artifact")?;
1023
1024 let write_result = (|| -> Result<()> {
1025 let mut file = OpenOptions::new()
1026 .write(true)
1027 .create_new(true)
1028 .open(&temp_path)
1029 .with_context(|| {
1030 format!(
1031 "Failed to create temporary private artifact {}",
1032 temp_path.display()
1033 )
1034 })?;
1035 file.write_all(contents).with_context(|| {
1036 format!(
1037 "Failed to write temporary private artifact {}",
1038 temp_path.display()
1039 )
1040 })?;
1041 file.sync_all().with_context(|| {
1042 format!(
1043 "Failed to sync temporary private artifact {}",
1044 temp_path.display()
1045 )
1046 })?;
1047 Ok(())
1048 })();
1049
1050 if let Err(err) = write_result {
1051 let _ = fs::remove_file(&temp_path);
1052 return Err(err);
1053 }
1054
1055 if let Err(err) = fs::rename(&temp_path, &final_path) {
1056 let _ = fs::remove_file(&temp_path);
1057 return Err(err).with_context(|| {
1058 format!(
1059 "Failed to install private artifact {}",
1060 final_path.display()
1061 )
1062 });
1063 }
1064 sync_parent_directory(&final_path)?;
1065 Ok(())
1066}
1067
1068pub(crate) fn write_private_artifacts_encrypted(
1069 private_dir: &Path,
1070 enc_config: &EncryptionConfig,
1071 recovery_secret: Option<&[u8]>,
1072 generate_qr: bool,
1073 cleanup_missing_recovery: bool,
1074) -> Result<()> {
1075 ensure_private_artifact_dir(private_dir)?;
1076
1077 let recovery_secret_path = private_dir.join("recovery-secret.txt");
1078 let qr_png_path = private_dir.join("qr-code.png");
1079 let qr_svg_path = private_dir.join("qr-code.svg");
1080
1081 if let Some(secret) = recovery_secret {
1083 let recovery_b64 = BASE64_URL_SAFE_NO_PAD.encode(secret);
1084 let recovery_content = format!(
1085 "Recovery Secret\n\
1086 ================\n\n\
1087 This secret can unlock your archive if you forget your password.\n\
1088 Store it securely and NEVER share it.\n\n\
1089 Secret (base64url):\n\
1090 {}\n\n\
1091 To use: Click \"Scan Recovery QR Code\" in the web viewer, or\n\
1092 use this base64 value with the recovery function.\n\n\
1093 Archive Export ID: {}\n\
1094 Generated: {}\n",
1095 recovery_b64,
1096 enc_config.export_id,
1097 Utc::now().to_rfc3339()
1098 );
1099 write_private_artifact_file(
1100 private_dir,
1101 "recovery-secret.txt",
1102 recovery_content.as_bytes(),
1103 )?;
1104
1105 if generate_qr {
1107 generate_qr_codes(private_dir, &recovery_b64)?;
1108 } else {
1109 remove_file_if_exists(&qr_png_path)?;
1110 remove_file_if_exists(&qr_svg_path)?;
1111 }
1112 } else if cleanup_missing_recovery {
1113 remove_file_if_exists(&recovery_secret_path)?;
1114 remove_file_if_exists(&qr_png_path)?;
1115 remove_file_if_exists(&qr_svg_path)?;
1116 }
1117
1118 let master_key_backup = master_key_backup_json(enc_config, Utc::now().to_rfc3339());
1120 let master_key_json = serde_json::to_vec_pretty(&master_key_backup)?;
1121 write_private_artifact_file(private_dir, "master-key.json", &master_key_json)?;
1122
1123 Ok(())
1124}
1125
1126fn master_key_backup_json(
1127 enc_config: &EncryptionConfig,
1128 generated_at: String,
1129) -> serde_json::Value {
1130 serde_json::json!({
1131 "export_id": &enc_config.export_id,
1132 "key_slots": &enc_config.key_slots,
1133 "note": MASTER_KEY_BACKUP_NOTE,
1134 "generated_at": generated_at,
1135 })
1136}
1137
1138fn remove_file_if_exists(path: &Path) -> Result<()> {
1139 match fs::remove_file(path) {
1140 Ok(()) => Ok(()),
1141 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1142 Err(err) => Err(err.into()),
1143 }
1144}
1145
1146fn write_private_unencrypted_notice(private_dir: &Path) -> Result<()> {
1147 let content = format!(
1148 "UNENCRYPTED ARCHIVE WARNING\n\
1149 ============================\n\n\
1150 This bundle was generated WITHOUT encryption.\n\
1151 Anyone with access to the site can read its contents.\n\n\
1152 Generated: {}\n",
1153 Utc::now().to_rfc3339()
1154 );
1155 write_private_artifact_file(private_dir, "unencrypted-warning.txt", content.as_bytes())?;
1156 Ok(())
1157}
1158
1159fn generate_qr_codes(private_dir: &Path, recovery_b64: &str) -> Result<()> {
1161 if let Ok(qr_png) = super::qr::generate_qr_png(recovery_b64) {
1163 write_private_artifact_file(private_dir, "qr-code.png", &qr_png)?;
1164 }
1165
1166 if let Ok(qr_svg) = super::qr::generate_qr_svg(recovery_b64) {
1167 write_private_artifact_file(private_dir, "qr-code.svg", qr_svg.as_bytes())?;
1168 }
1169
1170 Ok(())
1171}
1172
1173fn generate_public_readme(title: &str, description: &str, is_encrypted: bool) -> String {
1175 let about_line = if is_encrypted {
1176 "This is an encrypted, searchable archive of AI coding agent conversations"
1177 } else {
1178 "This is a searchable archive of AI coding agent conversations (not encrypted)"
1179 };
1180
1181 let security_section = if is_encrypted {
1182 r#"## Security
1183
1184- All data is encrypted with AES-256-GCM
1185- Password-based key derivation uses Argon2id
1186- The archive can be safely hosted on public servers
1187- No data is accessible without the correct password"#
1188 } else {
1189 r#"## Security
1190
1191⚠️ This archive is **NOT encrypted**.
1192Anyone with access to the site can read its contents.
1193Host it only on a trusted, private location."#
1194 };
1195
1196 let open_section = if is_encrypted {
1197 r#"## How to Open
1198
11991. Host these files on any static web server
12002. Open index.html in a modern browser
12013. Verify the fingerprint matches your records
12024. Enter your password to decrypt"#
1203 } else {
1204 r#"## How to Open
1205
12061. Host these files on any static web server
12072. Open index.html in a modern browser
12083. Verify the fingerprint matches your records
12094. The archive loads immediately (no password required)"#
1210 };
1211
1212 let technical_section = if is_encrypted {
1213 r#"## Technical Details
1214
1215- Encryption: AES-256-GCM with chunked streaming
1216- KDF: Argon2id (64MB memory, 3 iterations)
1217- Search: SQLite with FTS5 (runs in browser via sql.js)
1218- Requires: SharedArrayBuffer (COOP/COEP headers)"#
1219 } else {
1220 r#"## Technical Details
1221
1222- Encryption: none (unencrypted archive)
1223- Search: SQLite with FTS5 (runs in browser via sql.js)
1224- Requires: SharedArrayBuffer (COOP/COEP headers)"#
1225 };
1226
1227 format!(
1228 r#"# {}
1229
1230{}
1231
1232## About This Archive
1233
1234{}
1235generated by [cass](https://github.com/Dicklesworthstone/coding_agent_session_search).
1236
1237{}
1238
1239{}
1240
1241{}
1242
1243## Files
1244
1245- `index.html` - Entry point
1246- `config.json` - Public encryption parameters (no secrets)
1247- `integrity.json` - SHA256 hashes for all files
1248- `payload/` - Encrypted database chunks
1249- `*.js` - Application code
1250- `styles.css` - Styling
1251
1252## Hosting Requirements
1253
1254For the viewer to function correctly, your web server must set:
1255
1256```
1257Cross-Origin-Opener-Policy: same-origin
1258Cross-Origin-Embedder-Policy: require-corp
1259```
1260
1261The included service worker (sw.js) handles this automatically for
1262most static hosts (GitHub Pages, Cloudflare Pages, etc.).
1263
1264---
1265
1266Generated by cass v{}
1267"#,
1268 title,
1269 description,
1270 about_line,
1271 security_section,
1272 open_section,
1273 technical_section,
1274 env!("CARGO_PKG_VERSION")
1275 )
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280 use super::*;
1281 use crate::pages::archive_config::{ArchiveConfig, UnencryptedPayload};
1282 use tempfile::TempDir;
1283
1284 fn write_unencrypted_source(root: &Path, payload_name: &str, body: &str) {
1285 let payload_dir = root.join("payload");
1286 fs::create_dir_all(&payload_dir).unwrap();
1287 let payload_path = payload_dir.join(payload_name);
1288 fs::write(&payload_path, body).unwrap();
1289
1290 let config = ArchiveConfig::Unencrypted(UnencryptedConfig {
1291 encrypted: false,
1292 version: "1.0.0".to_string(),
1293 payload: UnencryptedPayload {
1294 path: format!("payload/{payload_name}"),
1295 format: "sqlite".to_string(),
1296 size_bytes: Some(body.len() as u64),
1297 },
1298 warning: Some("UNENCRYPTED".to_string()),
1299 });
1300
1301 let file = File::create(root.join("config.json")).unwrap();
1302 serde_json::to_writer_pretty(BufWriter::new(file), &config).unwrap();
1303 }
1304
1305 fn encrypted_config_for_files(files: Vec<&str>) -> EncryptionConfig {
1306 let chunk_count = files.len();
1307 EncryptionConfig {
1308 version: crate::pages::encrypt::SCHEMA_VERSION,
1309 export_id: "export-123".to_string(),
1310 base_nonce: "nonce".to_string(),
1311 compression: "deflate".to_string(),
1312 kdf_defaults: crate::pages::encrypt::Argon2Params::default(),
1313 payload: crate::pages::encrypt::PayloadMeta {
1314 chunk_size: 1024,
1315 chunk_count,
1316 total_compressed_size: 0,
1317 total_plaintext_size: 0,
1318 files: files.into_iter().map(str::to_string).collect(),
1319 },
1320 key_slots: Vec::new(),
1321 }
1322 }
1323
1324 #[test]
1325 fn test_bundle_builder_default() {
1326 let builder = BundleBuilder::new();
1327 assert_eq!(builder.config.title, "cass Archive");
1328 assert!(!builder.config.hide_metadata);
1329 assert!(!builder.config.generate_qr);
1330 }
1331
1332 #[test]
1333 fn test_bundle_builder_fluent() {
1334 let builder = BundleBuilder::new()
1335 .title("My Archive")
1336 .description("Test description")
1337 .hide_metadata(true)
1338 .generate_qr(true);
1339
1340 assert_eq!(builder.config.title, "My Archive");
1341 assert_eq!(builder.config.description, "Test description");
1342 assert!(builder.config.hide_metadata);
1343 assert!(builder.config.generate_qr);
1344 }
1345
1346 #[test]
1347 fn test_compute_fingerprint() {
1348 let mut files = BTreeMap::new();
1349 files.insert(
1350 "test.txt".to_string(),
1351 IntegrityEntry {
1352 sha256: "abc123".to_string(),
1353 size: 100,
1354 },
1355 );
1356
1357 let manifest = IntegrityManifest {
1358 version: 1,
1359 generated_at: "2024-01-01T00:00:00Z".to_string(),
1360 files,
1361 };
1362
1363 let fingerprint = compute_fingerprint(&manifest);
1364 assert_eq!(fingerprint.len(), 16);
1365
1366 let fingerprint2 = compute_fingerprint(&manifest);
1368 assert_eq!(fingerprint, fingerprint2);
1369 }
1370
1371 #[test]
1372 fn test_master_key_backup_json_shape() {
1373 let config = EncryptionConfig {
1374 version: 2,
1375 export_id: "export-123".to_string(),
1376 base_nonce: "nonce".to_string(),
1377 compression: "deflate".to_string(),
1378 kdf_defaults: crate::pages::encrypt::Argon2Params::default(),
1379 payload: crate::pages::encrypt::PayloadMeta {
1380 chunk_size: 1024,
1381 chunk_count: 0,
1382 total_compressed_size: 0,
1383 total_plaintext_size: 0,
1384 files: Vec::new(),
1385 },
1386 key_slots: Vec::new(),
1387 };
1388
1389 let backup = master_key_backup_json(&config, "2026-04-25T19:08:00Z".to_string());
1390
1391 assert_eq!(backup["export_id"], "export-123");
1392 assert_eq!(backup["key_slots"], serde_json::json!([]));
1393 assert_eq!(backup["note"], MASTER_KEY_BACKUP_NOTE);
1394 assert_eq!(backup["generated_at"], "2026-04-25T19:08:00Z");
1395 }
1396
1397 #[test]
1398 #[cfg(unix)]
1399 fn test_private_artifacts_reject_symlinked_secret_file() {
1400 use std::os::unix::fs::symlink;
1401
1402 let temp = TempDir::new().unwrap();
1403 let private_dir = temp.path().join("private");
1404 let outside_dir = temp.path().join("outside");
1405 fs::create_dir_all(&private_dir).unwrap();
1406 fs::create_dir_all(&outside_dir).unwrap();
1407 let protected_secret = outside_dir.join("protected-secret.txt");
1408 fs::write(&protected_secret, "do not overwrite").unwrap();
1409 symlink(&protected_secret, private_dir.join("recovery-secret.txt")).unwrap();
1410
1411 let config = encrypted_config_for_files(Vec::new());
1412 let err = write_private_artifacts_encrypted(
1413 &private_dir,
1414 &config,
1415 Some(&[7u8; 32]),
1416 false,
1417 false,
1418 )
1419 .unwrap_err();
1420
1421 assert!(
1422 err.to_string().contains("must not be a symlink"),
1423 "unexpected error: {err:#}"
1424 );
1425 assert_eq!(
1426 fs::read_to_string(&protected_secret).unwrap(),
1427 "do not overwrite"
1428 );
1429 assert!(
1430 fs::symlink_metadata(private_dir.join("recovery-secret.txt"))
1431 .unwrap()
1432 .file_type()
1433 .is_symlink(),
1434 "rejected private artifact symlink should be left intact"
1435 );
1436 }
1437
1438 #[test]
1439 #[cfg(unix)]
1440 fn test_private_artifacts_cleanup_rejects_symlinked_private_dir_before_removal() {
1441 use std::os::unix::fs::symlink;
1442
1443 let temp = TempDir::new().unwrap();
1444 let outside_dir = temp.path().join("outside");
1445 let private_dir = temp.path().join("private");
1446 fs::create_dir_all(&outside_dir).unwrap();
1447 fs::write(outside_dir.join("recovery-secret.txt"), "keep recovery").unwrap();
1448 fs::write(outside_dir.join("qr-code.png"), "keep png").unwrap();
1449 fs::write(outside_dir.join("qr-code.svg"), "keep svg").unwrap();
1450 symlink(&outside_dir, &private_dir).unwrap();
1451
1452 let config = encrypted_config_for_files(Vec::new());
1453 let err = write_private_artifacts_encrypted(&private_dir, &config, None, false, true)
1454 .unwrap_err();
1455
1456 assert!(
1457 err.to_string().contains("must not be a symlink"),
1458 "unexpected error: {err:#}"
1459 );
1460 assert_eq!(
1461 fs::read_to_string(outside_dir.join("recovery-secret.txt")).unwrap(),
1462 "keep recovery"
1463 );
1464 assert_eq!(
1465 fs::read_to_string(outside_dir.join("qr-code.png")).unwrap(),
1466 "keep png"
1467 );
1468 assert_eq!(
1469 fs::read_to_string(outside_dir.join("qr-code.svg")).unwrap(),
1470 "keep svg"
1471 );
1472 }
1473
1474 #[test]
1475 #[cfg(unix)]
1476 fn test_private_artifacts_reject_symlinked_parent_before_writing() {
1477 use std::os::unix::fs::symlink;
1478
1479 let temp = TempDir::new().unwrap();
1480 let outside_dir = TempDir::new().unwrap();
1481 let linked_parent = temp.path().join("linked-parent");
1482 let private_dir = linked_parent.join("private");
1483 symlink(outside_dir.path(), &linked_parent).unwrap();
1484
1485 let err = write_private_fingerprint(&private_dir, "fingerprint").unwrap_err();
1486
1487 assert!(
1488 err.to_string().contains("parent must not contain symlinks"),
1489 "unexpected error: {err:#}"
1490 );
1491 assert!(
1492 fs::read_dir(outside_dir.path()).unwrap().next().is_none(),
1493 "private artifact writer must not create files through a symlinked parent"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_generate_public_readme() {
1499 let readme = generate_public_readme("Test Archive", "A test archive", true);
1500 assert!(readme.contains("Test Archive"));
1501 assert!(readme.contains("A test archive"));
1502 assert!(readme.contains("AES-256-GCM"));
1503 assert!(readme.contains("Argon2id"));
1504
1505 let unencrypted = generate_public_readme("Test Archive", "A test archive", false);
1506 assert!(unencrypted.contains("NOT encrypted"));
1507 assert!(unencrypted.contains("no password required"));
1508 }
1509
1510 #[test]
1511 fn test_integrity_manifest_excludes_itself() {
1512 let temp = TempDir::new().unwrap();
1513 let temp_path = temp.path();
1514
1515 fs::write(temp_path.join("test.txt"), "hello").unwrap();
1517 fs::write(temp_path.join("integrity.json"), "{}").unwrap();
1518
1519 let manifest = generate_integrity_manifest(temp_path).unwrap();
1520
1521 assert!(manifest.files.contains_key("test.txt"));
1523 assert!(!manifest.files.contains_key("integrity.json"));
1524 }
1525
1526 #[test]
1527 fn test_collect_file_hashes() {
1528 let temp = TempDir::new().unwrap();
1529 let temp_path = temp.path();
1530
1531 fs::create_dir_all(temp_path.join("subdir")).unwrap();
1533 fs::write(temp_path.join("root.txt"), "root").unwrap();
1534 fs::write(temp_path.join("subdir/nested.txt"), "nested").unwrap();
1535
1536 let mut files = BTreeMap::new();
1537 collect_file_hashes(temp_path, temp_path, &mut files).unwrap();
1538
1539 assert_eq!(files.len(), 2);
1540 assert!(files.contains_key("root.txt"));
1541 assert!(files.contains_key("subdir/nested.txt"));
1542
1543 for entry in files.values() {
1545 assert_eq!(entry.sha256.len(), 64);
1546 }
1547 }
1548
1549 #[test]
1550 #[cfg(unix)]
1551 fn test_collect_file_hashes_includes_symlinked_files_within_site() {
1552 use std::os::unix::fs::symlink;
1553
1554 let temp = TempDir::new().unwrap();
1555 let temp_path = temp.path();
1556
1557 fs::write(temp_path.join("real.txt"), "real").unwrap();
1558 symlink("real.txt", temp_path.join("linked-file.txt")).unwrap();
1559
1560 let mut files = BTreeMap::new();
1561 collect_file_hashes(temp_path, temp_path, &mut files).unwrap();
1562
1563 assert_eq!(files.len(), 2);
1564 assert!(files.contains_key("real.txt"));
1565 assert!(files.contains_key("linked-file.txt"));
1566 assert_eq!(files["real.txt"].sha256, files["linked-file.txt"].sha256);
1567 assert_eq!(files["real.txt"].size, files["linked-file.txt"].size);
1568 }
1569
1570 #[test]
1571 #[cfg(unix)]
1572 fn test_collect_file_hashes_rejects_symlinks_outside_site() {
1573 use std::os::unix::fs::symlink;
1574
1575 let temp = TempDir::new().unwrap();
1576 let temp_path = temp.path();
1577 let outside = TempDir::new().unwrap();
1578
1579 fs::write(temp_path.join("root.txt"), "root").unwrap();
1580 fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1581 fs::create_dir_all(outside.path().join("nested")).unwrap();
1582 fs::write(outside.path().join("nested/hidden.txt"), "hidden").unwrap();
1583 symlink(
1584 outside.path().join("secret.txt"),
1585 temp_path.join("linked-file.txt"),
1586 )
1587 .unwrap();
1588 symlink(outside.path().join("nested"), temp_path.join("linked-dir")).unwrap();
1589
1590 let mut files = BTreeMap::new();
1591 let err = collect_file_hashes(temp_path, temp_path, &mut files).unwrap_err();
1592 assert!(
1593 err.to_string().contains("outside site directory"),
1594 "unexpected error: {err:#}"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_copy_payload_chunks_copies_only_manifest_files() {
1600 let src = TempDir::new().unwrap();
1601 let dst = TempDir::new().unwrap();
1602 let payload_dir = src.path().join("payload");
1603 fs::create_dir_all(&payload_dir).unwrap();
1604
1605 fs::write(payload_dir.join("chunk-00000.bin"), "chunk").unwrap();
1606 fs::write(payload_dir.join("chunk-99999.bin"), "stale chunk").unwrap();
1607 fs::write(payload_dir.join("secret.bin"), "unlisted payload").unwrap();
1608
1609 let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
1610 let copied = copy_payload_chunks(src.path(), &payload_dir, dst.path(), &config).unwrap();
1611 assert_eq!(copied, 1);
1612 assert!(dst.path().join("chunk-00000.bin").exists());
1613 assert!(!dst.path().join("chunk-99999.bin").exists());
1614 assert!(!dst.path().join("secret.bin").exists());
1615 }
1616
1617 #[test]
1618 #[cfg(unix)]
1619 fn test_copy_payload_chunks_rejects_manifest_symlinked_chunk() {
1620 use std::os::unix::fs::symlink;
1621
1622 let src = TempDir::new().unwrap();
1623 let dst = TempDir::new().unwrap();
1624 let outside = TempDir::new().unwrap();
1625 let payload_dir = src.path().join("payload");
1626 fs::create_dir_all(&payload_dir).unwrap();
1627
1628 fs::write(outside.path().join("secret.bin"), "secret").unwrap();
1629 symlink(
1630 outside.path().join("secret.bin"),
1631 payload_dir.join("chunk-00000.bin"),
1632 )
1633 .unwrap();
1634
1635 let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
1636 let err = copy_payload_chunks(src.path(), &payload_dir, dst.path(), &config).unwrap_err();
1637 assert!(
1638 err.to_string().contains("must not be a symlink"),
1639 "unexpected error: {err:#}"
1640 );
1641 assert!(!dst.path().join("chunk-00000.bin").exists());
1642 }
1643
1644 #[test]
1645 #[cfg(unix)]
1646 fn test_copy_payload_chunks_rejects_symlinked_source_directory() {
1647 use std::os::unix::fs::symlink;
1648
1649 let source = TempDir::new().unwrap();
1650 let dst = TempDir::new().unwrap();
1651 let outside = TempDir::new().unwrap();
1652
1653 fs::write(outside.path().join("chunk-0.bin"), "outside chunk").unwrap();
1654 symlink(outside.path(), source.path().join("payload")).unwrap();
1655
1656 let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
1657 let err = copy_payload_chunks(
1658 source.path(),
1659 &source.path().join("payload"),
1660 dst.path(),
1661 &config,
1662 )
1663 .unwrap_err();
1664 assert!(
1665 err.to_string().contains("must not be a symlink"),
1666 "unexpected error: {err:#}"
1667 );
1668 assert!(!dst.path().join("chunk-0.bin").exists());
1669 }
1670
1671 #[test]
1672 #[cfg(unix)]
1673 fn test_copy_unencrypted_payload_rejects_final_symlink() {
1674 use std::os::unix::fs::symlink;
1675
1676 let source = TempDir::new().unwrap();
1677 let site = TempDir::new().unwrap();
1678 let outside = TempDir::new().unwrap();
1679
1680 fs::create_dir_all(source.path().join("payload")).unwrap();
1681 fs::write(outside.path().join("secret.db"), "outside secret").unwrap();
1682 symlink(
1683 outside.path().join("secret.db"),
1684 source.path().join("payload/data.db"),
1685 )
1686 .unwrap();
1687
1688 let config = UnencryptedConfig {
1689 encrypted: false,
1690 version: "1.0.0".to_string(),
1691 payload: UnencryptedPayload {
1692 path: "payload/data.db".to_string(),
1693 format: "sqlite".to_string(),
1694 size_bytes: None,
1695 },
1696 warning: None,
1697 };
1698
1699 let err = copy_payload_file(source.path(), site.path(), &config).unwrap_err();
1700 assert!(
1701 err.to_string().contains("must not be a symlink"),
1702 "unexpected error: {err:#}"
1703 );
1704 assert!(!site.path().join("payload/data.db").exists());
1705 }
1706
1707 #[test]
1708 #[cfg(unix)]
1709 fn test_copy_unencrypted_payload_rejects_symlinked_parent_escape() {
1710 use std::os::unix::fs::symlink;
1711
1712 let source = TempDir::new().unwrap();
1713 let site = TempDir::new().unwrap();
1714 let outside = TempDir::new().unwrap();
1715
1716 fs::write(outside.path().join("data.db"), "outside secret").unwrap();
1717 symlink(outside.path(), source.path().join("payload")).unwrap();
1718
1719 let config = UnencryptedConfig {
1720 encrypted: false,
1721 version: "1.0.0".to_string(),
1722 payload: UnencryptedPayload {
1723 path: "payload/data.db".to_string(),
1724 format: "sqlite".to_string(),
1725 size_bytes: None,
1726 },
1727 warning: None,
1728 };
1729
1730 let err = copy_payload_file(source.path(), site.path(), &config).unwrap_err();
1731 assert!(
1732 err.to_string().contains("outside bundle source directory"),
1733 "unexpected error: {err:#}"
1734 );
1735 assert!(!site.path().join("payload/data.db").exists());
1736 }
1737
1738 #[test]
1739 fn test_generated_docs_reject_path_traversal_filename() {
1740 let source = TempDir::new().unwrap();
1741 let output_parent = TempDir::new().unwrap();
1742 let output_dir = output_parent.path().join("bundle");
1743
1744 write_unencrypted_source(source.path(), "data.db", "payload");
1745
1746 let config = BundleConfig {
1747 generated_docs: vec![GeneratedDoc {
1748 filename: "../escaped.md".to_string(),
1749 content: "escaped".to_string(),
1750 location: DocLocation::WebRoot,
1751 }],
1752 ..BundleConfig::default()
1753 };
1754
1755 let err = BundleBuilder::with_config(config)
1756 .build(source.path(), output_dir.as_path(), |_, _| {})
1757 .unwrap_err();
1758 assert!(
1759 err.to_string().contains("must not contain path separators"),
1760 "unexpected error: {err:#}"
1761 );
1762 assert!(!output_parent.path().join("escaped.md").exists());
1763 }
1764
1765 #[test]
1766 fn test_generated_docs_reject_backslash_separator_filename() {
1767 let doc = GeneratedDoc {
1768 filename: r"nested\escaped.md".to_string(),
1769 content: "escaped".to_string(),
1770 location: DocLocation::WebRoot,
1771 };
1772
1773 let err = resolve_generated_doc_path(Path::new("site"), &doc).unwrap_err();
1774 assert!(
1775 err.to_string().contains("must not contain path separators"),
1776 "unexpected error: {err:#}"
1777 );
1778 }
1779
1780 #[test]
1781 #[cfg(unix)]
1782 fn test_copy_blobs_directory_skips_symlinked_files() {
1783 use std::os::unix::fs::symlink;
1784
1785 let src = TempDir::new().unwrap();
1786 let dst = TempDir::new().unwrap();
1787 let outside = TempDir::new().unwrap();
1788
1789 fs::write(src.path().join("blob.bin"), "blob").unwrap();
1790 fs::write(outside.path().join("secret.bin"), "secret").unwrap();
1791 symlink(
1792 outside.path().join("secret.bin"),
1793 src.path().join("linked-blob.bin"),
1794 )
1795 .unwrap();
1796
1797 let copied = copy_blobs_directory(src.path(), src.path(), dst.path()).unwrap();
1798 assert_eq!(copied, 1);
1799 assert!(dst.path().join("blob.bin").exists());
1800 assert!(!dst.path().join("linked-blob.bin").exists());
1801 }
1802
1803 #[test]
1804 #[cfg(unix)]
1805 fn test_copy_blobs_directory_rejects_symlinked_source_directory() {
1806 use std::os::unix::fs::symlink;
1807
1808 let source = TempDir::new().unwrap();
1809 let dst = TempDir::new().unwrap();
1810 let outside = TempDir::new().unwrap();
1811
1812 fs::write(outside.path().join("blob.bin"), "outside blob").unwrap();
1813 symlink(outside.path(), source.path().join("blobs")).unwrap();
1814
1815 let err = copy_blobs_directory(source.path(), &source.path().join("blobs"), dst.path())
1816 .unwrap_err();
1817 assert!(
1818 err.to_string().contains("must not be a symlink"),
1819 "unexpected error: {err:#}"
1820 );
1821 assert!(!dst.path().join("blob.bin").exists());
1822 }
1823
1824 #[test]
1825 fn test_build_replaces_existing_bundle_without_stale_files() {
1826 let source = TempDir::new().unwrap();
1827 let output_parent = TempDir::new().unwrap();
1828 let output_dir = output_parent.path().join("bundle");
1829
1830 write_unencrypted_source(source.path(), "data.db", "fresh payload");
1831
1832 let builder = BundleBuilder::new();
1833 builder
1834 .build(source.path(), output_dir.as_path(), |_, _| {})
1835 .expect("initial build");
1836
1837 fs::write(output_dir.join("site/stale.txt"), "stale").unwrap();
1838 fs::write(output_dir.join("private/old-secret.txt"), "secret").unwrap();
1839 fs::write(output_dir.join("site/payload/old.bin"), "old").unwrap();
1840
1841 builder
1842 .build(source.path(), output_dir.as_path(), |_, _| {})
1843 .expect("rebuild");
1844
1845 assert!(output_dir.join("site/config.json").exists());
1846 assert!(
1847 output_dir
1848 .join("private/integrity-fingerprint.txt")
1849 .exists()
1850 );
1851 assert!(!output_dir.join("site/stale.txt").exists());
1852 assert!(!output_dir.join("private/old-secret.txt").exists());
1853 assert!(!output_dir.join("site/payload/old.bin").exists());
1854 assert!(output_dir.join("site/payload/data.db").exists());
1855 }
1856
1857 #[test]
1858 fn test_build_failure_preserves_existing_bundle() {
1859 let source = TempDir::new().unwrap();
1860 let output_parent = TempDir::new().unwrap();
1861 let output_dir = output_parent.path().join("bundle");
1862 let broken_source = TempDir::new().unwrap();
1863
1864 write_unencrypted_source(source.path(), "data.db", "fresh payload");
1865
1866 let builder = BundleBuilder::new();
1867 builder
1868 .build(source.path(), output_dir.as_path(), |_, _| {})
1869 .expect("initial build");
1870
1871 fs::write(output_dir.join("site/marker.txt"), "keep me").unwrap();
1872
1873 let result = builder.build(broken_source.path(), output_dir.as_path(), |_, _| {});
1874 assert!(result.is_err(), "broken rebuild should fail");
1875
1876 assert!(output_dir.join("site/marker.txt").exists());
1877 assert!(output_dir.join("site/config.json").exists());
1878 assert!(
1879 output_dir
1880 .join("private/integrity-fingerprint.txt")
1881 .exists()
1882 );
1883 }
1884
1885 #[test]
1886 #[cfg(unix)]
1887 fn test_build_rejects_symlinked_output_directory() {
1888 use std::os::unix::fs::symlink;
1889
1890 let source = TempDir::new().unwrap();
1891 let output_parent = TempDir::new().unwrap();
1892 let outside = TempDir::new().unwrap();
1893 let output_dir = output_parent.path().join("bundle-link");
1894
1895 write_unencrypted_source(source.path(), "data.db", "payload");
1896 symlink(outside.path(), &output_dir).unwrap();
1897
1898 let err = BundleBuilder::new()
1899 .build(source.path(), output_dir.as_path(), |_, _| {})
1900 .unwrap_err();
1901
1902 assert!(
1903 err.to_string().contains("must not be a symlink"),
1904 "unexpected error: {err:#}"
1905 );
1906 assert!(
1907 fs::symlink_metadata(&output_dir)
1908 .unwrap()
1909 .file_type()
1910 .is_symlink(),
1911 "rejected symlink output path must be preserved for operator inspection"
1912 );
1913 assert!(
1914 !outside.path().join("site").exists(),
1915 "build must not write through a symlinked output directory"
1916 );
1917 }
1918
1919 #[test]
1920 #[cfg(unix)]
1921 fn test_build_rejects_symlinked_output_parent_before_staging() {
1922 use std::os::unix::fs::symlink;
1923
1924 let source = TempDir::new().unwrap();
1925 let output_parent = TempDir::new().unwrap();
1926 let outside = TempDir::new().unwrap();
1927 let linked_parent = output_parent.path().join("linked-parent");
1928 let output_dir = linked_parent.join("bundle");
1929
1930 write_unencrypted_source(source.path(), "data.db", "payload");
1931 symlink(outside.path(), &linked_parent).unwrap();
1932
1933 let err = BundleBuilder::new()
1934 .build(source.path(), output_dir.as_path(), |_, _| {})
1935 .unwrap_err();
1936
1937 assert!(
1938 err.to_string().contains("parent must not contain symlinks"),
1939 "unexpected error: {err:#}"
1940 );
1941 assert!(
1942 fs::read_dir(outside.path()).unwrap().next().is_none(),
1943 "bundle builder must not stage output through a symlinked parent"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_replace_dir_from_temp_overwrites_existing_bundle() {
1949 let temp = TempDir::new().unwrap();
1950 let final_dir = temp.path().join("bundle");
1951 let staged_dir = temp.path().join("bundle.staged");
1952
1953 fs::create_dir_all(final_dir.join("site")).unwrap();
1954 fs::write(final_dir.join("site/old.txt"), "old").unwrap();
1955
1956 fs::create_dir_all(staged_dir.join("site")).unwrap();
1957 fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
1958
1959 replace_dir_from_temp(&staged_dir, &final_dir).unwrap();
1960
1961 assert!(!staged_dir.exists());
1962 assert!(final_dir.join("site/new.txt").exists());
1963 assert!(!final_dir.join("site/old.txt").exists());
1964 let sidecars = fs::read_dir(temp.path())
1965 .unwrap()
1966 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1967 .collect::<Vec<_>>();
1968 assert!(
1969 !sidecars.iter().any(|name| name.contains(".bundle.bak.")),
1970 "backup sidecar should be cleaned up, found: {sidecars:?}"
1971 );
1972 }
1973
1974 #[test]
1975 #[cfg(unix)]
1976 fn test_replace_dir_from_temp_rejects_dangling_symlink_target() {
1977 use std::os::unix::fs::symlink;
1978
1979 let temp = TempDir::new().unwrap();
1980 let final_dir = temp.path().join("bundle");
1981 let staged_dir = temp.path().join("bundle.staged");
1982
1983 fs::create_dir_all(staged_dir.join("site")).unwrap();
1984 fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
1985 symlink(temp.path().join("missing-target"), &final_dir).unwrap();
1986
1987 let err = replace_dir_from_temp(&staged_dir, &final_dir).unwrap_err();
1988 assert!(
1989 err.to_string().contains("must not be a symlink"),
1990 "unexpected error: {err:#}"
1991 );
1992 assert!(staged_dir.join("site/new.txt").exists());
1993 assert!(
1994 fs::symlink_metadata(&final_dir)
1995 .unwrap()
1996 .file_type()
1997 .is_symlink(),
1998 "dangling symlink target must not be silently replaced"
1999 );
2000 }
2001}