use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use globset::{Glob, GlobSetBuilder};
use walkdir::WalkDir;
use crate::error::{AxiomError, Result};
use crate::models::{Entry, TreeNode, TreeResult};
use crate::uri::{AxiomUri, Scope};
#[derive(Debug, Clone)]
pub struct LocalContextFs {
root: PathBuf,
}
impl LocalContextFs {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn initialize(&self) -> Result<()> {
fs::create_dir_all(&self.root)?;
for scope in [
Scope::Resources,
Scope::User,
Scope::Agent,
Scope::Session,
Scope::Temp,
Scope::Queue,
] {
let path = self.root.join(scope.as_str());
fs::create_dir_all(path)?;
}
Ok(())
}
#[must_use]
pub fn resolve_uri(&self, uri: &AxiomUri) -> PathBuf {
let mut out = self.root.join(uri.scope().as_str());
for segment in uri.segments() {
out.push(segment);
}
out
}
pub fn uri_from_path(&self, path: &Path) -> Result<AxiomUri> {
let relative = path.strip_prefix(&self.root).map_err(|_| {
AxiomError::Validation(format!("path is outside root: {}", path.display()))
})?;
let mut components = relative.components();
let scope = components
.next()
.ok_or_else(|| AxiomError::Validation("missing scope component".to_string()))?;
let scope_str = match scope {
Component::Normal(s) => s.to_string_lossy().to_string(),
_ => {
return Err(AxiomError::Validation(
"invalid scope component".to_string(),
));
}
};
let mut uri = AxiomUri::parse(&format!("axiom://{scope_str}"))?;
for comp in components {
if let Component::Normal(s) = comp {
uri = uri.join(&s.to_string_lossy())?;
}
}
Ok(uri)
}
#[must_use]
pub fn exists(&self, uri: &AxiomUri) -> bool {
self.resolve_uri(uri).exists()
}
#[must_use]
pub fn is_dir(&self, uri: &AxiomUri) -> bool {
self.resolve_uri(uri).is_dir()
}
pub fn create_dir_all(&self, uri: &AxiomUri, system: bool) -> Result<()> {
Self::ensure_writable(uri, system)?;
let path = self.resolve_uri(uri);
self.ensure_path_within_root(&path)?;
fs::create_dir_all(path)?;
Ok(())
}
pub fn read(&self, uri: &AxiomUri) -> Result<String> {
let path = self.resolve_uri(uri);
if !path.exists() {
return Err(AxiomError::NotFound(uri.to_string()));
}
if path.is_dir() {
return Err(AxiomError::Validation(format!(
"cannot read directory: {uri}"
)));
}
self.ensure_path_within_root(&path)?;
Ok(fs::read_to_string(path)?)
}
pub fn write(&self, uri: &AxiomUri, content: &str, system: bool) -> Result<()> {
Self::ensure_writable(uri, system)?;
let path = self.resolve_uri(uri);
self.ensure_path_within_root(&path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(())
}
pub fn write_atomic(&self, uri: &AxiomUri, content: &str, system: bool) -> Result<()> {
Self::ensure_writable(uri, system)?;
let path = self.resolve_uri(uri);
self.ensure_path_within_root(&path)?;
let parent = path
.parent()
.ok_or_else(|| AxiomError::Validation(format!("target has no parent: {uri}")))?;
fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.and_then(|x| x.to_str())
.ok_or_else(|| AxiomError::Validation(format!("invalid target filename: {uri}")))?;
let tmp_name = format!(
".{file_name}.axiomsync.tmp.{}",
uuid::Uuid::new_v4().simple()
);
let tmp_path = parent.join(tmp_name);
self.ensure_path_within_root(&tmp_path)?;
{
let mut tmp = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp_path)?;
tmp.write_all(content.as_bytes())?;
tmp.sync_all()?;
}
if let Err(err) = fs::rename(&tmp_path, &path) {
let _ = fs::remove_file(&tmp_path);
return Err(AxiomError::from(err));
}
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
pub fn append(&self, uri: &AxiomUri, content: &str, system: bool) -> Result<()> {
Self::ensure_writable(uri, system)?;
let path = self.resolve_uri(uri);
self.ensure_path_within_root(&path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
pub fn write_bytes(&self, uri: &AxiomUri, bytes: &[u8], system: bool) -> Result<()> {
Self::ensure_writable(uri, system)?;
let path = self.resolve_uri(uri);
self.ensure_path_within_root(&path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, bytes)?;
Ok(())
}
pub fn read_bytes(&self, uri: &AxiomUri) -> Result<Vec<u8>> {
let path = self.resolve_uri(uri);
if !path.exists() {
return Err(AxiomError::NotFound(uri.to_string()));
}
if path.is_dir() {
return Err(AxiomError::Validation(format!(
"cannot read directory: {uri}"
)));
}
self.ensure_path_within_root(&path)?;
Ok(fs::read(path)?)
}
pub fn list(&self, uri: &AxiomUri, recursive: bool) -> Result<Vec<Entry>> {
let base = self.resolve_uri(uri);
if !base.exists() {
return Err(AxiomError::NotFound(uri.to_string()));
}
self.ensure_path_within_root(&base)?;
let mut entries = Vec::new();
if recursive {
for item in WalkDir::new(&base).follow_links(false) {
let item = item.map_err(|e| AxiomError::Validation(e.to_string()))?;
if item.path() == base {
continue;
}
let meta = item
.metadata()
.map_err(|e| AxiomError::Validation(e.to_string()))?;
let item_uri = self.uri_from_path(item.path())?;
entries.push(Entry {
uri: item_uri.to_string(),
name: item.file_name().to_string_lossy().to_string(),
is_dir: meta.is_dir(),
size: if meta.is_file() { meta.len() } else { 0 },
});
}
} else {
for item in fs::read_dir(&base)? {
let item = item?;
let path = item.path();
let meta = fs::symlink_metadata(&path)?;
let item_uri = self.uri_from_path(&path)?;
entries.push(Entry {
uri: item_uri.to_string(),
name: item.file_name().to_string_lossy().to_string(),
is_dir: meta.file_type().is_dir(),
size: if meta.is_file() { meta.len() } else { 0 },
});
}
}
entries.sort_by(|a, b| a.uri.cmp(&b.uri));
Ok(entries)
}
pub fn glob(&self, uri: Option<&AxiomUri>, pattern: &str) -> Result<Vec<String>> {
let base_uri = uri
.cloned()
.unwrap_or_else(|| AxiomUri::root(Scope::Resources));
let base = self.resolve_uri(&base_uri);
if !base.exists() {
return Ok(Vec::new());
}
self.ensure_path_within_root(&base)?;
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new(pattern).map_err(|e| AxiomError::Validation(e.to_string()))?);
let matcher = builder
.build()
.map_err(|e| AxiomError::Validation(e.to_string()))?;
let mut matched_uris = Vec::new();
for item in WalkDir::new(&base).follow_links(false) {
let item = item.map_err(|e| AxiomError::Validation(e.to_string()))?;
if item.path() == base {
continue;
}
let rel = item
.path()
.strip_prefix(&base)
.map_err(|e| AxiomError::Validation(e.to_string()))?;
if matcher.is_match(rel) {
let item_uri = self.uri_from_path(item.path())?;
matched_uris.push(item_uri.to_string());
}
}
matched_uris.sort();
Ok(matched_uris)
}
pub fn rm(&self, uri: &AxiomUri, recursive: bool, system: bool) -> Result<()> {
Self::ensure_writable(uri, system)?;
let path = self.resolve_uri(uri);
if !path.exists() {
return Ok(());
}
self.ensure_path_within_root(&path)?;
if path.is_dir() {
if recursive {
fs::remove_dir_all(path)?;
} else {
fs::remove_dir(path)?;
}
} else {
fs::remove_file(path)?;
}
Ok(())
}
pub fn mv(&self, from: &AxiomUri, to: &AxiomUri, system: bool) -> Result<()> {
Self::ensure_writable(from, system)?;
Self::ensure_writable(to, system)?;
let from_path = self.resolve_uri(from);
let to_path = self.resolve_uri(to);
self.ensure_path_within_root(&from_path)?;
self.ensure_path_within_root(&to_path)?;
if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(from_path, to_path)?;
Ok(())
}
pub fn tree(&self, uri: &AxiomUri) -> Result<TreeResult> {
let path = self.resolve_uri(uri);
if !path.exists() {
return Err(AxiomError::NotFound(uri.to_string()));
}
self.ensure_path_within_root(&path)?;
let root = self.build_tree(uri, &path)?;
Ok(TreeResult { root })
}
fn build_tree(&self, uri: &AxiomUri, path: &Path) -> Result<TreeNode> {
let meta = fs::symlink_metadata(path)?;
let is_dir = meta.file_type().is_dir();
let mut node = TreeNode {
uri: uri.to_string(),
is_dir,
children: Vec::new(),
};
if is_dir {
let mut children = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
let child_path = entry.path();
let child_uri = self.uri_from_path(&child_path)?;
children.push(self.build_tree(&child_uri, &child_path)?);
}
children.sort_by(|a, b| a.uri.cmp(&b.uri));
node.children = children;
}
Ok(node)
}
fn ensure_writable(uri: &AxiomUri, system: bool) -> Result<()> {
if !system && matches!(uri.scope(), Scope::Queue) {
return Err(AxiomError::PermissionDenied(
"queue scope is read-only for non-system operations".to_string(),
));
}
Ok(())
}
fn ensure_path_within_root(&self, path: &Path) -> Result<()> {
let root = self.canonical_root()?;
let mut probe = path.to_path_buf();
while !probe.exists() {
if !probe.pop() {
return Err(AxiomError::SecurityViolation(format!(
"path has no existing ancestor: {}",
path.display()
)));
}
}
let probe_canonical = fs::canonicalize(&probe)?;
if !probe_canonical.starts_with(&root) {
return Err(AxiomError::SecurityViolation(format!(
"path escapes root boundary: {}",
path.display()
)));
}
if path.exists() {
let path_canonical = fs::canonicalize(path)?;
if !path_canonical.starts_with(&root) {
return Err(AxiomError::SecurityViolation(format!(
"path resolves outside root boundary: {}",
path.display()
)));
}
}
Ok(())
}
fn canonical_root(&self) -> Result<PathBuf> {
if !self.root.exists() {
fs::create_dir_all(&self.root)?;
}
Ok(fs::canonicalize(&self.root)?)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
use crate::models::RelationLink;
use crate::relation_documents::{read_relations, write_relations};
use crate::tier_documents::{read_abstract, read_overview, write_tiers};
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[test]
fn queue_scope_is_read_only() {
let temp = tempdir().expect("tempdir");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let uri = AxiomUri::parse("axiom://queue/events.log").expect("parse failed");
let err = fs.write(&uri, "x", false).expect_err("must fail");
assert!(matches!(err, AxiomError::PermissionDenied(_)));
}
#[test]
fn append_supports_incremental_log_writes() {
let temp = tempdir().expect("tempdir");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let uri = AxiomUri::parse("axiom://queue/logs/requests.jsonl").expect("parse failed");
fs.append(&uri, "{\"a\":1}\n", true).expect("append 1");
fs.append(&uri, "{\"b\":2}\n", true).expect("append 2");
let raw = fs.read(&uri).expect("read");
assert!(raw.contains("{\"a\":1}"));
assert!(raw.contains("{\"b\":2}"));
}
#[test]
fn write_atomic_overwrites_existing_file() {
let temp = tempdir().expect("tempdir");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let uri = AxiomUri::parse("axiom://resources/docs/atomic.md").expect("parse");
fs.write(&uri, "v1", true).expect("write v1");
fs.write_atomic(&uri, "v2", true).expect("write atomic");
let raw = fs.read(&uri).expect("read");
assert_eq!(raw, "v2");
}
#[cfg(unix)]
#[test]
fn write_rejects_symlink_escape_outside_root() {
let temp = tempdir().expect("tempdir");
let outside = tempdir().expect("outside");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let link_path = temp.path().join("resources").join("escape-link");
symlink(outside.path(), &link_path).expect("symlink");
let uri = AxiomUri::parse("axiom://resources/escape-link/pwned.txt").expect("parse uri");
let err = fs.write(&uri, "owned", true).expect_err("must fail");
assert!(matches!(err, AxiomError::SecurityViolation(_)));
}
#[cfg(unix)]
#[test]
fn read_rejects_symlink_escape_outside_root() {
let temp = tempdir().expect("tempdir");
let outside = tempdir().expect("outside");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let outside_file = outside.path().join("secret.txt");
fs::write(&outside_file, "secret").expect("write outside");
let link_path = temp.path().join("resources").join("secret-link.txt");
symlink(&outside_file, &link_path).expect("symlink file");
let uri = AxiomUri::parse("axiom://resources/secret-link.txt").expect("parse uri");
let err = fs.read(&uri).expect_err("must fail");
assert!(matches!(err, AxiomError::SecurityViolation(_)));
}
#[cfg(unix)]
#[test]
fn write_tiers_rejects_symlink_escape_outside_root() {
let temp = tempdir().expect("tempdir");
let outside = tempdir().expect("outside");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let link_path = temp.path().join("resources").join("escape-tiers");
symlink(outside.path(), &link_path).expect("symlink");
let uri = AxiomUri::parse("axiom://resources/escape-tiers").expect("parse uri");
let err = write_tiers(&fs, &uri, "abstract", "overview", true).expect_err("must fail");
assert!(matches!(err, AxiomError::SecurityViolation(_)));
}
#[cfg(unix)]
#[test]
fn read_tiers_reject_symlink_escape_outside_root() {
let temp = tempdir().expect("tempdir");
let outside = tempdir().expect("outside");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
fs::write(outside.path().join(".abstract.md"), "secret abstract").expect("write abstract");
fs::write(outside.path().join(".overview.md"), "secret overview").expect("write overview");
let link_path = temp.path().join("resources").join("escape-tiers");
symlink(outside.path(), &link_path).expect("symlink");
let uri = AxiomUri::parse("axiom://resources/escape-tiers").expect("parse uri");
let abstract_err = read_abstract(&fs, &uri).expect_err("must fail abstract read");
assert!(matches!(abstract_err, AxiomError::SecurityViolation(_)));
let overview_err = read_overview(&fs, &uri).expect_err("must fail overview read");
assert!(matches!(overview_err, AxiomError::SecurityViolation(_)));
}
#[test]
fn relations_roundtrip_read_write() {
let temp = tempdir().expect("tempdir");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let owner = AxiomUri::parse("axiom://resources/docs").expect("owner parse");
let links = vec![RelationLink {
id: "auth-security".to_string(),
uris: vec![
"axiom://resources/docs/auth".to_string(),
"axiom://resources/docs/security".to_string(),
],
reason: "Security dependency".to_string(),
}];
write_relations(&fs, &owner, &links, true).expect("write relations");
let loaded = read_relations(&fs, &owner).expect("read relations");
assert_eq!(loaded, links);
}
#[test]
fn relations_reject_invalid_uri_schema() {
let temp = tempdir().expect("tempdir");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let owner = AxiomUri::parse("axiom://resources/docs").expect("owner parse");
let links = vec![RelationLink {
id: "invalid".to_string(),
uris: vec![
"axiom://resources/docs/auth".to_string(),
"not-a-axiom-uri".to_string(),
],
reason: "Broken relation".to_string(),
}];
let err = write_relations(&fs, &owner, &links, true).expect_err("must reject invalid uri");
assert!(matches!(err, AxiomError::Validation(_)));
}
#[test]
fn relations_reject_duplicate_ids() {
let temp = tempdir().expect("tempdir");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let owner = AxiomUri::parse("axiom://resources/docs").expect("owner parse");
let links = vec![
RelationLink {
id: "dup".to_string(),
uris: vec![
"axiom://resources/docs/auth".to_string(),
"axiom://resources/docs/security".to_string(),
],
reason: "First".to_string(),
},
RelationLink {
id: "dup".to_string(),
uris: vec![
"axiom://resources/docs/auth".to_string(),
"axiom://resources/docs/api".to_string(),
],
reason: "Second".to_string(),
},
];
let err = write_relations(&fs, &owner, &links, true).expect_err("must reject duplicate id");
assert!(matches!(err, AxiomError::Validation(_)));
}
#[test]
fn relations_owner_must_be_directory() {
let temp = tempdir().expect("tempdir");
let fs = LocalContextFs::new(temp.path());
fs.initialize().expect("init failed");
let file_uri = AxiomUri::parse("axiom://resources/docs/readme.md").expect("uri parse");
fs.write(&file_uri, "hello", true).expect("write file");
let err = read_relations(&fs, &file_uri).expect_err("must fail");
assert!(matches!(err, AxiomError::Validation(_)));
}
}