use std::fs;
use std::path::Path;
use anyhow::{bail, Context, Result};
use super::redact_home;
pub(super) const MAX_FILE_BYTES: u64 = 1024 * 1024;
pub(super) const MAX_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
pub(super) const MAX_FILES: usize = 10_000;
#[derive(Debug)]
pub(super) enum SourceDiscovery {
NotConfigured,
Ready(Vec<DiscoveredFile>),
}
#[derive(Debug, Clone)]
pub(super) struct DiscoveredFile {
pub rel_id: String,
pub content: String,
}
pub(super) fn discover_source(source_dir: &Path) -> Result<SourceDiscovery> {
match fs::symlink_metadata(source_dir) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(SourceDiscovery::NotConfigured);
}
Err(err) => {
bail!(
"codex memories source {} is not readable: {err}",
redact_home(source_dir)
);
}
Ok(metadata) if !metadata.is_dir() => {
bail!(
"codex memories source {} exists but is not a directory",
redact_home(source_dir)
);
}
Ok(_) => {}
}
let entries = fs::read_dir(source_dir)
.with_context(|| format!("read codex memories source {}", redact_home(source_dir)))?;
let mut files = Vec::new();
let mut total_bytes: u64 = 0;
for entry in entries {
let entry =
entry.with_context(|| format!("enumerate entry under {}", redact_home(source_dir)))?;
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
bail!(
"codex memories source contains a non-UTF-8 file name under {}",
redact_home(source_dir)
);
};
let metadata = fs::symlink_metadata(entry.path())
.with_context(|| format!("stat codex memories entry {name}"))?;
if metadata.file_type().is_symlink() {
bail!("codex memories entry {name} is a symlink; refusing to follow it");
}
if metadata.is_dir() {
bail!("codex memories source contains unexpected subdirectory {name}");
}
if !is_rollout_summary_filename(name) {
bail!(
"codex memories entry {name} does not match any supported format \
(supported: codex-rollout-summary/v1)"
);
}
if metadata.len() > MAX_FILE_BYTES {
bail!(
"codex memories entry {name} is {} bytes, above the {} byte per-file limit",
metadata.len(),
MAX_FILE_BYTES
);
}
total_bytes = total_bytes.saturating_add(metadata.len());
if total_bytes > MAX_TOTAL_BYTES {
bail!(
"codex memories source exceeds the {} byte total limit",
MAX_TOTAL_BYTES
);
}
if files.len() >= MAX_FILES {
bail!("codex memories source exceeds the {MAX_FILES} file limit");
}
let content = stable_read(&entry.path(), name)?;
files.push(DiscoveredFile {
rel_id: name.to_string(),
content,
});
}
files.sort_by(|a, b| a.rel_id.cmp(&b.rel_id));
Ok(SourceDiscovery::Ready(files))
}
fn stable_read(path: &Path, name: &str) -> Result<String> {
let before = fs::symlink_metadata(path)
.with_context(|| format!("stat codex memories entry {name} before read"))?;
let bytes = fs::read(path).with_context(|| format!("read codex memories entry {name}"))?;
let after = fs::symlink_metadata(path)
.with_context(|| format!("stat codex memories entry {name} after read"))?;
let same = before.len() == after.len()
&& before.modified().ok() == after.modified().ok()
&& before.len() == bytes.len() as u64;
if !same {
bail!("codex memories entry {name} changed while being read; retry the import");
}
String::from_utf8(bytes)
.map_err(|_| anyhow::anyhow!("codex memories entry {name} is not valid UTF-8"))
}
pub(super) fn is_rollout_summary_filename(name: &str) -> bool {
crate::install::is_codex_rollout_summary_filename(name)
}