use anyhow::{Context, Result, anyhow, bail};
use base64::prelude::*;
use chrono::Utc;
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use super::archive_config::{ArchiveConfig, UnencryptedConfig};
use super::docs::{DocLocation, GeneratedDoc};
use super::encrypt::{EncryptionConfig, validate_supported_payload_format};
const PAGES_ASSETS: &[(&str, &[u8])] = &[
("index.html", include_bytes!("../pages_assets/index.html")),
("styles.css", include_bytes!("../pages_assets/styles.css")),
("auth.js", include_bytes!("../pages_assets/auth.js")),
(
"password-strength.js",
include_bytes!("../pages_assets/password-strength.js"),
),
("viewer.js", include_bytes!("../pages_assets/viewer.js")),
("router.js", include_bytes!("../pages_assets/router.js")),
("share.js", include_bytes!("../pages_assets/share.js")),
("stats.js", include_bytes!("../pages_assets/stats.js")),
("storage.js", include_bytes!("../pages_assets/storage.js")),
("search.js", include_bytes!("../pages_assets/search.js")),
(
"conversation.js",
include_bytes!("../pages_assets/conversation.js"),
),
("database.js", include_bytes!("../pages_assets/database.js")),
("session.js", include_bytes!("../pages_assets/session.js")),
("sw.js", include_bytes!("../pages_assets/sw.js")),
(
"sw-register.js",
include_bytes!("../pages_assets/sw-register.js"),
),
(
"crypto_worker.js",
include_bytes!("../pages_assets/crypto_worker.js"),
),
(
"virtual-list.js",
include_bytes!("../pages_assets/virtual-list.js"),
),
(
"coi-detector.js",
include_bytes!("../pages_assets/coi-detector.js"),
),
(
"attachments.js",
include_bytes!("../pages_assets/attachments.js"),
),
("settings.js", include_bytes!("../pages_assets/settings.js")),
];
const MASTER_KEY_BACKUP_NOTE: &str =
"This file contains the wrapped DEK. Keep it with your recovery secret.";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityEntry {
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityManifest {
pub version: u8,
pub generated_at: String,
pub files: BTreeMap<String, IntegrityEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteMetadata {
pub title: String,
pub description: String,
pub generated_at: String,
pub generator: String,
pub generator_version: String,
}
#[derive(Debug, Clone)]
pub struct BundleConfig {
pub title: String,
pub description: String,
pub hide_metadata: bool,
pub recovery_secret: Option<Vec<u8>>,
pub generate_qr: bool,
pub generated_docs: Vec<GeneratedDoc>,
}
impl Default for BundleConfig {
fn default() -> Self {
Self {
title: "cass Archive".to_string(),
description: "Encrypted archive of AI coding agent conversations".to_string(),
hide_metadata: false,
recovery_secret: None,
generate_qr: false,
generated_docs: Vec::new(),
}
}
}
#[derive(Default)]
pub struct BundleBuilder {
config: BundleConfig,
}
impl BundleBuilder {
pub fn new() -> Self {
Self {
config: BundleConfig::default(),
}
}
pub fn with_config(config: BundleConfig) -> Self {
Self { config }
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.config.title = title.into();
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.config.description = description.into();
self
}
pub fn hide_metadata(mut self, hide: bool) -> Self {
self.config.hide_metadata = hide;
self
}
pub fn recovery_secret(mut self, recovery_material: Option<Vec<u8>>) -> Self {
let recovery_slot = &mut self.config.recovery_secret;
*recovery_slot = recovery_material;
self
}
pub fn generate_qr(mut self, generate: bool) -> Self {
self.config.generate_qr = generate;
self
}
pub fn with_docs(mut self, docs: Vec<GeneratedDoc>) -> Self {
self.config.generated_docs = docs;
self
}
pub fn build<P: AsRef<Path>>(
&self,
encrypted_dir: P,
output_dir: P,
progress: impl Fn(&str, &str),
) -> Result<BundleResult> {
let encrypted_dir = encrypted_dir.as_ref();
let output_dir = output_dir.as_ref();
ensure_replaceable_bundle_output_dir(output_dir)?;
let config_path = encrypted_dir.join("config.json");
let payload_dir = encrypted_dir.join("payload");
if !config_path.exists() {
bail!("Missing config.json in encrypted directory");
}
if !payload_dir.exists() {
bail!("Missing payload/ directory in encrypted directory");
}
let archive_config: ArchiveConfig = {
let file = File::open(&config_path).context("Failed to open config.json")?;
serde_json::from_reader(BufReader::new(file))?
};
let temp_output_dir = unique_bundle_dir(output_dir, "tmp")?;
let final_site_dir = output_dir.join("site");
let final_private_dir = output_dir.join("private");
let mut replace_attempted = false;
let result = (|| -> Result<BundleResult> {
progress("setup", "Creating directory structure...");
let site_dir = temp_output_dir.join("site");
let private_dir = temp_output_dir.join("private");
fs::create_dir_all(&site_dir).context("Failed to create site/ directory")?;
fs::create_dir_all(&private_dir).context("Failed to create private/ directory")?;
let site_payload_dir = site_dir.join("payload");
fs::create_dir_all(&site_payload_dir).context("Failed to create site/payload/")?;
progress("assets", "Copying web assets...");
for (name, content) in PAGES_ASSETS {
let dest_path = site_dir.join(name);
fs::write(&dest_path, content)
.with_context(|| format!("Failed to write {}", name))?;
}
let (chunk_count, is_encrypted) = match archive_config.as_encrypted() {
Some(enc_config) => {
progress("payload", "Copying encrypted payload...");
let count = copy_payload_chunks(
encrypted_dir,
&payload_dir,
&site_payload_dir,
enc_config,
)?;
(count, true)
}
None => {
progress("payload", "Copying unencrypted payload...");
let unenc_config = archive_config
.as_unencrypted()
.context("Unencrypted config missing")?;
let count = copy_payload_file(encrypted_dir, &site_dir, unenc_config)?;
(count, false)
}
};
let blobs_dir = encrypted_dir.join("blobs");
let attachment_count = if blobs_dir.exists() && blobs_dir.is_dir() {
progress("attachments", "Copying encrypted attachments...");
let site_blobs_dir = site_dir.join("blobs");
copy_blobs_directory(encrypted_dir, &blobs_dir, &site_blobs_dir)?
} else {
0
};
progress("config", "Writing configuration files...");
let site_config_path = site_dir.join("config.json");
let config_file = File::create(&site_config_path)?;
serde_json::to_writer_pretty(BufWriter::new(config_file), &archive_config)?;
let site_metadata = SiteMetadata {
title: self.config.title.clone(),
description: self.config.description.clone(),
generated_at: Utc::now().to_rfc3339(),
generator: "cass".to_string(),
generator_version: env!("CARGO_PKG_VERSION").to_string(),
};
let site_json_path = site_dir.join("site.json");
let site_json_file = File::create(&site_json_path)?;
serde_json::to_writer_pretty(BufWriter::new(site_json_file), &site_metadata)?;
progress("static", "Writing static files...");
let robots_content = "User-agent: *\nDisallow: /\n";
fs::write(site_dir.join("robots.txt"), robots_content)?;
fs::write(site_dir.join(".nojekyll"), "")?;
if !self.config.generated_docs.is_empty() {
progress("docs", "Writing generated documentation...");
for doc in &self.config.generated_docs {
let dest_path = resolve_generated_doc_path(&site_dir, doc)?;
fs::write(&dest_path, &doc.content)
.with_context(|| format!("Failed to write {}", doc.filename))?;
}
} else {
let public_readme = generate_public_readme(
&self.config.title,
&self.config.description,
is_encrypted,
);
fs::write(site_dir.join("README.md"), public_readme)?;
}
progress("integrity", "Generating integrity manifest...");
let integrity_manifest = generate_integrity_manifest(&site_dir)?;
let integrity_path = site_dir.join("integrity.json");
let integrity_file = File::create(&integrity_path)?;
serde_json::to_writer_pretty(BufWriter::new(integrity_file), &integrity_manifest)?;
let fingerprint = compute_fingerprint(&integrity_manifest);
progress("private", "Writing private artifacts...");
write_private_fingerprint(&private_dir, &fingerprint)?;
if is_encrypted {
let enc_config = archive_config
.as_encrypted()
.context("Encrypted config missing")?;
write_private_artifacts_encrypted(
&private_dir,
enc_config,
self.config.recovery_secret.as_deref(),
self.config.generate_qr,
true,
)?;
} else {
write_private_unencrypted_notice(&private_dir)?;
}
sync_tree(&temp_output_dir)?;
replace_attempted = true;
replace_dir_from_temp(&temp_output_dir, output_dir)
.context("Failed to install completed bundle")?;
progress("complete", "Bundle complete!");
Ok(BundleResult {
site_dir: final_site_dir,
private_dir: final_private_dir,
chunk_count,
attachment_count,
fingerprint,
total_files: integrity_manifest.files.len(),
})
})();
if result.is_err() && !replace_attempted {
let _ = fs::remove_dir_all(&temp_output_dir);
}
result
}
}
fn unique_bundle_dir(path: &Path, suffix: &str) -> Result<PathBuf> {
unique_bundle_sidecar_path(path, suffix, "pages_bundle")
}
fn unique_bundle_backup_dir(path: &Path) -> Result<PathBuf> {
unique_bundle_sidecar_path(path, "bak", "pages_bundle")
}
fn unique_bundle_sidecar_path(path: &Path, suffix: &str, fallback_name: &str) -> Result<PathBuf> {
static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let random_nonce = bundle_sidecar_random_nonce()?;
let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(fallback_name);
Ok(path.with_file_name(format!(".{file_name}.{suffix}.{random_nonce:032x}.{nonce}")))
}
fn bundle_sidecar_random_nonce() -> Result<u128> {
let mut bytes = [0u8; 16];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| anyhow!("failed to generate random bundle sidecar nonce"))?;
Ok(u128::from_le_bytes(bytes))
}
fn ensure_replaceable_bundle_output_dir(path: &Path) -> Result<bool> {
ensure_existing_parent_ancestors_are_real_dirs(path, "bundle output path")?;
match fs::symlink_metadata(path) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
bail!(
"bundle output path must not be a symlink: {}",
path.display()
);
}
if !file_type.is_dir() {
bail!(
"bundle output path points to a file, expected a directory: {}",
path.display()
);
}
Ok(true)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err)
.with_context(|| format!("failed inspecting bundle output path {}", path.display())),
}
}
fn ensure_existing_parent_ancestors_are_real_dirs(path: &Path, label: &str) -> Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
let mut ancestors = Vec::new();
let mut current = Some(parent);
while let Some(ancestor) = current {
if ancestor.as_os_str().is_empty() {
break;
}
ancestors.push(ancestor.to_path_buf());
current = ancestor.parent();
}
ancestors.reverse();
for ancestor in ancestors {
match fs::symlink_metadata(&ancestor) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
if is_allowed_system_symlink_ancestor(&ancestor) {
continue;
}
bail!(
"{label} parent must not contain symlinks: {}",
ancestor.display()
);
}
if !file_type.is_dir() {
bail!("{label} parent must be a directory: {}", ancestor.display());
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(err).with_context(|| {
format!("failed inspecting {label} parent {}", ancestor.display())
});
}
}
}
Ok(())
}
#[cfg(target_os = "macos")]
fn is_allowed_system_symlink_ancestor(path: &Path) -> bool {
path == Path::new("/var") || path == Path::new("/tmp")
}
#[cfg(not(target_os = "macos"))]
fn is_allowed_system_symlink_ancestor(_path: &Path) -> bool {
false
}
fn replace_dir_from_temp(temp_dir: &Path, final_dir: &Path) -> Result<()> {
if !ensure_replaceable_bundle_output_dir(final_dir)? {
fs::rename(temp_dir, final_dir).with_context(|| {
format!(
"failed renaming completed bundle {} into place at {}",
temp_dir.display(),
final_dir.display()
)
})?;
sync_parent_directory(final_dir)?;
return Ok(());
}
let backup_dir = unique_bundle_backup_dir(final_dir)?;
fs::rename(final_dir, &backup_dir).with_context(|| {
format!(
"failed preparing backup {} before replacing {}",
backup_dir.display(),
final_dir.display()
)
})?;
match fs::rename(temp_dir, final_dir) {
Ok(()) => {
sync_parent_directory(final_dir)?;
let _ = fs::remove_dir_all(&backup_dir);
sync_parent_directory(final_dir)?;
Ok(())
}
Err(second_err) => match fs::rename(&backup_dir, final_dir) {
Ok(()) => {
let _ = fs::remove_dir_all(temp_dir);
sync_parent_directory(final_dir)?;
bail!(
"failed replacing {} with {}: {}; restored original bundle",
final_dir.display(),
temp_dir.display(),
second_err
);
}
Err(restore_err) => {
bail!(
"failed replacing {} with {}: {}; restore error: {}; temp bundle retained at {}",
final_dir.display(),
temp_dir.display(),
second_err,
restore_err,
temp_dir.display()
);
}
},
}
}
#[cfg(not(windows))]
fn sync_tree(path: &Path) -> Result<()> {
sync_tree_inner(path)?;
sync_parent_directory(path)
}
#[cfg(windows)]
fn sync_tree(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(not(windows))]
fn sync_tree_inner(path: &Path) -> Result<()> {
let metadata = fs::symlink_metadata(path)
.with_context(|| format!("failed reading metadata for {}", path.display()))?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Ok(());
}
if file_type.is_file() {
File::open(path)
.with_context(|| format!("failed opening {} for sync", path.display()))?
.sync_all()
.with_context(|| format!("failed syncing {}", path.display()))?;
return Ok(());
}
if file_type.is_dir() {
for entry in
fs::read_dir(path).with_context(|| format!("failed reading {}", path.display()))?
{
let entry = entry.with_context(|| format!("failed walking {}", path.display()))?;
sync_tree_inner(&entry.path())?;
}
File::open(path)
.with_context(|| format!("failed opening directory {} for sync", path.display()))?
.sync_all()
.with_context(|| format!("failed syncing directory {}", path.display()))?;
}
Ok(())
}
#[cfg(not(windows))]
fn sync_parent_directory(path: &Path) -> Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
File::open(parent)
.with_context(|| format!("failed opening parent directory {}", parent.display()))?
.sync_all()
.with_context(|| format!("failed syncing parent directory {}", parent.display()))
}
#[cfg(windows)]
fn sync_parent_directory(_path: &Path) -> Result<()> {
Ok(())
}
#[derive(Debug, Clone)]
pub struct BundleResult {
pub site_dir: PathBuf,
pub private_dir: PathBuf,
pub chunk_count: usize,
pub attachment_count: usize,
pub fingerprint: String,
pub total_files: usize,
}
fn copy_payload_chunks(
src_root: &Path,
src_dir: &Path,
dest_dir: &Path,
config: &EncryptionConfig,
) -> Result<usize> {
ensure_regular_copy_directory_under_root(src_root, src_dir, "Encrypted payload directory")?;
validate_supported_payload_format(config)?;
let mut count = 0;
for (idx, expected_file) in config.payload.files.iter().enumerate() {
let expected_path = format!("payload/chunk-{idx:05}.bin");
if expected_file != &expected_path {
bail!(
"Encrypted payload file entry {idx} must be {expected_path}, got {expected_file}"
);
}
let rel_path = Path::new(expected_file);
let src_path = src_root.join(rel_path);
let label = format!("Encrypted payload chunk {expected_file}");
ensure_regular_copy_source_under_root(src_root, &src_path, &label)?;
let Some(filename) = rel_path.file_name() else {
bail!("Encrypted payload chunk path has no file name: {expected_file}");
};
let dest_path = dest_dir.join(filename);
fs::copy(&src_path, &dest_path)?;
count += 1;
}
Ok(count)
}
fn copy_payload_file(
src_root: &Path,
site_dir: &Path,
config: &UnencryptedConfig,
) -> Result<usize> {
let rel_path = Path::new(&config.payload.path);
if rel_path.is_absolute() {
bail!("Unencrypted payload path must be relative");
}
if rel_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("Unencrypted payload path must not contain '..'");
}
if !rel_path.starts_with("payload") {
bail!("Unencrypted payload path must reside under payload/");
}
let src_path = src_root.join(rel_path);
ensure_regular_copy_source_under_root(src_root, &src_path, "Unencrypted payload file")?;
let dest_path = site_dir.join(rel_path);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&src_path, &dest_path)?;
Ok(1)
}
fn resolve_generated_doc_path(site_dir: &Path, doc: &GeneratedDoc) -> Result<PathBuf> {
if doc.filename.contains(['/', '\\']) {
bail!(
"Generated documentation filename must not contain path separators: {}",
doc.filename
);
}
let rel_path = Path::new(&doc.filename);
let mut components = rel_path.components();
let Some(std::path::Component::Normal(file_name)) = components.next() else {
bail!(
"Generated documentation filename must be a plain relative file name: {}",
doc.filename
);
};
if components.next().is_some() {
bail!(
"Generated documentation filename must not contain path separators: {}",
doc.filename
);
}
Ok(match doc.location {
DocLocation::RepoRoot | DocLocation::WebRoot => site_dir.join(file_name),
})
}
fn ensure_regular_copy_source_under_root(
src_root: &Path,
src_path: &Path,
label: &str,
) -> Result<()> {
let metadata = fs::symlink_metadata(src_path)
.with_context(|| format!("{label} not found: {}", src_path.display()))?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
bail!("{label} must not be a symlink: {}", src_path.display());
}
if !file_type.is_file() {
bail!("{label} must be a regular file: {}", src_path.display());
}
let canonical_root = src_root.canonicalize().with_context(|| {
format!(
"Failed to resolve bundle source directory {}",
src_root.display()
)
})?;
let canonical_source = src_path.canonicalize().with_context(|| {
format!(
"Failed to resolve {label} source path {}",
src_path.display()
)
})?;
if !canonical_source.starts_with(&canonical_root) {
bail!(
"{label} resolves outside bundle source directory: {}",
src_path.display()
);
}
Ok(())
}
fn ensure_regular_copy_directory_under_root(
src_root: &Path,
src_dir: &Path,
label: &str,
) -> Result<()> {
let metadata = fs::symlink_metadata(src_dir)
.with_context(|| format!("{label} not found: {}", src_dir.display()))?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
bail!("{label} must not be a symlink: {}", src_dir.display());
}
if !file_type.is_dir() {
bail!("{label} must be a directory: {}", src_dir.display());
}
let canonical_root = src_root.canonicalize().with_context(|| {
format!(
"Failed to resolve bundle source directory {}",
src_root.display()
)
})?;
let canonical_source = src_dir.canonicalize().with_context(|| {
format!(
"Failed to resolve {label} source directory {}",
src_dir.display()
)
})?;
if !canonical_source.starts_with(&canonical_root) {
bail!(
"{label} resolves outside bundle source directory: {}",
src_dir.display()
);
}
Ok(())
}
fn copy_blobs_directory(src_root: &Path, src_dir: &Path, dest_dir: &Path) -> Result<usize> {
ensure_regular_copy_directory_under_root(src_root, src_dir, "Attachment blobs directory")?;
fs::create_dir_all(dest_dir).context("Failed to create blobs directory")?;
let mut count = 0;
for entry in fs::read_dir(src_dir)? {
let entry = entry?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
let file_type = metadata.file_type();
if file_type.is_file() {
let Some(filename) = path.file_name() else {
continue; };
let dest_path = dest_dir.join(filename);
fs::copy(&path, &dest_path)?;
count += 1;
}
}
Ok(count)
}
pub(crate) fn generate_integrity_manifest(dir: &Path) -> Result<IntegrityManifest> {
let mut files = BTreeMap::new();
collect_file_hashes(dir, dir, &mut files)?;
Ok(IntegrityManifest {
version: 1,
generated_at: Utc::now().to_rfc3339(),
files,
})
}
fn collect_file_hashes(
base_dir: &Path,
current_dir: &Path,
files: &mut BTreeMap<String, IntegrityEntry>,
) -> Result<()> {
let canonical_base_dir = base_dir.canonicalize().with_context(|| {
format!(
"Failed to resolve site directory {} while generating integrity manifest",
base_dir.display()
)
})?;
collect_file_hashes_recursive(base_dir, current_dir, &canonical_base_dir, files)
}
fn collect_file_hashes_recursive(
base_dir: &Path,
current_dir: &Path,
canonical_base_dir: &Path,
files: &mut BTreeMap<String, IntegrityEntry>,
) -> Result<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
let file_type = metadata.file_type();
let rel_path = path.strip_prefix(base_dir)?;
let rel_str = rel_path.to_string_lossy().replace('\\', "/");
if rel_str == "integrity.json" {
continue;
}
if file_type.is_dir() {
collect_file_hashes_recursive(base_dir, &path, canonical_base_dir, files)?;
} else if file_type.is_symlink() {
let canonical_target = path.canonicalize().with_context(|| {
format!(
"Failed to resolve symlink {} while generating integrity manifest",
rel_str
)
})?;
if !canonical_target.starts_with(canonical_base_dir) {
bail!(
"Refusing to include symlink outside site directory in integrity manifest: {}",
rel_str
);
}
let target_meta = fs::metadata(&path).with_context(|| {
format!(
"Failed to read symlink target metadata for {} while generating integrity manifest",
rel_str
)
})?;
if !target_meta.is_file() {
bail!(
"Refusing to include symlink that does not point to a regular file in integrity manifest: {}",
rel_str
);
}
files.insert(rel_str, build_integrity_entry(&path)?);
} else if file_type.is_file() {
files.insert(rel_str, build_integrity_entry(&path)?);
}
}
Ok(())
}
fn build_integrity_entry(path: &Path) -> Result<IntegrityEntry> {
let file = File::open(path)?;
let metadata = file.metadata()?;
let size = metadata.len();
let mut hasher = Sha256::new();
let mut reader = BufReader::new(file);
let mut buffer = [0u8; 8192];
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
Ok(IntegrityEntry {
sha256: hex::encode(hasher.finalize()),
size,
})
}
pub(crate) fn compute_fingerprint(manifest: &IntegrityManifest) -> String {
let mut hasher = Sha256::new();
for (path, entry) in &manifest.files {
hasher.update(path.as_bytes());
hasher.update(entry.sha256.as_bytes());
}
let hash = hasher.finalize();
hex::encode(hash)[..16].to_string()
}
pub(crate) fn write_private_fingerprint(private_dir: &Path, fingerprint: &str) -> Result<()> {
let fingerprint_content = format!(
"Integrity Fingerprint: {}\n\n\
Generated: {}\n\n\
Verify this fingerprint matches the one displayed in the web viewer\n\
before proceeding. If it doesn't match, the archive may have been\n\
tampered with.\n",
fingerprint,
Utc::now().to_rfc3339()
);
write_private_artifact_file(
private_dir,
"integrity-fingerprint.txt",
fingerprint_content.as_bytes(),
)?;
Ok(())
}
fn ensure_private_artifact_dir(private_dir: &Path) -> Result<()> {
ensure_existing_parent_ancestors_are_real_dirs(private_dir, "private artifact directory")?;
match fs::symlink_metadata(private_dir) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
bail!(
"private artifact directory must not be a symlink: {}",
private_dir.display()
);
}
if !file_type.is_dir() {
bail!(
"private artifact path must be a directory: {}",
private_dir.display()
);
}
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(private_dir).with_context(|| {
format!(
"Failed to create private artifact directory {}",
private_dir.display()
)
})?;
ensure_private_artifact_dir(private_dir)
}
Err(err) => Err(err).with_context(|| {
format!(
"Failed to inspect private artifact directory {}",
private_dir.display()
)
}),
}
}
fn reject_symlinked_private_artifact(path: &Path) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_symlink() {
bail!(
"private artifact file must not be a symlink: {}",
path.display()
);
}
if file_type.is_dir() {
bail!(
"private artifact path must be a regular file, not a directory: {}",
path.display()
);
}
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err)
.with_context(|| format!("Failed to inspect private artifact {}", path.display())),
}
}
fn write_private_artifact_file(private_dir: &Path, filename: &str, contents: &[u8]) -> Result<()> {
if filename.contains(['/', '\\']) {
bail!("private artifact filename must not contain path separators: {filename}");
}
ensure_private_artifact_dir(private_dir)?;
let final_path = private_dir.join(filename);
reject_symlinked_private_artifact(&final_path)?;
let temp_path = unique_bundle_sidecar_path(&final_path, "tmp", "private_artifact")?;
let write_result = (|| -> Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)
.with_context(|| {
format!(
"Failed to create temporary private artifact {}",
temp_path.display()
)
})?;
file.write_all(contents).with_context(|| {
format!(
"Failed to write temporary private artifact {}",
temp_path.display()
)
})?;
file.sync_all().with_context(|| {
format!(
"Failed to sync temporary private artifact {}",
temp_path.display()
)
})?;
Ok(())
})();
if let Err(err) = write_result {
let _ = fs::remove_file(&temp_path);
return Err(err);
}
if let Err(err) = fs::rename(&temp_path, &final_path) {
let _ = fs::remove_file(&temp_path);
return Err(err).with_context(|| {
format!(
"Failed to install private artifact {}",
final_path.display()
)
});
}
sync_parent_directory(&final_path)?;
Ok(())
}
pub(crate) fn write_private_artifacts_encrypted(
private_dir: &Path,
enc_config: &EncryptionConfig,
recovery_secret: Option<&[u8]>,
generate_qr: bool,
cleanup_missing_recovery: bool,
) -> Result<()> {
ensure_private_artifact_dir(private_dir)?;
let recovery_secret_path = private_dir.join("recovery-secret.txt");
let qr_png_path = private_dir.join("qr-code.png");
let qr_svg_path = private_dir.join("qr-code.svg");
if let Some(secret) = recovery_secret {
let recovery_b64 = BASE64_URL_SAFE_NO_PAD.encode(secret);
let recovery_content = format!(
"Recovery Secret\n\
================\n\n\
This secret can unlock your archive if you forget your password.\n\
Store it securely and NEVER share it.\n\n\
Secret (base64url):\n\
{}\n\n\
To use: Click \"Scan Recovery QR Code\" in the web viewer, or\n\
use this base64 value with the recovery function.\n\n\
Archive Export ID: {}\n\
Generated: {}\n",
recovery_b64,
enc_config.export_id,
Utc::now().to_rfc3339()
);
write_private_artifact_file(
private_dir,
"recovery-secret.txt",
recovery_content.as_bytes(),
)?;
if generate_qr {
generate_qr_codes(private_dir, &recovery_b64)?;
} else {
remove_file_if_exists(&qr_png_path)?;
remove_file_if_exists(&qr_svg_path)?;
}
} else if cleanup_missing_recovery {
remove_file_if_exists(&recovery_secret_path)?;
remove_file_if_exists(&qr_png_path)?;
remove_file_if_exists(&qr_svg_path)?;
}
let master_key_backup = master_key_backup_json(enc_config, Utc::now().to_rfc3339());
let master_key_json = serde_json::to_vec_pretty(&master_key_backup)?;
write_private_artifact_file(private_dir, "master-key.json", &master_key_json)?;
Ok(())
}
fn master_key_backup_json(
enc_config: &EncryptionConfig,
generated_at: String,
) -> serde_json::Value {
serde_json::json!({
"export_id": &enc_config.export_id,
"key_slots": &enc_config.key_slots,
"note": MASTER_KEY_BACKUP_NOTE,
"generated_at": generated_at,
})
}
fn remove_file_if_exists(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
fn write_private_unencrypted_notice(private_dir: &Path) -> Result<()> {
let content = format!(
"UNENCRYPTED ARCHIVE WARNING\n\
============================\n\n\
This bundle was generated WITHOUT encryption.\n\
Anyone with access to the site can read its contents.\n\n\
Generated: {}\n",
Utc::now().to_rfc3339()
);
write_private_artifact_file(private_dir, "unencrypted-warning.txt", content.as_bytes())?;
Ok(())
}
fn generate_qr_codes(private_dir: &Path, recovery_b64: &str) -> Result<()> {
if let Ok(qr_png) = super::qr::generate_qr_png(recovery_b64) {
write_private_artifact_file(private_dir, "qr-code.png", &qr_png)?;
}
if let Ok(qr_svg) = super::qr::generate_qr_svg(recovery_b64) {
write_private_artifact_file(private_dir, "qr-code.svg", qr_svg.as_bytes())?;
}
Ok(())
}
fn generate_public_readme(title: &str, description: &str, is_encrypted: bool) -> String {
let about_line = if is_encrypted {
"This is an encrypted, searchable archive of AI coding agent conversations"
} else {
"This is a searchable archive of AI coding agent conversations (not encrypted)"
};
let security_section = if is_encrypted {
r#"## Security
- All data is encrypted with AES-256-GCM
- Password-based key derivation uses Argon2id
- The archive can be safely hosted on public servers
- No data is accessible without the correct password"#
} else {
r#"## Security
⚠️ This archive is **NOT encrypted**.
Anyone with access to the site can read its contents.
Host it only on a trusted, private location."#
};
let open_section = if is_encrypted {
r#"## How to Open
1. Host these files on any static web server
2. Open index.html in a modern browser
3. Verify the fingerprint matches your records
4. Enter your password to decrypt"#
} else {
r#"## How to Open
1. Host these files on any static web server
2. Open index.html in a modern browser
3. Verify the fingerprint matches your records
4. The archive loads immediately (no password required)"#
};
let technical_section = if is_encrypted {
r#"## Technical Details
- Encryption: AES-256-GCM with chunked streaming
- KDF: Argon2id (64MB memory, 3 iterations)
- Search: SQLite with FTS5 (runs in browser via sql.js)
- Requires: SharedArrayBuffer (COOP/COEP headers)"#
} else {
r#"## Technical Details
- Encryption: none (unencrypted archive)
- Search: SQLite with FTS5 (runs in browser via sql.js)
- Requires: SharedArrayBuffer (COOP/COEP headers)"#
};
format!(
r#"# {}
{}
## About This Archive
{}
generated by [cass](https://github.com/Dicklesworthstone/coding_agent_session_search).
{}
{}
{}
## Files
- `index.html` - Entry point
- `config.json` - Public encryption parameters (no secrets)
- `integrity.json` - SHA256 hashes for all files
- `payload/` - Encrypted database chunks
- `*.js` - Application code
- `styles.css` - Styling
## Hosting Requirements
For the viewer to function correctly, your web server must set:
```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
The included service worker (sw.js) handles this automatically for
most static hosts (GitHub Pages, Cloudflare Pages, etc.).
---
Generated by cass v{}
"#,
title,
description,
about_line,
security_section,
open_section,
technical_section,
env!("CARGO_PKG_VERSION")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::archive_config::{ArchiveConfig, UnencryptedPayload};
use tempfile::TempDir;
fn write_unencrypted_source(root: &Path, payload_name: &str, body: &str) {
let payload_dir = root.join("payload");
fs::create_dir_all(&payload_dir).unwrap();
let payload_path = payload_dir.join(payload_name);
fs::write(&payload_path, body).unwrap();
let config = ArchiveConfig::Unencrypted(UnencryptedConfig {
encrypted: false,
version: "1.0.0".to_string(),
payload: UnencryptedPayload {
path: format!("payload/{payload_name}"),
format: "sqlite".to_string(),
size_bytes: Some(body.len() as u64),
},
warning: Some("UNENCRYPTED".to_string()),
});
let file = File::create(root.join("config.json")).unwrap();
serde_json::to_writer_pretty(BufWriter::new(file), &config).unwrap();
}
fn encrypted_config_for_files(files: Vec<&str>) -> EncryptionConfig {
let chunk_count = files.len();
EncryptionConfig {
version: crate::pages::encrypt::SCHEMA_VERSION,
export_id: "export-123".to_string(),
base_nonce: "nonce".to_string(),
compression: "deflate".to_string(),
kdf_defaults: crate::pages::encrypt::Argon2Params::default(),
payload: crate::pages::encrypt::PayloadMeta {
chunk_size: 1024,
chunk_count,
total_compressed_size: 0,
total_plaintext_size: 0,
files: files.into_iter().map(str::to_string).collect(),
},
key_slots: Vec::new(),
}
}
#[test]
fn test_bundle_builder_default() {
let builder = BundleBuilder::new();
assert_eq!(builder.config.title, "cass Archive");
assert!(!builder.config.hide_metadata);
assert!(!builder.config.generate_qr);
}
#[test]
fn test_bundle_builder_fluent() {
let builder = BundleBuilder::new()
.title("My Archive")
.description("Test description")
.hide_metadata(true)
.generate_qr(true);
assert_eq!(builder.config.title, "My Archive");
assert_eq!(builder.config.description, "Test description");
assert!(builder.config.hide_metadata);
assert!(builder.config.generate_qr);
}
#[test]
fn test_compute_fingerprint() {
let mut files = BTreeMap::new();
files.insert(
"test.txt".to_string(),
IntegrityEntry {
sha256: "abc123".to_string(),
size: 100,
},
);
let manifest = IntegrityManifest {
version: 1,
generated_at: "2024-01-01T00:00:00Z".to_string(),
files,
};
let fingerprint = compute_fingerprint(&manifest);
assert_eq!(fingerprint.len(), 16);
let fingerprint2 = compute_fingerprint(&manifest);
assert_eq!(fingerprint, fingerprint2);
}
#[test]
fn test_master_key_backup_json_shape() {
let config = EncryptionConfig {
version: 2,
export_id: "export-123".to_string(),
base_nonce: "nonce".to_string(),
compression: "deflate".to_string(),
kdf_defaults: crate::pages::encrypt::Argon2Params::default(),
payload: crate::pages::encrypt::PayloadMeta {
chunk_size: 1024,
chunk_count: 0,
total_compressed_size: 0,
total_plaintext_size: 0,
files: Vec::new(),
},
key_slots: Vec::new(),
};
let backup = master_key_backup_json(&config, "2026-04-25T19:08:00Z".to_string());
assert_eq!(backup["export_id"], "export-123");
assert_eq!(backup["key_slots"], serde_json::json!([]));
assert_eq!(backup["note"], MASTER_KEY_BACKUP_NOTE);
assert_eq!(backup["generated_at"], "2026-04-25T19:08:00Z");
}
#[test]
#[cfg(unix)]
fn test_private_artifacts_reject_symlinked_secret_file() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let private_dir = temp.path().join("private");
let outside_dir = temp.path().join("outside");
fs::create_dir_all(&private_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
let protected_secret = outside_dir.join("protected-secret.txt");
fs::write(&protected_secret, "do not overwrite").unwrap();
symlink(&protected_secret, private_dir.join("recovery-secret.txt")).unwrap();
let config = encrypted_config_for_files(Vec::new());
let err = write_private_artifacts_encrypted(
&private_dir,
&config,
Some(&[7u8; 32]),
false,
false,
)
.unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert_eq!(
fs::read_to_string(&protected_secret).unwrap(),
"do not overwrite"
);
assert!(
fs::symlink_metadata(private_dir.join("recovery-secret.txt"))
.unwrap()
.file_type()
.is_symlink(),
"rejected private artifact symlink should be left intact"
);
}
#[test]
#[cfg(unix)]
fn test_private_artifacts_cleanup_rejects_symlinked_private_dir_before_removal() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let outside_dir = temp.path().join("outside");
let private_dir = temp.path().join("private");
fs::create_dir_all(&outside_dir).unwrap();
fs::write(outside_dir.join("recovery-secret.txt"), "keep recovery").unwrap();
fs::write(outside_dir.join("qr-code.png"), "keep png").unwrap();
fs::write(outside_dir.join("qr-code.svg"), "keep svg").unwrap();
symlink(&outside_dir, &private_dir).unwrap();
let config = encrypted_config_for_files(Vec::new());
let err = write_private_artifacts_encrypted(&private_dir, &config, None, false, true)
.unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert_eq!(
fs::read_to_string(outside_dir.join("recovery-secret.txt")).unwrap(),
"keep recovery"
);
assert_eq!(
fs::read_to_string(outside_dir.join("qr-code.png")).unwrap(),
"keep png"
);
assert_eq!(
fs::read_to_string(outside_dir.join("qr-code.svg")).unwrap(),
"keep svg"
);
}
#[test]
#[cfg(unix)]
fn test_private_artifacts_reject_symlinked_parent_before_writing() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
let linked_parent = temp.path().join("linked-parent");
let private_dir = linked_parent.join("private");
symlink(outside_dir.path(), &linked_parent).unwrap();
let err = write_private_fingerprint(&private_dir, "fingerprint").unwrap_err();
assert!(
err.to_string().contains("parent must not contain symlinks"),
"unexpected error: {err:#}"
);
assert!(
fs::read_dir(outside_dir.path()).unwrap().next().is_none(),
"private artifact writer must not create files through a symlinked parent"
);
}
#[test]
fn test_generate_public_readme() {
let readme = generate_public_readme("Test Archive", "A test archive", true);
assert!(readme.contains("Test Archive"));
assert!(readme.contains("A test archive"));
assert!(readme.contains("AES-256-GCM"));
assert!(readme.contains("Argon2id"));
let unencrypted = generate_public_readme("Test Archive", "A test archive", false);
assert!(unencrypted.contains("NOT encrypted"));
assert!(unencrypted.contains("no password required"));
}
#[test]
fn test_integrity_manifest_excludes_itself() {
let temp = TempDir::new().unwrap();
let temp_path = temp.path();
fs::write(temp_path.join("test.txt"), "hello").unwrap();
fs::write(temp_path.join("integrity.json"), "{}").unwrap();
let manifest = generate_integrity_manifest(temp_path).unwrap();
assert!(manifest.files.contains_key("test.txt"));
assert!(!manifest.files.contains_key("integrity.json"));
}
#[test]
fn test_collect_file_hashes() {
let temp = TempDir::new().unwrap();
let temp_path = temp.path();
fs::create_dir_all(temp_path.join("subdir")).unwrap();
fs::write(temp_path.join("root.txt"), "root").unwrap();
fs::write(temp_path.join("subdir/nested.txt"), "nested").unwrap();
let mut files = BTreeMap::new();
collect_file_hashes(temp_path, temp_path, &mut files).unwrap();
assert_eq!(files.len(), 2);
assert!(files.contains_key("root.txt"));
assert!(files.contains_key("subdir/nested.txt"));
for entry in files.values() {
assert_eq!(entry.sha256.len(), 64);
}
}
#[test]
#[cfg(unix)]
fn test_collect_file_hashes_includes_symlinked_files_within_site() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let temp_path = temp.path();
fs::write(temp_path.join("real.txt"), "real").unwrap();
symlink("real.txt", temp_path.join("linked-file.txt")).unwrap();
let mut files = BTreeMap::new();
collect_file_hashes(temp_path, temp_path, &mut files).unwrap();
assert_eq!(files.len(), 2);
assert!(files.contains_key("real.txt"));
assert!(files.contains_key("linked-file.txt"));
assert_eq!(files["real.txt"].sha256, files["linked-file.txt"].sha256);
assert_eq!(files["real.txt"].size, files["linked-file.txt"].size);
}
#[test]
#[cfg(unix)]
fn test_collect_file_hashes_rejects_symlinks_outside_site() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let temp_path = temp.path();
let outside = TempDir::new().unwrap();
fs::write(temp_path.join("root.txt"), "root").unwrap();
fs::write(outside.path().join("secret.txt"), "secret").unwrap();
fs::create_dir_all(outside.path().join("nested")).unwrap();
fs::write(outside.path().join("nested/hidden.txt"), "hidden").unwrap();
symlink(
outside.path().join("secret.txt"),
temp_path.join("linked-file.txt"),
)
.unwrap();
symlink(outside.path().join("nested"), temp_path.join("linked-dir")).unwrap();
let mut files = BTreeMap::new();
let err = collect_file_hashes(temp_path, temp_path, &mut files).unwrap_err();
assert!(
err.to_string().contains("outside site directory"),
"unexpected error: {err:#}"
);
}
#[test]
fn test_copy_payload_chunks_copies_only_manifest_files() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let payload_dir = src.path().join("payload");
fs::create_dir_all(&payload_dir).unwrap();
fs::write(payload_dir.join("chunk-00000.bin"), "chunk").unwrap();
fs::write(payload_dir.join("chunk-99999.bin"), "stale chunk").unwrap();
fs::write(payload_dir.join("secret.bin"), "unlisted payload").unwrap();
let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
let copied = copy_payload_chunks(src.path(), &payload_dir, dst.path(), &config).unwrap();
assert_eq!(copied, 1);
assert!(dst.path().join("chunk-00000.bin").exists());
assert!(!dst.path().join("chunk-99999.bin").exists());
assert!(!dst.path().join("secret.bin").exists());
}
#[test]
#[cfg(unix)]
fn test_copy_payload_chunks_rejects_manifest_symlinked_chunk() {
use std::os::unix::fs::symlink;
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let payload_dir = src.path().join("payload");
fs::create_dir_all(&payload_dir).unwrap();
fs::write(outside.path().join("secret.bin"), "secret").unwrap();
symlink(
outside.path().join("secret.bin"),
payload_dir.join("chunk-00000.bin"),
)
.unwrap();
let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
let err = copy_payload_chunks(src.path(), &payload_dir, dst.path(), &config).unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert!(!dst.path().join("chunk-00000.bin").exists());
}
#[test]
#[cfg(unix)]
fn test_copy_payload_chunks_rejects_symlinked_source_directory() {
use std::os::unix::fs::symlink;
let source = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("chunk-0.bin"), "outside chunk").unwrap();
symlink(outside.path(), source.path().join("payload")).unwrap();
let config = encrypted_config_for_files(vec!["payload/chunk-00000.bin"]);
let err = copy_payload_chunks(
source.path(),
&source.path().join("payload"),
dst.path(),
&config,
)
.unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert!(!dst.path().join("chunk-0.bin").exists());
}
#[test]
#[cfg(unix)]
fn test_copy_unencrypted_payload_rejects_final_symlink() {
use std::os::unix::fs::symlink;
let source = TempDir::new().unwrap();
let site = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::create_dir_all(source.path().join("payload")).unwrap();
fs::write(outside.path().join("secret.db"), "outside secret").unwrap();
symlink(
outside.path().join("secret.db"),
source.path().join("payload/data.db"),
)
.unwrap();
let config = UnencryptedConfig {
encrypted: false,
version: "1.0.0".to_string(),
payload: UnencryptedPayload {
path: "payload/data.db".to_string(),
format: "sqlite".to_string(),
size_bytes: None,
},
warning: None,
};
let err = copy_payload_file(source.path(), site.path(), &config).unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert!(!site.path().join("payload/data.db").exists());
}
#[test]
#[cfg(unix)]
fn test_copy_unencrypted_payload_rejects_symlinked_parent_escape() {
use std::os::unix::fs::symlink;
let source = TempDir::new().unwrap();
let site = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("data.db"), "outside secret").unwrap();
symlink(outside.path(), source.path().join("payload")).unwrap();
let config = UnencryptedConfig {
encrypted: false,
version: "1.0.0".to_string(),
payload: UnencryptedPayload {
path: "payload/data.db".to_string(),
format: "sqlite".to_string(),
size_bytes: None,
},
warning: None,
};
let err = copy_payload_file(source.path(), site.path(), &config).unwrap_err();
assert!(
err.to_string().contains("outside bundle source directory"),
"unexpected error: {err:#}"
);
assert!(!site.path().join("payload/data.db").exists());
}
#[test]
fn test_generated_docs_reject_path_traversal_filename() {
let source = TempDir::new().unwrap();
let output_parent = TempDir::new().unwrap();
let output_dir = output_parent.path().join("bundle");
write_unencrypted_source(source.path(), "data.db", "payload");
let config = BundleConfig {
generated_docs: vec![GeneratedDoc {
filename: "../escaped.md".to_string(),
content: "escaped".to_string(),
location: DocLocation::WebRoot,
}],
..BundleConfig::default()
};
let err = BundleBuilder::with_config(config)
.build(source.path(), output_dir.as_path(), |_, _| {})
.unwrap_err();
assert!(
err.to_string().contains("must not contain path separators"),
"unexpected error: {err:#}"
);
assert!(!output_parent.path().join("escaped.md").exists());
}
#[test]
fn test_generated_docs_reject_backslash_separator_filename() {
let doc = GeneratedDoc {
filename: r"nested\escaped.md".to_string(),
content: "escaped".to_string(),
location: DocLocation::WebRoot,
};
let err = resolve_generated_doc_path(Path::new("site"), &doc).unwrap_err();
assert!(
err.to_string().contains("must not contain path separators"),
"unexpected error: {err:#}"
);
}
#[test]
#[cfg(unix)]
fn test_copy_blobs_directory_skips_symlinked_files() {
use std::os::unix::fs::symlink;
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(src.path().join("blob.bin"), "blob").unwrap();
fs::write(outside.path().join("secret.bin"), "secret").unwrap();
symlink(
outside.path().join("secret.bin"),
src.path().join("linked-blob.bin"),
)
.unwrap();
let copied = copy_blobs_directory(src.path(), src.path(), dst.path()).unwrap();
assert_eq!(copied, 1);
assert!(dst.path().join("blob.bin").exists());
assert!(!dst.path().join("linked-blob.bin").exists());
}
#[test]
#[cfg(unix)]
fn test_copy_blobs_directory_rejects_symlinked_source_directory() {
use std::os::unix::fs::symlink;
let source = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("blob.bin"), "outside blob").unwrap();
symlink(outside.path(), source.path().join("blobs")).unwrap();
let err = copy_blobs_directory(source.path(), &source.path().join("blobs"), dst.path())
.unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert!(!dst.path().join("blob.bin").exists());
}
#[test]
fn test_build_replaces_existing_bundle_without_stale_files() {
let source = TempDir::new().unwrap();
let output_parent = TempDir::new().unwrap();
let output_dir = output_parent.path().join("bundle");
write_unencrypted_source(source.path(), "data.db", "fresh payload");
let builder = BundleBuilder::new();
builder
.build(source.path(), output_dir.as_path(), |_, _| {})
.expect("initial build");
fs::write(output_dir.join("site/stale.txt"), "stale").unwrap();
fs::write(output_dir.join("private/old-secret.txt"), "secret").unwrap();
fs::write(output_dir.join("site/payload/old.bin"), "old").unwrap();
builder
.build(source.path(), output_dir.as_path(), |_, _| {})
.expect("rebuild");
assert!(output_dir.join("site/config.json").exists());
assert!(
output_dir
.join("private/integrity-fingerprint.txt")
.exists()
);
assert!(!output_dir.join("site/stale.txt").exists());
assert!(!output_dir.join("private/old-secret.txt").exists());
assert!(!output_dir.join("site/payload/old.bin").exists());
assert!(output_dir.join("site/payload/data.db").exists());
}
#[test]
fn test_build_failure_preserves_existing_bundle() {
let source = TempDir::new().unwrap();
let output_parent = TempDir::new().unwrap();
let output_dir = output_parent.path().join("bundle");
let broken_source = TempDir::new().unwrap();
write_unencrypted_source(source.path(), "data.db", "fresh payload");
let builder = BundleBuilder::new();
builder
.build(source.path(), output_dir.as_path(), |_, _| {})
.expect("initial build");
fs::write(output_dir.join("site/marker.txt"), "keep me").unwrap();
let result = builder.build(broken_source.path(), output_dir.as_path(), |_, _| {});
assert!(result.is_err(), "broken rebuild should fail");
assert!(output_dir.join("site/marker.txt").exists());
assert!(output_dir.join("site/config.json").exists());
assert!(
output_dir
.join("private/integrity-fingerprint.txt")
.exists()
);
}
#[test]
#[cfg(unix)]
fn test_build_rejects_symlinked_output_directory() {
use std::os::unix::fs::symlink;
let source = TempDir::new().unwrap();
let output_parent = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let output_dir = output_parent.path().join("bundle-link");
write_unencrypted_source(source.path(), "data.db", "payload");
symlink(outside.path(), &output_dir).unwrap();
let err = BundleBuilder::new()
.build(source.path(), output_dir.as_path(), |_, _| {})
.unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert!(
fs::symlink_metadata(&output_dir)
.unwrap()
.file_type()
.is_symlink(),
"rejected symlink output path must be preserved for operator inspection"
);
assert!(
!outside.path().join("site").exists(),
"build must not write through a symlinked output directory"
);
}
#[test]
#[cfg(unix)]
fn test_build_rejects_symlinked_output_parent_before_staging() {
use std::os::unix::fs::symlink;
let source = TempDir::new().unwrap();
let output_parent = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let linked_parent = output_parent.path().join("linked-parent");
let output_dir = linked_parent.join("bundle");
write_unencrypted_source(source.path(), "data.db", "payload");
symlink(outside.path(), &linked_parent).unwrap();
let err = BundleBuilder::new()
.build(source.path(), output_dir.as_path(), |_, _| {})
.unwrap_err();
assert!(
err.to_string().contains("parent must not contain symlinks"),
"unexpected error: {err:#}"
);
assert!(
fs::read_dir(outside.path()).unwrap().next().is_none(),
"bundle builder must not stage output through a symlinked parent"
);
}
#[test]
fn test_replace_dir_from_temp_overwrites_existing_bundle() {
let temp = TempDir::new().unwrap();
let final_dir = temp.path().join("bundle");
let staged_dir = temp.path().join("bundle.staged");
fs::create_dir_all(final_dir.join("site")).unwrap();
fs::write(final_dir.join("site/old.txt"), "old").unwrap();
fs::create_dir_all(staged_dir.join("site")).unwrap();
fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
replace_dir_from_temp(&staged_dir, &final_dir).unwrap();
assert!(!staged_dir.exists());
assert!(final_dir.join("site/new.txt").exists());
assert!(!final_dir.join("site/old.txt").exists());
let sidecars = fs::read_dir(temp.path())
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(
!sidecars.iter().any(|name| name.contains(".bundle.bak.")),
"backup sidecar should be cleaned up, found: {sidecars:?}"
);
}
#[test]
#[cfg(unix)]
fn test_replace_dir_from_temp_rejects_dangling_symlink_target() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let final_dir = temp.path().join("bundle");
let staged_dir = temp.path().join("bundle.staged");
fs::create_dir_all(staged_dir.join("site")).unwrap();
fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
symlink(temp.path().join("missing-target"), &final_dir).unwrap();
let err = replace_dir_from_temp(&staged_dir, &final_dir).unwrap_err();
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err:#}"
);
assert!(staged_dir.join("site/new.txt").exists());
assert!(
fs::symlink_metadata(&final_dir)
.unwrap()
.file_type()
.is_symlink(),
"dangling symlink target must not be silently replaced"
);
}
}