use std::path::PathBuf;
pub fn is_rootless() -> bool {
unsafe { libc::getuid() != 0 }
}
pub fn config_file() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("pelagos/config.toml");
}
}
if is_rootless() {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".config/pelagos/config.toml");
}
}
PathBuf::from("/etc/pelagos/config.toml")
}
pub fn data_dir() -> PathBuf {
let system_dir = PathBuf::from("/var/lib/pelagos");
if system_dir.exists() || !is_rootless() {
return system_dir;
}
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("pelagos");
}
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".local/share/pelagos");
}
PathBuf::from(format!("/tmp/pelagos-data-{}", unsafe { libc::getuid() }))
}
pub fn runtime_dir() -> PathBuf {
if !is_rootless() {
return PathBuf::from("/run/pelagos");
}
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("pelagos");
}
}
let uid = unsafe { libc::getuid() };
let fallback = PathBuf::from(format!("/tmp/pelagos-{}", uid));
if !fallback.exists() {
let _ = std::fs::create_dir_all(&fallback);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&fallback, std::fs::Permissions::from_mode(0o700));
}
}
fallback
}
pub fn images_dir() -> PathBuf {
data_dir().join("images")
}
pub fn layers_dir() -> PathBuf {
data_dir().join("layers")
}
pub fn volumes_dir() -> PathBuf {
data_dir().join("volumes")
}
pub fn rootfs_store_dir() -> PathBuf {
data_dir().join("rootfs")
}
pub fn counter_file() -> PathBuf {
runtime_dir().join("container_counter")
}
pub fn build_cache_dir() -> PathBuf {
data_dir().join("build-cache")
}
pub fn blobs_dir() -> PathBuf {
data_dir().join("blobs")
}
pub fn blob_path(digest: &str) -> PathBuf {
let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
blobs_dir().join(format!("{}.tar.gz", hex))
}
pub fn blob_diffid_path(digest: &str) -> PathBuf {
let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
blobs_dir().join(format!("{}.diffid", hex))
}
pub fn containers_dir() -> PathBuf {
runtime_dir().join("containers")
}
pub fn oci_state_dir(id: &str) -> PathBuf {
runtime_dir().join(id)
}
pub fn overlay_base(pid: i32, n: u32) -> PathBuf {
runtime_dir().join(format!("overlay-{}-{}", pid, n))
}
pub fn dns_dir(pid: i32, n: u32) -> PathBuf {
runtime_dir().join(format!("dns-{}-{}", pid, n))
}
pub fn hosts_dir(pid: i32, n: u32) -> PathBuf {
runtime_dir().join(format!("hosts-{}-{}", pid, n))
}
pub fn ipam_file() -> PathBuf {
runtime_dir().join("next_ip")
}
pub fn nat_refcount_file() -> PathBuf {
runtime_dir().join("nat_refcount")
}
pub fn port_forwards_file() -> PathBuf {
runtime_dir().join("port_forwards")
}
pub fn dns_config_dir() -> PathBuf {
runtime_dir().join("dns")
}
pub fn dns_pid_file() -> PathBuf {
dns_config_dir().join("pid")
}
pub fn dns_network_file(name: &str) -> PathBuf {
dns_config_dir().join(name)
}
pub fn dns_backend_file() -> PathBuf {
dns_config_dir().join("backend")
}
pub fn dns_dnsmasq_conf() -> PathBuf {
dns_config_dir().join("dnsmasq.conf")
}
pub fn dns_hosts_file(network_name: &str) -> PathBuf {
dns_config_dir().join(format!("hosts.{}", network_name))
}
pub fn compose_dir() -> PathBuf {
runtime_dir().join("compose")
}
pub fn compose_project_dir(project: &str) -> PathBuf {
compose_dir().join(project)
}
pub fn compose_state_file(project: &str) -> PathBuf {
compose_project_dir(project).join("state.json")
}
pub fn networks_config_dir() -> PathBuf {
data_dir().join("networks")
}
pub fn network_config_dir(name: &str) -> PathBuf {
networks_config_dir().join(name)
}
pub fn network_runtime_dir(name: &str) -> PathBuf {
runtime_dir().join("networks").join(name)
}
pub fn network_ipam_file(name: &str) -> PathBuf {
network_runtime_dir(name).join("next_ip")
}
pub fn network_nat_refcount_file(name: &str) -> PathBuf {
network_runtime_dir(name).join("nat_refcount")
}
pub fn network_port_forwards_file(name: &str) -> PathBuf {
network_runtime_dir(name).join("port_forwards")
}
pub fn network_ipv6_ipam_file(name: &str) -> PathBuf {
network_runtime_dir(name).join("next_ipv6")
}
pub fn sandboxes_dir() -> PathBuf {
runtime_dir().join("sandboxes")
}
pub fn sandbox_dir(id: &str) -> PathBuf {
sandboxes_dir().join(id)
}
pub fn sandbox_pid_file(id: &str) -> PathBuf {
sandbox_dir(id).join("pause.pid")
}
pub fn sandbox_ns_name_file(id: &str) -> PathBuf {
sandbox_dir(id).join("ns_name")
}
pub fn sandbox_name_file(id: &str) -> PathBuf {
sandbox_dir(id).join("name")
}
#[derive(Debug)]
pub struct InstallIssue {
pub path: std::path::PathBuf,
pub message: String,
}
impl std::fmt::Display for InstallIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path.display(), self.message)
}
}
pub fn validate_install() -> Vec<InstallIssue> {
use std::os::unix::fs::MetadataExt;
let mut issues = Vec::new();
let pelagos_gid: Option<u32> = {
let name = std::ffi::CString::new("pelagos").unwrap();
let grp = unsafe { libc::getgrnam(name.as_ptr()) };
if grp.is_null() {
None
} else {
Some(unsafe { (*grp).gr_gid })
}
};
let data = data_dir();
if !data.exists() {
return issues;
}
struct DirSpec {
name: &'static str,
expected_uid: u32,
expected_gid: Option<u32>, min_mode: u32,
}
let group_writable_dirs = ["images", "layers", "blobs", "build-cache"];
let root_only_dirs = ["volumes", "networks", "rootfs"];
let dir_specs: Vec<DirSpec> = group_writable_dirs
.iter()
.map(|&name| DirSpec {
name,
expected_uid: 0,
expected_gid: None, min_mode: 0o2775,
})
.chain(root_only_dirs.iter().map(|&name| DirSpec {
name,
expected_uid: 0,
expected_gid: Some(0), min_mode: 0o755,
}))
.collect();
for spec in &dir_specs {
let path = data.join(spec.name);
match std::fs::metadata(&path) {
Err(_) => {
issues.push(InstallIssue {
path,
message: "does not exist — run: sudo scripts/setup.sh".into(),
});
}
Ok(meta) => {
let mode = meta.mode() & 0o7777;
let uid = meta.uid();
let gid = meta.gid();
let expected_gid = spec
.expected_gid
.unwrap_or_else(|| pelagos_gid.unwrap_or(u32::MAX));
if uid != spec.expected_uid {
issues.push(InstallIssue {
path: path.clone(),
message: format!(
"owned by uid {} (expected 0) — run: sudo scripts/setup.sh",
uid
),
});
}
if pelagos_gid.is_some() && gid != expected_gid {
issues.push(InstallIssue {
path: path.clone(),
message: format!(
"group gid {} (expected {}) — run: sudo scripts/setup.sh",
gid, expected_gid
),
});
}
if mode & spec.min_mode != spec.min_mode {
issues.push(InstallIssue {
path,
message: format!(
"mode {:04o} (expected at least {:04o}) — run: sudo scripts/setup.sh",
mode, spec.min_mode
),
});
}
}
}
}
let _ = dir_specs;
issues
}
fn normalize_abs(path: &std::path::Path) -> Option<PathBuf> {
use std::path::Component;
if !path.is_absolute() {
return None;
}
let mut out = PathBuf::from("/");
for comp in path.components() {
match comp {
Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Normal(c) => out.push(c),
}
}
Some(out)
}
fn protected_parent_dirs() -> Vec<PathBuf> {
[
containers_dir(),
sandboxes_dir(),
images_dir(),
layers_dir(),
volumes_dir(),
runtime_dir(),
data_dir(),
]
.into_iter()
.filter_map(|p| normalize_abs(&p))
.collect()
}
pub fn is_safe_to_remove(path: &std::path::Path) -> bool {
let Some(norm) = normalize_abs(path) else {
return false;
};
if protected_parent_dirs().contains(&norm) {
return false;
}
[runtime_dir(), data_dir()]
.iter()
.filter_map(|r| normalize_abs(r))
.any(|root| norm.starts_with(&root) && norm != root)
}
pub fn guarded_remove_dir_all(path: &std::path::Path) -> std::io::Result<()> {
if !is_safe_to_remove(path) {
log::error!(
"refusing to remove path outside pelagos-managed dirs: {} (#347 guard)",
path.display()
);
return Ok(());
}
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub fn guarded_remove_file(path: &std::path::Path) -> std::io::Result<()> {
if !is_safe_to_remove(path) {
log::error!(
"refusing to unlink path outside pelagos-managed dirs: {} (#347 guard)",
path.display()
);
return Ok(());
}
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_guard_rejects_host_and_root_paths() {
assert!(!is_safe_to_remove(std::path::Path::new("/")));
assert!(!is_safe_to_remove(std::path::Path::new("/bin")));
assert!(!is_safe_to_remove(std::path::Path::new("/usr/bin")));
assert!(!is_safe_to_remove(std::path::Path::new("/etc")));
assert!(!is_safe_to_remove(std::path::Path::new("")));
assert!(!is_safe_to_remove(std::path::Path::new("relative/path")));
assert!(!is_safe_to_remove(&runtime_dir()));
assert!(!is_safe_to_remove(&data_dir()));
assert!(!is_safe_to_remove(&containers_dir()));
assert!(!is_safe_to_remove(&sandboxes_dir()));
assert!(!is_safe_to_remove(&images_dir()));
assert!(!is_safe_to_remove(&layers_dir()));
}
#[test]
fn test_guard_allows_managed_subpaths() {
assert!(is_safe_to_remove(&containers_dir().join("pcri-abc123")));
assert!(is_safe_to_remove(&sandbox_dir("0123456789abcdef")));
assert!(is_safe_to_remove(&runtime_dir().join("overlay-1234-5")));
assert!(is_safe_to_remove(&images_dir().join("alpine_latest")));
assert!(is_safe_to_remove(&layers_dir().join("deadbeef")));
}
#[test]
fn test_guarded_remove_refuses_unmanaged_dir() {
let tmp = tempfile::tempdir().unwrap();
let victim = tmp.path().join("not-pelagos");
std::fs::create_dir_all(&victim).unwrap();
assert!(!is_safe_to_remove(&victim));
guarded_remove_dir_all(&victim).unwrap();
assert!(victim.exists(), "guard must not remove an unmanaged dir");
}
#[test]
fn test_guard_blocks_join_and_traversal_escapes() {
assert!(!is_safe_to_remove(&containers_dir().join("/bin")));
assert!(!is_safe_to_remove(&sandbox_dir("/bin")));
assert!(!is_safe_to_remove(
&containers_dir().join("../../../../bin")
));
assert!(!is_safe_to_remove(&containers_dir().join("")));
assert!(!is_safe_to_remove(&sandbox_dir("")));
}
#[test]
fn test_is_rootless_returns_bool() {
let _ = is_rootless();
}
#[test]
fn test_data_dir_is_absolute() {
assert!(data_dir().is_absolute());
}
#[test]
fn test_runtime_dir_is_absolute() {
assert!(runtime_dir().is_absolute());
}
#[test]
fn test_derived_paths_under_data_dir() {
let data = data_dir();
assert!(images_dir().starts_with(&data));
assert!(layers_dir().starts_with(&data));
assert!(volumes_dir().starts_with(&data));
assert!(rootfs_store_dir().starts_with(&data));
assert!(blobs_dir().starts_with(&data));
}
#[test]
fn test_blob_path() {
let p = blob_path("sha256:abc123");
assert_eq!(p, blobs_dir().join("abc123.tar.gz"));
let p2 = blob_path("abc123");
assert_eq!(p2, blobs_dir().join("abc123.tar.gz"));
}
#[test]
fn test_derived_paths_under_runtime_dir() {
let rt = runtime_dir();
assert!(containers_dir().starts_with(&rt));
assert!(oci_state_dir("test").starts_with(&rt));
assert!(overlay_base(1, 0).starts_with(&rt));
assert!(dns_dir(1, 0).starts_with(&rt));
assert!(hosts_dir(1, 0).starts_with(&rt));
assert!(ipam_file().starts_with(&rt));
assert!(nat_refcount_file().starts_with(&rt));
assert!(port_forwards_file().starts_with(&rt));
assert!(counter_file().starts_with(&rt));
}
#[test]
fn test_network_config_paths_under_data_dir() {
let data = data_dir();
assert!(networks_config_dir().starts_with(&data));
assert!(network_config_dir("frontend").starts_with(&data));
assert_eq!(
network_config_dir("frontend"),
networks_config_dir().join("frontend")
);
}
#[test]
fn test_network_runtime_paths_under_runtime_dir() {
let rt = runtime_dir();
assert!(network_runtime_dir("frontend").starts_with(&rt));
assert!(network_ipam_file("frontend").starts_with(&rt));
assert!(network_nat_refcount_file("frontend").starts_with(&rt));
assert!(network_port_forwards_file("frontend").starts_with(&rt));
}
#[test]
fn test_compose_paths_under_runtime_dir() {
let rt = runtime_dir();
assert!(compose_dir().starts_with(&rt));
assert!(compose_project_dir("myapp").starts_with(&rt));
assert!(compose_state_file("myapp").starts_with(&rt));
assert_eq!(compose_project_dir("myapp"), compose_dir().join("myapp"));
assert_eq!(
compose_state_file("myapp"),
compose_project_dir("myapp").join("state.json")
);
}
#[test]
fn test_dns_paths_under_runtime_dir() {
let rt = runtime_dir();
assert!(dns_config_dir().starts_with(&rt));
assert!(dns_pid_file().starts_with(&rt));
assert!(dns_network_file("pelagos0").starts_with(&rt));
assert_eq!(
dns_network_file("frontend"),
dns_config_dir().join("frontend")
);
}
#[test]
fn test_dns_dnsmasq_paths_under_runtime_dir() {
let rt = runtime_dir();
assert!(dns_backend_file().starts_with(&rt));
assert!(dns_dnsmasq_conf().starts_with(&rt));
assert!(dns_hosts_file("pelagos0").starts_with(&rt));
assert_eq!(
dns_hosts_file("frontend"),
dns_config_dir().join("hosts.frontend")
);
}
}