use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::schema::{DiscussionId, NodeId, ProposalId, RunId};
pub fn nofollow(opts: &mut OpenOptions) -> &mut OpenOptions {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
opts
}
pub(crate) fn reject_symlink(path: &Path, mk_err: impl FnOnce() -> Error) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(md) if md.file_type().is_symlink() => Err(mk_err()),
Ok(_) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::io(path, e)),
}
}
pub fn validate_run_id(run_id: &str) -> Result<()> {
RunId::parse_str(run_id)
.map(|_| ())
.map_err(|e| Error::InvalidRunId {
run_id: run_id.to_string(),
reason: e.to_string(),
})
}
pub struct RunPaths {
pub root: PathBuf,
pub run_id: RunId,
}
impl RunPaths {
pub fn new(root: impl Into<PathBuf>, run_id: impl Into<String>) -> Result<Self> {
let run_id = run_id.into();
let rid = RunId::parse_str(&run_id).map_err(|e| Error::InvalidRunId {
run_id,
reason: e.to_string(),
})?;
let root = root.into();
reject_symlink(&root, || Error::SymlinkRunDir { path: root.clone() })?;
Ok(Self { root, run_id: rid })
}
pub fn from_validated(root: impl Into<PathBuf>, run_id: RunId) -> Result<Self> {
let root = root.into();
reject_symlink(&root, || Error::SymlinkRunDir { path: root.clone() })?;
Ok(Self { root, run_id })
}
pub(crate) fn guard_root(&self) -> Result<()> {
reject_symlink(&self.root, || Error::SymlinkRunDir {
path: self.root.clone(),
})
}
pub(crate) fn checked_events(&self) -> Result<PathBuf> {
self.guard_root()?;
let p = self.events();
reject_symlink(&p, || Error::SymlinkStateFile {
name: "events",
path: p.clone(),
})?;
Ok(p)
}
pub fn manifest(&self) -> PathBuf {
self.root.join("manifest.json")
}
pub fn events(&self) -> PathBuf {
self.root.join("events.jsonl")
}
pub fn lock(&self) -> PathBuf {
self.root.join(".lock")
}
pub fn nodes_dir(&self) -> PathBuf {
self.root.join("nodes")
}
pub fn node(&self, node_id: &NodeId) -> PathBuf {
self.nodes_dir().join(format!("{}.json", node_id.as_str()))
}
pub fn discussions_dir(&self) -> PathBuf {
self.root.join("discussions")
}
pub fn discussion(&self, id: &DiscussionId) -> PathBuf {
self.discussions_dir().join(format!("{}.json", id.as_str()))
}
pub fn spinoffs_dir(&self) -> PathBuf {
self.root.join("spinoffs")
}
pub fn spinoff(&self, id: &ProposalId) -> PathBuf {
self.spinoffs_dir().join(format!("{}.json", id.as_str()))
}
pub fn supervisor_pid(&self) -> PathBuf {
self.root.join("supervisor.pid")
}
pub fn agent_log(&self) -> PathBuf {
self.root.join("agent.log")
}
}
pub fn run_dir(root: &Path, run_id: &RunId) -> PathBuf {
root.join("runs").join(run_id.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_freshly_generated_run_id() {
let id = crate::new_run_id();
assert!(
validate_run_id(&id).is_ok(),
"generator must satisfy validator: {id}"
);
let paths = RunPaths::new("/tmp/x", id.clone()).expect("valid run_id");
assert_eq!(paths.run_id.as_str(), id);
}
#[test]
fn validator_stays_in_lockstep_with_the_generator() {
for _ in 0..2000 {
let id = crate::new_run_id();
assert!(validate_run_id(&id).is_ok(), "generator emitted {id:?}");
}
}
#[test]
fn accepts_the_first_char_boundary_and_rejects_just_past_it() {
assert!(validate_run_id("7zzzzzzzzzzzzzzzzzzzzzzzzz").is_ok());
assert!(matches!(
RunPaths::new("/tmp/x", "8zzzzzzzzzzzzzzzzzzzzzzzzz"),
Err(Error::InvalidRunId { .. })
));
}
#[cfg(unix)]
#[test]
fn rejects_a_symlinked_run_dir_at_construction() {
use std::os::unix::fs::symlink;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let real = tmp.path().join("real");
std::fs::create_dir_all(&real).unwrap();
let link = tmp.path().join("link");
symlink(&real, &link).unwrap();
assert!(matches!(
RunPaths::new(&link, "01jxsnap000000000000000000"),
Err(Error::SymlinkRunDir { path }) if path == link
));
}
#[test]
fn accepts_a_real_directory_run_root() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("run");
std::fs::create_dir_all(&dir).unwrap();
assert!(RunPaths::new(&dir, "01jxsnap000000000000000000").is_ok());
}
#[test]
fn rejects_malformed_run_ids_at_construction() {
for bad in [
"tooshort", "01jxsnap0000000000000000000", "01JXSNAP000000000000000000", "01jxiiiiiiiiiiiiiiiiiiiiii", "80000000000000000000000000", ] {
assert!(
matches!(
RunPaths::new("/tmp/x", bad),
Err(Error::InvalidRunId { .. })
),
"expected {bad:?} to be rejected",
);
}
}
}