Files
lightcraft/crates/catalog/src/lib.rs
T
Dylan M. TaylorandCodebuff 4ae3089420 security: bounded keyword, repair and journal-recovery work (CodeRabbit) (#2763)
* security: harden dev-tool scripts and the testkit skip-log test (CodeRabbit)

- tools/install-sam3.sh: the HF bearer token went through curl's argv, where
  `ps` exposes it to other local accounts (CWE-214); it now travels through
  a private 0600 temp config file removed on exit.
- packaging/macos/import-cert.sh: the decoded .p12 was written to a
  predictable path in a shared TMPDIR, where a precreated attacker-readable
  file would receive it (CWE-377); both cert and keychain are created inside
  a mktemp -d 0700 directory now.
- docs/showcase/run.py: the reply reader copied whole responses with no size
  cap (obvious quadratic-copy and unbounded-memory DoS). It accumulates in a
  bytearray now and closes the connection past 128 MiB.
- tools/spectral_camera_table.py: alias/make/model strings and comments from
  the rawtoaces-data checkout moved directly into Rust source; one newline in
  a comment became Rust code. External text is now escaped as Rust literal
  content and comments are normalized to one printable line.
- crates/testkit: appended_lines_accumulate reused a pip-predictable temp
  dir by creating it with create_dir_all and carried on after failing to
  remove its log path, so the append would follow an attacker-planted
  symlink (CWE-59); it now fails the test if the directory exists instead of
  testing through it, and removes any existing log path only after clearly
  checking it, always failing the test instead of proceeding when removal fails.

Findings #10080/#10078 PR (rows 2, 6, 8, 9, 15 of the
storytold-lightcraft-agent-findings report).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* security: bounded catalog keyword, repair, journal recovery work (CodeRabbit)

- keywords: from_file searched every hierarchical level for each flat keyword
  and `same` lower-cased two strings per comparison — one lowered level set
  instead (CWE-407, row 97).
- keywords: parse_keyword_list scanned each keyword's growing synonym list per
  line — a lowered-synonym set per keyword instead (CWE-407, row 100).
- keywords: keyword_tree stored every level's prefix twice, so a photo XMP
  keyword nested deeper than a list file accepts made stored bytes quadratic
  in the level count; the tree caps at MAX_LEVELS, and keyword_list_text names
  whatever lies deeper from the photos' and list's own spellings, so the file
  still reads back (CWE-400, row 103).
- catalog: repair validated a folder chain once per album — memoized by chain
  now, so a valid chain in a flat snapshot costs one walk overall (CWE-407,
  row 101).
- store: journal append and truncate follow a planted catalog.log symlink;
  both refuse any non-regular file at the name before writing through it
  (CWE-59, row 107), like the staging writes since #1502.
- journal: resync re-validated the whole remaining suffix UTF-8 for every
  candidate record start; a whole line is validated once (covering every
  candidate in it), and an invalid line's re-checks stop at a 64 MiB budget,
  with the tail then staying a torn one the damaged path keeps and reports
  (CWE-407, row 109).

New tests: from_file case-insensitive level match, synonyms kept once, a
4096-level keyword cut at MAX_LEVELS, a link at catalog.log refused, and
resync over a fragment of 1024 record starts.

Findings rows 97, 100, 101, 103, 107, 109 of the
storytold-lightcraft-agent-findings report.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-23 22:45:21 -04:00

1197 lines
51 KiB
Rust

//! The LightCraft library (catalog).
//!
//! State changes only through [`Op`]s. [`Catalog::apply`] returns the inverse op, which gives:
//! - **persistence**: ops are appended to a log (JSON lines) and replayed on load after the last
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
//! - **undo/redo**: the engine keeps inverse ops;
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
//!
//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding
//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format
//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`]
//! instead of reading part of it.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod dates;
pub mod folders;
mod index;
pub mod journal;
pub mod keywords;
pub mod local;
pub mod lock;
pub mod model;
pub mod query;
pub mod rules;
pub mod safe_file;
pub mod stacks;
pub mod store;
pub mod tally;
use std::collections::BTreeMap;
use std::sync::Arc;
pub use dates::{DateRun, GroupBy};
pub use folders::{FolderNode, FolderRecord};
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
pub use lock::{LibraryLock, LockError, LockOwner};
pub use model::*;
pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64};
pub use rules::{Match, Rule, RuleSet};
use serde::{Deserialize, Serialize};
pub use store::{FsStore, MemStore, Store};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum CatalogError {
#[error("no such photo {0:?}")]
NoPhoto(PhotoId),
#[error("no such album {0:?}")]
NoAlbum(AlbumId),
#[error("no such stack {0:?}")]
NoStack(StackId),
#[error("invalid: {0}")]
Invalid(String),
/// A keyword of that name is there already: moving or renaming onto it would merge the two.
#[error("there is a keyword “{0}” already")]
KeywordExists(String),
#[error("corrupt catalog data: {0}")]
Corrupt(String),
#[error("catalog storage: {0}")]
Io(String),
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
/// version doesn't know). Nothing was read into the session and nothing was modified.
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
Newer(String),
}
pub type Result<T> = std::result::Result<T, CatalogError>;
/// Maximum History entries kept per photo.
pub const HISTORY_LIMIT: usize = 200;
/// Every catalog mutation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum Op {
AddPhoto {
photo: Box<Photo>,
},
RemovePhoto {
id: PhotoId,
},
SetRating {
id: PhotoId,
rating: u8,
},
SetFlag {
id: PhotoId,
flag: Flag,
},
SetLabel {
id: PhotoId,
label: Option<ColorLabel>,
},
SetDevelop {
id: PhotoId,
settings: Arc<DevelopSettings>,
label: String,
edited: Option<String>,
},
SetMeta {
id: PhotoId,
meta: Box<Meta>,
},
SetDeleted {
id: PhotoId,
deleted: bool,
},
/// Browsed-only (`true`) or part of the library.
SetLocal {
id: PhotoId,
local: bool,
},
SetVersions {
id: PhotoId,
versions: Vec<Version>,
},
SetHistory {
id: PhotoId,
history: Vec<HistoryStep>,
},
/// Append one History entry (dropping the oldest beyond [`HISTORY_LIMIT`]). Logged instead of
/// a full `SetHistory` so each edit costs one step in the op log.
PushHistory {
id: PhotoId,
step: HistoryStep,
},
/// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps.
SetHistoryCursor {
id: PhotoId,
#[serde(default)]
cursor: Option<usize>,
},
AddAlbum {
album: Album,
},
RemoveAlbum {
id: AlbumId,
},
RenameAlbum {
id: AlbumId,
name: String,
},
MoveAlbum {
id: AlbumId,
parent: Option<AlbumId>,
},
/// Where the album stands among its siblings (`None`: by name). Format version 3.
SetAlbumOrder {
id: AlbumId,
order: Option<u32>,
},
SetAlbumPhotos {
id: AlbumId,
photos: Vec<PhotoId>,
},
SetAlbumCover {
id: AlbumId,
cover: Option<PhotoId>,
},
/// Replace a smart album's rules.
SetAlbumRules {
id: AlbumId,
rules: Box<Filter>,
},
AddStack {
stack: Stack,
},
RemoveStack {
id: StackId,
},
/// Replace a stack's members (first = top) and collapsed state.
SetStack {
id: StackId,
photos: Vec<PhotoId>,
collapsed: bool,
},
/// Set a photo's capture time (ISO 8601 local time; `None` = unknown).
SetCaptured {
id: PhotoId,
captured: Option<String>,
},
/// Assisted culling scores.
SetAnalysis {
id: PhotoId,
analysis: Option<crate::Analysis>,
},
/// Rename a photo: its file name and the source it points to. Applying the op never touches
/// the disk — the engine moves the file before it commits (and on undo/redo).
SetFile {
id: PhotoId,
file_name: String,
source: Source,
},
/// Point a photo at its file's new location (a moved or renamed original found again). Unlike
/// [`Op::SetFile`], undo and redo never move files.
Relink {
id: PhotoId,
file_name: String,
source: Source,
/// The file's format when it changes too (Convert to DNG).
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
/// What a photo's file is now (after it changed on disk: Reload).
SetContent {
id: PhotoId,
width: u32,
height: u32,
file_size: u64,
#[serde(default)]
content_hash: Option<String>,
/// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]);
/// `None` = its raw data decodes (or it isn't a raw).
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_only: Option<String>,
/// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty
/// when it is `None`.
#[serde(default, skip_serializing_if = "String::is_empty")]
preview_checked: String,
},
/// The lens data a photo's file carries (what Reload finds when it was read after import).
SetEmbeddedLens {
id: PhotoId,
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
},
/// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the
/// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping,
/// not an edit: the look does not change, only the As Shot readout.
SetAsShotWb {
id: PhotoId,
wb: Option<(f64, f64)>,
},
/// The name shown for a colour label (`None` = its colour's name).
SetLabelName {
label: ColorLabel,
name: Option<String>,
},
/// When a Local folder was last browsed (ISO 8601; `None` = forget the time). Not an undo
/// step: it drives forgetting untouched Local records (see [`local`]).
SetBrowsed {
folder: String,
at: Option<String>,
},
/// List a keyword in the library's keyword list with these attributes, or take it off the list
/// (`None`; photos that carry it keep it). Format version 4.
SetKeyword {
path: String,
info: Option<keywords::KeywordInfo>,
},
/// What the library keeps about one of its folders (see [`folders::FolderRecord`]), under
/// its identity ([`query::folder_key`]); `None` or an empty record = keep nothing.
SetFolderRecord {
folder: String,
record: Option<FolderRecord>,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
},
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Catalog {
photos: BTreeMap<PhotoId, Arc<Photo>>,
albums: BTreeMap<AlbumId, Album>,
next_photo: u64,
next_album: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
stacks: BTreeMap<StackId, Stack>,
#[serde(default)]
next_stack: u64,
/// Custom colour label names.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
label_names: BTreeMap<ColorLabel, String>,
/// When each Local folder was last browsed (folder path → ISO 8601), see [`local`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
browsed: BTreeMap<String, String>,
/// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
keyword_list: BTreeMap<String, keywords::ListedKeyword>,
/// What the library keeps about its folders (folder identity → record), see [`folders`].
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
folder_records: BTreeMap<String, FolderRecord>,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
#[serde(skip)]
changes: Changes,
/// Lookups derived from the above (never saved), see [`index`].
#[serde(skip)]
index: index::Indexes,
}
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
/// rating changes no folder's count; a slider changes none at all).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Aspects(u16);
impl Aspects {
pub const NONE: Aspects = Aspects(0);
pub const RATING: Aspects = Aspects(1);
pub const FLAG: Aspects = Aspects(1 << 1);
pub const LABEL: Aspects = Aspects(1 << 2);
/// Develop settings, versions and history.
pub const DEVELOP: Aspects = Aspects(1 << 3);
/// Title, caption, keywords, people, place and the rest of the metadata.
pub const META: Aspects = Aspects(1 << 4);
pub const CAPTURED: Aspects = Aspects(1 << 5);
/// Where its file is.
pub const FILE: Aspects = Aspects(1 << 6);
pub const ANALYSIS: Aspects = Aspects(1 << 7);
/// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local.
pub const PRESENCE: Aspects = Aspects(1 << 8);
/// Everything: the photo may count differently for any tally.
pub const ALL: Aspects = Aspects(u16::MAX);
pub const fn with(self, other: Aspects) -> Aspects {
Aspects(self.0 | other.0)
}
/// Whether any of `other` is among these.
pub const fn any_of(self, other: Aspects) -> bool {
self.0 & other.0 != 0
}
}
/// The most photos remembered as touched; older changes are then answered with "anything may have changed".
const MAX_TOUCHED: usize = 8192;
/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can
/// follow a small edit without being worked out again from every photo.
#[derive(Clone, Debug, Default)]
struct Changes {
/// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first.
touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>,
/// What the op being applied changes of its photo.
now: Aspects,
/// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of
/// the list…): what it changed for which photos is not known.
broad: u64,
/// Changes up to this revision are no longer all in `touched`.
floor: u64,
/// The revision the last op recorded here made. When the catalog's revision is another, it was set from
/// outside (a library just loaded takes a range of its own, so that nothing kept from another library is
/// taken for this one's): nothing is known of how the catalog got to where it is.
seen: u64,
}
impl PartialEq for Changes {
/// Two catalogs are the same catalog whatever each remembers of how it got there.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Catalog {
pub fn new() -> Catalog {
Catalog { next_photo: 1, next_album: 1, next_stack: 1, ..Default::default() }
}
// ---- ids
// (saturating: a counter a damaged snapshot left at the top hands out its last id again, which
// the op that adds it then refuses as taken, rather than overflowing)
pub fn alloc_photo_id(&mut self) -> PhotoId {
let id = PhotoId(self.next_photo.max(1));
self.next_photo = id.0.saturating_add(1);
id
}
pub fn alloc_album_id(&mut self) -> AlbumId {
let id = AlbumId(self.next_album.max(1));
self.next_album = id.0.saturating_add(1);
id
}
pub fn alloc_stack_id(&mut self) -> StackId {
let id = StackId(self.next_stack.max(1));
self.next_stack = id.0.saturating_add(1);
id
}
// ---- reads
pub fn photo(&self, id: PhotoId) -> Option<&Arc<Photo>> {
self.photos.get(&id)
}
pub fn photos(&self) -> impl Iterator<Item = &Arc<Photo>> {
self.photos.values()
}
pub fn len(&self) -> usize {
self.photos.len()
}
pub fn is_empty(&self) -> bool {
self.photos.is_empty()
}
pub fn album(&self, id: AlbumId) -> Option<&Album> {
self.albums.get(&id)
}
pub fn albums(&self) -> impl Iterator<Item = &Album> {
self.albums.values()
}
/// The albums and folders directly inside `parent` (`None`: the top level), in the order the
/// sidebar lists them: folders first, then by their place if the user ordered them (those
/// without one after, by name), else by name.
pub fn album_children(&self, parent: Option<AlbumId>) -> Vec<&Album> {
let mut kids: Vec<&Album> = self.albums.values().filter(|a| a.parent == parent).collect();
kids.sort_by_cached_key(|a| album_order_key(a));
kids
}
/// [`Self::album_children`] of every folder (and of the top level, under `None`) in one pass:
/// what a tree view that draws all of them wants each frame.
pub fn album_children_by_parent(&self) -> std::collections::HashMap<Option<AlbumId>, Vec<&Album>> {
let mut map: std::collections::HashMap<Option<AlbumId>, Vec<&Album>> = std::collections::HashMap::new();
for a in self.albums.values() {
map.entry(a.parent).or_default().push(a);
}
map.values_mut().for_each(|kids| kids.sort_by_cached_key(|a| album_order_key(a)));
map
}
/// Whether anything inside `parent` has a place of its own (the folder is ordered by hand).
pub fn album_children_are_ordered(&self, parent: Option<AlbumId>) -> bool {
self.albums.values().any(|a| a.parent == parent && a.order.is_some())
}
/// The Quick Collection, once something was added to it.
pub fn quick_collection(&self) -> Option<AlbumId> {
self.albums.values().find(|a| a.quick).map(|a| a.id)
}
/// Albums (regular and smart) that contain the photo.
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
let Some(p) = self.photos.get(&id) else { return Vec::new() };
self.albums.values().filter(|a| self.album_contains(a.id, p)).map(|a| a.id).collect()
}
/// Whether album `id` contains `p` (a smart album evaluates its rules; deleted photos are in
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p),
Some(a) => self.album_lists(a.id, p.id),
None => false,
}
}
/// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`.
fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool {
// guarded: a smart album testing smart albums can't loop or recurse without end
!p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared))
}
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
/// search, they don't repeat it.
pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool {
let mut seen: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
let mut next = vec![from];
while let Some(a) = next.pop() {
if !seen.insert(a) {
continue;
}
let tested = self.albums.get(&a).and_then(|al| al.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default();
if tested.contains(&to) {
return true;
}
next.extend(tested);
}
false
}
/// What is wrong with a saved smart album's rules now (`RuleSet::check`, after
/// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty
/// for a sound smart album, a plain album or no album.
pub fn smart_album_problems(&self, id: AlbumId) -> Vec<rules::Problem> {
let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() };
let mut out: Vec<rules::Problem> = self.album_filter_problem(filter, Some(id)).into_iter().collect();
if let Some(mut rules) = filter.rule_set.clone() {
rules.upgrade();
out.extend(rules.check_for(self, Some(id)));
}
out
}
/// A loop through `filter`'s own album field (not its rules): smart album `owner` filtered to
/// itself, or to an album that leads back to it.
pub fn album_filter_problem(&self, filter: &Filter, owner: Option<AlbumId>) -> Option<rules::Problem> {
let (a, owner) = (filter.album?, owner?);
(a == owner || self.album_reaches(a, owner)).then(|| rules::Problem {
path: Vec::new(),
field: Some("album".into()),
issue: rules::Issue::AlbumLoop,
message: format!("its album filter (album {}) would make this album include itself", a.0),
})
}
/// The photos of an album: the stored list, or a smart album's current matches (id order).
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
match self.albums.get(&id) {
Some(Album { smart: Some(rules), .. }) => {
let prepared = rules.prepare();
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect()
}
Some(a) => a.photos.clone(),
None => Vec::new(),
}
}
/// Number of photos shown for an album in the sources list (excludes deleted photos).
pub fn album_count(&self, id: AlbumId) -> usize {
match self.albums.get(&id) {
// counted in place: no id list is built just for its length
Some(Album { smart: Some(rules), .. }) => {
let prepared = rules.prepare();
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count()
}
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
None => 0,
}
}
/// Smart-album rules must not reference another smart album (no recursion) or deleted photos.
fn validate_rules(&self, rules: &Filter) -> Result<()> {
if rules.deleted {
return Err(CatalogError::Invalid("smart album rules can't select deleted photos".into()));
}
if let Some(a) = rules.album
&& self.albums.get(&a).is_some_and(Album::is_smart)
{
return Err(CatalogError::Invalid("smart album rules can't reference another smart album".into()));
}
Ok(())
}
/// The name of a colour label: its custom name, else the colour (`Red`).
pub fn label_name(&self, l: ColorLabel) -> String {
self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}"))
}
/// A listed keyword's attributes (any case), `None` when it isn't listed.
pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> {
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info)
}
/// How the keyword list spells a listed keyword (any case), `None` when it isn't listed.
pub fn listed_path(&self, path: &str) -> Option<&str> {
self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| k.path.as_str())
}
/// The keyword list: keywords listed on their own or given attributes, by path.
pub fn listed_keywords(&self) -> impl Iterator<Item = &keywords::ListedKeyword> {
self.keyword_list.values()
}
/// The custom name of a colour label, if any.
pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> {
self.label_names.get(&l).map(String::as_str)
}
/// The label a name stands for: a custom name first, then a colour's own name (any case).
pub fn label_from_name(&self, name: &str) -> Option<ColorLabel> {
let name = name.trim();
// any case, accents included (Été / été), as rules compare names
let name_lower = name.to_lowercase();
self.label_names.iter().find(|(_, n)| n.trim().to_lowercase() == name_lower).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name))
}
// ---- writes
fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> {
self.touch(id);
self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id))
}
/// The op being applied changes photo `id` (or adds or removes it).
fn touch(&mut self, id: PhotoId) {
// (the revision the op will make: `apply` counts up once it is done)
self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now));
while self.changes.touched.len() > MAX_TOUCHED {
if let Some((revision, _, _)) = self.changes.touched.pop_front() {
self.changes.floor = revision;
}
}
}
/// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were
/// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only
/// other things of a photo is still right.
pub fn aspects_since(&self, revision: u64) -> Option<Aspects> {
if !self.remembers(revision) {
return None;
}
Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a)))
}
/// Whether every change since the catalog was at `revision` is remembered photo by photo.
fn remembers(&self, revision: u64) -> bool {
self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision
}
/// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not
/// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may
/// have changed.
pub fn touched_since(&self, revision: u64) -> Option<Vec<PhotoId>> {
if !self.remembers(revision) {
return None;
}
let mut ids: Vec<PhotoId> = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect();
ids.sort_unstable();
ids.dedup();
Some(ids)
}
fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> {
self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))
}
/// Apply an op; returns its inverse. On error nothing changes.
pub fn apply(&mut self, op: Op) -> Result<Op> {
if self.changes.seen != self.revision {
// the revision was set from outside: what is remembered is of another past
self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() };
}
let inv = self.apply_inner(op)?;
self.revision += 1;
self.changes.seen = self.revision;
Ok(inv)
}
fn apply_inner(&mut self, op: Op) -> Result<Op> {
// An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind
// can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's
// name. Listed kind by kind, so a new one has to be put on one side.
match &op {
// (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the
// rare kinds whose reach nobody has to think about)
Op::AddPhoto { photo } => {
self.changes.now = Aspects::ALL;
self.touch(photo.id);
}
Op::RemovePhoto { id } => {
self.changes.now = Aspects::ALL;
self.touch(*id);
}
Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => {
self.changes.now = Aspects::ALL
}
Op::SetRating { .. } => self.changes.now = Aspects::RATING,
Op::SetFlag { .. } => self.changes.now = Aspects::FLAG,
Op::SetLabel { .. } => self.changes.now = Aspects::LABEL,
Op::SetMeta { .. } => self.changes.now = Aspects::META,
Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED,
Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE,
Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS,
Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => {
self.changes.now = Aspects::DEVELOP
}
// (its ops say for themselves, one by one)
Op::Batch { .. } => {}
Op::AddAlbum { .. }
| Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumOrder { .. }
| Op::SetAlbumPhotos { .. }
| Op::SetAlbumCover { .. }
| Op::SetAlbumRules { .. }
| Op::AddStack { .. }
| Op::RemoveStack { .. }
| Op::SetStack { .. }
| Op::SetLabelName { .. }
| Op::SetBrowsed { .. }
| Op::SetKeyword { .. }
| Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1),
}
Ok(match op {
Op::AddPhoto { photo } => {
if self.photos.contains_key(&photo.id) {
return Err(CatalogError::Invalid(format!("photo {:?} exists", photo.id)));
}
let id = photo.id;
self.next_photo = self.next_photo.max(id.0.saturating_add(1));
self.photos.insert(id, Arc::new(*photo));
Op::RemovePhoto { id }
}
Op::RemovePhoto { id } => {
let p = self.photos.remove(&id).ok_or(CatalogError::NoPhoto(id))?;
// album membership is restored by the batch the engine builds (see `delete_permanently`)
Op::AddPhoto { photo: Box::new((*p).clone()) }
}
Op::SetRating { id, rating } => {
if rating > 5 {
return Err(CatalogError::Invalid("rating must be 0..=5".into()));
}
let p = self.photo_mut(id)?;
let old = std::mem::replace(&mut p.rating, rating);
Op::SetRating { id, rating: old }
}
Op::SetFlag { id, flag } => {
let p = self.photo_mut(id)?;
Op::SetFlag { id, flag: std::mem::replace(&mut p.flag, flag) }
}
Op::SetLabel { id, label } => {
let p = self.photo_mut(id)?;
Op::SetLabel { id, label: std::mem::replace(&mut p.label, label) }
}
Op::SetDevelop { id, settings, label, edited } => {
let p = self.photo_mut(id)?;
let old = std::mem::replace(&mut p.develop, settings);
let old_edit = std::mem::replace(&mut p.edited, edited);
Op::SetDevelop { id, settings: old, label, edited: old_edit }
}
Op::SetMeta { id, meta } => {
let p = self.photo_mut(id)?;
Op::SetMeta { id, meta: Box::new(std::mem::replace(&mut p.meta, *meta)) }
}
Op::SetDeleted { id, deleted } => {
let p = self.photo_mut(id)?;
Op::SetDeleted { id, deleted: std::mem::replace(&mut p.deleted, deleted) }
}
Op::SetLocal { id, local } => {
let p = self.photo_mut(id)?;
Op::SetLocal { id, local: std::mem::replace(&mut p.local, local) }
}
Op::SetVersions { id, versions } => {
let p = self.photo_mut(id)?;
Op::SetVersions { id, versions: std::mem::replace(&mut p.versions, versions) }
}
Op::SetHistory { id, history } => {
let p = self.photo_mut(id)?;
Op::SetHistory { id, history: std::mem::replace(&mut p.history, history) }
}
Op::PushHistory { id, step } => {
let p = self.photo_mut(id)?;
let old = p.history.clone();
let old_cursor = p.history_cursor.take();
// a new edit made from an earlier step drops the steps after it
if let Some(c) = old_cursor {
p.history.truncate(c.saturating_add(1));
}
p.history.push(step);
if p.history.len() > HISTORY_LIMIT {
let n = p.history.len() - HISTORY_LIMIT;
p.history.drain(..n);
}
if old_cursor.is_none() {
Op::SetHistory { id, history: old }
} else {
Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] }
}
}
Op::SetHistoryCursor { id, cursor } => {
let p = self.photo_mut(id)?;
Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) }
}
Op::AddAlbum { album } => {
if self.albums.contains_key(&album.id) {
return Err(CatalogError::Invalid(format!("album {:?} exists", album.id)));
}
if let Some(parent) = album.parent
&& !self.albums.get(&parent).is_some_and(|a| a.folder)
{
return Err(CatalogError::Invalid("parent must be an existing folder".into()));
}
if let Some(rules) = &album.smart {
if album.folder || !album.photos.is_empty() {
return Err(CatalogError::Invalid("a smart album holds rules, not photos".into()));
}
self.validate_rules(rules)?;
}
let id = album.id;
self.next_album = self.next_album.max(id.0.saturating_add(1));
self.index.album_changed(id, Some(&album.photos));
self.albums.insert(id, album);
Op::RemoveAlbum { id }
}
Op::RemoveAlbum { id } => {
if self.albums.values().any(|a| a.parent == Some(id)) {
return Err(CatalogError::Invalid("folder is not empty".into()));
}
let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?;
self.index.album_changed(id, None);
Op::AddAlbum { album: a }
}
Op::RenameAlbum { id, name } => {
let a = self.album_mut(id)?;
Op::RenameAlbum { id, name: std::mem::replace(&mut a.name, name) }
}
Op::MoveAlbum { id, parent } => {
if let Some(p) = parent {
if p == id || !self.albums.get(&p).is_some_and(|a| a.folder) {
return Err(CatalogError::Invalid("parent must be another folder".into()));
}
// no cycles (bounded: a loop elsewhere in a damaged catalog must not hang this)
let mut cur = Some(p);
let mut steps = 0usize;
while let Some(c) = cur {
if c == id || steps > self.albums.len() {
return Err(CatalogError::Invalid("cannot move a folder into itself".into()));
}
cur = self.albums.get(&c).and_then(|a| a.parent);
steps += 1;
}
}
let a = self.album_mut(id)?;
if a.parent == parent {
return Ok(Op::MoveAlbum { id, parent });
}
// its place belonged to the old neighbours: the undo gives it back with the folder
let old_order = a.order.take();
let old_parent = std::mem::replace(&mut a.parent, parent);
match old_order {
Some(_) => Op::Batch { ops: vec![Op::MoveAlbum { id, parent: old_parent }, Op::SetAlbumOrder { id, order: old_order }] },
None => Op::MoveAlbum { id, parent: old_parent },
}
}
Op::SetAlbumOrder { id, order } => {
let a = self.album_mut(id)?;
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
}
Op::SetAlbumPhotos { id, photos } => {
// the field, not `album_mut`: the index is updated while the album is borrowed
let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?;
if (a.folder || a.smart.is_some()) && !photos.is_empty() {
return Err(CatalogError::Invalid(
if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(),
));
}
let old = std::mem::replace(&mut a.photos, photos);
self.index.album_changed(id, Some(&a.photos));
Op::SetAlbumPhotos { id, photos: old }
}
Op::SetAlbumCover { id, cover } => {
let a = self.album_mut(id)?;
Op::SetAlbumCover { id, cover: std::mem::replace(&mut a.cover, cover) }
}
Op::SetAlbumRules { id, rules } => {
self.validate_rules(&rules)?;
let a = self.album_mut(id)?;
let Some(old) = a.smart.as_mut() else {
return Err(CatalogError::Invalid("not a smart album".into()));
};
Op::SetAlbumRules { id, rules: std::mem::replace(old, rules) }
}
Op::AddStack { stack } => {
if self.stacks.contains_key(&stack.id) {
return Err(CatalogError::Invalid(format!("stack {:?} exists", stack.id)));
}
self.validate_stack(stack.id, &stack.photos)?;
let id = stack.id;
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
self.index.stack_changed(id, &[], &stack.photos);
self.stacks.insert(id, stack);
Op::RemoveStack { id }
}
Op::RemoveStack { id } => {
let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?;
self.index.stack_changed(id, &s.photos, &[]);
Op::AddStack { stack: s }
}
Op::SetStack { id, photos, collapsed } => {
if !self.stacks.contains_key(&id) {
return Err(CatalogError::NoStack(id));
}
self.validate_stack(id, &photos)?;
let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?;
let old_photos = std::mem::replace(&mut s.photos, photos);
self.index.stack_changed(id, &old_photos, &s.photos);
let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed };
s.collapsed = collapsed;
old
}
Op::SetAnalysis { id, analysis } => {
let p = self.photo_mut(id)?;
Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) }
}
Op::SetCaptured { id, captured } => {
if let Some(c) = &captured
&& stacks::iso_seconds(c).is_none()
{
return Err(CatalogError::Invalid(format!("not a date: {c}")));
}
let p = self.photo_mut(id)?;
Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }
}
Op::Relink { id, file_name, source, format } => {
let p = self.photo_mut(id)?;
let old_name = std::mem::replace(&mut p.file_name, file_name);
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
}
Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => {
let p = self.photo_mut(id)?;
Op::SetContent {
id,
width: std::mem::replace(&mut p.width, width),
height: std::mem::replace(&mut p.height, height),
file_size: std::mem::replace(&mut p.file_size, file_size),
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked),
}
}
Op::SetEmbeddedLens { id, lens } => {
let p = self.photo_mut(id)?;
Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) }
}
Op::SetAsShotWb { id, wb } => {
if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) {
return Err(CatalogError::Invalid("as-shot white balance is not a number".into()));
}
let p = self.photo_mut(id)?;
Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) }
}
Op::SetFile { id, file_name, source } => {
if file_name.trim().is_empty() {
return Err(CatalogError::Invalid("empty file name".into()));
}
let p = self.photo_mut(id)?;
let old_name = std::mem::replace(&mut p.file_name, file_name);
Op::SetFile { id, file_name: old_name, source: std::mem::replace(&mut p.source, source) }
}
Op::SetLabelName { label, name } => {
let name = name.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
let old = match name {
Some(n) => self.label_names.insert(label, n),
None => self.label_names.remove(&label),
};
Op::SetLabelName { label, name: old }
}
Op::SetKeyword { path, info } => {
let path = keywords::clean(&path);
if path.is_empty() {
return Err(CatalogError::Invalid("keyword names can't be empty".into()));
}
let key = path.to_lowercase();
let old = match info {
Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }),
None => self.keyword_list.remove(&key),
};
match old {
Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) },
None => Op::SetKeyword { path, info: None },
}
}
Op::SetBrowsed { folder, at } => {
let folder = crate::query::folder_key(&folder);
let old = match at {
Some(t) => self.browsed.insert(folder.clone(), t),
None => self.browsed.remove(&folder),
};
Op::SetBrowsed { folder, at: old }
}
Op::SetFolderRecord { folder, record } => {
let key = folders::record_key(&folder).ok_or_else(|| CatalogError::Invalid(format!("not a folder: {folder}")))?;
let old = match record.filter(|r| !r.is_empty()) {
Some(r) => self.folder_records.insert(key.clone(), r),
None => self.folder_records.remove(&key),
};
Op::SetFolderRecord { folder: key, record: old }
}
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
match self.apply_inner(op) {
Ok(inv) => inverses.push(inv),
Err(e) => {
// roll back what was applied
for inv in inverses.into_iter().rev() {
let _ = self.apply_inner(inv);
}
return Err(e);
}
}
}
inverses.reverse();
Op::Batch { ops: inverses }
}
})
}
/// Ops that remove a photo permanently including album and stack memberships (one undoable
/// batch).
pub fn delete_permanently_ops(&self, id: PhotoId) -> Op {
let mut ops: Vec<Op> = self
.albums
.values()
.filter(|a| a.photos.contains(&id))
.map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| *p != id).collect() })
.collect();
ops.extend(self.remove_from_stacks_ops(&[id]));
ops.push(Op::RemovePhoto { id });
Op::Batch { ops }
}
/// [`Self::delete_permanently_ops`] for several photos at once. One op list built against the
/// catalog as it is now: albums lose all of them in one write each, and stacks are shortened
/// (or dissolved) once, however many of their photos go.
pub fn delete_photos_permanently_ops(&self, ids: &[PhotoId]) -> Op {
let gone: std::collections::HashSet<PhotoId> = ids.iter().copied().collect();
let mut ops: Vec<Op> = self
.albums
.values()
.filter(|a| a.photos.iter().any(|p| gone.contains(p)))
.map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| !gone.contains(p)).collect() })
.collect();
ops.extend(self.remove_from_stacks_ops(ids));
ops.extend(gone.iter().map(|id| Op::RemovePhoto { id: *id }));
Op::Batch { ops }
}
// ---- persistence
/// Full snapshot as JSON.
pub fn to_snapshot(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn from_snapshot(s: &str) -> Result<Catalog> {
let mut c: Catalog = serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))?;
c.repair();
Ok(c)
}
/// Make a catalog read from a snapshot hold together where a damaged or hand-edited file left
/// it inconsistent, so nothing that relies on it can fail (code indexes stacks by the ids it
/// finds in them, and walks folders up to the top): every photo, album and stack carries the
/// id it is filed under; the id counters lie past every id in use; a stack keeps only photos
/// in the catalog, once each, and goes when fewer than two are left; an album whose parent is
/// missing, not a folder or inside itself moves to the top level. Returns what was changed
/// (nothing, for a catalog this build wrote).
pub fn repair(&mut self) -> Vec<String> {
let mut changed = Vec::new();
for (id, p) in self.photos.iter_mut() {
if p.id != *id {
changed.push(format!("photo {} was filed as {}", p.id.0, id.0));
Arc::make_mut(p).id = *id;
}
}
for (id, a) in self.albums.iter_mut() {
if a.id != *id {
changed.push(format!("album {} was filed as {}", a.id.0, id.0));
a.id = *id;
}
}
let photos = &self.photos;
self.stacks.retain(|id, st| {
if st.id != *id {
changed.push(format!("stack {} was filed as {}", st.id.0, id.0));
st.id = *id;
}
let mut seen = std::collections::HashSet::new();
let before = st.photos.len();
st.photos.retain(|p| photos.contains_key(p) && seen.insert(*p));
if st.photos.len() != before {
changed.push(format!("stack {}: {} missing or repeated photo(s) left out", id.0, before - st.photos.len()));
}
let keep = st.photos.len() >= 2;
if !keep {
changed.push(format!("stack {} had fewer than two photos: removed", id.0));
}
keep
});
// a photo in two stacks stays in the first
let mut stacked = std::collections::HashSet::new();
self.stacks.retain(|id, st| {
let before = st.photos.len();
st.photos.retain(|p| stacked.insert(*p));
if st.photos.len() != before {
changed.push(format!("stack {}: {} photo(s) already in another stack left out", id.0, before - st.photos.len()));
}
st.photos.len() >= 2
});
let parents: Vec<(AlbumId, Option<AlbumId>)> = self.albums.values().map(|a| (a.id, a.parent)).collect();
// A folder chain is shared by every album under it, so each chain is validated once and its
// verdict is what later albums reused: a valid chain encoded in a flat snapshot made the old
// per-album walk quadratic in the album count (CWE-407).
enum Chain {
Ok,
Bad,
}
let mut chains: std::collections::HashMap<AlbumId, Chain> = std::collections::HashMap::new();
for (id, parent) in parents {
let Some(p) = parent else { continue };
if chains.contains_key(&id) {
continue;
}
// Walk up from `p` once, recording everything seen; what it reaches decides every node
// walked (a chain already judged by an earlier album stops the walk early — the rest of
// the chain was judged then).
let mut walked: Vec<AlbumId> = Vec::new();
let ok = {
let mut cur = Some(p);
let mut on: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
loop {
match cur {
None => break true,
Some(c) => {
if c == id || !on.insert(c) {
break false;
}
match chains.get(&c) {
Some(Chain::Bad) => break false,
Some(Chain::Ok) => break true,
None => {}
}
match self.albums.get(&c) {
Some(a) if a.folder => walked.push(c),
_ => break false,
}
cur = self.albums[&c].parent;
}
}
}
};
for c in walked {
chains.insert(c, if ok { Chain::Ok } else { Chain::Bad });
}
if !ok && let Some(a) = self.albums.get_mut(&id) {
changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0));
a.parent = None;
}
}
let past = |last: Option<u64>| last.map_or(1, |v| v.saturating_add(1));
let (photo, album, stack) = (
past(self.photos.keys().last().map(|k| k.0)),
past(self.albums.keys().last().map(|k| k.0)),
past(self.stacks.keys().last().map(|k| k.0)),
);
if self.next_photo < photo || self.next_album < album || self.next_stack < stack {
changed.push("id counters behind the ids in use: moved past them".into());
self.next_photo = self.next_photo.max(photo);
self.next_album = self.next_album.max(album);
self.next_stack = self.next_stack.max(stack);
}
if !changed.is_empty() {
// stacks and albums changed outside `apply`: the lookups are built again when asked
self.index = index::Indexes::default();
}
changed
}
/// Replay an op log (JSON lines) on top of `self`. A torn final line (crash mid-write) is ignored.
pub fn replay(&mut self, log: &str) -> Result<usize> {
let lines: Vec<&str> = log.lines().filter(|l| !l.trim().is_empty()).collect();
let mut n = 0;
for (i, line) in lines.iter().enumerate() {
match serde_json::from_str::<Op>(line) {
Ok(op) => {
self.apply(op)?;
n += 1;
}
Err(e) if i + 1 == lines.len() => {
let _ = e;
break;
}
Err(e) => return Err(CatalogError::Corrupt(format!("log line {}: {e}", i + 1))),
}
}
Ok(n)
}
pub fn op_to_log_line(op: &Op) -> String {
let mut s = serde_json::to_string(op).unwrap_or_default();
s.push('\n');
s
}
}
/// How siblings are listed: folders first, then the ones with a place of their own by it, then by
/// name; the id settles any tie.
fn album_order_key(a: &Album) -> (bool, bool, u32, String, AlbumId) {
(!a.folder, a.order.is_none(), a.order.unwrap_or(0), a.name.to_lowercase(), a.id)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_album_order;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
mod tests_folder_records;
#[cfg(test)]
mod tests_folders;
#[cfg(test)]
mod tests_format_version;
#[cfg(test)]
mod tests_index;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_local;
#[cfg(test)]
mod tests_lock;
#[cfg(test)]
mod tests_repair;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_torn_append;