mod local;
mod mem;
mod mount;
mod path;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub use local::{
AvailabilityProbe, LocalVault, atomic_write, create_unique_temp_file, read_prefix_native,
read_range_native,
};
pub use mem::MemVault;
pub use mount::{Mount, MountPolicy};
pub use path::{VaultPath, VaultPathError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum EntryKind {
File,
Dir,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Availability {
#[default]
Local,
NotDownloaded,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub path: VaultPath,
pub kind: EntryKind,
pub size: u64,
pub modified: SystemTime,
pub created: Option<SystemTime>,
pub availability: Availability,
}
impl Entry {
pub fn is_dir(&self) -> bool {
self.kind == EntryKind::Dir
}
pub fn is_file(&self) -> bool {
self.kind == EntryKind::File
}
pub fn name(&self) -> &str {
self.path.file_name().unwrap_or("")
}
pub fn epoch_secs(&self) -> Option<(u64, u64)> {
let secs = |t: SystemTime| {
t.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
};
let modified = secs(self.modified)?;
let created = self.created.and_then(secs).unwrap_or(modified);
Some((created, modified))
}
}
#[derive(Debug, thiserror::Error)]
pub enum VaultError {
#[error("not found: {path}")]
NotFound { path: String },
#[error("not downloaded: {path}")]
NotDownloaded { path: String },
#[error("permission denied: {path}")]
PermissionDenied { path: String },
#[error("outside the vault root: {path}")]
OutsideRoot { path: String },
#[error("read-only (an external folder mounted through a symlink): {path}")]
ReadOnly { path: String },
#[error(transparent)]
InvalidPath(#[from] VaultPathError),
#[error("{path}: {source}")]
Io {
path: String,
#[source]
source: io::Error,
},
}
impl VaultError {
pub fn from_io(path: impl fmt::Display, source: io::Error) -> Self {
let path = path.to_string();
match source.kind() {
io::ErrorKind::NotFound => Self::NotFound { path },
io::ErrorKind::PermissionDenied => Self::PermissionDenied { path },
io::ErrorKind::Deadlock => Self::NotDownloaded { path },
_ => Self::Io { path, source },
}
}
pub fn is_not_found(&self) -> bool {
matches!(self, Self::NotFound { .. })
}
pub fn not_found(path: impl fmt::Display) -> Self {
Self::NotFound {
path: path.to_string(),
}
}
}
impl From<VaultError> for io::Error {
fn from(error: VaultError) -> Self {
let kind = match &error {
VaultError::NotFound { .. } => io::ErrorKind::NotFound,
VaultError::PermissionDenied { .. }
| VaultError::OutsideRoot { .. }
| VaultError::ReadOnly { .. } => io::ErrorKind::PermissionDenied,
VaultError::NotDownloaded { .. } => io::ErrorKind::WouldBlock,
VaultError::InvalidPath(_) => io::ErrorKind::InvalidInput,
VaultError::Io { .. } => {
let VaultError::Io { source, .. } = error else {
unreachable!()
};
return source;
}
};
io::Error::new(kind, error)
}
}
pub trait Vault: Send + Sync + fmt::Debug {
fn root(&self) -> &Path;
fn stat(&self, path: &VaultPath) -> Result<Option<Entry>, VaultError>;
fn list_dir(&self, dir: &VaultPath) -> Result<Vec<Entry>, VaultError>;
fn canonicalize(&self, path: &VaultPath) -> Result<VaultPath, VaultError>;
fn read(&self, path: &VaultPath) -> Result<Vec<u8>, VaultError>;
fn read_prefix(&self, path: &VaultPath, max_len: usize) -> Result<Vec<u8>, VaultError>;
fn read_range(&self, path: &VaultPath, offset: u64, len: usize) -> Result<Vec<u8>, VaultError> {
let bytes = self.read(path)?;
let start = usize::try_from(offset)
.unwrap_or(usize::MAX)
.min(bytes.len());
let end = start.saturating_add(len).min(bytes.len());
Ok(bytes[start..end].to_vec())
}
fn availability(&self, _path: &VaultPath) -> Availability {
Availability::Local
}
fn write_atomic(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError>;
fn create_dir_all(&self, path: &VaultPath) -> Result<(), VaultError>;
fn remove_file(&self, path: &VaultPath) -> Result<(), VaultError>;
fn rename(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError>;
fn request_download(&self, _paths: &[VaultPath]) {}
fn local_path(&self, _path: &VaultPath) -> Option<PathBuf> {
None
}
fn read_to_string(&self, path: &VaultPath) -> Result<String, VaultError> {
let bytes = self.read(path)?;
String::from_utf8(bytes).map_err(|e| VaultError::Io {
path: path.to_string(),
source: io::Error::new(io::ErrorKind::InvalidData, e),
})
}
fn key(&self, path: &VaultPath) -> PathBuf {
path.to_native(self.root())
}
fn vault_path(&self, key: &Path) -> Result<VaultPath, VaultError> {
VaultPath::from_absolute(self.root(), key).map_err(|e| match e {
VaultPathError::NotUnderRoot { path, .. } => VaultError::OutsideRoot {
path: path.display().to_string(),
},
other => VaultError::InvalidPath(other),
})
}
fn is_file(&self, path: &VaultPath) -> bool {
matches!(self.stat(path), Ok(Some(entry)) if entry.is_file())
}
fn is_dir(&self, path: &VaultPath) -> bool {
matches!(self.stat(path), Ok(Some(entry)) if entry.is_dir())
}
fn is_read_only(&self, _path: &VaultPath) -> bool {
false
}
fn mounts(&self) -> Vec<Mount> {
Vec::new()
}
}
pub fn is_hidden_segment(segment: &str) -> bool {
segment.starts_with('.') && segment != ".well-known"
}
pub fn has_hidden_segment(path: &VaultPath, exempt: &[VaultPath]) -> bool {
let full = path.as_str();
let mut end = 0;
path.segments().any(|segment| {
end += segment.len() + usize::from(end != 0);
is_hidden_segment(segment) && !exempt.iter().any(|dir| dir.as_str() == &full[..end])
})
}
pub fn configured_folder(vault: &dyn Vault, configured: &str) -> Option<VaultPath> {
VaultPath::new(configured).ok().or_else(|| {
let local_root = vault.local_path(&VaultPath::root())?;
let resolved = crate::config::resolve_existing_or_lexical(&local_root.join(configured));
vault.vault_path(&resolved).ok()
})
}
pub fn resolve_under(
vault: &dyn Vault,
base: &VaultPath,
relative: &VaultPath,
) -> Option<VaultPath> {
let canonical_base = if base.is_root() {
VaultPath::root()
} else {
vault.canonicalize(base).ok()?
};
let candidate = canonical_base.join_path(relative);
match vault.canonicalize(&candidate) {
Ok(canonical) => canonical.starts_with(&canonical_base).then_some(canonical),
Err(VaultError::OutsideRoot { .. }) => None,
Err(_) => {
let name = candidate.file_name()?;
let canonical_parent = vault.canonicalize(&candidate.parent()?).ok()?;
if !canonical_parent.starts_with(&canonical_base) {
return None;
}
canonical_parent.child(name).ok()
}
}
}
pub fn walk_files(vault: &dyn Vault, enter: impl Fn(&Entry) -> bool) -> Vec<Entry> {
walk_files_under(vault, &VaultPath::root(), enter)
}
pub fn walk_files_under(
vault: &dyn Vault,
base: &VaultPath,
enter: impl Fn(&Entry) -> bool,
) -> Vec<Entry> {
walk_vault_under(vault, base, enter).files
}
#[derive(Debug, Default)]
pub struct VaultWalk {
pub files: Vec<Entry>,
pub not_downloaded_dirs: Vec<VaultPath>,
pub dirs_listed: usize,
}
pub fn walk_vault_under(
vault: &dyn Vault,
base: &VaultPath,
enter: impl Fn(&Entry) -> bool,
) -> VaultWalk {
let mut walk = VaultWalk::default();
let mut visited = std::collections::HashSet::new();
let mut pending = vec![base.clone()];
while let Some(dir) = pending.pop() {
let Ok(canonical) = vault.canonicalize(&dir) else {
continue;
};
if !visited.insert(canonical.clone()) {
continue;
}
let entries = match vault.list_dir(&canonical) {
Ok(entries) => {
walk.dirs_listed += 1;
entries
}
Err(VaultError::NotDownloaded { .. }) => {
walk.not_downloaded_dirs.push(canonical);
continue;
}
Err(_) => continue,
};
for entry in entries {
if entry.is_dir() {
if enter(&entry) {
pending.push(entry.path);
}
} else {
walk.files.push(entry);
}
}
}
walk
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn io_errors_are_classified() {
let nf = VaultError::from_io("a.md", io::Error::from(io::ErrorKind::NotFound));
assert!(nf.is_not_found());
assert!(matches!(
VaultError::from_io("a.md", io::Error::from(io::ErrorKind::PermissionDenied)),
VaultError::PermissionDenied { .. }
));
assert!(matches!(
VaultError::from_io("a.md", io::Error::from(io::ErrorKind::Deadlock)),
VaultError::NotDownloaded { .. }
));
assert!(matches!(
VaultError::from_io("a.md", io::Error::other("boom")),
VaultError::Io { .. }
));
}
#[test]
fn vault_errors_keep_their_io_kind() {
let kind = |e: VaultError| io::Error::from(e).kind();
assert_eq!(kind(VaultError::not_found("x")), io::ErrorKind::NotFound);
assert_eq!(
kind(VaultError::PermissionDenied { path: "x".into() }),
io::ErrorKind::PermissionDenied
);
assert_eq!(
kind(VaultError::OutsideRoot { path: "x".into() }),
io::ErrorKind::PermissionDenied
);
let original = io::Error::new(io::ErrorKind::StorageFull, "disk full");
assert_eq!(
kind(VaultError::from_io("x", original)),
io::ErrorKind::StorageFull
);
}
#[test]
fn epoch_secs_falls_back_to_modified_and_refuses_pre_epoch() {
let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
let entry = Entry {
path: VaultPath::new("a.md").unwrap(),
kind: EntryKind::File,
size: 1,
modified: at(200),
created: None,
availability: Availability::Local,
};
assert_eq!(entry.epoch_secs(), Some((200, 200)));
let with_created = Entry {
created: Some(at(100)),
..entry.clone()
};
assert_eq!(with_created.epoch_secs(), Some((100, 200)));
let pre_epoch = Entry {
modified: SystemTime::UNIX_EPOCH - std::time::Duration::from_secs(1),
..entry
};
assert_eq!(pre_epoch.epoch_secs(), None);
}
#[test]
fn walk_files_lists_every_file_and_prunes_directories() {
let vault = MemVault::new()
.with_file("a.md", "a")
.with_file("docs/b.md", "b")
.with_file("docs/deep/c.md", "c")
.with_file("node_modules/x.md", "x")
.with_dir("empty");
let mut found: Vec<String> = walk_files(&vault, |dir| dir.name() != "node_modules")
.into_iter()
.map(|e| e.path.to_string())
.collect();
found.sort();
assert_eq!(found, vec!["a.md", "docs/b.md", "docs/deep/c.md"]);
}
#[cfg(unix)]
#[test]
fn walk_files_follows_links_inside_once_and_never_leaves_the_vault() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("real")).unwrap();
std::fs::write(dir.path().join("real/note.md"), "n").unwrap();
std::fs::write(outside.path().join("secret.md"), "s").unwrap();
symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
symlink(dir.path(), dir.path().join("real/loop")).unwrap();
symlink(outside.path(), dir.path().join("leak")).unwrap();
let vault = LocalVault::new(dir.path());
let found: Vec<String> = walk_files(&vault, |_| true)
.into_iter()
.map(|e| e.path.to_string())
.collect();
assert_eq!(
found,
vec!["real/note.md"],
"listed once, canonically, nothing outside"
);
}
#[test]
fn hidden_segments_and_their_exemptions() {
let p = |s: &str| VaultPath::new(s).unwrap();
assert!(!has_hidden_segment(&VaultPath::root(), &[]));
assert!(!has_hidden_segment(&p("docs/a.md"), &[]));
assert!(!has_hidden_segment(&p("docs/a.b.md"), &[]));
assert!(has_hidden_segment(&p(".env"), &[]));
assert!(has_hidden_segment(&p(".git/config"), &[]));
assert!(has_hidden_segment(&p("docs/.private/a.md"), &[]));
assert!(has_hidden_segment(&p("docs/.hidden.png"), &[]));
assert!(!has_hidden_segment(&p(".well-known/security.txt"), &[]));
assert!(has_hidden_segment(&p(".well-known/.secret"), &[]));
let exempt = [p(".scratch"), p(".scratch/b/.deep")];
assert!(!has_hidden_segment(&p(".scratch/a.md"), &exempt));
assert!(!has_hidden_segment(&p(".scratch/b/.deep/c.md"), &exempt));
assert!(has_hidden_segment(&p(".scratch/.git/config"), &exempt));
assert!(has_hidden_segment(&p("x/.scratch/a.md"), &exempt));
assert!(has_hidden_segment(&p(".scratchy/a.md"), &exempt));
}
#[test]
fn resolve_under_on_a_mem_vault() {
let vault = MemVault::new()
.with_file("docs/a.md", "# A")
.with_file("static/img.png", "png");
let root = VaultPath::root();
let p = |s: &str| VaultPath::new(s).unwrap();
assert_eq!(
resolve_under(&vault, &root, &p("docs/a.md")),
Some(p("docs/a.md"))
);
assert_eq!(
resolve_under(&vault, &root, &p("docs/b.md")),
Some(p("docs/b.md"))
);
assert_eq!(resolve_under(&vault, &root, &p("nope/b.md")), None);
assert_eq!(
resolve_under(&vault, &p("static"), &p("img.png")),
Some(p("static/img.png"))
);
assert_eq!(resolve_under(&vault, &p("missing"), &p("img.png")), None);
}
}