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 if is_allowed_system_symlink_ancestor(&ancestor) {
457 continue;
458 }
459 bail!(
460 "{label} parent must not contain symlinks: {}",
461 ancestor.display()
462 );
463 }
464 if !file_type.is_dir() {
465 bail!("{label} parent must be a directory: {}", ancestor.display());
466 }
467 }
468 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
469 Err(err) => {
470 return Err(err).with_context(|| {
471 format!("failed inspecting {label} parent {}", ancestor.display())
472 });
473 }
474 }
475 }
476
477 Ok(())
478}
479
480#[cfg(target_os = "macos")]
481fn is_allowed_system_symlink_ancestor(path: &Path) -> bool {
482 path == Path::new("/var") || path == Path::new("/tmp")
483}
484
485#[cfg(not(target_os = "macos"))]
486fn is_allowed_system_symlink_ancestor(_path: &Path) -> bool {
487 false
488}
489
490fn replace_dir_from_temp(temp_dir: &Path, final_dir: &Path) -> Result<()> {
491 if !ensure_replaceable_bundle_output_dir(final_dir)? {
492 fs::rename(temp_dir, final_dir).with_context(|| {
493 format!(
494 "failed renaming completed bundle {} into place at {}",
495 temp_dir.display(),
496 final_dir.display()
497 )
498 })?;
499 sync_parent_directory(final_dir)?;
500 return Ok(());
501 }
502
503 let backup_dir = unique_bundle_backup_dir(final_dir)?;
504 fs::rename(final_dir, &backup_dir).with_context(|| {
505 format!(
506 "failed preparing backup {} before replacing {}",
507 backup_dir.display(),
508 final_dir.display()
509 )
510 })?;
511
512 match fs::rename(temp_dir, final_dir) {
513 Ok(()) => {
514 sync_parent_directory(final_dir)?;
515 let _ = fs::remove_dir_all(&backup_dir);
516 sync_parent_directory(final_dir)?;
517 Ok(())
518 }
519 Err(second_err) => match fs::rename(&backup_dir, final_dir) {
520 Ok(()) => {
521 let _ = fs::remove_dir_all(temp_dir);
522 sync_parent_directory(final_dir)?;
523 bail!(
524 "failed replacing {} with {}: {}; restored original bundle",
525 final_dir.display(),
526 temp_dir.display(),
527 second_err
528 );
529 }
530 Err(restore_err) => {
531 bail!(
532 "failed replacing {} with {}: {}; restore error: {}; temp bundle retained at {}",
533 final_dir.display(),
534 temp_dir.display(),
535 second_err,
536 restore_err,
537 temp_dir.display()
538 );
539 }
540 },
541 }
542}
543
544#[cfg(not(windows))]
545fn sync_tree(path: &Path) -> Result<()> {
546 sync_tree_inner(path)?;
547 sync_parent_directory(path)
548}
549
550#[cfg(windows)]
551fn sync_tree(_path: &Path) -> Result<()> {
552 Ok(())
553}
554
555#[cfg(not(windows))]
556fn sync_tree_inner(path: &Path) -> Result<()> {
557 let metadata = fs::symlink_metadata(path)
558 .with_context(|| format!("failed reading metadata for {}", path.display()))?;
559 let file_type = metadata.file_type();
560 if file_type.is_symlink() {
561 return Ok(());
562 }
563 if file_type.is_file() {
564 File::open(path)
565 .with_context(|| format!("failed opening {} for sync", path.display()))?
566 .sync_all()
567 .with_context(|| format!("failed syncing {}", path.display()))?;
568 return Ok(());
569 }
570 if file_type.is_dir() {
571 for entry in
572 fs::read_dir(path).with_context(|| format!("failed reading {}", path.display()))?
573 {
574 let entry = entry.with_context(|| format!("failed walking {}", path.display()))?;
575 sync_tree_inner(&entry.path())?;
576 }
577 File::open(path)
578 .with_context(|| format!("failed opening directory {} for sync", path.display()))?
579 .sync_all()
580 .with_context(|| format!("failed syncing directory {}", path.display()))?;
581 }
582 Ok(())
583}
584
585#[cfg(not(windows))]
586fn sync_parent_directory(path: &Path) -> Result<()> {
587 let Some(parent) = path.parent() else {
588 return Ok(());
589 };
590 File::open(parent)
591 .with_context(|| format!("failed opening parent directory {}", parent.display()))?
592 .sync_all()
593 .with_context(|| format!("failed syncing parent directory {}", parent.display()))
594}
595
596#[cfg(windows)]
597fn sync_parent_directory(_path: &Path) -> Result<()> {
598 Ok(())
599}
600
601#[derive(Debug, Clone)]
603pub struct BundleResult {
604 pub site_dir: PathBuf,
606 pub private_dir: PathBuf,
608 pub chunk_count: usize,
610 pub attachment_count: usize,
612 pub fingerprint: String,
614 pub total_files: usize,
616}
617
618fn copy_payload_chunks(
623 src_root: &Path,
624 src_dir: &Path,
625 dest_dir: &Path,
626 config: &EncryptionConfig,
627) -> Result<usize> {
628 ensure_regular_copy_directory_under_root(src_root, src_dir, "Encrypted payload directory")?;
629 validate_supported_payload_format(config)?;
630
631 let mut count = 0;
632
633 for (idx, expected_file) in config.payload.files.iter().enumerate() {
634 let expected_path = format!("payload/chunk-{idx:05}.bin");
635 if expected_file != &expected_path {
636 bail!(
637 "Encrypted payload file entry {idx} must be {expected_path}, got {expected_file}"
638 );
639 }
640
641 let rel_path = Path::new(expected_file);
642 let src_path = src_root.join(rel_path);
643 let label = format!("Encrypted payload chunk {expected_file}");
644 ensure_regular_copy_source_under_root(src_root, &src_path, &label)?;
645
646 let Some(filename) = rel_path.file_name() else {
647 bail!("Encrypted payload chunk path has no file name: {expected_file}");
648 };
649 let dest_path = dest_dir.join(filename);
650 fs::copy(&src_path, &dest_path)?;
651 count += 1;
652 }
653
654 Ok(count)
655}
656
657fn copy_payload_file(
659 src_root: &Path,
660 site_dir: &Path,
661 config: &UnencryptedConfig,
662) -> Result<usize> {
663 let rel_path = Path::new(&config.payload.path);
664 if rel_path.is_absolute() {
665 bail!("Unencrypted payload path must be relative");
666 }
667 if rel_path
668 .components()
669 .any(|c| matches!(c, std::path::Component::ParentDir))
670 {
671 bail!("Unencrypted payload path must not contain '..'");
672 }
673 if !rel_path.starts_with("payload") {
674 bail!("Unencrypted payload path must reside under payload/");
675 }
676
677 let src_path = src_root.join(rel_path);
678 ensure_regular_copy_source_under_root(src_root, &src_path, "Unencrypted payload file")?;
679
680 let dest_path = site_dir.join(rel_path);
681 if let Some(parent) = dest_path.parent() {
682 fs::create_dir_all(parent)?;
683 }
684
685 fs::copy(&src_path, &dest_path)?;
686 Ok(1)
687}
688
689fn resolve_generated_doc_path(site_dir: &Path, doc: &GeneratedDoc) -> Result<PathBuf> {
690 if doc.filename.contains(['/', '\\']) {
691 bail!(
692 "Generated documentation filename must not contain path separators: {}",
693 doc.filename
694 );
695 }
696
697 let rel_path = Path::new(&doc.filename);
698 let mut components = rel_path.components();
699 let Some(std::path::Component::Normal(file_name)) = components.next() else {
700 bail!(
701 "Generated documentation filename must be a plain relative file name: {}",
702 doc.filename
703 );
704 };
705 if components.next().is_some() {
706 bail!(
707 "Generated documentation filename must not contain path separators: {}",
708 doc.filename
709 );
710 }
711
712 Ok(match doc.location {
713 DocLocation::RepoRoot | DocLocation::WebRoot => site_dir.join(file_name),
714 })
715}
716
717fn ensure_regular_copy_source_under_root(
718 src_root: &Path,
719 src_path: &Path,
720 label: &str,
721) -> Result<()> {
722 let metadata = fs::symlink_metadata(src_path)
723 .with_context(|| format!("{label} not found: {}", src_path.display()))?;
724 let file_type = metadata.file_type();
725 if file_type.is_symlink() {
726 bail!("{label} must not be a symlink: {}", src_path.display());
727 }
728 if !file_type.is_file() {
729 bail!("{label} must be a regular file: {}", src_path.display());
730 }
731
732 let canonical_root = src_root.canonicalize().with_context(|| {
733 format!(
734 "Failed to resolve bundle source directory {}",
735 src_root.display()
736 )
737 })?;
738 let canonical_source = src_path.canonicalize().with_context(|| {
739 format!(
740 "Failed to resolve {label} source path {}",
741 src_path.display()
742 )
743 })?;
744 if !canonical_source.starts_with(&canonical_root) {
745 bail!(
746 "{label} resolves outside bundle source directory: {}",
747 src_path.display()
748 );
749 }
750
751 Ok(())
752}
753
754fn ensure_regular_copy_directory_under_root(
755 src_root: &Path,
756 src_dir: &Path,
757 label: &str,
758) -> Result<()> {
759 let metadata = fs::symlink_metadata(src_dir)
760 .with_context(|| format!("{label} not found: {}", src_dir.display()))?;
761 let file_type = metadata.file_type();
762 if file_type.is_symlink() {
763 bail!("{label} must not be a symlink: {}", src_dir.display());
764 }
765 if !file_type.is_dir() {
766 bail!("{label} must be a directory: {}", src_dir.display());
767 }
768
769 let canonical_root = src_root.canonicalize().with_context(|| {
770 format!(
771 "Failed to resolve bundle source directory {}",
772 src_root.display()
773 )
774 })?;
775 let canonical_source = src_dir.canonicalize().with_context(|| {
776 format!(
777 "Failed to resolve {label} source directory {}",
778 src_dir.display()
779 )
780 })?;
781 if !canonical_source.starts_with(&canonical_root) {
782 bail!(
783 "{label} resolves outside bundle source directory: {}",
784 src_dir.display()
785 );
786 }
787
788 Ok(())
789}
790
791fn copy_blobs_directory(src_root: &Path, src_dir: &Path, dest_dir: &Path) -> Result<usize> {
793 ensure_regular_copy_directory_under_root(src_root, src_dir, "Attachment blobs directory")?;
794 fs::create_dir_all(dest_dir).context("Failed to create blobs directory")?;
795
796 let mut count = 0;
797
798 for entry in fs::read_dir(src_dir)? {
799 let entry = entry?;
800 let path = entry.path();
801 let metadata = fs::symlink_metadata(&path)?;
802 let file_type = metadata.file_type();
803
804 if file_type.is_file() {
805 let Some(filename) = path.file_name() else {
806 continue; };
808 let dest_path = dest_dir.join(filename);
809 fs::copy(&path, &dest_path)?;
810 count += 1;
811 }
812 }
813
814 Ok(count)
815}
816
817pub(crate) fn generate_integrity_manifest(dir: &Path) -> Result<IntegrityManifest> {
819 let mut files = BTreeMap::new();
820
821 collect_file_hashes(dir, dir, &mut files)?;
822
823 Ok(IntegrityManifest {
824 version: 1,
825 generated_at: Utc::now().to_rfc3339(),
826 files,
827 })
828}
829
830fn collect_file_hashes(
832 base_dir: &Path,
833 current_dir: &Path,
834 files: &mut BTreeMap<String, IntegrityEntry>,
835) -> Result<()> {
836 let canonical_base_dir = base_dir.canonicalize().with_context(|| {
837 format!(
838 "Failed to resolve site directory {} while generating integrity manifest",
839 base_dir.display()
840 )
841 })?;
842 collect_file_hashes_recursive(base_dir, current_dir, &canonical_base_dir, files)
843}
844
845fn collect_file_hashes_recursive(
846 base_dir: &Path,
847 current_dir: &Path,
848 canonical_base_dir: &Path,
849 files: &mut BTreeMap<String, IntegrityEntry>,
850) -> Result<()> {
851 for entry in fs::read_dir(current_dir)? {
852 let entry = entry?;
853 let path = entry.path();
854 let metadata = fs::symlink_metadata(&path)?;
855 let file_type = metadata.file_type();
856 let rel_path = path.strip_prefix(base_dir)?;
857 let rel_str = rel_path.to_string_lossy().replace('\\', "/");
858
859 if rel_str == "integrity.json" {
861 continue;
862 }
863
864 if file_type.is_dir() {
865 collect_file_hashes_recursive(base_dir, &path, canonical_base_dir, files)?;
866 } else if file_type.is_symlink() {
867 let canonical_target = path.canonicalize().with_context(|| {
868 format!(
869 "Failed to resolve symlink {} while generating integrity manifest",
870 rel_str
871 )
872 })?;
873 if !canonical_target.starts_with(canonical_base_dir) {
874 bail!(
875 "Refusing to include symlink outside site directory in integrity manifest: {}",
876 rel_str
877 );
878 }
879
880 let target_meta = fs::metadata(&path).with_context(|| {
881 format!(
882 "Failed to read symlink target metadata for {} while generating integrity manifest",
883 rel_str
884 )
885 })?;
886 if !target_meta.is_file() {
887 bail!(
888 "Refusing to include symlink that does not point to a regular file in integrity manifest: {}",
889 rel_str
890 );
891 }
892
893 files.insert(rel_str, build_integrity_entry(&path)?);
894 } else if file_type.is_file() {
895 files.insert(rel_str, build_integrity_entry(&path)?);
896 }
897 }
898
899 Ok(())
900}
901
902fn build_integrity_entry(path: &Path) -> Result<IntegrityEntry> {
903 let file = File::open(path)?;
904 let metadata = file.metadata()?;
905 let size = metadata.len();
906
907 let mut hasher = Sha256::new();
908 let mut reader = BufReader::new(file);
909 let mut buffer = [0u8; 8192];
910
911 loop {
912 let bytes_read = reader.read(&mut buffer)?;
913 if bytes_read == 0 {
914 break;
915 }
916 hasher.update(&buffer[..bytes_read]);
917 }
918
919 Ok(IntegrityEntry {
920 sha256: hex::encode(hasher.finalize()),
924 size,
925 })
926}
927
928pub(crate) fn compute_fingerprint(manifest: &IntegrityManifest) -> String {
930 let mut hasher = Sha256::new();
932
933 for (path, entry) in &manifest.files {
934 hasher.update(path.as_bytes());
935 hasher.update(entry.sha256.as_bytes());
936 }
937
938 let hash = hasher.finalize();
939
940 hex::encode(hash)[..16].to_string()
944}
945
946pub(crate) fn write_private_fingerprint(private_dir: &Path, fingerprint: &str) -> Result<()> {
948 let fingerprint_content = format!(
949 "Integrity Fingerprint: {}\n\n\
950 Generated: {}\n\n\
951 Verify this fingerprint matches the one displayed in the web viewer\n\
952 before proceeding. If it doesn't match, the archive may have been\n\
953 tampered with.\n",
954 fingerprint,
955 Utc::now().to_rfc3339()
956 );
957 write_private_artifact_file(
958 private_dir,
959 "integrity-fingerprint.txt",
960 fingerprint_content.as_bytes(),
961 )?;
962 Ok(())
963}
964
965fn ensure_private_artifact_dir(private_dir: &Path) -> Result<()> {
966 ensure_existing_parent_ancestors_are_real_dirs(private_dir, "private artifact directory")?;
967
968 match fs::symlink_metadata(private_dir) {
969 Ok(metadata) => {
970 let file_type = metadata.file_type();
971 if file_type.is_symlink() {
972 bail!(
973 "private artifact directory must not be a symlink: {}",
974 private_dir.display()
975 );
976 }
977 if !file_type.is_dir() {
978 bail!(
979 "private artifact path must be a directory: {}",
980 private_dir.display()
981 );
982 }
983 Ok(())
984 }
985 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
986 fs::create_dir_all(private_dir).with_context(|| {
987 format!(
988 "Failed to create private artifact directory {}",
989 private_dir.display()
990 )
991 })?;
992 ensure_private_artifact_dir(private_dir)
993 }
994 Err(err) => Err(err).with_context(|| {
995 format!(
996 "Failed to inspect private artifact directory {}",
997 private_dir.display()
998 )
999 }),
1000 }
1001}
1002
1003fn reject_symlinked_private_artifact(path: &Path) -> Result<()> {
1004 match fs::symlink_metadata(path) {
1005 Ok(metadata) => {
1006 let file_type = metadata.file_type();
1007 if file_type.is_symlink() {
1008 bail!(
1009 "private artifact file must not be a symlink: {}",
1010 path.display()
1011 );
1012 }
1013 if file_type.is_dir() {
1014 bail!(
1015 "private artifact path must be a regular file, not a directory: {}",
1016 path.display()
1017 );
1018 }
1019 Ok(())
1020 }
1021 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1022 Err(err) => Err(err)
1023 .with_context(|| format!("Failed to inspect private artifact {}", path.display())),
1024 }
1025}
1026
1027fn write_private_artifact_file(private_dir: &Path, filename: &str, contents: &[u8]) -> Result<()> {
1028 if filename.contains(['/', '\\']) {
1029 bail!("private artifact filename must not contain path separators: {filename}");
1030 }
1031
1032 ensure_private_artifact_dir(private_dir)?;
1033 let final_path = private_dir.join(filename);
1034 reject_symlinked_private_artifact(&final_path)?;
1035 let temp_path = unique_bundle_sidecar_path(&final_path, "tmp", "private_artifact")?;
1036
1037 let write_result = (|| -> Result<()> {
1038 let mut file = OpenOptions::new()
1039 .write(true)
1040 .create_new(true)
1041 .open(&temp_path)
1042 .with_context(|| {
1043 format!(
1044 "Failed to create temporary private artifact {}",
1045 temp_path.display()
1046 )
1047 })?;
1048 file.write_all(contents).with_context(|| {
1049 format!(
1050 "Failed to write temporary private artifact {}",
1051 temp_path.display()
1052 )
1053 })?;
1054 file.sync_all().with_context(|| {
1055 format!(
1056 "Failed to sync temporary private artifact {}",
1057 temp_path.display()
1058 )
1059 })?;
1060 Ok(())
1061 })();
1062
1063 if let Err(err) = write_result {
1064 let _ = fs::remove_file(&temp_path);
1065 return Err(err);
1066 }
1067
1068 if let Err(err) = fs::rename(&temp_path, &final_path) {
1069 let _ = fs::remove_file(&temp_path);
1070 return Err(err).with_context(|| {
1071 format!(
1072 "Failed to install private artifact {}",
1073 final_path.display()
1074 )
1075 });
1076 }
1077 sync_parent_directory(&final_path)?;
1078 Ok(())
1079}
1080
1081pub(crate) fn write_private_artifacts_encrypted(
1082 private_dir: &Path,
1083 enc_config: &EncryptionConfig,
1084 recovery_secret: Option<&[u8]>,
1085 generate_qr: bool,
1086 cleanup_missing_recovery: bool,
1087) -> Result<()> {
1088 ensure_private_artifact_dir(private_dir)?;
1089
1090 let recovery_secret_path = private_dir.join("recovery-secret.txt");
1091 let qr_png_path = private_dir.join("qr-code.png");
1092 let qr_svg_path = private_dir.join("qr-code.svg");
1093
1094 if let Some(secret) = recovery_secret {
1096 let recovery_b64 = BASE64_URL_SAFE_NO_PAD.encode(secret);
1097 let recovery_content = format!(
1098 "Recovery Secret\n\
1099 ================\n\n\
1100 This secret can unlock your archive if you forget your password.\n\
1101 Store it securely and NEVER share it.\n\n\
1102 Secret (base64url):\n\
1103 {}\n\n\
1104 To use: Click \"Scan Recovery QR Code\" in the web viewer, or\n\
1105 use this base64 value with the recovery function.\n\n\
1106 Archive Export ID: {}\n\
1107 Generated: {}\n",
1108 recovery_b64,
1109 enc_config.export_id,
1110 Utc::now().to_rfc3339()
1111 );
1112 write_private_artifact_file(
1113 private_dir,
1114 "recovery-secret.txt",
1115 recovery_content.as_bytes(),
1116 )?;
1117
1118 if generate_qr {
1120 generate_qr_codes(private_dir, &recovery_b64)?;
1121 } else {
1122 remove_file_if_exists(&qr_png_path)?;
1123 remove_file_if_exists(&qr_svg_path)?;
1124 }
1125 } else if cleanup_missing_recovery {
1126 remove_file_if_exists(&recovery_secret_path)?;
1127 remove_file_if_exists(&qr_png_path)?;
1128 remove_file_if_exists(&qr_svg_path)?;
1129 }
1130
1131 let master_key_backup = master_key_backup_json(enc_config, Utc::now().to_rfc3339());
1133 let master_key_json = serde_json::to_vec_pretty(&master_key_backup)?;
1134 write_private_artifact_file(private_dir, "master-key.json", &master_key_json)?;
1135
1136 Ok(())
1137}
1138
1139fn master_key_backup_json(
1140 enc_config: &EncryptionConfig,
1141 generated_at: String,
1142) -> serde_json::Value {
1143 serde_json::json!({
1144 "export_id": &enc_config.export_id,
1145 "key_slots": &enc_config.key_slots,
1146 "note": MASTER_KEY_BACKUP_NOTE,
1147 "generated_at": generated_at,
1148 })
1149}
1150
1151fn remove_file_if_exists(path: &Path) -> Result<()> {
1152 match fs::remove_file(path) {
1153 Ok(()) => Ok(()),
1154 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1155 Err(err) => Err(err.into()),
1156 }
1157}
1158
1159fn write_private_unencrypted_notice(private_dir: &Path) -> Result<()> {
1160 let content = format!(
1161 "UNENCRYPTED ARCHIVE WARNING\n\
1162 ============================\n\n\
1163 This bundle was generated WITHOUT encryption.\n\
1164 Anyone with access to the site can read its contents.\n\n\
1165 Generated: {}\n",
1166 Utc::now().to_rfc3339()
1167 );
1168 write_private_artifact_file(private_dir, "unencrypted-warning.txt", content.as_bytes())?;
1169 Ok(())
1170}
1171
1172fn generate_qr_codes(private_dir: &Path, recovery_b64: &str) -> Result<()> {
1174 if let Ok(qr_png) = super::qr::generate_qr_png(recovery_b64) {
1176 write_private_artifact_file(private_dir, "qr-code.png", &qr_png)?;
1177 }
1178
1179 if let Ok(qr_svg) = super::qr::generate_qr_svg(recovery_b64) {
1180 write_private_artifact_file(private_dir, "qr-code.svg", qr_svg.as_bytes())?;
1181 }
1182
1183 Ok(())
1184}
1185
1186fn generate_public_readme(title: &str, description: &str, is_encrypted: bool) -> String {
1188 let about_line = if is_encrypted {
1189 "This is an encrypted, searchable archive of AI coding agent conversations"
1190 } else {
1191 "This is a searchable archive of AI coding agent conversations (not encrypted)"
1192 };
1193
1194 let security_section = if is_encrypted {
1195 r#"## Security
1196
1197- All data is encrypted with AES-256-GCM
1198- Password-based key derivation uses Argon2id
1199- The archive can be safely hosted on public servers
1200- No data is accessible without the correct password"#
1201 } else {
1202 r#"## Security
1203
1204⚠️ This archive is **NOT encrypted**.
1205Anyone with access to the site can read its contents.
1206Host it only on a trusted, private location."#
1207 };
1208
1209 let open_section = if is_encrypted {
1210 r#"## How to Open
1211
12121. Host these files on any static web server
12132. Open index.html in a modern browser
12143. Verify the fingerprint matches your records
12154. Enter your password to decrypt"#
1216 } else {
1217 r#"## How to Open
1218
12191. Host these files on any static web server
12202. Open index.html in a modern browser
12213. Verify the fingerprint matches your records
12224. The archive loads immediately (no password required)"#
1223 };
1224
1225 let technical_section = if is_encrypted {
1226 r#"## Technical Details
1227
1228- Encryption: AES-256-GCM with chunked streaming
1229- KDF: Argon2id (64MB memory, 3 iterations)
1230- Search: SQLite with FTS5 (runs in browser via sql.js)
1231- Requires: SharedArrayBuffer (COOP/COEP headers)"#
1232 } else {
1233 r#"## Technical Details
1234
1235- Encryption: none (unencrypted archive)
1236- Search: SQLite with FTS5 (runs in browser via sql.js)
1237- Requires: SharedArrayBuffer (COOP/COEP headers)"#
1238 };
1239
1240 format!(
1241 r#"# {}
1242
1243{}
1244
1245## About This Archive
1246
1247{}
1248generated by [cass](https://github.com/Dicklesworthstone/coding_agent_session_search).
1249
1250{}
1251
1252{}
1253
1254{}
1255
1256## Files
1257
1258- `index.html` - Entry point
1259- `config.json` - Public encryption parameters (no secrets)
1260- `integrity.json` - SHA256 hashes for all files
1261- `payload/` - Encrypted database chunks
1262- `*.js` - Application code
1263- `styles.css` - Styling
1264
1265## Hosting Requirements
1266
1267For the viewer to function correctly, your web server must set:
1268
1269```
1270Cross-Origin-Opener-Policy: same-origin
1271Cross-Origin-Embedder-Policy: require-corp
1272```
1273
1274The included service worker (sw.js) handles this automatically for
1275most static hosts (GitHub Pages, Cloudflare Pages, etc.).
1276
1277---
1278
1279Generated by cass v{}
1280"#,
1281 title,
1282 description,
1283 about_line,
1284 security_section,
1285 open_section,
1286 technical_section,
1287 env!("CARGO_PKG_VERSION")
1288 )
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294 use crate::pages::archive_config::{ArchiveConfig, UnencryptedPayload};
1295 use tempfile::TempDir;
1296
1297 fn write_unencrypted_source(root: &Path, payload_name: &str, body: &str) {
1298 let payload_dir = root.join("payload");
1299 fs::create_dir_all(&payload_dir).unwrap();
1300 let payload_path = payload_dir.join(payload_name);
1301 fs::write(&payload_path, body).unwrap();
1302
1303 let config = ArchiveConfig::Unencrypted(UnencryptedConfig {
1304 encrypted: false,
1305 version: "1.0.0".to_string(),
1306 payload: UnencryptedPayload {
1307 path: format!("payload/{payload_name}"),
1308 format: "sqlite".to_string(),
1309 size_bytes: Some(body.len() as u64),
1310 },
1311 warning: Some("UNENCRYPTED".to_string()),
1312 });
1313
1314 let file = File::create(root.join("config.json")).unwrap();
1315 serde_json::to_writer_pretty(BufWriter::new(file), &config).unwrap();
1316 }
1317
1318 fn encrypted_config_for_files(files: Vec<&str>) -> EncryptionConfig {
1319 let chunk_count = files.len();
1320 EncryptionConfig {
1321 version: crate::pages::encrypt::SCHEMA_VERSION,
1322 export_id: "export-123".to_string(),
1323 base_nonce: "nonce".to_string(),
1324 compression: "deflate".to_string(),
1325 kdf_defaults: crate::pages::encrypt::Argon2Params::default(),
1326 payload: crate::pages::encrypt::PayloadMeta {
1327 chunk_size: 1024,
1328 chunk_count,
1329 total_compressed_size: 0,
1330 total_plaintext_size: 0,
1331 files: files.into_iter().map(str::to_string).collect(),
1332 },
1333 key_slots: Vec::new(),
1334 }
1335 }
1336
1337 #[test]
1338 fn test_bundle_builder_default() {
1339 let builder = BundleBuilder::new();
1340 assert_eq!(builder.config.title, "cass Archive");
1341 assert!(!builder.config.hide_metadata);
1342 assert!(!builder.config.generate_qr);
1343 }
1344
1345 #[test]
1346 fn test_bundle_builder_fluent() {
1347 let builder = BundleBuilder::new()
1348 .title("My Archive")
1349 .description("Test description")
1350 .hide_metadata(true)
1351 .generate_qr(true);
1352
1353 assert_eq!(builder.config.title, "My Archive");
1354 assert_eq!(builder.config.description, "Test description");
1355 assert!(builder.config.hide_metadata);
1356 assert!(builder.config.generate_qr);
1357 }
1358
1359 #[test]
1360 fn test_compute_fingerprint() {
1361 let mut files = BTreeMap::new();
1362 files.insert(
1363 "test.txt".to_string(),
1364 IntegrityEntry {
1365 sha256: "abc123".to_string(),
1366 size: 100,
1367 },
1368 );
1369
1370 let manifest = IntegrityManifest {
1371 version: 1,
1372 generated_at: "2024-01-01T00:00:00Z".to_string(),
1373 files,
1374 };
1375
1376 let fingerprint = compute_fingerprint(&manifest);
1377 assert_eq!(fingerprint.len(), 16);
1378
1379 let fingerprint2 = compute_fingerprint(&manifest);
1381 assert_eq!(fingerprint, fingerprint2);
1382 }
1383
1384 #[test]
1385 fn test_master_key_backup_json_shape() {
1386 let config = EncryptionConfig {
1387 version: 2,
1388 export_id: "export-123".to_string(),
1389 base_nonce: "nonce".to_string(),
1390 compression: "deflate".to_string(),
1391 kdf_defaults: crate::pages::encrypt::Argon2Params::default(),
1392 payload: crate::pages::encrypt::PayloadMeta {
1393 chunk_size: 1024,
1394 chunk_count: 0,
1395 total_compressed_size: 0,
1396 total_plaintext_size: 0,
1397 files: Vec::new(),
1398 },
1399 key_slots: Vec::new(),
1400 };
1401
1402 let backup = master_key_backup_json(&config, "2026-04-25T19:08:00Z".to_string());
1403
1404 assert_eq!(backup["export_id"], "export-123");
1405 assert_eq!(backup["key_slots"], serde_json::json!([]));
1406 assert_eq!(backup["note"], MASTER_KEY_BACKUP_NOTE);
1407 assert_eq!(backup["generated_at"], "2026-04-25T19:08:00Z");
1408 }
1409
1410 #[test]
1411 #[cfg(unix)]
1412 fn test_private_artifacts_reject_symlinked_secret_file() {
1413 use std::os::unix::fs::symlink;
1414
1415 let temp = TempDir::new().unwrap();
1416 let private_dir = temp.path().join("private");
1417 let outside_dir = temp.path().join("outside");
1418 fs::create_dir_all(&private_dir).unwrap();
1419 fs::create_dir_all(&outside_dir).unwrap();
1420 let protected_secret = outside_dir.join("protected-secret.txt");
1421 fs::write(&protected_secret, "do not overwrite").unwrap();
1422 symlink(&protected_secret, private_dir.join("recovery-secret.txt")).unwrap();
1423
1424 let config = encrypted_config_for_files(Vec::new());
1425 let err = write_private_artifacts_encrypted(
1426 &private_dir,
1427 &config,
1428 Some(&[7u8; 32]),
1429 false,
1430 false,
1431 )
1432 .unwrap_err();
1433
1434 assert!(
1435 err.to_string().contains("must not be a symlink"),
1436 "unexpected error: {err:#}"
1437 );
1438 assert_eq!(
1439 fs::read_to_string(&protected_secret).unwrap(),
1440 "do not overwrite"
1441 );
1442 assert!(
1443 fs::symlink_metadata(private_dir.join("recovery-secret.txt"))
1444 .unwrap()
1445 .file_type()
1446 .is_symlink(),
1447 "rejected private artifact symlink should be left intact"
1448 );
1449 }
1450
1451 #[test]
1452 #[cfg(unix)]
1453 fn test_private_artifacts_cleanup_rejects_symlinked_private_dir_before_removal() {
1454 use std::os::unix::fs::symlink;
1455
1456 let temp = TempDir::new().unwrap();
1457 let outside_dir = temp.path().join("outside");
1458 let private_dir = temp.path().join("private");
1459 fs::create_dir_all(&outside_dir).unwrap();
1460 fs::write(outside_dir.join("recovery-secret.txt"), "keep recovery").unwrap();
1461 fs::write(outside_dir.join("qr-code.png"), "keep png").unwrap();
1462 fs::write(outside_dir.join("qr-code.svg"), "keep svg").unwrap();
1463 symlink(&outside_dir, &private_dir).unwrap();
1464
1465 let config = encrypted_config_for_files(Vec::new());
1466 let err = write_private_artifacts_encrypted(&private_dir, &config, None, false, true)
1467 .unwrap_err();
1468
1469 assert!(
1470 err.to_string().contains("must not be a symlink"),
1471 "unexpected error: {err:#}"
1472 );
1473 assert_eq!(
1474 fs::read_to_string(outside_dir.join("recovery-secret.txt")).unwrap(),
1475 "keep recovery"
1476 );
1477 assert_eq!(
1478 fs::read_to_string(outside_dir.join("qr-code.png")).unwrap(),
1479 "keep png"
1480 );
1481 assert_eq!(
1482 fs::read_to_string(outside_dir.join("qr-code.svg")).unwrap(),
1483 "keep svg"
1484 );
1485 }
1486
1487 #[test]
1488 #[cfg(unix)]
1489 fn test_private_artifacts_reject_symlinked_parent_before_writing() {
1490 use std::os::unix::fs::symlink;
1491
1492 let temp = TempDir::new().unwrap();
1493 let outside_dir = TempDir::new().unwrap();
1494 let linked_parent = temp.path().join("linked-parent");
1495 let private_dir = linked_parent.join("private");
1496 symlink(outside_dir.path(), &linked_parent).unwrap();
1497
1498 let err = write_private_fingerprint(&private_dir, "fingerprint").unwrap_err();
1499
1500 assert!(
1501 err.to_string().contains("parent must not contain symlinks"),
1502 "unexpected error: {err:#}"
1503 );
1504 assert!(
1505 fs::read_dir(outside_dir.path()).unwrap().next().is_none(),
1506 "private artifact writer must not create files through a symlinked parent"
1507 );
1508 }
1509
1510 #[test]
1511 fn test_generate_public_readme() {
1512 let readme = generate_public_readme("Test Archive", "A test archive", true);
1513 assert!(readme.contains("Test Archive"));
1514 assert!(readme.contains("A test archive"));
1515 assert!(readme.contains("AES-256-GCM"));
1516 assert!(readme.contains("Argon2id"));
1517
1518 let unencrypted = generate_public_readme("Test Archive", "A test archive", false);
1519 assert!(unencrypted.contains("NOT encrypted"));
1520 assert!(unencrypted.contains("no password required"));
1521 }
1522
1523 #[test]
1524 fn test_integrity_manifest_excludes_itself() {
1525 let temp = TempDir::new().unwrap();
1526 let temp_path = temp.path();
1527
1528 fs::write(temp_path.join("test.txt"), "hello").unwrap();
1530 fs::write(temp_path.join("integrity.json"), "{}").unwrap();
1531
1532 let manifest = generate_integrity_manifest(temp_path).unwrap();
1533
1534 assert!(manifest.files.contains_key("test.txt"));
1536 assert!(!manifest.files.contains_key("integrity.json"));
1537 }
1538
1539 #[test]
1540 fn test_collect_file_hashes() {
1541 let temp = TempDir::new().unwrap();
1542 let temp_path = temp.path();
1543
1544 fs::create_dir_all(temp_path.join("subdir")).unwrap();
1546 fs::write(temp_path.join("root.txt"), "root").unwrap();
1547 fs::write(temp_path.join("subdir/nested.txt"), "nested").unwrap();
1548
1549 let mut files = BTreeMap::new();
1550 collect_file_hashes(temp_path, temp_path, &mut files).unwrap();
1551
1552 assert_eq!(files.len(), 2);
1553 assert!(files.contains_key("root.txt"));
1554 assert!(files.contains_key("subdir/nested.txt"));
1555
1556 for entry in files.values() {
1558 assert_eq!(entry.sha256.len(), 64);
1559 }
1560 }
1561
1562 #[test]
1563 #[cfg(unix)]
1564 fn test_collect_file_hashes_includes_symlinked_files_within_site() {
1565 use std::os::unix::fs::symlink;
1566
1567 let temp = TempDir::new().unwrap();
1568 let temp_path = temp.path();
1569
1570 fs::write(temp_path.join("real.txt"), "real").unwrap();
1571 symlink("real.txt", temp_path.join("linked-file.txt")).unwrap();
1572
1573 let mut files = BTreeMap::new();
1574 collect_file_hashes(temp_path, temp_path, &mut files).unwrap();
1575
1576 assert_eq!(files.len(), 2);
1577 assert!(files.contains_key("real.txt"));
1578 assert!(files.contains_key("linked-file.txt"));
1579 assert_eq!(files["real.txt"].sha256, files["linked-file.txt"].sha256);
1580 assert_eq!(files["real.txt"].size, files["linked-file.txt"].size);
1581 }
1582
1583 #[test]
1584 #[cfg(unix)]
1585 fn test_collect_file_hashes_rejects_symlinks_outside_site() {
1586 use std::os::unix::fs::symlink;
1587
1588 let temp = TempDir::new().unwrap();
1589 let temp_path = temp.path();
1590 let outside = TempDir::new().unwrap();
1591
1592 fs::write(temp_path.join("root.txt"), "root").unwrap();
1593 fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1594 fs::create_dir_all(outside.path().join("nested")).unwrap();
1595 fs::write(outside.path().join("nested/hidden.txt"), "hidden").unwrap();
1596 symlink(
1597 outside.path().join("secret.txt"),
1598 temp_path.join("linked-file.txt"),
1599 )
1600 .unwrap();
1601 symlink(outside.path().join("nested"), temp_path.join("linked-dir")).unwrap();
1602
1603 let mut files = BTreeMap::new();
1604 let err = collect_file_hashes(temp_path, temp_path, &mut files).unwrap_err();
1605 assert!(
1606 err.to_string().contains("outside site directory"),
1607 "unexpected error: {err:#}"
1608 );
1609 }
1610
1611 #[test]
1612 fn test_copy_payload_chunks_copies_only_manifest_files() {
1613 let src = TempDir::new().unwrap();
1614 let dst = TempDir::new().unwrap();
1615 let payload_dir = src.path().join("payload");
1616 fs::create_dir_all(&payload_dir).unwrap();
1617
1618 fs::write(payload_dir.join("chunk-00000.bin"), "chunk").unwrap();
1619 fs::write(payload_dir.join("chunk-99999.bin"), "stale chunk").unwrap();
1620 fs::write(payload_dir.join("secret.bin"), "unlisted payload").unwrap();
1621
1622 let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
1623 let copied = copy_payload_chunks(src.path(), &payload_dir, dst.path(), &config).unwrap();
1624 assert_eq!(copied, 1);
1625 assert!(dst.path().join("chunk-00000.bin").exists());
1626 assert!(!dst.path().join("chunk-99999.bin").exists());
1627 assert!(!dst.path().join("secret.bin").exists());
1628 }
1629
1630 #[test]
1631 #[cfg(unix)]
1632 fn test_copy_payload_chunks_rejects_manifest_symlinked_chunk() {
1633 use std::os::unix::fs::symlink;
1634
1635 let src = TempDir::new().unwrap();
1636 let dst = TempDir::new().unwrap();
1637 let outside = TempDir::new().unwrap();
1638 let payload_dir = src.path().join("payload");
1639 fs::create_dir_all(&payload_dir).unwrap();
1640
1641 fs::write(outside.path().join("secret.bin"), "secret").unwrap();
1642 symlink(
1643 outside.path().join("secret.bin"),
1644 payload_dir.join("chunk-00000.bin"),
1645 )
1646 .unwrap();
1647
1648 let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
1649 let err = copy_payload_chunks(src.path(), &payload_dir, dst.path(), &config).unwrap_err();
1650 assert!(
1651 err.to_string().contains("must not be a symlink"),
1652 "unexpected error: {err:#}"
1653 );
1654 assert!(!dst.path().join("chunk-00000.bin").exists());
1655 }
1656
1657 #[test]
1658 #[cfg(unix)]
1659 fn test_copy_payload_chunks_rejects_symlinked_source_directory() {
1660 use std::os::unix::fs::symlink;
1661
1662 let source = TempDir::new().unwrap();
1663 let dst = TempDir::new().unwrap();
1664 let outside = TempDir::new().unwrap();
1665
1666 fs::write(outside.path().join("chunk-0.bin"), "outside chunk").unwrap();
1667 symlink(outside.path(), source.path().join("payload")).unwrap();
1668
1669 let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
1670 let err = copy_payload_chunks(
1671 source.path(),
1672 &source.path().join("payload"),
1673 dst.path(),
1674 &config,
1675 )
1676 .unwrap_err();
1677 assert!(
1678 err.to_string().contains("must not be a symlink"),
1679 "unexpected error: {err:#}"
1680 );
1681 assert!(!dst.path().join("chunk-0.bin").exists());
1682 }
1683
1684 #[test]
1685 #[cfg(unix)]
1686 fn test_copy_unencrypted_payload_rejects_final_symlink() {
1687 use std::os::unix::fs::symlink;
1688
1689 let source = TempDir::new().unwrap();
1690 let site = TempDir::new().unwrap();
1691 let outside = TempDir::new().unwrap();
1692
1693 fs::create_dir_all(source.path().join("payload")).unwrap();
1694 fs::write(outside.path().join("secret.db"), "outside secret").unwrap();
1695 symlink(
1696 outside.path().join("secret.db"),
1697 source.path().join("payload/data.db"),
1698 )
1699 .unwrap();
1700
1701 let config = UnencryptedConfig {
1702 encrypted: false,
1703 version: "1.0.0".to_string(),
1704 payload: UnencryptedPayload {
1705 path: "payload/data.db".to_string(),
1706 format: "sqlite".to_string(),
1707 size_bytes: None,
1708 },
1709 warning: None,
1710 };
1711
1712 let err = copy_payload_file(source.path(), site.path(), &config).unwrap_err();
1713 assert!(
1714 err.to_string().contains("must not be a symlink"),
1715 "unexpected error: {err:#}"
1716 );
1717 assert!(!site.path().join("payload/data.db").exists());
1718 }
1719
1720 #[test]
1721 #[cfg(unix)]
1722 fn test_copy_unencrypted_payload_rejects_symlinked_parent_escape() {
1723 use std::os::unix::fs::symlink;
1724
1725 let source = TempDir::new().unwrap();
1726 let site = TempDir::new().unwrap();
1727 let outside = TempDir::new().unwrap();
1728
1729 fs::write(outside.path().join("data.db"), "outside secret").unwrap();
1730 symlink(outside.path(), source.path().join("payload")).unwrap();
1731
1732 let config = UnencryptedConfig {
1733 encrypted: false,
1734 version: "1.0.0".to_string(),
1735 payload: UnencryptedPayload {
1736 path: "payload/data.db".to_string(),
1737 format: "sqlite".to_string(),
1738 size_bytes: None,
1739 },
1740 warning: None,
1741 };
1742
1743 let err = copy_payload_file(source.path(), site.path(), &config).unwrap_err();
1744 assert!(
1745 err.to_string().contains("outside bundle source directory"),
1746 "unexpected error: {err:#}"
1747 );
1748 assert!(!site.path().join("payload/data.db").exists());
1749 }
1750
1751 #[test]
1752 fn test_generated_docs_reject_path_traversal_filename() {
1753 let source = TempDir::new().unwrap();
1754 let output_parent = TempDir::new().unwrap();
1755 let output_dir = output_parent.path().join("bundle");
1756
1757 write_unencrypted_source(source.path(), "data.db", "payload");
1758
1759 let config = BundleConfig {
1760 generated_docs: vec![GeneratedDoc {
1761 filename: "../escaped.md".to_string(),
1762 content: "escaped".to_string(),
1763 location: DocLocation::WebRoot,
1764 }],
1765 ..BundleConfig::default()
1766 };
1767
1768 let err = BundleBuilder::with_config(config)
1769 .build(source.path(), output_dir.as_path(), |_, _| {})
1770 .unwrap_err();
1771 assert!(
1772 err.to_string().contains("must not contain path separators"),
1773 "unexpected error: {err:#}"
1774 );
1775 assert!(!output_parent.path().join("escaped.md").exists());
1776 }
1777
1778 #[test]
1779 fn test_generated_docs_reject_backslash_separator_filename() {
1780 let doc = GeneratedDoc {
1781 filename: r"nested\escaped.md".to_string(),
1782 content: "escaped".to_string(),
1783 location: DocLocation::WebRoot,
1784 };
1785
1786 let err = resolve_generated_doc_path(Path::new("site"), &doc).unwrap_err();
1787 assert!(
1788 err.to_string().contains("must not contain path separators"),
1789 "unexpected error: {err:#}"
1790 );
1791 }
1792
1793 #[test]
1794 #[cfg(unix)]
1795 fn test_copy_blobs_directory_skips_symlinked_files() {
1796 use std::os::unix::fs::symlink;
1797
1798 let src = TempDir::new().unwrap();
1799 let dst = TempDir::new().unwrap();
1800 let outside = TempDir::new().unwrap();
1801
1802 fs::write(src.path().join("blob.bin"), "blob").unwrap();
1803 fs::write(outside.path().join("secret.bin"), "secret").unwrap();
1804 symlink(
1805 outside.path().join("secret.bin"),
1806 src.path().join("linked-blob.bin"),
1807 )
1808 .unwrap();
1809
1810 let copied = copy_blobs_directory(src.path(), src.path(), dst.path()).unwrap();
1811 assert_eq!(copied, 1);
1812 assert!(dst.path().join("blob.bin").exists());
1813 assert!(!dst.path().join("linked-blob.bin").exists());
1814 }
1815
1816 #[test]
1817 #[cfg(unix)]
1818 fn test_copy_blobs_directory_rejects_symlinked_source_directory() {
1819 use std::os::unix::fs::symlink;
1820
1821 let source = TempDir::new().unwrap();
1822 let dst = TempDir::new().unwrap();
1823 let outside = TempDir::new().unwrap();
1824
1825 fs::write(outside.path().join("blob.bin"), "outside blob").unwrap();
1826 symlink(outside.path(), source.path().join("blobs")).unwrap();
1827
1828 let err = copy_blobs_directory(source.path(), &source.path().join("blobs"), dst.path())
1829 .unwrap_err();
1830 assert!(
1831 err.to_string().contains("must not be a symlink"),
1832 "unexpected error: {err:#}"
1833 );
1834 assert!(!dst.path().join("blob.bin").exists());
1835 }
1836
1837 #[test]
1838 fn test_build_replaces_existing_bundle_without_stale_files() {
1839 let source = TempDir::new().unwrap();
1840 let output_parent = TempDir::new().unwrap();
1841 let output_dir = output_parent.path().join("bundle");
1842
1843 write_unencrypted_source(source.path(), "data.db", "fresh payload");
1844
1845 let builder = BundleBuilder::new();
1846 builder
1847 .build(source.path(), output_dir.as_path(), |_, _| {})
1848 .expect("initial build");
1849
1850 fs::write(output_dir.join("site/stale.txt"), "stale").unwrap();
1851 fs::write(output_dir.join("private/old-secret.txt"), "secret").unwrap();
1852 fs::write(output_dir.join("site/payload/old.bin"), "old").unwrap();
1853
1854 builder
1855 .build(source.path(), output_dir.as_path(), |_, _| {})
1856 .expect("rebuild");
1857
1858 assert!(output_dir.join("site/config.json").exists());
1859 assert!(
1860 output_dir
1861 .join("private/integrity-fingerprint.txt")
1862 .exists()
1863 );
1864 assert!(!output_dir.join("site/stale.txt").exists());
1865 assert!(!output_dir.join("private/old-secret.txt").exists());
1866 assert!(!output_dir.join("site/payload/old.bin").exists());
1867 assert!(output_dir.join("site/payload/data.db").exists());
1868 }
1869
1870 #[test]
1871 fn test_build_failure_preserves_existing_bundle() {
1872 let source = TempDir::new().unwrap();
1873 let output_parent = TempDir::new().unwrap();
1874 let output_dir = output_parent.path().join("bundle");
1875 let broken_source = TempDir::new().unwrap();
1876
1877 write_unencrypted_source(source.path(), "data.db", "fresh payload");
1878
1879 let builder = BundleBuilder::new();
1880 builder
1881 .build(source.path(), output_dir.as_path(), |_, _| {})
1882 .expect("initial build");
1883
1884 fs::write(output_dir.join("site/marker.txt"), "keep me").unwrap();
1885
1886 let result = builder.build(broken_source.path(), output_dir.as_path(), |_, _| {});
1887 assert!(result.is_err(), "broken rebuild should fail");
1888
1889 assert!(output_dir.join("site/marker.txt").exists());
1890 assert!(output_dir.join("site/config.json").exists());
1891 assert!(
1892 output_dir
1893 .join("private/integrity-fingerprint.txt")
1894 .exists()
1895 );
1896 }
1897
1898 #[test]
1899 #[cfg(unix)]
1900 fn test_build_rejects_symlinked_output_directory() {
1901 use std::os::unix::fs::symlink;
1902
1903 let source = TempDir::new().unwrap();
1904 let output_parent = TempDir::new().unwrap();
1905 let outside = TempDir::new().unwrap();
1906 let output_dir = output_parent.path().join("bundle-link");
1907
1908 write_unencrypted_source(source.path(), "data.db", "payload");
1909 symlink(outside.path(), &output_dir).unwrap();
1910
1911 let err = BundleBuilder::new()
1912 .build(source.path(), output_dir.as_path(), |_, _| {})
1913 .unwrap_err();
1914
1915 assert!(
1916 err.to_string().contains("must not be a symlink"),
1917 "unexpected error: {err:#}"
1918 );
1919 assert!(
1920 fs::symlink_metadata(&output_dir)
1921 .unwrap()
1922 .file_type()
1923 .is_symlink(),
1924 "rejected symlink output path must be preserved for operator inspection"
1925 );
1926 assert!(
1927 !outside.path().join("site").exists(),
1928 "build must not write through a symlinked output directory"
1929 );
1930 }
1931
1932 #[test]
1933 #[cfg(unix)]
1934 fn test_build_rejects_symlinked_output_parent_before_staging() {
1935 use std::os::unix::fs::symlink;
1936
1937 let source = TempDir::new().unwrap();
1938 let output_parent = TempDir::new().unwrap();
1939 let outside = TempDir::new().unwrap();
1940 let linked_parent = output_parent.path().join("linked-parent");
1941 let output_dir = linked_parent.join("bundle");
1942
1943 write_unencrypted_source(source.path(), "data.db", "payload");
1944 symlink(outside.path(), &linked_parent).unwrap();
1945
1946 let err = BundleBuilder::new()
1947 .build(source.path(), output_dir.as_path(), |_, _| {})
1948 .unwrap_err();
1949
1950 assert!(
1951 err.to_string().contains("parent must not contain symlinks"),
1952 "unexpected error: {err:#}"
1953 );
1954 assert!(
1955 fs::read_dir(outside.path()).unwrap().next().is_none(),
1956 "bundle builder must not stage output through a symlinked parent"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_replace_dir_from_temp_overwrites_existing_bundle() {
1962 let temp = TempDir::new().unwrap();
1963 let final_dir = temp.path().join("bundle");
1964 let staged_dir = temp.path().join("bundle.staged");
1965
1966 fs::create_dir_all(final_dir.join("site")).unwrap();
1967 fs::write(final_dir.join("site/old.txt"), "old").unwrap();
1968
1969 fs::create_dir_all(staged_dir.join("site")).unwrap();
1970 fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
1971
1972 replace_dir_from_temp(&staged_dir, &final_dir).unwrap();
1973
1974 assert!(!staged_dir.exists());
1975 assert!(final_dir.join("site/new.txt").exists());
1976 assert!(!final_dir.join("site/old.txt").exists());
1977 let sidecars = fs::read_dir(temp.path())
1978 .unwrap()
1979 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1980 .collect::<Vec<_>>();
1981 assert!(
1982 !sidecars.iter().any(|name| name.contains(".bundle.bak.")),
1983 "backup sidecar should be cleaned up, found: {sidecars:?}"
1984 );
1985 }
1986
1987 #[test]
1988 #[cfg(unix)]
1989 fn test_replace_dir_from_temp_rejects_dangling_symlink_target() {
1990 use std::os::unix::fs::symlink;
1991
1992 let temp = TempDir::new().unwrap();
1993 let final_dir = temp.path().join("bundle");
1994 let staged_dir = temp.path().join("bundle.staged");
1995
1996 fs::create_dir_all(staged_dir.join("site")).unwrap();
1997 fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
1998 symlink(temp.path().join("missing-target"), &final_dir).unwrap();
1999
2000 let err = replace_dir_from_temp(&staged_dir, &final_dir).unwrap_err();
2001 assert!(
2002 err.to_string().contains("must not be a symlink"),
2003 "unexpected error: {err:#}"
2004 );
2005 assert!(staged_dir.join("site/new.txt").exists());
2006 assert!(
2007 fs::symlink_metadata(&final_dir)
2008 .unwrap()
2009 .file_type()
2010 .is_symlink(),
2011 "dangling symlink target must not be silently replaced"
2012 );
2013 }
2014}