use std::path::{Path, PathBuf};
#[must_use]
pub fn expand_tilde(p: &str) -> String {
let Ok(home) = std::env::var("HOME") else {
return p.to_string();
};
if let Some(rest) = p.strip_prefix("~/") {
format!("{home}/{rest}")
} else if p == "~" {
home
} else {
p.to_string()
}
}
#[must_use]
pub fn has_parent_component(path: &str) -> bool {
use std::path::Component;
Path::new(path)
.components()
.any(|c| matches!(c, Component::ParentDir))
}
#[must_use]
pub fn is_unsafe_join_target(path: &str) -> bool {
let p = Path::new(path);
p.is_absolute() || has_parent_component(path)
}
#[must_use]
pub fn cwd_under_prefix(cwd: &str, prefix: &str) -> bool {
let cwd_p = Path::new(cwd);
let pre_p = PathBuf::from(prefix);
cwd_p.starts_with(&pre_p)
}
pub fn config_dir() -> anyhow::Result<PathBuf> {
if let Ok(dir) = std::env::var("LLMENV_CONFIG_DIR") {
Ok(PathBuf::from(dir))
} else {
let home = std::env::var("HOME")?;
Ok(PathBuf::from(home).join(".config/llmenv"))
}
}
pub fn config_path() -> anyhow::Result<PathBuf> {
Ok(config_dir()?.join("config.yaml"))
}
pub fn state_dir() -> anyhow::Result<PathBuf> {
if let Ok(dir) = std::env::var("LLMENV_STATE_DIR") {
Ok(PathBuf::from(dir))
} else {
let home = std::env::var("HOME")?;
Ok(PathBuf::from(home).join(".local/state/llmenv"))
}
}
pub fn write_owner_only(path: &Path, content: &[u8]) -> std::io::Result<()> {
use std::io::Write;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(content)?;
}
#[cfg(not(unix))]
{
std::fs::write(path, content)?;
}
Ok(())
}
pub fn write_owner_only_atomic(path: &Path, content: &[u8]) -> std::io::Result<()> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("path has no parent: {}", path.display()),
)
})?;
let file_name = path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("path has no file name: {}", path.display()),
)
})?;
if parent.as_os_str().is_empty() {
return write_owner_only_atomic_in_dir(Path::new("."), file_name, path, content);
}
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
write_owner_only_atomic_in_dir(parent, file_name, path, content)
}
static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn write_owner_only_atomic_in_dir(
parent: &Path,
file_name: &std::ffi::OsStr,
final_path: &Path,
content: &[u8],
) -> std::io::Result<()> {
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut last_err: Option<std::io::Error> = None;
for _ in 0..8 {
let counter = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut tmp_name = file_name.to_os_string();
tmp_name.push(format!(".{pid}.{nanos}.{counter}.tmp"));
let tmp_path = parent.join(&tmp_name);
let result = (|| -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
}
#[cfg(not(unix))]
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)?;
file.write_all(content)?;
file.sync_all()?;
}
std::fs::rename(&tmp_path, final_path)?;
Ok(())
})();
match result {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
last_err = Some(e);
continue;
}
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
return Err(e);
}
}
}
Err(last_err.unwrap_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"exhausted temp-file collision retries",
)
}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn cwd_under_prefix_respects_component_boundary() {
assert!(cwd_under_prefix("/home/alice/git/x", "/home/alice/git/x"));
assert!(cwd_under_prefix(
"/home/alice/git/x/sub",
"/home/alice/git/x"
));
assert!(!cwd_under_prefix(
"/home/alice/git/xyz",
"/home/alice/git/x"
));
assert!(!cwd_under_prefix("/home/alice", "/home/alice/git"));
}
#[test]
fn has_parent_component_detects_traversal_substring_misses() {
assert!(has_parent_component("foo/.."));
assert!(has_parent_component(".."));
assert!(has_parent_component("/foo/../bar"));
assert!(has_parent_component("a/b/../c"));
}
#[test]
fn has_parent_component_allows_safe_paths() {
assert!(!has_parent_component("/home/alice/.cache/llmenv"));
assert!(!has_parent_component("relative/path"));
assert!(!has_parent_component("~/.cache/llmenv"));
assert!(!has_parent_component("/foo/..bar/baz"));
assert!(!has_parent_component("file..txt"));
assert!(!has_parent_component(""));
}
#[test]
fn has_parent_component_does_not_check_absolute_paths() {
assert!(!has_parent_component("/etc/passwd"));
assert!(!has_parent_component("/abs/secret"));
}
#[test]
fn is_unsafe_join_target_rejects_traversal_and_absolute() {
assert!(is_unsafe_join_target(".."));
assert!(is_unsafe_join_target("foo/.."));
assert!(is_unsafe_join_target("a/b/../c"));
assert!(is_unsafe_join_target("/etc/passwd"));
assert!(is_unsafe_join_target("/abs"));
assert!(!is_unsafe_join_target("rel/path"));
assert!(!is_unsafe_join_target("file.txt"));
assert!(!is_unsafe_join_target("a/b/c"));
assert!(!is_unsafe_join_target("file..txt"));
}
#[cfg(unix)]
#[test]
fn write_owner_only_sets_mode_0o600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("secret");
write_owner_only(&path, b"sensitive").expect("write");
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o077, 0, "group/other bits set: {mode:o}");
let body = std::fs::read(&path).expect("read");
assert_eq!(body, b"sensitive");
}
#[cfg(unix)]
#[test]
fn write_owner_only_truncates_existing_file() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("file");
write_owner_only(&path, b"longer content").expect("write1");
write_owner_only(&path, b"short").expect("write2");
let body = std::fs::read(&path).expect("read");
assert_eq!(body, b"short");
}
#[cfg(unix)]
#[test]
fn write_owner_only_atomic_creates_file_with_mode_0o600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("atomic");
write_owner_only_atomic(&path, b"payload").expect("atomic write");
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o077, 0, "group/other bits set: {mode:o}");
assert_eq!(std::fs::read(&path).expect("read"), b"payload");
}
#[test]
fn write_owner_only_atomic_replaces_existing_file() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("file");
write_owner_only_atomic(&path, b"v1").expect("v1");
write_owner_only_atomic(&path, b"v2-longer").expect("v2");
assert_eq!(std::fs::read(&path).expect("read"), b"v2-longer");
}
#[test]
fn write_owner_only_atomic_leaves_no_temp_files() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("file");
write_owner_only_atomic(&path, b"x").expect("write");
write_owner_only_atomic(&path, b"y").expect("write");
let entries: Vec<_> = std::fs::read_dir(tmp.path())
.expect("read_dir")
.filter_map(Result::ok)
.map(|e| e.file_name())
.collect();
assert_eq!(entries.len(), 1, "found stray files: {entries:?}");
}
#[test]
fn write_owner_only_atomic_creates_parent_dir() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("a/b/c/file.json");
write_owner_only_atomic(&path, b"nested").expect("write");
assert_eq!(std::fs::read(&path).expect("read"), b"nested");
}
#[test]
fn write_owner_only_atomic_concurrent_writers_no_torn_reads() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("contended.json");
write_owner_only_atomic(&path, b"initial").expect("seed");
let payloads: Vec<Vec<u8>> = (0..8)
.map(|i| format!("{{\"writer\":{i},\"data\":\"{}\"}}", "x".repeat(256)).into_bytes())
.collect();
let valid: std::collections::HashSet<Vec<u8>> = std::iter::once(b"initial".to_vec())
.chain(payloads.iter().cloned())
.collect();
let writers: Vec<_> = payloads
.into_iter()
.map(|payload| {
let p = path.clone();
std::thread::spawn(move || {
for _ in 0..20 {
write_owner_only_atomic(&p, &payload).expect("concurrent write");
}
})
})
.collect();
let reader_path = path.clone();
let reader_valid = valid.clone();
let reader = std::thread::spawn(move || {
for _ in 0..200 {
let body = std::fs::read(&reader_path).expect("concurrent read");
assert!(
reader_valid.contains(&body),
"reader observed torn write: {body:?}"
);
}
});
for w in writers {
w.join().expect("writer join");
}
reader.join().expect("reader join");
}
#[test]
fn tilde_passthrough_for_absolute_and_relative() {
assert_eq!(expand_tilde("/abs/path"), "/abs/path");
assert_eq!(expand_tilde("rel/path"), "rel/path");
assert_eq!(expand_tilde(""), "");
}
use proptest::prelude::*;
proptest! {
#[test]
fn expand_tilde_passthrough_non_tilde(s in "[^~].*") {
prop_assert_eq!(expand_tilde(&s), s);
}
#[test]
fn expand_tilde_never_panics(s in ".*") {
let _ = expand_tilde(&s);
}
#[test]
fn expand_tilde_slash_contains_home_and_rest(rest in "[a-z0-9/_.-]{0,20}") {
let home_result = std::env::var("HOME");
prop_assume!(home_result.is_ok());
let home = home_result.unwrap();
let input = format!("~/{rest}");
let result = expand_tilde(&input);
prop_assert!(result.starts_with(&home),
"expected {result} to start with home={home}");
prop_assert!(result.ends_with(&rest) || rest.is_empty(),
"expected {result} to end with rest={rest}");
}
#[test]
fn cwd_under_prefix_reflexive(p in "/[a-z/]{1,20}") {
prop_assert!(cwd_under_prefix(&p, &p));
}
#[test]
fn cwd_under_prefix_child_under_parent(
parent in "/[a-z]{1,10}",
child in "[a-z]{1,10}",
) {
let full = format!("{parent}/{child}");
prop_assert!(cwd_under_prefix(&full, &parent));
}
#[test]
fn cwd_under_prefix_no_string_prefix_false_positive(
base in "[a-z]{2,8}",
extra in "[a-z]{1,4}",
) {
let cwd = format!("/{base}{extra}");
let prefix = format!("/{base}");
prop_assert!(!cwd_under_prefix(&cwd, &prefix));
}
#[test]
fn cwd_under_prefix_never_panics(cwd in ".*", prefix in ".*") {
let _ = cwd_under_prefix(&cwd, &prefix);
}
#[test]
fn cwd_under_prefix_transitive(
root in "/[a-z]{1,6}",
mid in "[a-z]{1,6}",
leaf in "[a-z]{1,6}",
) {
let b = format!("{root}/{mid}");
let a = format!("{b}/{leaf}");
prop_assert!(cwd_under_prefix(&b, &root));
prop_assert!(cwd_under_prefix(&a, &b));
prop_assert!(cwd_under_prefix(&a, &root));
}
#[test]
fn cwd_under_prefix_not_symmetric(
parent in "/[a-z]{1,10}",
child in "[a-z]{1,10}",
) {
let child_path = format!("{parent}/{child}");
prop_assert!(!cwd_under_prefix(&parent, &child_path));
}
#[test]
fn has_parent_component_never_panics(s in ".*") {
let _ = has_parent_component(&s);
}
#[test]
fn is_unsafe_join_target_never_panics(s in ".*") {
let _ = is_unsafe_join_target(&s);
}
#[test]
fn has_parent_component_safe_components(
a in "[a-z]{1,8}",
b in "[a-z]{1,8}",
) {
let path = format!("{a}/{b}");
prop_assert!(!has_parent_component(&path));
}
#[test]
fn is_unsafe_join_target_join_safety(p in "[a-z/]{1,20}") {
prop_assume!(!is_unsafe_join_target(&p));
let joined = std::path::PathBuf::from("/base").join(&p);
prop_assert!(joined.starts_with("/base"), "join escaped base: {:?}", joined);
}
#[test]
fn atomic_write_byte_roundtrip(payload in prop::collection::vec(any::<u8>(), 0..8192)) {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("payload.bin");
write_owner_only_atomic(&path, &payload).expect("atomic write");
let read = std::fs::read(&path).expect("read");
prop_assert_eq!(payload, read);
}
#[test]
fn atomic_write_overwrite_idempotent(
first in prop::collection::vec(any::<u8>(), 0..4096),
second in prop::collection::vec(any::<u8>(), 0..4096),
) {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("payload.bin");
write_owner_only_atomic(&path, &first).expect("write 1");
write_owner_only_atomic(&path, &second).expect("write 2");
let read = std::fs::read(&path).expect("read");
prop_assert_eq!(second, read);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).expect("meta").permissions().mode();
prop_assert_eq!(mode & 0o077, 0, "group/other bits set after overwrite: {:o}", mode);
}
}
}
#[test]
fn expand_tilde_bare_tilde_equals_home() {
let home = std::env::var("HOME").expect("HOME must be set; expand_tilde relies on it");
let result = expand_tilde("~");
assert_eq!(result, home);
assert!(!result.ends_with('/'));
}
}