Skip to main content

coding_agent_search/pages/
bundle.rs

1//! Bundle builder for pages export.
2//!
3//! Creates the deployable static site bundle (site/) and private offline artifacts (private/)
4//! from an export. Output is safe for public hosting (GitHub Pages / Cloudflare Pages).
5
6use 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
21/// Files embedded from pages_assets at compile time
22const 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/// Integrity entry for a single file
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct IntegrityEntry {
72    /// SHA256 hash as hex string
73    pub sha256: String,
74    /// File size in bytes
75    pub size: u64,
76}
77
78/// Full integrity manifest
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct IntegrityManifest {
81    /// Schema version for integrity format
82    pub version: u8,
83    /// Generated timestamp
84    pub generated_at: String,
85    /// Map of relative path -> integrity entry
86    pub files: BTreeMap<String, IntegrityEntry>,
87}
88
89/// Site metadata for public config
90#[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/// Bundle configuration
100#[derive(Debug, Clone)]
101pub struct BundleConfig {
102    /// Archive title
103    pub title: String,
104    /// Archive description
105    pub description: String,
106    /// Whether to obfuscate metadata (workspace paths etc)
107    pub hide_metadata: bool,
108    /// Recovery secret bytes (if generated)
109    pub recovery_secret: Option<Vec<u8>>,
110    /// Whether to generate QR codes for recovery
111    pub generate_qr: bool,
112    /// Additional generated documentation files to include
113    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/// Bundle builder for creating static site exports
130#[derive(Default)]
131pub struct BundleBuilder {
132    config: BundleConfig,
133}
134
135impl BundleBuilder {
136    /// Create a new bundle builder with default config
137    pub fn new() -> Self {
138        Self {
139            config: BundleConfig::default(),
140        }
141    }
142
143    /// Create a bundle builder with specific config
144    pub fn with_config(config: BundleConfig) -> Self {
145        Self { config }
146    }
147
148    /// Set the archive title
149    pub fn title(mut self, title: impl Into<String>) -> Self {
150        self.config.title = title.into();
151        self
152    }
153
154    /// Set the archive description
155    pub fn description(mut self, description: impl Into<String>) -> Self {
156        self.config.description = description.into();
157        self
158    }
159
160    /// Set metadata hiding option
161    pub fn hide_metadata(mut self, hide: bool) -> Self {
162        self.config.hide_metadata = hide;
163        self
164    }
165
166    /// Set the recovery secret
167    pub fn recovery_secret(mut self, recovery_material: Option<Vec<u8>>) -> Self {
168        // ubs:ignore — this stores caller-provided recovery bytes; no secret literal is embedded.
169        let recovery_slot = &mut self.config.recovery_secret;
170        *recovery_slot = recovery_material;
171        self
172    }
173
174    /// Set QR code generation option
175    pub fn generate_qr(mut self, generate: bool) -> Self {
176        self.config.generate_qr = generate;
177        self
178    }
179
180    /// Add generated documentation files to include in the bundle
181    pub fn with_docs(mut self, docs: Vec<GeneratedDoc>) -> Self {
182        self.config.generated_docs = docs;
183        self
184    }
185
186    /// Build the bundle from encrypted output
187    ///
188    /// # Arguments
189    /// * `encrypted_dir` - Directory containing encryption output (config.json, payload/)
190    /// * `output_dir` - Directory to write the bundle (will create site/ and private/ subdirs)
191    /// * `progress` - Progress callback (phase, message)
192    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        // Validate encrypted_dir has required files
204        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        // Load archive config (encrypted or unencrypted)
215        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            // Stage the bundle under a unique temp root so reruns do not retain stale files.
228            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            // Create site subdirectories
235            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            // Copy embedded assets to site/
241            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            // Copy payload into site/payload/
248            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            // Copy attachment blobs if present
270            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            // Write config.json to site/ (already has public params only)
282            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            // Write site metadata
287            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            // Write robots.txt
301            let robots_content = "User-agent: *\nDisallow: /\n";
302            fs::write(site_dir.join("robots.txt"), robots_content)?;
303
304            // Write .nojekyll (empty file to disable Jekyll processing)
305            fs::write(site_dir.join(".nojekyll"), "")?;
306
307            // Write generated documentation if provided, otherwise fallback to basic readme
308            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                // Fallback to basic README.md
317                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            // Generate integrity.json for all files in site/
328            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            // Compute integrity fingerprint (short hash for visual verification)
334            let fingerprint = compute_fingerprint(&integrity_manifest);
335
336            progress("private", "Writing private artifacts...");
337
338            // Write private artifacts
339            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/// Result from bundle building
589#[derive(Debug, Clone)]
590pub struct BundleResult {
591    /// Path to site/ directory (deploy this)
592    pub site_dir: PathBuf,
593    /// Path to private/ directory (never deploy)
594    pub private_dir: PathBuf,
595    /// Number of encrypted payload chunks
596    pub chunk_count: usize,
597    /// Number of encrypted attachment blobs
598    pub attachment_count: usize,
599    /// Integrity fingerprint (for visual verification)
600    pub fingerprint: String,
601    /// Total number of files in site/
602    pub total_files: usize,
603}
604
605/// Copy encrypted payload chunks from source to destination.
606///
607/// The archive config is the authority: copying by directory scan can publish
608/// stale chunks left behind by an earlier export.
609fn 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
644/// Copy a single unencrypted payload file into the site directory.
645fn 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
778/// Copy encrypted attachment blobs from source to destination
779fn 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; // Skip entries without valid filenames
794            };
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
804/// Generate integrity manifest for all files in a directory
805pub(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
817/// Recursively collect SHA256 hashes of all files
818fn 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        // Skip integrity.json itself (chicken/egg)
847        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        // sha2 ≥ 0.11 dropped `LowerHex` for the `Output` GenericArray;
908        // route through `hex::encode` for the same lowercase-hex wire
909        // format.
910        sha256: hex::encode(hasher.finalize()),
911        size,
912    })
913}
914
915/// Compute a short fingerprint from the integrity manifest
916pub(crate) fn compute_fingerprint(manifest: &IntegrityManifest) -> String {
917    // Compute a fingerprint by hashing the sorted list of file hashes
918    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    // Return first 16 hex chars as fingerprint. `hex::encode` replaces the
928    // pre-sha2-0.11 `format!("{:x}", hash)` path (Output no longer
929    // implements `LowerHex`).
930    hex::encode(hash)[..16].to_string()
931}
932
933/// Write private artifacts that should never be deployed
934pub(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    // Write recovery secret if provided
1082    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        // Generate QR code if requested
1106        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    // Write master key backup (encrypted DEK wrapped with KEK)
1119    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
1159/// Generate QR code images for recovery secret
1160fn generate_qr_codes(private_dir: &Path, recovery_b64: &str) -> Result<()> {
1161    // Use the qr module from pages if available
1162    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
1173/// Generate public README for the site directory
1174fn 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        // Same manifest should produce same fingerprint
1367        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        // Create some test files
1516        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        // Should include test.txt but not integrity.json
1522        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        // Create nested structure
1532        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        // Verify hash is SHA256 hex (64 chars)
1544        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}