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                    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/// Result from bundle building
602#[derive(Debug, Clone)]
603pub struct BundleResult {
604    /// Path to site/ directory (deploy this)
605    pub site_dir: PathBuf,
606    /// Path to private/ directory (never deploy)
607    pub private_dir: PathBuf,
608    /// Number of encrypted payload chunks
609    pub chunk_count: usize,
610    /// Number of encrypted attachment blobs
611    pub attachment_count: usize,
612    /// Integrity fingerprint (for visual verification)
613    pub fingerprint: String,
614    /// Total number of files in site/
615    pub total_files: usize,
616}
617
618/// Copy encrypted payload chunks from source to destination.
619///
620/// The archive config is the authority: copying by directory scan can publish
621/// stale chunks left behind by an earlier export.
622fn 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
657/// Copy a single unencrypted payload file into the site directory.
658fn 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
791/// Copy encrypted attachment blobs from source to destination
792fn 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; // Skip entries without valid filenames
807            };
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
817/// Generate integrity manifest for all files in a directory
818pub(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
830/// Recursively collect SHA256 hashes of all files
831fn 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        // Skip integrity.json itself (chicken/egg)
860        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        // sha2 ≥ 0.11 dropped `LowerHex` for the `Output` GenericArray;
921        // route through `hex::encode` for the same lowercase-hex wire
922        // format.
923        sha256: hex::encode(hasher.finalize()),
924        size,
925    })
926}
927
928/// Compute a short fingerprint from the integrity manifest
929pub(crate) fn compute_fingerprint(manifest: &IntegrityManifest) -> String {
930    // Compute a fingerprint by hashing the sorted list of file hashes
931    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    // Return first 16 hex chars as fingerprint. `hex::encode` replaces the
941    // pre-sha2-0.11 `format!("{:x}", hash)` path (Output no longer
942    // implements `LowerHex`).
943    hex::encode(hash)[..16].to_string()
944}
945
946/// Write private artifacts that should never be deployed
947pub(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    // Write recovery secret if provided
1095    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        // Generate QR code if requested
1119        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    // Write master key backup (encrypted DEK wrapped with KEK)
1132    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
1172/// Generate QR code images for recovery secret
1173fn generate_qr_codes(private_dir: &Path, recovery_b64: &str) -> Result<()> {
1174    // Use the qr module from pages if available
1175    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
1186/// Generate public README for the site directory
1187fn 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        // Same manifest should produce same fingerprint
1380        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        // Create some test files
1529        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        // Should include test.txt but not integrity.json
1535        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        // Create nested structure
1545        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        // Verify hash is SHA256 hex (64 chars)
1557        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}