use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum PinError {
#[error("pin {spec:?} must be <dest>=<source-path>")]
Spec { spec: String },
#[error("pin destination {dest:?} {rule}")]
Dest { dest: String, rule: String },
#[error("pin destination {dest:?} is named twice")]
Collision { dest: String },
#[error("read pin source {path}: {source}")]
Source {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
fn reserved(first_segment: &str) -> bool {
let harness: &[&str] = &[
crate::prompt::subagent::GOAL_FILE,
crate::prompt::subagent::SOUL_FILE,
crate::workspace::agent_name::NAME_FILE,
crate::template::descriptions::DESCRIPTIONS_DIR,
crate::prompt::dispatch::MESSAGES_DIR,
crate::prompt::compactor::tools::SUMMARY_DIR,
];
crate::workspace::CONTROL_PATHS.contains(&first_segment) || harness.contains(&first_segment)
}
#[derive(Debug)]
pub struct PinnedDoc {
dest: String,
bytes: Vec<u8>,
}
impl PinnedDoc {
pub fn new(dest: String, bytes: Vec<u8>) -> Result<Self, PinError> {
let refuse = |rule: &str| PinError::Dest {
dest: dest.clone(),
rule: rule.to_owned(),
};
if dest.is_empty() {
return Err(refuse("is empty"));
}
if dest.starts_with('/') {
return Err(refuse("must be relative to the worktree root"));
}
for seg in dest.split('/') {
if seg.is_empty() || seg == "." || seg == ".." {
return Err(refuse("may not contain empty, '.' or '..' segments"));
}
if seg.eq_ignore_ascii_case(".git") {
return Err(refuse("may not enter .git"));
}
}
let first = dest.split('/').next().expect("split yields at least one");
if reserved(first) {
return Err(PinError::Dest {
dest: dest.clone(),
rule: format!("collides with the harness-owned path {first:?}"),
});
}
Ok(Self { dest, bytes })
}
pub fn dest(&self) -> &str {
&self.dest
}
}
#[derive(Debug)]
pub struct PinnedDocs(Vec<PinnedDoc>);
impl PinnedDocs {
pub fn new(docs: Vec<PinnedDoc>) -> Result<Self, PinError> {
for (i, doc) in docs.iter().enumerate() {
if docs[..i].iter().any(|d| d.dest == doc.dest) {
return Err(PinError::Collision {
dest: doc.dest.clone(),
});
}
}
Ok(Self(docs))
}
pub fn none() -> &'static PinnedDocs {
static NONE: PinnedDocs = PinnedDocs(Vec::new());
&NONE
}
pub fn iter(&self) -> impl Iterator<Item = &PinnedDoc> {
self.0.iter()
}
pub(crate) fn write_into(&self, worktree: &Path) -> std::io::Result<()> {
for doc in &self.0 {
let mut path = worktree.to_path_buf();
for seg in doc.dest.split('/') {
path.push(seg);
let is_link =
std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink());
if is_link {
return Err(std::io::Error::other(format!(
"pin destination {:?} passes through a symlink at {:?}; \
refusing to write outside the worktree",
doc.dest, seg
)));
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &doc.bytes)?;
}
Ok(())
}
}
pub fn load(specs: &[String]) -> Result<PinnedDocs, PinError> {
let mut docs = Vec::with_capacity(specs.len());
for spec in specs {
let (dest, src) = spec
.split_once('=')
.filter(|(d, s)| !d.is_empty() && !s.is_empty())
.ok_or_else(|| PinError::Spec { spec: spec.clone() })?;
let bytes = std::fs::read(src).map_err(|source| PinError::Source {
path: PathBuf::from(src),
source,
})?;
docs.push(PinnedDoc::new(dest.to_owned(), bytes)?);
}
PinnedDocs::new(docs)
}
#[cfg(test)]
mod tests;