use std::fs;
use std::io;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::Path;
use carte_core::dirs::{DirRoots, InstanceDirs, STACK_RECORD, StackRecord, owner_marker};
use carte_core::model::{InstanceKey, ProjectState};
pub fn prepare(
roots: &DirRoots,
key: &InstanceKey,
state: &ProjectState,
) -> io::Result<InstanceDirs> {
let dirs = roots.instance(key);
create_run_root(&roots.run)?;
private_dir(&dirs.run)?;
let marker = owner_marker(&dirs.run);
let owner = roots.data.display().to_string();
if fs::read_to_string(&marker).ok().as_deref() != Some(owner.as_str()) {
fs::write(&marker, owner)?;
}
fs::create_dir_all(&dirs.state)?;
let stack = key.stack.slug();
let stack_dir = roots.stack_dir(&key.project, stack);
let existing = read_stack_record(&stack_dir).ok().flatten();
let mut record = existing.clone().unwrap_or_default();
record.update(state.worktrees.iter().flat_map(|(app, worktrees)| {
worktrees
.iter()
.filter(|wt| wt.slug == stack)
.map(move |wt| (app.as_str(), wt))
}));
if existing.as_ref() != Some(&record) {
write_stack_record(&stack_dir, &record)?;
}
Ok(dirs)
}
fn create_run_root(root: &Path) -> io::Result<()> {
if let Some(parent) = root.parent() {
fs::create_dir_all(parent)?;
}
let meta = private_dir(root)?;
if meta.permissions().mode() & 0o777 != 0o700 {
fs::set_permissions(root, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn private_dir(path: &Path) -> io::Result<fs::Metadata> {
match fs::DirBuilder::new().mode(0o700).create(path) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
let meta = fs::symlink_metadata(path)?;
if !meta.is_dir() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} isn't a directory", path.display()),
));
}
if meta.uid() != unsafe { libc::getuid() } {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("{} belongs to another user", path.display()),
));
}
Ok(meta)
}
pub fn read_stack_record(stack_dir: &Path) -> io::Result<Option<StackRecord>> {
match fs::read_to_string(stack_dir.join(STACK_RECORD)) {
Ok(text) => StackRecord::parse(&text)
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub fn write_stack_record(stack_dir: &Path, record: &StackRecord) -> io::Result<()> {
fs::create_dir_all(stack_dir)?;
let mut file = tempfile::NamedTempFile::new_in(stack_dir)?;
io::Write::write_all(&mut file, record.to_toml().as_bytes())?;
file.persist(stack_dir.join(STACK_RECORD))
.map_err(|e| e.error)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::path::PathBuf;
use carte_core::model::{Project, Scheme, Stack, Worktree};
use super::*;
fn worktree(app: &str, slug: &str) -> Worktree {
Worktree {
branch: Some(format!("feature/{slug}")),
slug: slug.into(),
path: PathBuf::from("/w").join(format!("{app}-{slug}")),
}
}
fn project_state(worktrees: &[(&str, &str)]) -> ProjectState {
let mut map: BTreeMap<String, Vec<Worktree>> = BTreeMap::new();
for (app, slug) in worktrees {
map.entry(app.to_string())
.or_default()
.push(worktree(app, slug));
}
ProjectState {
project: Project {
folder: None,
name: "nova".into(),
config_path: "/c/nova.toml".into(),
strip_prefix: None,
env_provider: None,
env: Vec::new(),
requires: Vec::new(),
scheme: Scheme::Https,
idle_timeout: None,
teardown_delay: None,
apps: Vec::new(),
},
worktrees: map,
processes: BTreeMap::new(),
repo_worktrees: BTreeMap::new(),
scan_dirs: Default::default(),
checkout_apps: BTreeMap::new(),
}
}
fn key(app: &str, stack: Stack) -> InstanceKey {
InstanceKey {
project: "nova".into(),
app: app.into(),
stack,
}
}
#[test]
fn creates_the_directories_and_nothing_inside() {
let tmp = tempfile::tempdir().unwrap();
let roots = DirRoots {
data: tmp.path().join("state/data"),
run: tmp.path().join("tmp"),
};
let key = key("web", Stack::Branch("x".into()));
let dirs = prepare(&roots, &key, &project_state(&[("web", "x")])).unwrap();
assert_eq!(dirs, roots.instance(&key));
assert!(dirs.state.is_dir());
assert!(dirs.run.is_dir());
assert_eq!(fs::read_dir(&dirs.state).unwrap().count(), 0);
assert_eq!(fs::read_dir(&dirs.run).unwrap().count(), 0);
assert_eq!(
fs::read_to_string(owner_marker(&dirs.run)).unwrap(),
roots.data.display().to_string()
);
let mode = fs::metadata(&roots.run).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
fs::set_permissions(&roots.run, fs::Permissions::from_mode(0o755)).unwrap();
prepare(&roots, &key, &project_state(&[("web", "x")])).unwrap();
let mode = fs::metadata(&roots.run).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn refuses_run_directories_that_are_symlinks_or_files() {
let tmp = tempfile::tempdir().unwrap();
let elsewhere = tmp.path().join("elsewhere");
fs::create_dir_all(&elsewhere).unwrap();
fs::set_permissions(&elsewhere, fs::Permissions::from_mode(0o755)).unwrap();
let key = key("web", Stack::Branch("x".into()));
let state = project_state(&[("web", "x")]);
let roots = |run: PathBuf| DirRoots {
data: tmp.path().join("state/data"),
run,
};
let linked = roots(tmp.path().join("linked"));
std::os::unix::fs::symlink(&elsewhere, &linked.run).unwrap();
let err = prepare(&linked, &key, &state).unwrap_err();
assert!(err.to_string().contains("isn't a directory"), "{err}");
let mode = fs::metadata(&elsewhere).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755);
assert_eq!(fs::read_dir(&elsewhere).unwrap().count(), 0);
let file = roots(tmp.path().join("file"));
fs::write(&file.run, "").unwrap();
assert!(prepare(&file, &key, &state).is_err());
let run = roots(tmp.path().join("run"));
let dirs = run.instance(&key);
fs::create_dir_all(&run.run).unwrap();
std::os::unix::fs::symlink(&elsewhere, &dirs.run).unwrap();
let err = prepare(&run, &key, &state).unwrap_err();
assert!(err.to_string().contains("isn't a directory"), "{err}");
}
#[test]
fn records_the_stacks_worktrees() {
let tmp = tempfile::tempdir().unwrap();
let roots = DirRoots {
data: tmp.path().join("state/data"),
run: tmp.path().join("tmp"),
};
let stack_dir = roots.stack_dir("nova", "x");
assert_eq!(read_stack_record(&stack_dir).unwrap(), None);
let state = project_state(&[("web", "x"), ("api", "x"), ("api", "y")]);
prepare(&roots, &key("billing", Stack::Branch("x".into())), &state).unwrap();
let record = read_stack_record(&stack_dir).unwrap().unwrap();
assert_eq!(record.branch.as_deref(), Some("feature/x"));
assert_eq!(
record.paths().collect::<Vec<_>>(),
[Path::new("/w/api-x"), Path::new("/w/web-x")]
);
let state = project_state(&[("web", "x")]);
prepare(&roots, &key("web", Stack::Branch("x".into())), &state).unwrap();
let kept = read_stack_record(&stack_dir).unwrap().unwrap();
assert_eq!(kept, record);
prepare(&roots, &key("web", Stack::Main), &state).unwrap();
let main = read_stack_record(&roots.stack_dir("nova", "main")).unwrap();
assert_eq!(main, Some(StackRecord::default()));
}
#[test]
fn an_unreadable_record_is_replaced() {
let tmp = tempfile::tempdir().unwrap();
let roots = DirRoots {
data: tmp.path().join("state/data"),
run: tmp.path().join("tmp"),
};
let stack_dir = roots.stack_dir("nova", "x");
fs::create_dir_all(&stack_dir).unwrap();
fs::write(stack_dir.join(STACK_RECORD), "worktrees = 1").unwrap();
assert!(read_stack_record(&stack_dir).is_err());
let state = project_state(&[("web", "x")]);
prepare(&roots, &key("web", Stack::Branch("x".into())), &state).unwrap();
let record = read_stack_record(&stack_dir).unwrap().unwrap();
assert_eq!(record.paths().collect::<Vec<_>>(), [Path::new("/w/web-x")]);
}
}