use std::path::{Path, PathBuf};
use crate::error::SnapshotError;
use crate::id::SnapshotId;
const MAX_LEN: usize = 64;
pub fn validate_tenant(s: &str) -> Result<&str, SnapshotError> {
validate_path_segment(s, "tenant")
}
pub fn validate_agent_id(s: &str) -> Result<&str, SnapshotError> {
validate_path_segment(s, "agent_id")
}
fn validate_path_segment<'a>(s: &'a str, what: &'static str) -> Result<&'a str, SnapshotError> {
if s.is_empty() || s.len() > MAX_LEN {
return Err(SnapshotError::RestoreRefused(format!(
"{what} length {} not in 1..={}",
s.len(),
MAX_LEN
)));
}
if s.contains('\0') {
return Err(SnapshotError::RestoreRefused(format!(
"{what} contains NUL byte"
)));
}
let bytes = s.as_bytes();
if matches!(bytes[0], b'_' | b'-') || matches!(bytes[bytes.len() - 1], b'_' | b'-') {
return Err(SnapshotError::RestoreRefused(format!(
"{what} cannot start or end with `_` or `-`"
)));
}
let charset_ok = bytes.iter().all(|b| {
matches!(b,
b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-'
)
});
if !charset_ok {
return Err(SnapshotError::RestoreRefused(format!(
"{what} must match [a-z0-9_-]"
)));
}
Ok(s)
}
pub fn tenant_root(state_root: &Path, tenant: &str) -> Result<PathBuf, SnapshotError> {
let tenant = validate_tenant(tenant)?;
let path = state_root.join("tenants").join(tenant);
if !path.starts_with(state_root) {
return Err(SnapshotError::CrossTenant);
}
Ok(path)
}
pub fn snapshots_dir(
state_root: &Path,
tenant: &str,
agent_id: &str,
) -> Result<PathBuf, SnapshotError> {
let agent_id = validate_agent_id(agent_id)?;
let dir = tenant_root(state_root, tenant)?
.join("snapshots")
.join(agent_id);
if !dir.starts_with(state_root) {
return Err(SnapshotError::CrossTenant);
}
Ok(dir)
}
pub fn snapshot_bundle_path(
state_root: &Path,
tenant: &str,
agent_id: &str,
id: SnapshotId,
encrypted: bool,
) -> Result<PathBuf, SnapshotError> {
let dir = snapshots_dir(state_root, tenant, agent_id)?;
let suffix = if encrypted {
".tar.zst.age"
} else {
".tar.zst"
};
Ok(dir.join(format!("{}{}", id.as_filename(), suffix)))
}
pub fn bundle_sha256_sibling(bundle: &Path) -> PathBuf {
let mut p = bundle.as_os_str().to_owned();
p.push(".sha256");
PathBuf::from(p)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_typical_ids() {
validate_tenant("acme").unwrap();
validate_tenant("acme-corp").unwrap();
validate_tenant("acme_corp").unwrap();
validate_tenant("a1").unwrap();
validate_agent_id("ana").unwrap();
validate_agent_id("ventas-etb").unwrap();
}
#[test]
fn rejects_uppercase() {
let err = validate_tenant("Acme").unwrap_err();
assert!(format!("{err}").contains("[a-z0-9_-]"));
}
#[test]
fn rejects_leading_or_trailing_punct() {
assert!(validate_tenant("-acme").is_err());
assert!(validate_tenant("acme-").is_err());
assert!(validate_tenant("_acme").is_err());
assert!(validate_tenant("acme_").is_err());
}
#[test]
fn rejects_too_long() {
let too_long = "a".repeat(65);
assert!(validate_tenant(&too_long).is_err());
}
#[test]
fn rejects_empty_and_nul() {
assert!(validate_tenant("").is_err());
assert!(validate_tenant("ac\0me").is_err());
}
#[test]
fn rejects_path_components() {
assert!(validate_tenant("..").is_err());
assert!(validate_tenant("../etc").is_err());
assert!(validate_tenant("a/b").is_err());
}
#[test]
fn tenant_root_lives_under_state_root() {
let root = Path::new("/var/lib/nexo");
let path = tenant_root(root, "acme").unwrap();
assert_eq!(path, Path::new("/var/lib/nexo/tenants/acme"));
assert!(path.starts_with(root));
}
#[test]
fn snapshots_dir_includes_agent_id() {
let root = Path::new("/var/lib/nexo");
let path = snapshots_dir(root, "acme", "ana").unwrap();
assert_eq!(path, Path::new("/var/lib/nexo/tenants/acme/snapshots/ana"));
}
#[test]
fn bundle_path_uses_extension_for_encryption_flag() {
let root = Path::new("/var/lib/nexo");
let id = SnapshotId::new();
let plain = snapshot_bundle_path(root, "acme", "ana", id, false).unwrap();
assert!(plain.to_string_lossy().ends_with(".tar.zst"));
let enc = snapshot_bundle_path(root, "acme", "ana", id, true).unwrap();
assert!(enc.to_string_lossy().ends_with(".tar.zst.age"));
}
#[test]
fn bundle_sha256_sibling_appends_extension() {
let p = Path::new("/x/y.tar.zst");
assert_eq!(bundle_sha256_sibling(p), Path::new("/x/y.tar.zst.sha256"));
}
}