use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use futures::future::BoxFuture;
use thiserror::Error;
use crate::error::BoxError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fingerprint {
pub bytes: u64,
pub hash: u64,
}
impl Fingerprint {
pub fn of(content: &str) -> Self {
let mut h = DefaultHasher::new();
content.hash(&mut h);
Self {
bytes: content.len() as u64,
hash: h.finish(),
}
}
}
pub struct NoopFsBackend;
impl FsBackend for NoopFsBackend {
fn read_text(
&self,
_path: PathBuf,
_line: Option<u32>,
_limit: Option<u32>,
) -> BoxFuture<'_, Result<String, FsError>> {
Box::pin(async {
Err(FsError::NotPermitted(
"NoopFsBackend cannot read".to_string(),
))
})
}
fn write_text(&self, _path: PathBuf, _content: String) -> BoxFuture<'_, Result<(), FsError>> {
Box::pin(async {
Err(FsError::NotPermitted(
"NoopFsBackend cannot write".to_string(),
))
})
}
}
pub trait FsBackend: Send + Sync {
fn read_text(
&self,
path: PathBuf,
line: Option<u32>,
limit: Option<u32>,
) -> BoxFuture<'_, Result<String, FsError>>;
fn read_bytes(&self, path: PathBuf) -> BoxFuture<'_, Result<Vec<u8>, FsError>> {
Box::pin(async move {
let _ = path;
Err(FsError::NotPermitted(
"this backend cannot read raw bytes (e.g. images); delegated environments only support text reads".to_string(),
))
})
}
fn write_text(&self, path: PathBuf, content: String) -> BoxFuture<'_, Result<(), FsError>>;
fn fingerprint(&self, path: PathBuf) -> BoxFuture<'_, Result<Fingerprint, FsError>> {
Box::pin(async move {
let text = self.read_text(path, None, None).await?;
Ok(Fingerprint::of(&text))
})
}
}
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum FsError {
#[error("file not found: {0}")]
NotFound(PathBuf),
#[error("operation not permitted: {0}")]
NotPermitted(String),
#[error("file too large: {bytes} bytes > {limit}")]
TooLarge { bytes: u64, limit: u64 },
#[error("file changed since last read: {0}")]
Conflict(PathBuf),
#[error("backend failure: {0}")]
Backend(#[source] BoxError),
}
pub fn resolve_workspace_path(workspace_root: &Path, requested: &Path) -> Result<PathBuf, FsError> {
let target = if requested.is_absolute() {
requested.to_path_buf()
} else {
workspace_root.join(requested)
};
let parent = target.parent().ok_or_else(|| {
FsError::NotPermitted(format!("path has no parent: {}", target.display()))
})?;
let (existing_ancestor, missing_suffix) = find_existing_ancestor(parent).ok_or_else(|| {
FsError::NotPermitted(format!(
"no existing ancestor found for: {}",
target.display()
))
})?;
let existing_canon =
std::fs::canonicalize(existing_ancestor).map_err(|e| FsError::Backend(BoxError::new(e)))?;
let root_canon =
std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
if !existing_canon.starts_with(&root_canon) {
return Err(FsError::NotPermitted(format!(
"path {} escapes workspace root {}",
target.display(),
root_canon.display()
)));
}
let file_name = target.file_name().ok_or_else(|| {
FsError::NotPermitted(format!("path has no file component: {}", target.display()))
})?;
Ok(existing_canon.join(missing_suffix).join(file_name))
}
fn find_existing_ancestor(path: &Path) -> Option<(&Path, PathBuf)> {
let mut missing = Vec::new();
let mut current = path;
loop {
if current.exists() {
missing.reverse();
return Some((current, missing.into_iter().collect()));
}
missing.push(current.file_name()?.to_os_string());
current = current.parent()?;
}
}
#[cfg(test)]
mod tests;