use crate::manifest::ItemKind;
use std::path::{Path, PathBuf};
const SKIP_DIRS: &[&str] = &[
".git", ".hg", ".svn", "target", "node_modules", ".venv", "venv",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build", ".next",
".turbo", ".gradle", ".terraform", "vendor", ".DS_Store", ".ctx",
];
const SECRET_DIRS: &[&str] = &[
".ssh", ".aws", ".gnupg", ".docker", ".kube", ".config/gcloud", "secrets", "secret",
".azure", ".gcp", "keys", ".netlify", ".terraform.d", ".chef", ".vault",
];
fn looks_secret(name: &str) -> bool {
let n = name.to_ascii_lowercase();
n.starts_with(".env")
|| n.ends_with(".env")
|| n.contains(".env.")
|| n.contains(".netrc")
|| n.contains(".npmrc")
|| n.contains(".pypirc")
|| n.contains("git-credentials")
|| n == "credentials"
|| n.starts_with("credentials.")
|| n == "identity.key"
|| n.starts_with("id_rsa")
|| n.starts_with("id_ed25519")
|| n.starts_with("id_ecdsa")
|| n.starts_with("id_dsa")
|| n.ends_with(".pem")
|| n.ends_with(".key")
|| n.ends_with(".p12")
|| n.ends_with(".p8")
|| n.ends_with(".pfx")
|| n.ends_with(".jks")
|| n.ends_with(".keystore")
|| n.ends_with(".kdbx")
|| n.ends_with(".ovpn")
|| n == "service-account.json"
|| n == "token.json"
|| n == "token"
|| n.contains("secret")
|| n.contains("_token")
|| n.contains("-token")
|| n.contains("token_")
|| n.contains("apikey")
|| n.contains("api_key")
|| n.contains("api-key")
|| n.contains("passwd")
|| n.contains("password")
}
fn in_secret_dir(rel: &str) -> Option<String> {
let lower = rel.to_ascii_lowercase().replace('\\', "/");
let parts: Vec<&str> = lower.split('/').collect();
for (i, seg) in parts.iter().enumerate() {
if i + 1 == parts.len() {
break;
}
if SECRET_DIRS.contains(seg) {
return Some((*seg).to_string());
}
if i + 2 < parts.len() {
let pair = format!("{}/{}", seg, parts[i + 1]);
if SECRET_DIRS.contains(&pair.as_str()) {
return Some(pair);
}
}
}
None
}
pub struct Collected {
pub items: Vec<(ItemKind, String, PathBuf)>,
pub skipped_secret: Vec<String>,
pub skipped_dir: Vec<String>,
pub skipped_big: Vec<String>,
pub skipped_link: Vec<String>,
}
pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_TOTAL_BYTES: u64 = 25 * 1024 * 1024;
impl Collected {
fn new() -> Self {
Collected {
items: Vec::new(),
skipped_secret: Vec::new(),
skipped_dir: Vec::new(),
skipped_big: Vec::new(),
skipped_link: Vec::new(),
}
}
pub fn total(&self) -> u64 {
self.items
.iter()
.filter_map(|(_, _, p)| std::fs::metadata(p).ok().map(|m| m.len()))
.sum()
}
}
fn add_one(
c: &mut Collected,
kind: ItemKind,
rel: String,
src: &Path,
allow_secret: bool,
) -> std::io::Result<()> {
match std::fs::symlink_metadata(src) {
Ok(md) if md.file_type().is_symlink() => {
let target = std::fs::read_link(src)
.map(|t| t.display().to_string())
.unwrap_or_else(|_| "?".into());
c.skipped_link.push(format!("{rel} -> {target}"));
return Ok(());
}
_ => {}
}
let name = src.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
if !allow_secret {
if looks_secret(&name) {
c.skipped_secret.push(rel);
return Ok(());
}
if let Some(dir) = in_secret_dir(&rel) {
c.skipped_secret.push(format!("{rel} (inside {dir}/)"));
return Ok(());
}
}
let len = std::fs::metadata(src)?.len();
if len > MAX_ITEM_BYTES {
c.skipped_big.push(format!("{rel} ({len} bytes)"));
return Ok(());
}
let bundle_path = format!("items/{rel}");
if !crate::manifest::path_is_safe(&bundle_path) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unsafe path: {rel}"),
));
}
if c.items.iter().any(|(_, p, _)| *p == bundle_path) {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("two items would land on {bundle_path}; rename one or send the directory"),
));
}
c.items.push((kind, bundle_path, src.to_path_buf()));
Ok(())
}
fn walk(c: &mut Collected, kind: ItemKind, root: &Path, base: &Path, allow_secret: bool) -> std::io::Result<()> {
for e in std::fs::read_dir(base)? {
let e = e?;
let p = e.path();
let name = e.file_name().to_string_lossy().to_string();
if std::fs::symlink_metadata(&p).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
let target = std::fs::read_link(&p)
.map(|t| t.display().to_string())
.unwrap_or_else(|_| "?".into());
let rel = p.strip_prefix(root).unwrap_or(&p).to_string_lossy().replace('\\', "/");
c.skipped_link.push(format!("{rel} -> {target}"));
continue;
}
if p.is_dir() {
let lower = name.to_ascii_lowercase();
if SKIP_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&lower)) {
c.skipped_dir.push(name);
continue;
}
walk(c, kind, root, &p, allow_secret)?;
} else if p.is_file() {
let rel = p
.strip_prefix(root)
.unwrap_or(&p)
.to_string_lossy()
.replace('\\', "/");
add_one(c, kind, rel, &p, allow_secret)?;
}
}
Ok(())
}
pub fn collect(
files: &[PathBuf],
prompts: &[PathBuf],
decisions: &[PathBuf],
memories: &[PathBuf],
dirs: &[PathBuf],
allow_secret: bool,
) -> std::io::Result<Collected> {
let mut c = Collected::new();
for (kind, flag, list) in [
(ItemKind::File, "--file", files),
(ItemKind::Prompt, "--prompt", prompts),
(ItemKind::Decision, "--decision", decisions),
(ItemKind::Memory, "--memory", memories),
] {
for p in list {
if !p.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"{flag} {}: no such file. {flag} takes a path to a file, \
not text to include.",
p.display()
),
));
}
if p.is_dir() {
let root = p.parent().unwrap_or(Path::new(""));
walk(&mut c, kind, root, p, allow_secret)?;
} else {
let name = p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
add_one(&mut c, kind, name, p, allow_secret)?;
}
}
}
for d in dirs {
if std::fs::symlink_metadata(d).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"--dir {} is a symlink. Pass the real path: a link is a pointer \
somewhere else, and what it points at is not what you named.",
d.display()
),
));
}
if !d.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("--dir {} is not a directory", d.display()),
));
}
let root = d.parent().unwrap_or(Path::new(""));
walk(&mut c, ItemKind::File, root, d, allow_secret)?;
}
let total = c.total();
if total > MAX_TOTAL_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"bundle would be {total} bytes, over the {MAX_TOTAL_BYTES} limit; \
send fewer paths or a narrower directory"
),
));
}
Ok(c)
}