use std::fs;
use std::path::{Path, PathBuf};
use crate::setup_core::digest;
use crate::setup_core::error::{Error, ReasonCode, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
root: PathBuf,
control_directory_name: String,
}
impl Target {
pub fn resolve(path: &Path, control_directory_name: &str) -> Result<Self> {
if !path.is_absolute() {
return Err(Error::new(
ReasonCode::InvalidTarget,
format!("target must be absolute, got {}", path.display()),
));
}
let metadata = fs::symlink_metadata(path).map_err(|source| {
Error::new(
ReasonCode::InvalidTarget,
format!("target {} cannot be inspected: {source}", path.display()),
)
.with_source(source)
})?;
if metadata.is_symlink() {
return Err(Error::new(
ReasonCode::InvalidTarget,
format!("target {} is a symbolic link", path.display()),
));
}
if !metadata.is_dir() {
return Err(Error::new(
ReasonCode::InvalidTarget,
format!("target {} is not a directory", path.display()),
));
}
let root = match fs::canonicalize(path) {
Ok(root) => root,
Err(_denied) => std::path::absolute(path).map_err(|source| {
Error::new(
ReasonCode::InvalidTarget,
format!(
"target {} cannot be canonicalized and cannot be made absolute: {source}",
path.display()
),
)
.with_source(source)
})?,
};
Ok(Self {
root: without_verbatim_prefix(root),
control_directory_name: control_directory_name.to_owned(),
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn control_directory(&self) -> PathBuf {
self.root.join(&self.control_directory_name)
}
pub fn ensure_control_directory(&self) -> Result<PathBuf> {
let control = self.control_directory();
match fs::symlink_metadata(&control) {
Ok(metadata) if metadata.is_dir() && !metadata.is_symlink() => Ok(control),
Ok(_) => Err(Error::new(
ReasonCode::InvalidTarget,
format!("{} exists but is not a plain directory", control.display()),
)),
Err(_) => {
fs::create_dir_all(&control).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot create {}", control.display()),
)
.with_source(source)
})?;
restrict_to_owner(&control)?;
Ok(control)
}
}
}
pub fn identity_digest(&self) -> Result<String> {
self.identity_digest_excluding(&[])
}
pub fn identity_digest_excluding(&self, also_excluded: &[&str]) -> Result<String> {
let mut excluded = vec![self.control_directory_name.as_str()];
excluded.extend_from_slice(also_excluded);
digest::of_tree_excluding(&self.root, &excluded)
}
pub fn identity_of_owned(&self, namespaces: &[&str], excluded: &[&str]) -> Result<String> {
let mut not_ours = vec![self.control_directory_name.as_str()];
not_ours.extend_from_slice(excluded);
digest::of_owned(&self.root, namespaces, ¬_ours)
}
}
fn without_verbatim_prefix(path: PathBuf) -> PathBuf {
let Some(text) = path.to_str() else {
return path;
};
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
if let Some(rest) = text.strip_prefix(r"\\?\") {
return PathBuf::from(rest);
}
path
}
pub fn restrict_to_owner(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot restrict {}", path.display()),
)
.with_source(source)
})?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
fn scratch(name: &str) -> PathBuf {
let base =
std::env::temp_dir().join(format!("setup-core-target-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
fs::canonicalize(&base).unwrap()
}
#[test]
fn a_relative_target_is_refused() {
let error = Target::resolve(Path::new("relative/path"), ".ctl").unwrap_err();
assert_eq!(error.reason(), ReasonCode::InvalidTarget);
}
#[test]
fn a_missing_target_is_refused() {
let error = Target::resolve(Path::new("/definitely/not/here"), ".ctl").unwrap_err();
assert_eq!(error.reason(), ReasonCode::InvalidTarget);
}
#[test]
fn a_file_is_not_a_target() {
let base = scratch("file");
let file = base.join("f");
fs::write(&file, "x").unwrap();
let error = Target::resolve(&file, ".ctl").unwrap_err();
assert_eq!(error.reason(), ReasonCode::InvalidTarget);
}
#[test]
fn the_verbatim_prefix_is_dropped_and_a_plain_path_is_kept() {
assert_eq!(
without_verbatim_prefix(PathBuf::from(r"\\?\C:\Users\me\.codex")),
PathBuf::from(r"C:\Users\me\.codex")
);
assert_eq!(
without_verbatim_prefix(PathBuf::from(r"\\?\UNC\host\share\dir")),
PathBuf::from(r"\\host\share\dir")
);
assert_eq!(
without_verbatim_prefix(PathBuf::from("/home/me/.codex")),
PathBuf::from("/home/me/.codex")
);
}
#[test]
fn a_resolved_root_never_carries_the_verbatim_prefix() {
let base = scratch("plain");
let target = Target::resolve(&base, ".ctl").unwrap();
assert!(
!target.root().to_string_lossy().starts_with(r"\\?\"),
"{}",
target.root().display()
);
assert!(target.root().is_absolute());
}
#[cfg(unix)]
#[test]
fn a_symlinked_final_component_is_refused_before_it_can_be_swapped() {
let base = scratch("link");
let real = base.join("real");
fs::create_dir_all(&real).unwrap();
let link = base.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let error = Target::resolve(&link, ".ctl").unwrap_err();
assert_eq!(error.reason(), ReasonCode::InvalidTarget);
}
#[test]
fn the_control_directory_is_created_inside_the_root() {
let base = scratch("control");
let target = Target::resolve(&base, ".ctl").unwrap();
let control = target.ensure_control_directory().unwrap();
assert_eq!(control, without_verbatim_prefix(base).join(".ctl"));
assert!(control.is_dir());
}
#[test]
fn identity_can_also_ignore_the_state_file_it_describes() {
let base = scratch("identity-state");
fs::write(base.join("kept.txt"), "value").unwrap();
let target = Target::resolve(&base, ".ctl").unwrap();
let before = target.identity_digest_excluding(&["STATE.json"]).unwrap();
fs::write(base.join("STATE.json"), "{\"state_schema\":3}").unwrap();
assert_eq!(
target.identity_digest_excluding(&["STATE.json"]).unwrap(),
before
);
assert_ne!(target.identity_digest().unwrap(), before);
}
#[test]
fn identity_ignores_the_control_directory_it_is_measured_beside() {
let base = scratch("identity");
fs::write(base.join("kept.txt"), "value").unwrap();
let target = Target::resolve(&base, ".ctl").unwrap();
let before = target.identity_digest().unwrap();
target.ensure_control_directory().unwrap();
fs::write(base.join(".ctl").join("journal.json"), "{}").unwrap();
assert_eq!(target.identity_digest().unwrap(), before);
}
}