Files
dtourolleandClaude Opus 5 37d8744db8 Let storage write as well as read
Storage enumerates and reads, which is all a scan ever needed. An import
writes, and there was nothing to write through.

WritableStorage is separate from Storage rather than folded into it, because
the two are not granted together: a card mounted read-only, or a share the
user has view rights on, should fail to typecheck as a destination rather
than fail with EROFS halfway through a copy. Ingest takes a &dyn Storage
source and a &dyn WritableStorage destination, which is exactly the asymmetry
of copying off a card.

Shaped for SAF throughout, for the same reason the read side is: a document
id is not composable, so every call takes a parent reference plus one name
and hands back the reference the provider itself produced. create_dir is
idempotent because importing a second card on the same day must land in the
folder the first made, and the naive SAF call would produce "2026-08-22 (1)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:26 +02:00

1160 lines
44 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-CAT-1 | FR-CAT-1a | NFR-PORT-1 | NFR-PORT-3
//! Reaching stored bytes without naming a path (ARCH §3.1, §10).
//!
//! Android's Storage Access Framework hands out no filesystem path (ARCH §6.9),
//! so **nothing above this module may take one**. A library location is a
//! [`RootId`] the user granted; everything inside it is a [`DirRef`] or a
//! [`SourceRef`], both of which are opaque `(root, key)` pairs whose keys only
//! the implementation that produced them knows how to read.
//!
//! A `Path` therefore appears exactly once in the whole application: at
//! [`LocalStorage::grant`], where the folder the user picked is handed in. From
//! there on it is a `RootId`.
//!
//! # Adding Android SAF later
//!
//! It is a second implementation of [`Storage`] and no change at any call site.
//! Two properties of this API are what buy that, and both look like ceremony
//! until SAF is the thing being written:
//!
//! - **A listing hands back references, never names for the caller to join.**
//! A SAF document id is not composable — `parent_id + "/" + name` is not the
//! child's id, and the only way to learn a child's id is the children query
//! that produced the listing. So [`Entry`] carries the [`DirRef`] or
//! [`SourceRef`] the provider itself returned, and no caller ever builds one
//! by concatenation. [`LocalStorage`] could perfectly well have exposed a
//! "join a name onto a directory" helper; that helper is the one thing a SAF
//! implementation could not have provided.
//! - **A reference is a key that survives a restart.** The catalog stores the
//! key and rebuilds the reference with [`DirRef::from_parts`] on the next
//! run. On Linux the key is a relative path; on SAF it is a document id under
//! a persisted tree grant, which is re-resolvable for exactly the same
//! reason.
//!
//! What SAF will need in addition is the grant itself — the persisted tree URI,
//! which the catalog's `roots.grant_blob` column already has a home for, and
//! which is handed to the implementation at construction just as a path is
//! here.
use std::collections::BTreeMap;
use std::fmt;
use std::io::{Read, Seek, Write};
use std::path::{Component, Path, PathBuf};
use dr_types::{ByteRange, DirEntry, DirState, RootId, SourceRef};
/// TRACES: FR-CAT-1a
/// An opaque, re-resolvable reference to a *directory* under a granted root.
///
/// The counterpart of [`SourceRef`], which addresses a file. One type rather
/// than a mirrored three-variant enum because a directory is never resolved by
/// anything except the storage that owns its root: the root's kind already
/// determines how the key is read, so a discriminator on each reference would
/// only repeat it.
///
/// The `key` is **opaque to callers** and stable across restarts. Its meaning
/// belongs to the implementation — a relative path under the root on a
/// filesystem, a `DocumentsContract` document id on SAF — and the catalog
/// stores it verbatim in `folders.path` so a later run can rebuild the
/// reference with [`DirRef::from_parts`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DirRef {
root: RootId,
key: String,
}
impl DirRef {
/// The granted root itself, which every walk starts from.
pub fn root(root: RootId) -> Self {
Self {
root,
key: String::new(),
}
}
/// Rebuild a reference from a key a previous scan stored.
///
/// The re-resolution FR-CAT-1a requires: after a restart the catalog holds
/// keys and nothing else, and a scan that could not resume from them would
/// have to walk the whole library to find the folder it left off in.
pub fn from_parts(root: RootId, key: impl Into<String>) -> Self {
Self {
root,
key: key.into(),
}
}
pub fn root_id(&self) -> RootId {
self.root
}
/// The stored form. Meaningful only to the storage that produced it.
pub fn key(&self) -> &str {
&self.key
}
/// Whether this is the granted root rather than something inside it.
///
/// The walk needs it: a failure at the root is the whole library being
/// unreachable, and the deletion sweep must not run; a failure below it is
/// one folder (FR-CAT-9).
pub fn is_root(&self) -> bool {
self.key.is_empty()
}
/// The last component, for exclusion checks and display.
pub fn name(&self) -> &str {
self.key.rsplit(['/', ':']).next().unwrap_or(&self.key)
}
}
impl fmt::Display for DirRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.key.is_empty() {
write!(f, "root {}", self.root.0)
} else {
write!(f, "{}", self.key)
}
}
}
/// How to reach a listed entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Node {
Dir(DirRef),
File(SourceRef),
}
/// One entry from a listing: what the scanner classifies on, and how to reach
/// it.
///
/// `meta.is_dir` and the [`Node`] variant always agree — the implementation
/// sets both from one observation. They are separate because they serve
/// different readers: `meta` goes to `dr_catalog::scan`, which decides, and
/// `node` goes to whatever acts on the decision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub meta: DirEntry,
pub node: Node,
}
/// A stream that can be read and seeked — what a decoder wants.
///
/// Blanket-implemented, so a `File`, a `Cursor<Vec<u8>>` in a test, and a
/// future SAF `ParcelFileDescriptor` wrapper all qualify without ceremony.
pub trait SeekableRead: Read + Seek + Send {}
impl<T: Read + Seek + Send> SeekableRead for T {}
/// TRACES: NFR-ARCH-4
/// Something went wrong reaching storage.
///
/// Typed, and never a panic: a library is on removable media, on a network
/// mount, or behind a permission the user can revoke while the app is running,
/// so every one of these is a normal Tuesday rather than a bug.
///
/// Distinct from [`dr_types::SourceError`], which is what the *decode* path
/// sees. This one also speaks about roots and directories, which a decoder has
/// no concept of.
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
/// A reference naming a root this storage was never granted.
///
/// Almost always a stale catalog row: the library was removed and its rows
/// outlived it.
#[error("root {0} was not granted to this storage")]
UnknownRoot(u64),
/// A reference of the wrong shape — a SAF document id handed to the
/// filesystem implementation, or the reverse.
///
/// Refused rather than guessed at: the two address spaces have no overlap,
/// and a guess would read the wrong file rather than fail.
#[error("{0}")]
Unsupported(&'static str),
/// A stored key that would leave its root.
///
/// The catalog is a file on disk that other programs can edit, so a key
/// containing `..` is possible however it got there. Refused, because a
/// grant to one folder must not become a read of the whole filesystem
/// (NFR-SEC-1).
#[error("key {0:?} escapes its root")]
EscapesRoot(String),
#[error("not found: {0}")]
NotFound(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("not a directory: {0}")]
NotADirectory(String),
#[error("range reads unsupported by this storage")]
RangeUnsupported,
/// A name that is already taken.
///
/// Distinguished from a plain io error because it is the one failure an
/// import can recover from on its own, by resolving a different name.
#[error("already exists: {0}")]
AlreadyExists(String),
/// A name no destination could hold — empty, `.`, `..`, or containing a
/// separator.
///
/// A separator is refused rather than created as nested directories: the
/// caller asked for one child and would get a tree, and on SAF the call
/// would create a single document with a slash in its display name.
#[error("invalid name: {0:?}")]
InvalidName(String),
#[error("io error: {0}")]
Io(String),
}
/// TRACES: FR-CAT-1 | FR-CAT-1a | NFR-PORT-1
/// Enumerate and read the contents of granted library roots.
///
/// Implemented per platform and injected at construction, so `core/` contains
/// no `#[cfg(target_os)]` (ARCH §10).
pub trait Storage: Send + Sync {
/// Every root this storage can currently reach.
fn roots(&self) -> Vec<RootId>;
/// The granted root as a directory, which is where a walk begins.
///
/// Fails on a root that was never granted, so a stale catalog row is a
/// typed error rather than an empty library.
fn root_dir(&self, root: RootId) -> Result<DirRef, StorageError>;
/// Probe a directory without reading its contents.
///
/// The cheap half of the pair, and the reason incremental scanning is
/// affordable: this costs one `stat` plus a name-only directory read,
/// where [`list`](Self::list) costs a `stat` per child. On a library of 2k
/// folders and 50k images that is 2k probes against 50k, which is the
/// difference between meeting and missing NFR-P1.
fn dir_state(&self, dir: &DirRef) -> Result<DirState, StorageError>;
/// List a directory's direct children, with the metadata to classify them.
///
/// Returns the whole listing rather than streaming it through a callback,
/// because the caller needs the complete set at once: an image in the
/// catalog that this listing does *not* contain has been deleted, and that
/// conclusion cannot be drawn one entry at a time.
fn list(&self, dir: &DirRef) -> Result<Vec<Entry>, StorageError>;
/// Open a seekable stream over a source.
fn open(&self, src: &SourceRef) -> Result<Box<dyn SeekableRead>, StorageError>;
/// Read a byte range without opening the whole source.
///
/// First-class rather than a convenience over [`open`](Self::open), because
/// for the case that matters it is a different operation and not a smaller
/// one: extracting an embedded JPEG preview from an 80 MB RAW over the
/// network transfers 1–3 MB (ARCH §3.1, FR-CULL-2, FR-NC-3).
///
/// A range reaching past the end yields the bytes that exist — a short read
/// is the honest answer, and a caller needing exactly `n` bytes must say so
/// by checking the length.
fn read_range(&self, src: &SourceRef, range: ByteRange) -> Result<Vec<u8>, StorageError>;
}
/// A file that has been created but not yet filled.
///
/// The reference comes back *with* the sink because on SAF those are one
/// operation and two results: `createDocument` returns the new document's URI,
/// and only that URI can open the stream. A caller that had to ask for the
/// reference afterwards would have to find the file by name — the one thing
/// [`WritableStorage::create_file`] exists to avoid.
pub struct NewFile {
/// How to reach the file once it is written. This is the provider's own
/// answer, not a name the caller composed.
pub source: SourceRef,
/// Where the bytes go. Dropping it closes the file.
pub sink: Box<dyn Write + Send>,
}
impl std::fmt::Debug for NewFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NewFile")
.field("source", &self.source)
.finish_non_exhaustive()
}
}
/// TRACES: FR-CAT-10 | NFR-PORT-1
/// Create directories and files under a granted root.
///
/// Separate from [`Storage`] rather than folded into it, because the two are
/// not granted together. A library root may be read-only — a card mounted
/// `ro`, a share the user has view rights on — and the type system should say
/// so at the point a caller needs to write rather than at the point one
/// discovers `EROFS`. Ingest asks for a `&dyn WritableStorage` destination and
/// a plain `&dyn Storage` source, which is exactly the asymmetry of copying
/// off a card.
///
/// # Why this is not `write(path, bytes)`
///
/// Same reason [`Storage::list`] hands back references (see the module docs):
/// a SAF document id is not composable, so a destination is reached by
/// creating each level and keeping what the provider returned. Every method
/// here therefore takes a *parent reference plus one name* and returns the
/// reference the provider produced.
///
/// The other constraint SAF imposes is that `createDocument` renames on
/// collision by itself, appending ` (1)` and returning a URI for a name nobody
/// asked for. So an implementation must decide the name before creating
/// anything, and callers must read [`NewFile::source`] rather than assume the
/// file is called what they asked for.
pub trait WritableStorage: Storage {
/// Get or create a child directory.
///
/// Idempotent: an existing directory of that name is returned as it is,
/// never duplicated. That is load-bearing rather than a convenience —
/// importing a second card from the same day must land in the same folder,
/// and on SAF the naive call would produce `2026-08-22 (1)`.
fn create_dir(&self, parent: &DirRef, name: &str) -> Result<DirRef, StorageError>;
/// Create a new file and open it for writing.
///
/// Fails with [`StorageError::AlreadyExists`] rather than truncating.
/// Overwriting is never what an import wants, and the caller that *does*
/// want a distinct name resolves one first with [`Self::exists`] — the
/// same shape `dr_export::resolve_name` uses.
fn create_file(&self, parent: &DirRef, name: &str) -> Result<NewFile, StorageError>;
/// Whether a name is already taken in this directory.
///
/// Answers for files and directories alike: a destination cannot hold both
/// a folder and an image called `2026-08-22`, so a collision check that
/// only looked at one would be wrong half the time.
fn exists(&self, parent: &DirRef, name: &str) -> Result<bool, StorageError>;
/// Remove a file this storage created.
///
/// Needed for the failure path rather than as a feature: an import that
/// dies partway through verification has written a file that no catalog
/// knows about, and leaving it behind means the next run sees a duplicate
/// of something that was never successfully imported (FR-CAT-10).
fn remove_file(&self, src: &SourceRef) -> Result<(), StorageError>;
}
/// TRACES: FR-PLAT-LIN-1 | NFR-PORT-1
/// The filesystem implementation: a root is a directory the user picked.
///
/// Also the right implementation for a Flatpak, where the portal returns a real
/// path the sandbox can see (FR-PLAT-LIN-3).
///
/// Present on Android too, and harmless there: the type compiles wherever
/// `std::fs` does, so `ui/` can name it unconditionally. It is not how an
/// Android user's library is reached — that is SAF, and ARCH §6.9 explains
/// why nothing else is on offer.
#[derive(Debug, Default)]
pub struct LocalStorage {
/// Ordered so [`roots`](Storage::roots) is stable, which keeps a scan of
/// several roots reproducible.
roots: BTreeMap<RootId, PathBuf>,
}
impl LocalStorage {
pub fn new() -> Self {
Self::default()
}
/// One granted root, the common case.
pub fn with_root(root: RootId, dir: impl Into<PathBuf>) -> Self {
let mut s = Self::new();
s.grant(root, dir);
s
}
/// Record that the user granted `dir` as `root`.
///
/// **The only place a `Path` enters the application.** Above this line a
/// library location is a `RootId`, which is what lets the same catalog and
/// the same scanner run against SAF, where no path exists at all.
pub fn grant(&mut self, root: RootId, dir: impl Into<PathBuf>) {
self.roots.insert(root, dir.into());
}
/// Where a root sits, for the app that granted it — to show the user, or to
/// store so the grant survives a restart.
pub fn root_path(&self, root: RootId) -> Option<&Path> {
self.roots.get(&root).map(|p| p.as_path())
}
fn base(&self, root: RootId) -> Result<&Path, StorageError> {
self.roots
.get(&root)
.map(|p| p.as_path())
.ok_or(StorageError::UnknownRoot(root.0))
}
/// Turn a `(root, key)` pair back into a path, refusing anything that
/// leaves the root.
///
/// Every component must be an ordinary name: `..` would climb out, and an
/// absolute key would discard the root entirely — `Path::join` silently
/// replaces rather than appends when handed one, which is how a "relative"
/// path of `/etc` becomes a read of `/etc`.
fn resolve(&self, root: RootId, key: &str) -> Result<PathBuf, StorageError> {
let base = self.base(root)?;
if key.is_empty() {
return Ok(base.to_path_buf());
}
let rel = Path::new(key);
if !rel.components().all(|c| matches!(c, Component::Normal(_))) {
return Err(StorageError::EscapesRoot(key.to_string()));
}
Ok(base.join(rel))
}
fn file_path(&self, src: &SourceRef) -> Result<PathBuf, StorageError> {
match src {
SourceRef::Local { root, relative } => self.resolve(*root, relative),
SourceRef::Document { .. } => Err(StorageError::Unsupported(
"a SAF document reference cannot be read from the filesystem",
)),
SourceRef::Remote { .. } => Err(StorageError::Unsupported(
"a remote reference is resolved by the sync layer, not by storage",
)),
}
}
}
/// A child's key, built the one place that is allowed to build one.
///
/// Concatenation is safe here and only here: this implementation *chose* to
/// make its keys relative paths, so it is the only code entitled to know that
/// they compose. A SAF implementation has no equivalent (see the module docs),
/// which is why this is a private helper and not a method on [`DirRef`].
fn child_key(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
impl Storage for LocalStorage {
fn roots(&self) -> Vec<RootId> {
self.roots.keys().copied().collect()
}
fn root_dir(&self, root: RootId) -> Result<DirRef, StorageError> {
self.base(root)?;
Ok(DirRef::root(root))
}
fn dir_state(&self, dir: &DirRef) -> Result<DirState, StorageError> {
let path = self.resolve(dir.root_id(), dir.key())?;
let meta = std::fs::metadata(&path).map_err(|e| map_io(&path, e))?;
if !meta.is_dir() {
return Err(StorageError::NotADirectory(path.display().to_string()));
}
// Names only — `read_dir` yields entries from `getdents` without a
// `stat` per child, so the count costs one pass and no per-file I/O.
// That is what makes this the cheap probe `list` is not.
let entry_count = std::fs::read_dir(&path)
.map_err(|e| map_io(&path, e))?
.count();
Ok(DirState {
mtime: modified_millis(&meta),
// Saturating rather than wrapping: a directory of four billion
// entries would otherwise wrap to a small number and could compare
// equal after a change. It cannot happen, and being wrong about it
// would be silent.
entry_count: u32::try_from(entry_count).unwrap_or(u32::MAX),
})
}
fn list(&self, dir: &DirRef) -> Result<Vec<Entry>, StorageError> {
let path = self.resolve(dir.root_id(), dir.key())?;
let read = std::fs::read_dir(&path).map_err(|e| map_io(&path, e))?;
let mut out = Vec::new();
for entry in read {
let entry = match entry {
Ok(e) => e,
// One unreadable entry is not an unreadable directory. Skipping
// it loses one file; failing the listing would make the folder
// look empty, and an empty folder is a *deletion* to the sweep.
Err(e) => {
log::debug!("list {}: skipping unreadable entry: {e}", path.display());
continue;
}
};
// A name that is not UTF-8 cannot become a key, and a lossy
// conversion would produce a key that resolves to nothing — an
// image catalogued and then permanently unreadable. Skipped, and
// said out loud, because the user's file is real and we are
// choosing not to see it.
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
log::warn!(
"list {}: skipping {:?}, whose name is not valid UTF-8",
path.display(),
entry.file_name()
);
continue;
};
// Follows symlinks, unlike `DirEntry::metadata`. A photographer who
// symlinks last year's drive into the library means it as part of
// the library. The walk's depth limit is what stops a loop.
let child = path.join(&name);
let meta = match std::fs::metadata(&child) {
Ok(m) => m,
// A broken symlink, or a file deleted between the listing and
// this stat. Neither is an error worth failing a folder for.
Err(e) => {
log::debug!("list {}: skipping {name}: {e}", path.display());
continue;
}
};
let key = child_key(dir.key(), &name);
let is_dir = meta.is_dir();
out.push(Entry {
meta: DirEntry {
name,
is_dir,
size: meta.len(),
mtime: modified_millis(&meta),
},
node: if is_dir {
Node::Dir(DirRef::from_parts(dir.root_id(), key))
} else {
Node::File(SourceRef::Local {
root: dir.root_id(),
relative: key,
})
},
});
}
// Directory order is filesystem order, which is arbitrary and differs
// between runs. Sorting makes a scan reproducible and a test able to
// assert on what it found.
out.sort_by(|a, b| a.meta.name.cmp(&b.meta.name));
Ok(out)
}
fn open(&self, src: &SourceRef) -> Result<Box<dyn SeekableRead>, StorageError> {
let path = self.file_path(src)?;
let file = std::fs::File::open(&path).map_err(|e| map_io(&path, e))?;
Ok(Box::new(file))
}
fn read_range(&self, src: &SourceRef, range: ByteRange) -> Result<Vec<u8>, StorageError> {
use std::io::SeekFrom;
if range.end <= range.start {
return Ok(Vec::new());
}
let path = self.file_path(src)?;
let mut file = std::fs::File::open(&path).map_err(|e| map_io(&path, e))?;
file.seek(SeekFrom::Start(range.start))
.map_err(|e| map_io(&path, e))?;
// `take` and grow, rather than a buffer sized to the request: the range
// comes from a header the file itself declared, and a corrupt one
// asking for four gigabytes must not be allocated before it is known
// that four gigabytes exist (NFR-SEC-1).
let mut buf = Vec::new();
file.take(range.end - range.start)
.read_to_end(&mut buf)
.map_err(|e| map_io(&path, e))?;
Ok(buf)
}
}
/// Milliseconds since the epoch, or 0 where the platform will not say.
///
/// Milliseconds because that is the unit change detection is expressed in, and
/// the unit's granularity is the width of the window in which a change is
/// invisible — see [`dr_types::DirState::mtime`]. Seconds would hide a card
/// imported and scanned within the same tick.
///
/// A file whose mtime is unreadable compares equal to itself forever and so is
/// never re-read. That is the better failure: the alternative, a value that
/// changes each time it is asked for, would re-process the file on every scan.
/// TRACES: FR-CAT-10
/// A name that is one ordinary child, and not a way out of its directory.
///
/// Checked here rather than left to `resolve`, because the failure is
/// different in kind: `resolve` guards against a *stored key* the catalog
/// carries, where this guards against a name a *template* just produced. The
/// user typing `{make}/{model}` into a filename field should be told the name
/// is invalid, not have two directories appear.
fn check_name(name: &str) -> Result<(), StorageError> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
{
return Err(StorageError::InvalidName(name.to_string()));
}
Ok(())
}
impl WritableStorage for LocalStorage {
fn create_dir(&self, parent: &DirRef, name: &str) -> Result<DirRef, StorageError> {
check_name(name)?;
let key = child_key(parent.key(), name);
let path = self.resolve(parent.root_id(), &key)?;
match std::fs::create_dir(&path) {
Ok(()) => {}
// Already there is success, not a collision: see the trait docs
// on why importing twice in a day depends on it. A *file* of that
// name is a real conflict and is reported as one.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if !path.is_dir() {
return Err(StorageError::AlreadyExists(path.display().to_string()));
}
}
Err(e) => return Err(map_io(&path, e)),
}
Ok(DirRef::from_parts(parent.root_id(), key))
}
fn create_file(&self, parent: &DirRef, name: &str) -> Result<NewFile, StorageError> {
check_name(name)?;
let key = child_key(parent.key(), name);
let path = self.resolve(parent.root_id(), &key)?;
// `create_new` rather than `create`: the check and the creation are
// one syscall, so two imports running at once cannot both decide a
// name is free and have the second silently truncate the first.
let file = std::fs::File::options()
.write(true)
.create_new(true)
.open(&path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => {
StorageError::AlreadyExists(path.display().to_string())
}
_ => map_io(&path, e),
})?;
Ok(NewFile {
source: SourceRef::Local {
root: parent.root_id(),
relative: key,
},
sink: Box::new(std::io::BufWriter::new(file)),
})
}
fn exists(&self, parent: &DirRef, name: &str) -> Result<bool, StorageError> {
check_name(name)?;
let path = self.resolve(parent.root_id(), &child_key(parent.key(), name))?;
// `symlink_metadata` rather than `exists()`: a broken symlink is a
// name that is taken, and `exists()` reports it as free — after which
// `create_new` fails and the import stops on a name it was told was
// available.
match std::fs::symlink_metadata(&path) {
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(map_io(&path, e)),
}
}
fn remove_file(&self, src: &SourceRef) -> Result<(), StorageError> {
let path = self.file_path(src)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
// Already gone is the state the caller wanted. This runs on a
// failure path, where a second error would mask the first.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(map_io(&path, e)),
}
}
}
fn modified_millis(meta: &std::fs::Metadata) -> i64 {
let Ok(t) = meta.modified() else {
return 0;
};
match t.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => i64::try_from(d.as_millis()).unwrap_or(i64::MAX),
// Before 1970. Rare, but an archive of digitised film can carry one,
// and it must not become a huge positive number.
Err(e) => i64::try_from(e.duration().as_millis())
.unwrap_or(i64::MAX)
.saturating_neg(),
}
}
fn map_io(path: &Path, e: std::io::Error) -> StorageError {
let what = path.display().to_string();
match e.kind() {
std::io::ErrorKind::NotFound => StorageError::NotFound(what),
std::io::ErrorKind::PermissionDenied => StorageError::PermissionDenied(what),
_ => StorageError::Io(format!("{what}: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// A throwaway directory tree, removed when the test ends.
struct Tree(PathBuf);
impl Tree {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"dr-plat-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("temp dir");
Tree(dir)
}
fn dir(&self, rel: &str) -> &Self {
fs::create_dir_all(self.0.join(rel)).expect("mkdir");
self
}
fn file(&self, rel: &str, bytes: &[u8]) -> &Self {
let p = self.0.join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(p, bytes).expect("write");
self
}
fn storage(&self) -> LocalStorage {
LocalStorage::with_root(RootId(1), self.0.clone())
}
}
impl Drop for Tree {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
const ROOT: RootId = RootId(1);
// ---- the write half (FR-CAT-10) -------------------------------------
#[test]
fn a_created_file_is_reachable_by_the_reference_it_returned() {
let t = Tree::new("create-file");
let st = t.storage();
let root = st.root_dir(ROOT).unwrap();
let mut nf = st.create_file(&root, "IMG_0001.CR3").unwrap();
nf.sink.write_all(b"raw bytes").unwrap();
drop(nf.sink);
// The point of handing the reference back: the caller reads what it
// wrote without ever composing a key.
let got = st.read_range(&nf.source, 0..64).unwrap();
assert_eq!(got, b"raw bytes");
}
#[test]
fn creating_a_directory_twice_returns_the_same_one() {
let t = Tree::new("create-dir-twice");
let st = t.storage();
let root = st.root_dir(ROOT).unwrap();
// Two cards imported on the same day. The second must land in the
// folder the first made, not beside it.
let a = st.create_dir(&root, "2026").unwrap();
let b = st.create_dir(&root, "2026").unwrap();
assert_eq!(a, b);
let day = st.create_dir(&a, "2026-08-22").unwrap();
assert_eq!(day.key(), "2026/2026-08-22");
}
#[test]
fn an_existing_file_is_refused_rather_than_truncated() {
let t = Tree::new("no-truncate");
t.file("keep.CR3", b"the original");
let st = t.storage();
let root = st.root_dir(ROOT).unwrap();
let err = st.create_file(&root, "keep.CR3").unwrap_err();
assert!(matches!(err, StorageError::AlreadyExists(_)), "{err:?}");
// And the bytes are still there — the failure happened before any
// truncation, which is the whole reason `create_new` is used.
assert_eq!(fs::read(t.0.join("keep.CR3")).unwrap(), b"the original");
}
#[test]
fn a_directory_of_that_name_is_not_mistaken_for_a_file() {
let t = Tree::new("dir-not-file");
t.dir("2026-08-22");
let st = t.storage();
let root = st.root_dir(ROOT).unwrap();
// A *file* where a directory is wanted is a genuine conflict...
t.file("taken", b"x");
let err = st.create_dir(&root, "taken").unwrap_err();
assert!(matches!(err, StorageError::AlreadyExists(_)), "{err:?}");
}
#[test]
fn a_name_with_a_separator_is_refused_rather_than_creating_a_tree() {
let t = Tree::new("no-separators");
let st = t.storage();
let root = st.root_dir(ROOT).unwrap();
for bad in ["a/b", "..", ".", "", "a\\b"] {
let err = st.create_file(&root, bad).unwrap_err();
assert!(
matches!(err, StorageError::InvalidName(_)),
"{bad:?} gave {err:?}"
);
}
// And the escape does not happen by another route: nothing was made
// outside the root.
assert!(!t.0.parent().unwrap().join("b").exists());
}
#[test]
fn a_taken_name_is_reported_before_anything_is_created() {
let t = Tree::new("exists");
t.file("IMG_0001.CR3", b"x").dir("2026");
let st = t.storage();
let root = st.root_dir(ROOT).unwrap();
assert!(st.exists(&root, "IMG_0001.CR3").unwrap());
// Directories count too: a destination cannot hold both.
assert!(st.exists(&root, "2026").unwrap());
assert!(!st.exists(&root, "IMG_0002.CR3").unwrap());
}
#[test]
fn a_half_written_file_can_be_taken_back() {
let t = Tree::new("remove");
let st = t.storage();
let root = st.root_dir(ROOT).unwrap();
let nf = st.create_file(&root, "partial.CR3").unwrap();
let src = nf.source.clone();
drop(nf.sink);
st.remove_file(&src).unwrap();
assert!(!t.0.join("partial.CR3").exists());
// Removing what is already gone is the state the caller wanted, and
// this runs on a failure path where a second error would mask the
// first.
st.remove_file(&src).unwrap();
}
#[test]
fn a_listing_names_files_and_directories_apart() {
let t = Tree::new("listing");
t.file("IMG_0001.CR3", b"raw").dir("2026");
let s = t.storage();
let entries = s.list(&s.root_dir(ROOT).unwrap()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].meta.name, "2026");
assert!(entries[0].meta.is_dir);
assert!(matches!(entries[0].node, Node::Dir(_)));
assert_eq!(entries[1].meta.name, "IMG_0001.CR3");
assert_eq!(entries[1].meta.size, 3);
assert!(matches!(entries[1].node, Node::File(_)));
}
#[test]
fn a_listing_hands_back_references_the_caller_never_composes() {
// The property that makes a SAF implementation a drop-in: the child's
// reference comes from the listing, because on SAF it is the only place
// it can come from. If a test ever has to build one by joining strings,
// the abstraction has already leaked.
let t = Tree::new("refs");
t.file("2026/08/IMG_0042.CR3", b"raw");
let s = t.storage();
let year = match &s.list(&s.root_dir(ROOT).unwrap()).unwrap()[0].node {
Node::Dir(d) => d.clone(),
other => panic!("expected a directory, got {other:?}"),
};
let month = match &s.list(&year).unwrap()[0].node {
Node::Dir(d) => d.clone(),
other => panic!("expected a directory, got {other:?}"),
};
let file = match &s.list(&month).unwrap()[0].node {
Node::File(f) => f.clone(),
other => panic!("expected a file, got {other:?}"),
};
assert_eq!(
file,
SourceRef::Local {
root: ROOT,
relative: "2026/08/IMG_0042.CR3".into()
}
);
assert!(s.open(&file).is_ok());
}
#[test]
fn a_stored_key_reopens_the_same_directory_after_a_restart() {
// What FR-CAT-1a's "re-resolvable" means in practice: the catalog keeps
// keys, not handles, and a scan resuming tomorrow rebuilds the
// reference from one. Without this a restart is a full rewalk.
let t = Tree::new("reresolve");
t.file("2026/IMG.CR3", b"raw");
let s = t.storage();
let key = match &s.list(&s.root_dir(ROOT).unwrap()).unwrap()[0].node {
Node::Dir(d) => d.key().to_string(),
other => panic!("expected a directory, got {other:?}"),
};
let rebuilt = DirRef::from_parts(ROOT, key);
assert_eq!(s.list(&rebuilt).unwrap()[0].meta.name, "IMG.CR3");
}
#[test]
fn a_key_that_climbs_out_of_the_root_is_refused() {
// A grant is to one folder. The catalog is an ordinary file that other
// programs can edit, so a `..` in a key is reachable however it got
// there, and honouring it would turn a grant to ~/Photos into a read of
// the whole filesystem (NFR-SEC-1).
let t = Tree::new("escape");
let s = t.storage();
for key in ["../etc", "a/../../etc", "/etc"] {
let dir = DirRef::from_parts(ROOT, key);
assert!(
matches!(s.dir_state(&dir), Err(StorageError::EscapesRoot(_))),
"{key} was not refused"
);
}
}
#[test]
fn an_absolute_key_does_not_silently_replace_the_root() {
// `Path::join` replaces rather than appends when given an absolute
// path, so this one is not merely an escape — it is an escape that
// looks like ordinary joining and would never be noticed in review.
let t = Tree::new("absolute");
let s = t.storage();
let src = SourceRef::Local {
root: ROOT,
relative: "/etc/passwd".into(),
};
assert!(matches!(
s.read_range(&src, 0..16),
Err(StorageError::EscapesRoot(_))
));
}
#[test]
fn probing_a_directory_reports_what_change_detection_needs() {
let t = Tree::new("probe");
t.file("a.CR3", b"1").file("b.CR3", b"2");
let s = t.storage();
let before = s.dir_state(&s.root_dir(ROOT).unwrap()).unwrap();
assert_eq!(before.entry_count, 2);
t.file("c.CR3", b"3");
let after = s.dir_state(&s.root_dir(ROOT).unwrap()).unwrap();
assert_ne!(
before, after,
"an added file must move the state, or the folder is pruned and the \
image never enters the catalog"
);
assert_eq!(after.entry_count, 3);
}
#[test]
fn the_probe_counts_directories_as_well_as_files() {
// A new subfolder full of images changes nothing about the parent's
// files. If the count ignored directories, the parent would look
// unchanged and the whole subtree would go unseen.
let t = Tree::new("probe-dirs");
t.file("a.CR3", b"1");
let s = t.storage();
let before = s.dir_state(&s.root_dir(ROOT).unwrap()).unwrap();
t.dir("2026");
let after = s.dir_state(&s.root_dir(ROOT).unwrap()).unwrap();
assert_eq!(after.entry_count, before.entry_count + 1);
}
#[test]
fn a_range_read_returns_only_the_bytes_asked_for() {
let t = Tree::new("range");
t.file("IMG.CR3", b"0123456789");
let s = t.storage();
let src = SourceRef::Local {
root: ROOT,
relative: "IMG.CR3".into(),
};
assert_eq!(s.read_range(&src, 2..6).unwrap(), b"2345");
assert_eq!(s.read_range(&src, 0..0).unwrap(), b"");
}
#[test]
fn a_range_past_the_end_is_short_rather_than_an_error() {
// Preview offsets come out of the file's own header. A truncated or
// mis-parsed one must yield "here is what exists", not a failed decode
// and not a four-gigabyte allocation (NFR-SEC-1).
let t = Tree::new("range-eof");
t.file("IMG.CR3", b"0123456789");
let s = t.storage();
let src = SourceRef::Local {
root: ROOT,
relative: "IMG.CR3".into(),
};
assert_eq!(s.read_range(&src, 8..u64::MAX / 2).unwrap(), b"89");
assert_eq!(s.read_range(&src, 999..1_000).unwrap(), b"");
}
#[test]
fn an_open_stream_can_seek() {
// The decoders need it: a RAW's preview lives at an offset the header
// names, and a forward-only stream would mean reading 80 MB to get 2.
let t = Tree::new("seek");
t.file("IMG.CR3", b"0123456789");
let s = t.storage();
let mut r = s
.open(&SourceRef::Local {
root: ROOT,
relative: "IMG.CR3".into(),
})
.unwrap();
r.seek(std::io::SeekFrom::Start(5)).unwrap();
let mut buf = [0u8; 2];
r.read_exact(&mut buf).unwrap();
assert_eq!(&buf, b"56");
}
#[test]
fn an_ungranted_root_is_a_typed_error_not_an_empty_library() {
// A removed library leaves catalog rows behind. Reporting them as
// "nothing here" would let the deletion sweep take the lot (FR-CAT-9).
let s = LocalStorage::new();
assert!(matches!(
s.root_dir(RootId(7)),
Err(StorageError::UnknownRoot(7))
));
assert!(matches!(
s.dir_state(&DirRef::root(RootId(7))),
Err(StorageError::UnknownRoot(7))
));
assert!(s.roots().is_empty());
}
#[test]
fn a_missing_directory_is_not_reported_as_empty() {
// The same failure from the other direction: an unplugged drive must
// error, because an empty listing means every image under it was
// deleted.
let t = Tree::new("missing");
let s = t.storage();
let gone = DirRef::from_parts(ROOT, "nowhere");
assert!(matches!(s.dir_state(&gone), Err(StorageError::NotFound(_))));
assert!(matches!(s.list(&gone), Err(StorageError::NotFound(_))));
}
#[test]
fn a_reference_of_the_wrong_kind_is_refused_rather_than_guessed_at() {
// A SAF document id is not a path. Treating it as one would resolve to
// some other file, which is worse than failing.
let t = Tree::new("wrong-kind");
let s = t.storage();
let saf = SourceRef::Document {
tree: ROOT,
document_id: "primary:DCIM/IMG.CR3".into(),
};
assert!(matches!(s.open(&saf), Err(StorageError::Unsupported(_))));
let remote = SourceRef::Remote {
file_id: 1,
path: "Photos/IMG.CR3".into(),
};
assert!(matches!(s.open(&remote), Err(StorageError::Unsupported(_))));
}
#[test]
fn several_roots_coexist() {
// Two libraries on two drives is FR-CAT-1's "one or more roots", and
// each reference carries which one it belongs to.
let a = Tree::new("multi-a");
a.file("a.CR3", b"1");
let b = Tree::new("multi-b");
b.file("b.CR3", b"2");
let mut s = LocalStorage::new();
s.grant(RootId(1), a.0.clone());
s.grant(RootId(2), b.0.clone());
assert_eq!(s.roots(), vec![RootId(1), RootId(2)]);
assert_eq!(
s.list(&s.root_dir(RootId(2)).unwrap()).unwrap()[0]
.meta
.name,
"b.CR3"
);
}
#[test]
fn a_listing_is_ordered_the_same_way_twice() {
// Filesystem order is arbitrary and differs between runs; a scan that
// depended on it would produce a different catalog each time.
let t = Tree::new("order");
for n in ["c.CR3", "a.CR3", "b.CR3"] {
t.file(n, b"x");
}
let s = t.storage();
let names: Vec<String> = s
.list(&s.root_dir(ROOT).unwrap())
.unwrap()
.into_iter()
.map(|e| e.meta.name)
.collect();
assert_eq!(names, vec!["a.CR3", "b.CR3", "c.CR3"]);
}
#[cfg(unix)]
#[test]
fn a_name_that_is_not_utf8_is_skipped_rather_than_mangled() {
// A lossy conversion would produce a key that resolves to nothing: the
// image would be catalogued and then permanently unopenable. Better to
// not see the file than to promise it and fail later.
use std::os::unix::ffi::OsStrExt;
let t = Tree::new("non-utf8");
t.file("good.CR3", b"1");
let bad = t.0.join(std::ffi::OsStr::from_bytes(b"bad\xff.CR3"));
fs::write(&bad, b"2").expect("write");
let s = t.storage();
let entries = s.list(&s.root_dir(ROOT).unwrap()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].meta.name, "good.CR3");
}
#[test]
fn the_root_is_distinguishable_from_what_is_inside_it() {
// The walk keys the difference between "the library is unreachable" and
// "one folder failed" on this, and those have opposite consequences for
// the deletion sweep (FR-CAT-9).
assert!(DirRef::root(ROOT).is_root());
assert!(!DirRef::from_parts(ROOT, "2026").is_root());
assert_eq!(DirRef::from_parts(ROOT, "2026/08").name(), "08");
}
}