use super::local::*;
use crate::operations::ledger::{Record, now, unknown};
use buntrail_core::contracts::*;
use std::{
fs,
fs::File,
io::Write,
os::unix::{ffi::OsStrExt, fs::MetadataExt},
path::Path,
};
impl LocalFileService {
pub fn list_sync(&self, directory: FileRef) -> Result<Vec<FileEntry>, CoreError> {
let path = self.validate(&directory)?;
let mut entries = vec![];
for entry in fs::read_dir(path).map_err(io_error)? {
let e = entry.map_err(io_error)?;
if entries.len() == 100_000 {
return Err(CoreError::new(
ErrorCode::Unavailable,
"directory exceeds 100,000 entries",
));
}
let m = e.metadata().map_err(io_error)?;
let kind = if e.file_type().map_err(io_error)?.is_symlink() {
FileKind::Symlink
} else if m.is_dir() {
FileKind::Directory
} else if m.is_file() {
FileKind::File
} else {
FileKind::Other
};
entries.push(FileEntry {
file: FileRef {
target: directory.target.clone(),
path: target_path(&e.path()),
resolved_identity: None,
},
display_name: e.file_name().to_string_lossy().into_owned(),
kind,
});
}
entries.sort_by(|a, b| {
(a.kind != FileKind::Directory, &a.display_name)
.cmp(&(b.kind != FileKind::Directory, &b.display_name))
});
Ok(entries)
}
pub fn path_version(&self, file: FileRef) -> Result<FileVersion, CoreError> {
let p = self.validate(&file)?;
let m = fs::symlink_metadata(&p).map_err(io_error)?;
if m.is_file() {
return self.current_version(file);
}
let tree = super::watcher::fingerprint(&p)?;
let content = digest(&tree);
Ok(FileVersion {
target: file.target,
opaque_token: content.to_vec(),
content_digest: content,
file_identity: [m.dev().to_be_bytes(), m.ino().to_be_bytes()].concat(),
})
}
pub fn mutate_sync(&self, request: FileMutationRequest) -> OperationReceipt {
let fail = |e| failure(request.operation, request.file.target.clone(), e);
let result = (|| -> Result<OperationReceipt, CoreError> {
let original = self.validate(&request.file)?;
let bytes = match &request.command {
FileMutation::Create { source } => {
Some(self.source_bytes(source, super::MAX_ENCODED_BYTES, true)?)
}
_ => None,
};
let command_digest = match &request.command {
FileMutation::Create { .. } => digest(bytes.as_ref().unwrap()),
_ => digest(&serde_json::to_vec(&request.command).map_err(io_error)?),
};
let fingerprint = digest(
&serde_json::to_vec(&(&request.file, &request.expected, command_digest))
.map_err(io_error)?,
);
let ledger = self.ledger.lock().map_err(io_error)?;
let _lock = ledger.lock()?;
if let Some(old) = ledger.read(request.operation)? {
if old.envelope.request_digest != fingerprint
|| old.envelope.target != request.file.target
{
return Err(CoreError::new(
ErrorCode::InvalidArgument,
"operation ID reused with different request",
));
}
return ledger.inspect(request.operation, request.file.target.clone());
}
let validate = || -> Result<(), CoreError> {
match &request.expected {
ExpectedPathState::Absent
if fs::symlink_metadata(&original)
.is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
{
Ok(())
}
ExpectedPathState::Present(v)
if self.path_version(request.file.clone())? == *v =>
{
Ok(())
}
_ => Err(CoreError::new(
ErrorCode::StaleRevision,
"path changed; refresh before retrying",
)),
}
};
validate()?;
let trash = if matches!(request.command, FileMutation::Trash) {
Some(super::trash::prepare(&original, request.operation)?)
} else {
None
};
let destination = match &request.command {
FileMutation::Rename { destination } | FileMutation::Move { destination } => {
Some(path(destination)?)
}
FileMutation::Trash => Some(trash.as_ref().unwrap().destination.clone()),
_ => None,
};
if let Some(dest) = &destination {
if fs::symlink_metadata(dest).is_ok() {
return Err(CoreError::new(
ErrorCode::StaleRevision,
"destination exists",
));
}
if dest.starts_with(&original) {
return Err(CoreError::new(
ErrorCode::InvalidArgument,
"cannot move a folder into itself",
));
}
}
let mut record = Record {
envelope: OperationEnvelope {
id: request.operation,
target: request.file.target.clone(),
request_digest: fingerprint,
kind: MutationKind::File,
expected: ExpectedState::File {
file: request.file.clone(),
version: match &request.expected {
ExpectedPathState::Absent => None,
ExpectedPathState::Present(v) => Some(v.clone()),
},
},
},
destination: None,
save_proof: None,
terminal_request: None,
git_intent: None,
file_destination: destination.as_ref().map(|p| target_path(p)),
requested_digest: command_digest,
revision: BufferRevision(0),
receipt: OperationReceipt {
id: request.operation,
state: OperationState::Accepted,
},
created: now(),
};
if let Some(existing) = ledger.accept(&record, &_lock)? {
return Ok(existing);
}
let mut effected = false;
let effect = (|| -> Result<MutationResult, CoreError> {
validate()?;
if let Some(trash) = &trash {
super::trash::write_info(trash)?;
}
match &request.command {
FileMutation::Create { .. } => {
let parent = original
.parent()
.ok_or_else(|| io_error("missing parent"))?;
let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(io_error)?;
tmp.write_all(bytes.as_ref().unwrap()).map_err(io_error)?;
tmp.as_file().sync_all().map_err(io_error)?;
tmp.persist_noclobber(&original).map_err(io_error)?;
}
FileMutation::CreateDirectory => fs::create_dir(&original).map_err(io_error)?,
FileMutation::Rename { .. }
| FileMutation::Move { .. }
| FileMutation::Trash => {
rename_exclusive(&original, destination.as_ref().unwrap())?
}
FileMutation::Remove => {
let m = fs::symlink_metadata(&original).map_err(io_error)?;
if m.is_dir() {
fs::remove_dir(&original).map_err(io_error)?;
} else {
fs::remove_file(&original).map_err(io_error)?;
}
}
FileMutation::Delete => {
let m = fs::symlink_metadata(&original).map_err(io_error)?;
if m.is_dir() {
effected = true;
fs::remove_dir_all(&original).map_err(io_error)?;
} else {
fs::remove_file(&original).map_err(io_error)?;
}
}
}
effected = true;
File::open(
original
.parent()
.ok_or_else(|| io_error("missing parent"))?,
)
.and_then(|f| f.sync_all())
.map_err(io_error)?;
if let Some(dest) = &destination {
File::open(dest.parent().unwrap())
.and_then(|f| f.sync_all())
.map_err(io_error)?;
}
let removed = matches!(
request.command,
FileMutation::Delete | FileMutation::Remove | FileMutation::Trash
);
let mut file = request.file.clone();
if let Some(dest) = destination
.as_ref()
.filter(|_| !removed || matches!(request.command, FileMutation::Trash))
{
file.path = target_path(dest);
}
file.resolved_identity = None;
let version = if removed {
None
} else {
Some(self.path_version(file.clone())?)
};
Ok(MutationResult::FileChanged { file, version })
})();
record.receipt = match effect {
Ok(v) => OperationReceipt {
id: request.operation,
state: OperationState::Confirmed(v),
},
Err(e) if !effected => fail(e),
Err(_) => unknown(
request.operation,
request.file.target.clone(),
"mutation may have partial effects; inspect paths before another action",
),
};
if ledger.write(&record).is_err() {
return Ok(unknown(
request.operation,
request.file.target.clone(),
"mutation completion not durable; inspect paths",
));
}
Ok(record.receipt)
})();
result.unwrap_or_else(fail)
}
}
#[cfg(target_os = "macos")]
pub(crate) fn rename_exclusive(from: &Path, to: &Path) -> Result<(), CoreError> {
let from = std::ffi::CString::new(from.as_os_str().as_bytes()).map_err(io_error)?;
let to = std::ffi::CString::new(to.as_os_str().as_bytes()).map_err(io_error)?;
if unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_EXCL) } == 0 {
Ok(())
} else {
Err(io_error(std::io::Error::last_os_error()))
}
}
#[cfg(target_os = "linux")]
pub(crate) fn rename_exclusive(from: &Path, to: &Path) -> Result<(), CoreError> {
let from = std::ffi::CString::new(from.as_os_str().as_bytes()).map_err(io_error)?;
let to = std::ffi::CString::new(to.as_os_str().as_bytes()).map_err(io_error)?;
if unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD,
from.as_ptr(),
libc::AT_FDCWD,
to.as_ptr(),
libc::RENAME_NOREPLACE,
)
} == 0
{
Ok(())
} else {
Err(io_error(std::io::Error::last_os_error()))
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn rename_exclusive(_: &Path, _: &Path) -> Result<(), CoreError> {
Err(CoreError::new(
ErrorCode::Unavailable,
"exclusive rename unavailable on this platform",
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn directory_version_matches_original_and_stale_delete_has_no_effect() {
let temp = tempfile::tempdir().unwrap();
let base = fs::canonicalize(temp.path()).unwrap();
let project = base.join("project");
fs::create_dir_all(project.join("sub")).unwrap();
fs::write(project.join("sub/file"), b"original").unwrap();
fs::write(project.join("keep"), b"keep").unwrap();
let alias = base.join("external-hardlink");
fs::hard_link(project.join("sub/file"), &alias).unwrap();
let target = TargetIdentity {
security_binding: vec![1],
effective_user: vec![2],
filesystem_namespace: vec![3],
container: None,
};
let service = LocalFileService::new(target.clone(), base.join("private")).unwrap();
let file = FileRef {
target: target.clone(),
path: target_path(&project),
resolved_identity: None,
};
let m = fs::symlink_metadata(&project).unwrap();
let old_digest = digest(&super::super::watcher::reference_fingerprint(&project).unwrap());
let expected = FileVersion {
target,
opaque_token: old_digest.to_vec(),
content_digest: old_digest,
file_identity: [m.dev().to_be_bytes(), m.ino().to_be_bytes()].concat(),
};
let before = service.path_version(file.clone()).unwrap();
assert_eq!(before, expected);
fs::write(&alias, b"external longer change").unwrap();
let after = service.path_version(file.clone()).unwrap();
assert_ne!(before, after);
assert_eq!(
after.content_digest,
digest(&super::super::watcher::reference_fingerprint(&project).unwrap())
);
let receipt = service.mutate_sync(FileMutationRequest {
operation: OperationID([243; 16]),
file,
expected: ExpectedPathState::Present(before),
command: FileMutation::Delete,
});
let OperationState::Failed(failure) = receipt.state else {
panic!("stale destructive request was not refused")
};
assert_eq!(failure.error.code, ErrorCode::StaleRevision);
assert_eq!(failure.effect, FailureEffect::ProvenNoEffect);
assert!(project.is_dir());
assert_eq!(
fs::read(project.join("sub/file")).unwrap(),
b"external longer change"
);
assert_eq!(fs::read(project.join("keep")).unwrap(), b"keep");
assert_eq!(fs::read(alias).unwrap(), b"external longer change");
}
}
#[cfg(test)]
mod explorer_trash_tests {
use super::*;
#[test]
fn explorer_trash_rejects_nonempty_directories_without_effect() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("folder");
fs::create_dir(&source).unwrap();
fs::write(source.join("kept"), b"kept").unwrap();
assert!(super::super::trash::prepare(&source, OperationID([4; 16])).is_err());
assert_eq!(fs::read(source.join("kept")).unwrap(), b"kept");
}
#[test]
fn explorer_trash_metadata_preserves_hostile_path_bytes() {
assert_eq!(
super::super::trash::info_text(Path::new("/a b/%\n"), "2026-11-11T12:00:00"),
"[Trash Info]\nPath=/a%20b/%25%0A\nDeletionDate=2026-11-11T12:00:00\n"
);
}
}