Skip to main content

mach/
store.rs

1//! SQLite-backed persistence for tasks, categories, and settings.
2//!
3//! A [`Store`] is an explicit instance: callers can open independent data
4//! directories in one process. Writes use `BEGIN IMMEDIATE`, validate a fresh
5//! snapshot, and commit tasks/categories/settings/attachment metadata plus a
6//! monotonic revision in one transaction. Image bytes are immutable,
7//! content-addressed files beside the database. Legacy JSON is imported once
8//! and left untouched.
9
10use std::collections::{HashMap, HashSet};
11use std::fs;
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use chrono::{Local, NaiveDateTime};
17use rusqlite::limits::Limit;
18use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
19use serde::Deserialize;
20use serde::de::DeserializeOwned;
21use sha2::{Digest, Sha256};
22use unicode_normalization::UnicodeNormalization;
23use unicode_segmentation::UnicodeSegmentation;
24
25use crate::due;
26use crate::model::{
27    Block, Category, MAX_CATEGORY_COUNT, MAX_CATEGORY_DESC_LINE_LEN, MAX_CATEGORY_DESC_LINES,
28    MAX_CATEGORY_NAME_LEN, MAX_DESCRIPTION_LINES, MAX_IMPORTANCE, MAX_NOTES_LINE_LEN,
29    MAX_TASK_COUNT, MAX_TITLE_LEN, SCHEMA_VERSION, Task, category_name_key, text_byte_limit,
30};
31use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, Settings, THEMES};
32
33const DATABASE_FILE: &str = "mach.db";
34const DATABASE_SCHEMA_VERSION: i64 = 2;
35const LEGACY_MIGRATION_KEY: &str = "legacy_json_migrated";
36pub(crate) const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(10);
37pub(crate) const ID_MAX_BYTES: usize = 128;
38pub(crate) const DUE_MAX_BYTES: usize = 128;
39pub(crate) const CREATED_MAX_BYTES: usize = 64;
40const SETTINGS_VALUE_MAX_BYTES: usize = 128;
41const MAX_LEGACY_JSON_BYTES: u64 = 128 * 1024 * 1024;
42const MAX_SQLITE_VALUE_BYTES: i32 = 8 * 1024 * 1024;
43pub(crate) const MAX_ATTACHMENT_BYTES: u64 = 128 * 1024 * 1024;
44pub(crate) const ATTACHMENT_ID_LEN: usize = 64;
45
46#[derive(Debug)]
47pub enum StoreError {
48    Io {
49        operation: &'static str,
50        path: PathBuf,
51        source: std::io::Error,
52    },
53    Json {
54        path: PathBuf,
55        source: serde_json::Error,
56    },
57    Database(rusqlite::Error),
58    UnsupportedLegacySchema {
59        path: PathBuf,
60        found: u32,
61        expected: u32,
62    },
63    UnsupportedDatabaseSchema {
64        path: PathBuf,
65        found: i64,
66        expected: i64,
67    },
68    Conflict {
69        expected: u64,
70        actual: u64,
71    },
72    StaleEntity {
73        entity: &'static str,
74        id: String,
75    },
76    NotFound {
77        entity: &'static str,
78        query: String,
79    },
80    Ambiguous {
81        entity: &'static str,
82        query: String,
83        matches: Vec<String>,
84    },
85    Validation(String),
86    Corrupt(String),
87}
88
89impl StoreError {
90    pub fn validation(message: impl Into<String>) -> Self {
91        Self::Validation(message.into())
92    }
93
94    pub(crate) fn io(operation: &'static str, path: &Path, source: std::io::Error) -> Self {
95        Self::Io {
96            operation,
97            path: path.to_path_buf(),
98            source,
99        }
100    }
101}
102
103impl std::fmt::Display for StoreError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::Io {
107                operation,
108                path,
109                source,
110            } => write!(f, "could not {operation} {}: {source}", path.display()),
111            Self::Json { path, source } => {
112                write!(f, "could not parse {}: {source}", path.display())
113            }
114            Self::Database(source) => write!(f, "database error: {source}"),
115            Self::UnsupportedLegacySchema {
116                path,
117                found,
118                expected,
119            } => write!(
120                f,
121                "{} uses unsupported schema {found} (expected {expected})",
122                path.display()
123            ),
124            Self::UnsupportedDatabaseSchema {
125                path,
126                found,
127                expected,
128            } => write!(
129                f,
130                "{} uses unsupported database schema {found} (expected {expected})",
131                path.display()
132            ),
133            Self::Conflict { expected, actual } => write!(
134                f,
135                "store changed since it was loaded (expected revision {expected}, found {actual})"
136            ),
137            Self::StaleEntity { entity, id } => {
138                write!(f, "{entity} {id:?} changed since it was loaded")
139            }
140            Self::NotFound { entity, query } => {
141                write!(f, "no {entity} matching {query:?}")
142            }
143            Self::Ambiguous {
144                entity,
145                query,
146                matches,
147            } => write!(
148                f,
149                "ambiguous {entity} {query:?}; matches: {}",
150                matches.join(", ")
151            ),
152            Self::Validation(message) => write!(f, "invalid data: {message}"),
153            Self::Corrupt(message) => write!(f, "corrupt database: {message}"),
154        }
155    }
156}
157
158impl std::error::Error for StoreError {
159    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
160        match self {
161            Self::Io { source, .. } => Some(source),
162            Self::Json { source, .. } => Some(source),
163            Self::Database(source) => Some(source),
164            _ => None,
165        }
166    }
167}
168
169impl From<rusqlite::Error> for StoreError {
170    fn from(value: rusqlite::Error) -> Self {
171        Self::Database(value)
172    }
173}
174
175#[derive(Debug, Clone, Default)]
176pub struct StoreData {
177    pub revision: u64,
178    pub categories: Vec<Category>,
179    pub tasks: Vec<Task>,
180    pub settings: Settings,
181    pub(crate) attachments: Vec<Attachment>,
182}
183
184/// Immutable metadata for one content-addressed image owned by this store.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Attachment {
187    pub id: String,
188    pub sha256: String,
189    pub media_type: String,
190    pub byte_len: u64,
191    pub storage_name: String,
192}
193
194/// Verified attachment bytes staged by a caller for installation inside the
195/// same write transaction as their task references.
196#[derive(Debug, Clone)]
197pub(crate) struct StagedAttachment {
198    pub metadata: Attachment,
199    pub path: PathBuf,
200}
201
202#[derive(Debug, Clone, Default)]
203pub struct TaskPatch {
204    pub title: Option<String>,
205    pub description: Option<Vec<Block>>,
206    pub due: Option<String>,
207    pub done: Option<bool>,
208    pub importance: Option<u8>,
209    /// `None` leaves the category unchanged; `Some(None)` clears it.
210    pub category_id: Option<Option<String>>,
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct CategoryPatch {
215    pub name: Option<String>,
216    pub description: Option<String>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum PurgeScope {
221    All,
222    Category(String),
223    Uncategorized,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum RelativePosition {
228    Before,
229    After,
230}
231
232impl StoreData {
233    pub fn attachments(&self) -> &[Attachment] {
234        &self.attachments
235    }
236
237    /// Resolve a task by full id or unique id prefix.
238    pub fn resolve_task_id(&self, query: &str) -> Result<String, StoreError> {
239        let query = query.trim();
240        if query.is_empty() {
241            return Err(StoreError::validation("task id cannot be empty"));
242        }
243        validate_byte_limit(query, ID_MAX_BYTES, "task id query")?;
244        if let Some(task) = self.tasks.iter().find(|task| task.id == query) {
245            return Ok(task.id.clone());
246        }
247        let matches: Vec<_> = self
248            .tasks
249            .iter()
250            .filter(|task| task.id.starts_with(query))
251            .map(|task| task.id.clone())
252            .collect();
253        match matches.as_slice() {
254            [id] => Ok(id.clone()),
255            [] => Err(StoreError::NotFound {
256                entity: "task",
257                query: query.to_string(),
258            }),
259            _ => Err(StoreError::Ambiguous {
260                entity: "task id",
261                query: query.to_string(),
262                matches,
263            }),
264        }
265    }
266
267    /// Resolve a category by id, Unicode-caseless name, or unique name prefix.
268    pub fn resolve_category_id(&self, query: &str) -> Result<String, StoreError> {
269        let query = query.trim();
270        if query.is_empty() {
271            return Err(StoreError::validation("category name cannot be empty"));
272        }
273        if let Some(category) = self.categories.iter().find(|category| category.id == query) {
274            return Ok(category.id.clone());
275        }
276        validate_byte_limit(
277            query,
278            text_byte_limit(MAX_CATEGORY_NAME_LEN),
279            "category query",
280        )?;
281        let folded = category_name_key(query);
282        if let Some(category) = self
283            .categories
284            .iter()
285            .find(|category| category_name_key(&category.name) == folded)
286        {
287            return Ok(category.id.clone());
288        }
289        let matches: Vec<_> = self
290            .categories
291            .iter()
292            .filter(|category| category_name_has_prefix(&category.name, &folded))
293            .collect();
294        match matches.as_slice() {
295            [category] => Ok(category.id.clone()),
296            [] => Err(StoreError::NotFound {
297                entity: "category",
298                query: query.to_string(),
299            }),
300            _ => Err(StoreError::Ambiguous {
301                entity: "category",
302                query: query.to_string(),
303                matches: matches
304                    .into_iter()
305                    .map(|category| category.name.clone())
306                    .collect(),
307            }),
308        }
309    }
310
311    pub fn task(&self, id: &str) -> Result<&Task, StoreError> {
312        self.task_index(id).map(|index| &self.tasks[index])
313    }
314
315    fn task_index(&self, id: &str) -> Result<usize, StoreError> {
316        self.tasks
317            .iter()
318            .position(|task| task.id == id)
319            .ok_or_else(|| StoreError::NotFound {
320                entity: "task",
321                query: id.to_string(),
322            })
323    }
324
325    pub fn category(&self, id: &str) -> Result<&Category, StoreError> {
326        self.category_index(id).map(|index| &self.categories[index])
327    }
328
329    fn category_index(&self, id: &str) -> Result<usize, StoreError> {
330        self.categories
331            .iter()
332            .position(|category| category.id == id)
333            .ok_or_else(|| StoreError::NotFound {
334                entity: "category",
335                query: id.to_string(),
336            })
337    }
338
339    pub fn create_task(
340        &mut self,
341        title: impl Into<String>,
342        description: Vec<Block>,
343        due: impl Into<String>,
344        importance: u8,
345        category_id: Option<String>,
346    ) -> Result<Task, StoreError> {
347        if importance > MAX_IMPORTANCE {
348            return Err(StoreError::validation(format!(
349                "importance must be 0-{MAX_IMPORTANCE}"
350            )));
351        }
352        let title = title.into();
353        let due = due.into();
354        let mut task = Task::new(&title, importance, category_id, &due);
355        task.description = description;
356        self.insert_task(task)
357    }
358
359    pub fn insert_task(&mut self, task: Task) -> Result<Task, StoreError> {
360        let index = self.tasks.len();
361        self.tasks.push(task);
362        if let Err(error) = self.normalize_and_validate_new_write() {
363            self.tasks.truncate(index);
364            return Err(error);
365        }
366        Ok(self.tasks[index].clone())
367    }
368
369    pub fn edit_task(&mut self, id: &str, patch: TaskPatch) -> Result<Task, StoreError> {
370        let index = self.task_index(id)?;
371        let before = self.tasks[index].clone();
372        {
373            let task = &mut self.tasks[index];
374            if let Some(title) = patch.title {
375                task.title = title;
376            }
377            if let Some(description) = patch.description {
378                task.description = description;
379            }
380            if let Some(due) = patch.due {
381                task.due = due;
382            }
383            if let Some(done) = patch.done {
384                task.done = done;
385            }
386            if let Some(importance) = patch.importance {
387                task.importance = importance;
388            }
389            if let Some(category_id) = patch.category_id {
390                task.category_id = category_id;
391            }
392        }
393        if let Err(error) = self.normalize_and_validate_new_write() {
394            self.tasks[index] = before;
395            return Err(error);
396        }
397        Ok(self.tasks[index].clone())
398    }
399
400    /// Apply only the fields represented by `patch`, but fail if one of those
401    /// fields changed since `expected` was loaded. Unrelated concurrent edits
402    /// (for example toggling `done` while a title form is open) are preserved.
403    pub fn edit_task_if_unchanged(
404        &mut self,
405        expected: &Task,
406        patch: TaskPatch,
407    ) -> Result<Task, StoreError> {
408        let current = self
409            .tasks
410            .iter()
411            .find(|task| task.id == expected.id)
412            .ok_or_else(|| StoreError::StaleEntity {
413                entity: "task",
414                id: expected.id.clone(),
415            })?;
416        let stale = field_conflicts(patch.title.as_ref(), &current.title, &expected.title)
417            || field_conflicts(
418                patch.description.as_ref(),
419                &current.description,
420                &expected.description,
421            )
422            || field_conflicts(patch.due.as_ref(), &current.due, &expected.due)
423            || field_conflicts(patch.done.as_ref(), &current.done, &expected.done)
424            || field_conflicts(
425                patch.importance.as_ref(),
426                &current.importance,
427                &expected.importance,
428            )
429            || field_conflicts(
430                patch.category_id.as_ref(),
431                &current.category_id,
432                &expected.category_id,
433            );
434        if stale {
435            return Err(StoreError::StaleEntity {
436                entity: "task",
437                id: expected.id.clone(),
438            });
439        }
440        self.edit_task(&expected.id, patch)
441    }
442
443    pub fn delete_task(&mut self, id: &str) -> Result<Task, StoreError> {
444        let index = self.task_index(id)?;
445        Ok(self.tasks.remove(index))
446    }
447
448    pub fn set_task_done(&mut self, id: &str, done: bool) -> Result<Task, StoreError> {
449        self.edit_task(
450            id,
451            TaskPatch {
452                done: Some(done),
453                ..TaskPatch::default()
454            },
455        )
456    }
457
458    pub fn toggle_task_done(&mut self, id: &str) -> Result<Task, StoreError> {
459        let done = !self.task(id)?.done;
460        self.set_task_done(id, done)
461    }
462
463    pub fn set_task_importance(&mut self, id: &str, importance: u8) -> Result<Task, StoreError> {
464        self.edit_task(
465            id,
466            TaskPatch {
467                importance: Some(importance),
468                ..TaskPatch::default()
469            },
470        )
471    }
472
473    pub fn set_task_category(
474        &mut self,
475        id: &str,
476        category_id: Option<String>,
477    ) -> Result<Task, StoreError> {
478        self.edit_task(
479            id,
480            TaskPatch {
481                category_id: Some(category_id),
482                ..TaskPatch::default()
483            },
484        )
485    }
486
487    pub fn move_task(&mut self, id: &str, target: usize) -> Result<(), StoreError> {
488        let index = self.task_index(id)?;
489        if target >= self.tasks.len() {
490            return Err(StoreError::validation(format!(
491                "task target index {target} is out of range"
492            )));
493        }
494        if self.tasks[index].category_id != self.tasks[target].category_id {
495            return Err(StoreError::validation(
496                "tasks can only be reordered within the same category",
497            ));
498        }
499        let task = self.tasks.remove(index);
500        self.tasks.insert(target, task);
501        Ok(())
502    }
503
504    pub fn move_task_relative(
505        &mut self,
506        id: &str,
507        target_id: &str,
508        position: RelativePosition,
509    ) -> Result<Task, StoreError> {
510        if id == target_id {
511            return Err(StoreError::validation(
512                "cannot move a task relative to itself",
513            ));
514        }
515        let index = self.task_index(id)?;
516        let target_index = self.task_index(target_id)?;
517        if self.tasks[index].category_id != self.tasks[target_index].category_id {
518            return Err(StoreError::validation(
519                "tasks can only be reordered within the same category",
520            ));
521        }
522        let task = self.tasks.remove(index);
523        let target_after_removal = target_index - usize::from(index < target_index);
524        let insertion = match position {
525            RelativePosition::Before => target_after_removal,
526            RelativePosition::After => target_after_removal + 1,
527        };
528        self.tasks.insert(insertion, task.clone());
529        Ok(task)
530    }
531
532    pub fn purge_completed(&mut self, scope: &PurgeScope) -> Result<Vec<Task>, StoreError> {
533        if let PurgeScope::Category(id) = scope {
534            self.category(id)?;
535        }
536        Ok(self.remove_tasks(|task| {
537            let in_scope = match scope {
538                PurgeScope::All => true,
539                PurgeScope::Category(id) => task.category_id.as_deref() == Some(id),
540                PurgeScope::Uncategorized => task.category_id.is_none(),
541            };
542            task.done && in_scope
543        }))
544    }
545
546    /// Purge only the completed tasks captured by a confirmation prompt.
547    pub fn purge_completed_ids(&mut self, ids: &[String]) -> Result<Vec<Task>, StoreError> {
548        let ids: HashSet<_> = ids.iter().map(String::as_str).collect();
549        Ok(self.remove_tasks(|task| task.done && ids.contains(task.id.as_str())))
550    }
551
552    fn remove_tasks(&mut self, mut should_remove: impl FnMut(&Task) -> bool) -> Vec<Task> {
553        let mut removed = Vec::new();
554        self.tasks.retain(|task| {
555            let remove = should_remove(task);
556            if remove {
557                removed.push(task.clone());
558            }
559            !remove
560        });
561        removed
562    }
563
564    pub fn create_category(
565        &mut self,
566        name: impl Into<String>,
567        description: impl Into<String>,
568    ) -> Result<Category, StoreError> {
569        let name = name.into();
570        let mut category = Category::new(&name);
571        category.description = description.into();
572        self.insert_category(category)
573    }
574
575    pub fn insert_category(&mut self, category: Category) -> Result<Category, StoreError> {
576        let index = self.categories.len();
577        self.categories.push(category);
578        if let Err(error) = self.normalize_and_validate_new_write() {
579            self.categories.truncate(index);
580            return Err(error);
581        }
582        Ok(self.categories[index].clone())
583    }
584
585    pub fn edit_category(
586        &mut self,
587        id: &str,
588        patch: CategoryPatch,
589    ) -> Result<Category, StoreError> {
590        let index = self.category_index(id)?;
591        let before = self.categories[index].clone();
592        {
593            let category = &mut self.categories[index];
594            if let Some(name) = patch.name {
595                category.name = name;
596            }
597            if let Some(description) = patch.description {
598                category.description = description;
599            }
600        }
601        if let Err(error) = self.normalize_and_validate_new_write() {
602            self.categories[index] = before;
603            return Err(error);
604        }
605        Ok(self.categories[index].clone())
606    }
607
608    pub fn edit_category_if_unchanged(
609        &mut self,
610        expected: &Category,
611        patch: CategoryPatch,
612    ) -> Result<Category, StoreError> {
613        let current = self
614            .categories
615            .iter()
616            .find(|category| category.id == expected.id)
617            .ok_or_else(|| StoreError::StaleEntity {
618                entity: "category",
619                id: expected.id.clone(),
620            })?;
621        let stale = field_conflicts(patch.name.as_ref(), &current.name, &expected.name)
622            || field_conflicts(
623                patch.description.as_ref(),
624                &current.description,
625                &expected.description,
626            );
627        if stale {
628            return Err(StoreError::StaleEntity {
629                entity: "category",
630                id: expected.id.clone(),
631            });
632        }
633        self.edit_category(&expected.id, patch)
634    }
635
636    /// Delete a category while preserving its tasks as uncategorized.
637    pub fn delete_category(&mut self, id: &str) -> Result<Category, StoreError> {
638        let index = self.category_index(id)?;
639        let category = self.categories.remove(index);
640        for task in &mut self.tasks {
641            if task.category_id.as_deref() == Some(id) {
642                task.category_id = None;
643            }
644        }
645        Ok(category)
646    }
647
648    pub fn move_category(&mut self, id: &str, target: usize) -> Result<(), StoreError> {
649        let index = self.category_index(id)?;
650        if target >= self.categories.len() {
651            return Err(StoreError::validation(format!(
652                "category target index {target} is out of range"
653            )));
654        }
655        let category = self.categories.remove(index);
656        self.categories.insert(target, category);
657        Ok(())
658    }
659
660    pub fn move_category_relative(
661        &mut self,
662        id: &str,
663        target_id: &str,
664        position: RelativePosition,
665    ) -> Result<Category, StoreError> {
666        if id == target_id {
667            return Err(StoreError::validation(
668                "cannot move a category relative to itself",
669            ));
670        }
671        let index = self.category_index(id)?;
672        let target_index = self.category_index(target_id)?;
673        let category = self.categories.remove(index);
674        let target_after_removal = target_index - usize::from(index < target_index);
675        let insertion = match position {
676            RelativePosition::Before => target_after_removal,
677            RelativePosition::After => target_after_removal + 1,
678        };
679        self.categories.insert(insertion, category.clone());
680        Ok(category)
681    }
682
683    pub fn replace_settings(&mut self, settings: Settings) -> Result<Settings, StoreError> {
684        self.update_settings(move |current| *current = settings)
685    }
686
687    pub fn update_settings(
688        &mut self,
689        operation: impl FnOnce(&mut Settings),
690    ) -> Result<Settings, StoreError> {
691        let before = self.settings.clone();
692        operation(&mut self.settings);
693        if let Err(error) = validate_settings(&self.settings) {
694            self.settings = before;
695            return Err(error);
696        }
697        Ok(self.settings.clone())
698    }
699
700    pub(crate) fn validate_as_stored(&mut self) -> Result<(), StoreError> {
701        normalize_and_validate(
702            self,
703            Local::now().naive_local(),
704            DueMode::Stored,
705            AttachmentMode::Persisted,
706        )
707    }
708
709    fn normalize_and_validate_new_write(&mut self) -> Result<(), StoreError> {
710        normalize_and_validate(
711            self,
712            Local::now().naive_local(),
713            DueMode::NewWrite,
714            AttachmentMode::Draft,
715        )
716    }
717}
718
719/// A three-way field merge conflicts only when the remote and desired values
720/// both diverged from the captured base in different directions.
721fn field_conflicts<T: PartialEq>(desired: Option<&T>, current: &T, expected: &T) -> bool {
722    desired.is_some_and(|desired| current != expected && current != desired)
723}
724
725#[derive(Debug, Clone)]
726pub struct Paths {
727    pub dir: PathBuf,
728    pub database: PathBuf,
729    pub tasks: PathBuf,
730    pub categories: PathBuf,
731    pub settings: PathBuf,
732    pub images: PathBuf,
733}
734
735impl Paths {
736    fn new(dir: PathBuf) -> Self {
737        Self {
738            database: dir.join(DATABASE_FILE),
739            tasks: dir.join("tasks.json"),
740            categories: dir.join("categories.json"),
741            settings: dir.join("settings.json"),
742            images: dir.join("images"),
743            dir,
744        }
745    }
746}
747
748pub struct Store {
749    connection: Connection,
750    paths: Paths,
751    persistent_attachments: bool,
752}
753
754impl Store {
755    pub fn open(dir: impl AsRef<Path>) -> Result<Self, StoreError> {
756        let paths = Paths::new(expand_user(dir.as_ref().to_path_buf())?);
757        ensure_private_directory(&paths.dir)?;
758        prepare_private_database_file(&paths.database)?;
759        let mut connection = Connection::open(&paths.database)?;
760        set_private_file(&paths.database)?;
761        connection.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
762        configure_resource_limits(&connection)?;
763        initialize_schema(&mut connection, &paths.database)?;
764        configure_connection(&connection)?;
765        sqlite_quick_check(&connection, "")?;
766        let mut store = Self {
767            connection,
768            paths,
769            persistent_attachments: true,
770        };
771        store.migrate_legacy_json()?;
772        store.reconcile_attachment_storage()?;
773        Ok(store)
774    }
775
776    /// Open an ephemeral store with no filesystem persistence.
777    ///
778    /// `data_dir` is only the logical base for relative image references. The
779    /// directory is not created or modified.
780    pub fn open_in_memory_with_paths(data_dir: impl AsRef<Path>) -> Result<Self, StoreError> {
781        let paths = Paths::new(expand_user(data_dir.as_ref().to_path_buf())?);
782        let mut connection = Connection::open_in_memory()?;
783        connection.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
784        configure_resource_limits(&connection)?;
785        initialize_schema(&mut connection, Path::new(":memory:"))?;
786        configure_in_memory_connection(&connection)?;
787        sqlite_quick_check(&connection, "")?;
788        connection.execute(
789            "INSERT INTO metadata(key, value) VALUES (?1, '1')",
790            [LEGACY_MIGRATION_KEY],
791        )?;
792        Ok(Self {
793            connection,
794            paths,
795            persistent_attachments: false,
796        })
797    }
798
799    pub fn open_default(explicit: Option<PathBuf>) -> Result<Self, StoreError> {
800        Self::open(resolve_data_dir(explicit)?)
801    }
802
803    pub fn paths(&self) -> &Paths {
804        &self.paths
805    }
806
807    pub fn data_dir(&self) -> &Path {
808        &self.paths.dir
809    }
810
811    pub fn images_dir(&self) -> &Path {
812        &self.paths.images
813    }
814
815    pub fn database_path(&self) -> &Path {
816        &self.paths.database
817    }
818
819    /// Cheap external-change probe for a long-running TUI.
820    pub fn revision(&self) -> Result<u64, StoreError> {
821        read_revision(&self.connection)
822    }
823
824    pub fn snapshot(&self) -> Result<StoreData, StoreError> {
825        let tx = self.connection.unchecked_transaction()?;
826        let data = load_snapshot(&tx)?;
827        tx.commit()?;
828        Ok(data)
829    }
830
831    pub fn load_settings(&self) -> Result<Settings, StoreError> {
832        Ok(self.snapshot()?.settings)
833    }
834
835    pub fn save_settings(&mut self, settings: &Settings) -> Result<(), StoreError> {
836        self.update(|data| {
837            data.replace_settings(settings.clone())?;
838            Ok(())
839        })
840    }
841
842    /// Run a read-modify-write against a fresh snapshot under
843    /// `BEGIN IMMEDIATE`. Every successful call increments `revision` once.
844    pub fn update<R>(
845        &mut self,
846        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
847    ) -> Result<R, StoreError> {
848        self.update_with_snapshot(operation)
849            .map(|(result, _)| result)
850    }
851
852    /// Commit a mutation and return the exact normalized snapshot that was
853    /// persisted, including its new revision.
854    pub fn update_with_snapshot<R>(
855        &mut self,
856        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
857    ) -> Result<(R, StoreData), StoreError> {
858        self.update_inner(None, &[], operation)
859    }
860
861    /// Apply a mutation only if the caller's snapshot is still current.
862    pub fn update_if_revision<R>(
863        &mut self,
864        expected_revision: u64,
865        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
866    ) -> Result<R, StoreError> {
867        self.update_if_revision_with_snapshot(expected_revision, operation)
868            .map(|(result, _)| result)
869    }
870
871    pub fn update_if_revision_with_snapshot<R>(
872        &mut self,
873        expected_revision: u64,
874        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
875    ) -> Result<(R, StoreData), StoreError> {
876        self.update_inner(Some(expected_revision), &[], operation)
877    }
878
879    pub(crate) fn update_if_revision_with_staged_attachments<R>(
880        &mut self,
881        expected_revision: u64,
882        staged_attachments: &[StagedAttachment],
883        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
884    ) -> Result<(R, StoreData), StoreError> {
885        self.update_inner(Some(expected_revision), staged_attachments, operation)
886    }
887
888    fn update_inner<R>(
889        &mut self,
890        expected_revision: Option<u64>,
891        staged_attachments: &[StagedAttachment],
892        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
893    ) -> Result<(R, StoreData), StoreError> {
894        let images_root = self
895            .persistent_attachments
896            .then(|| self.paths.images.clone());
897        let tx = self
898            .connection
899            .transaction_with_behavior(TransactionBehavior::Immediate)?;
900        let mut installed_attachments = Vec::new();
901        let prepared = (|| {
902            let before = load_snapshot(&tx)?;
903            let base_revision = before.revision;
904            if let Some(expected) = expected_revision
905                && expected != base_revision
906            {
907                return Err(StoreError::Conflict {
908                    expected,
909                    actual: base_revision,
910                });
911            }
912            let mut data = before.clone();
913            let result = operation(&mut data)?;
914            import_task_description_attachments(
915                &mut data,
916                images_root.as_deref(),
917                staged_attachments,
918                &mut installed_attachments,
919            )?;
920            prune_unreferenced_attachments(&mut data);
921            normalize_and_validate(
922                &mut data,
923                Local::now().naive_local(),
924                DueMode::NewWrite,
925                AttachmentMode::Persisted,
926            )?;
927            let next_revision = base_revision
928                .checked_add(1)
929                .ok_or_else(|| StoreError::Corrupt("revision overflow".into()))?;
930            data.revision = next_revision;
931            persist_diff(&tx, &before, &data)?;
932            Ok((result, data))
933        })();
934
935        let prepared = match prepared {
936            Ok(prepared) => prepared,
937            Err(error) => {
938                let cleanup = remove_installed_attachment_files(&installed_attachments);
939                let rollback = tx.rollback();
940                cleanup?;
941                rollback?;
942                return Err(error);
943            }
944        };
945
946        if let Err(error) = tx.commit() {
947            if let Some(images_root) = images_root.as_deref() {
948                cleanup_attachments_after_failed_commit(
949                    &mut self.connection,
950                    images_root,
951                    &installed_attachments,
952                )?;
953            }
954            return Err(error.into());
955        }
956        let _ = self.cleanup_pending_attachments();
957        Ok(prepared)
958    }
959
960    fn migrate_legacy_json(&mut self) -> Result<(), StoreError> {
961        let images_root = self.paths.images.clone();
962        let tx = self
963            .connection
964            .transaction_with_behavior(TransactionBehavior::Immediate)?;
965        if migration_complete(&tx)? {
966            tx.commit()?;
967            return Ok(());
968        }
969        let mut installed_attachments = Vec::new();
970        let prepared = (|| {
971            let existing = load_snapshot(&tx)?;
972            if !existing.categories.is_empty()
973                || !existing.tasks.is_empty()
974                || !existing.attachments.is_empty()
975                || existing.revision != 0
976            {
977                return Err(StoreError::Corrupt(
978                    "database contains data but has no completed legacy migration marker".into(),
979                ));
980            }
981
982            let categories_file = read_optional_json::<CategoriesFile>(&self.paths.categories)?;
983            let tasks_file = read_optional_json::<TasksFile>(&self.paths.tasks)?;
984            let settings = read_optional_json::<Settings>(&self.paths.settings)?;
985            validate_legacy_schema(
986                &self.paths.categories,
987                categories_file.as_ref().map(|file| file.schema),
988            )?;
989            validate_legacy_schema(
990                &self.paths.tasks,
991                tasks_file.as_ref().map(|file| file.schema),
992            )?;
993
994            let has_legacy =
995                categories_file.is_some() || tasks_file.is_some() || settings.is_some();
996            if has_legacy {
997                let mut data = StoreData {
998                    revision: 1,
999                    categories: categories_file
1000                        .map(|file| {
1001                            file.categories
1002                                .into_iter()
1003                                .filter(|category| !category.is_all())
1004                                .collect()
1005                        })
1006                        .unwrap_or_default(),
1007                    tasks: tasks_file.map(|file| file.tasks).unwrap_or_default(),
1008                    settings: settings.unwrap_or_default().normalized(),
1009                    attachments: Vec::new(),
1010                };
1011                import_task_description_attachments(
1012                    &mut data,
1013                    Some(&images_root),
1014                    &[],
1015                    &mut installed_attachments,
1016                )?;
1017                prune_unreferenced_attachments(&mut data);
1018                // Legacy relative values used the reader's current date/year. Freeze
1019                // that interpretation now so it cannot drift after migration.
1020                normalize_and_validate(
1021                    &mut data,
1022                    Local::now().naive_local(),
1023                    DueMode::LegacyMigration,
1024                    AttachmentMode::Persisted,
1025                )?;
1026                persist_diff(&tx, &existing, &data)?;
1027            }
1028            tx.execute(
1029                "INSERT INTO metadata(key, value) VALUES (?1, '1')",
1030                [LEGACY_MIGRATION_KEY],
1031            )?;
1032            Ok(())
1033        })();
1034
1035        if let Err(error) = prepared {
1036            let cleanup = remove_installed_attachment_files(&installed_attachments);
1037            let rollback = tx.rollback();
1038            cleanup?;
1039            rollback?;
1040            return Err(error);
1041        }
1042        if let Err(error) = tx.commit() {
1043            cleanup_attachments_after_failed_commit(
1044                &mut self.connection,
1045                &images_root,
1046                &installed_attachments,
1047            )?;
1048            return Err(error.into());
1049        }
1050        Ok(())
1051    }
1052
1053    fn cleanup_pending_attachments(&mut self) -> Result<(), StoreError> {
1054        if self.persistent_attachments {
1055            cleanup_pending_attachment_files(&mut self.connection, &self.paths.images)?;
1056        }
1057        Ok(())
1058    }
1059
1060    fn reconcile_attachment_storage(&mut self) -> Result<(), StoreError> {
1061        if self.persistent_attachments {
1062            reconcile_attachment_files(&mut self.connection, &self.paths.images)?;
1063        }
1064        Ok(())
1065    }
1066}
1067
1068pub fn resolve_data_dir(explicit: Option<PathBuf>) -> Result<PathBuf, StoreError> {
1069    resolve_data_dir_from(
1070        explicit,
1071        std::env::var_os("MACH_DIR").map(PathBuf::from),
1072        dirs::home_dir(),
1073    )
1074}
1075
1076fn resolve_data_dir_from(
1077    explicit: Option<PathBuf>,
1078    configured: Option<PathBuf>,
1079    home: Option<PathBuf>,
1080) -> Result<PathBuf, StoreError> {
1081    if let Some(dir) = explicit.or(configured) {
1082        return expand_user_with_home(dir, home.as_deref());
1083    }
1084    home.map(|home| home.join(".mach")).ok_or_else(|| {
1085        StoreError::validation("could not determine the home directory; use --dir or set MACH_DIR")
1086    })
1087}
1088
1089fn expand_user(path: PathBuf) -> Result<PathBuf, StoreError> {
1090    let home = dirs::home_dir();
1091    expand_user_with_home(path, home.as_deref())
1092}
1093
1094fn expand_user_with_home(path: PathBuf, home: Option<&Path>) -> Result<PathBuf, StoreError> {
1095    if path.as_os_str().is_empty() {
1096        return Err(StoreError::validation("data directory cannot be empty"));
1097    }
1098    let text = path.to_string_lossy();
1099    if text == "~" {
1100        return home
1101            .map(Path::to_path_buf)
1102            .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1103    }
1104    if let Some(rest) = text.strip_prefix("~/") {
1105        return home
1106            .map(|home| home.join(rest))
1107            .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1108    }
1109    Ok(path)
1110}
1111
1112pub(crate) fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
1113    connection.pragma_update(None, "foreign_keys", "ON")?;
1114    connection.pragma_update(None, "synchronous", "FULL")?;
1115    let mode: String = connection.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;
1116    if !mode.eq_ignore_ascii_case("wal") {
1117        return Err(StoreError::Corrupt(format!(
1118            "SQLite refused WAL mode (using {mode})"
1119        )));
1120    }
1121    Ok(())
1122}
1123
1124pub(crate) fn configure_resource_limits(connection: &Connection) -> Result<(), StoreError> {
1125    connection.set_limit(Limit::SQLITE_LIMIT_LENGTH, MAX_SQLITE_VALUE_BYTES)?;
1126    Ok(())
1127}
1128
1129fn configure_in_memory_connection(connection: &Connection) -> Result<(), StoreError> {
1130    connection.pragma_update(None, "foreign_keys", "ON")?;
1131    connection.pragma_update(None, "journal_mode", "MEMORY")?;
1132    Ok(())
1133}
1134
1135fn initialize_schema(connection: &mut Connection, path: &Path) -> Result<(), StoreError> {
1136    let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1137    if !matches!(version, 0 | 1 | DATABASE_SCHEMA_VERSION) {
1138        return Err(StoreError::UnsupportedDatabaseSchema {
1139            path: path.to_path_buf(),
1140            found: version,
1141            expected: DATABASE_SCHEMA_VERSION,
1142        });
1143    }
1144
1145    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1146    if version == 1 {
1147        tx.execute_batch(
1148            "ALTER TABLE tasks RENAME COLUMN body_json TO description_json;
1149             DROP INDEX IF EXISTS task_attachments_by_attachment;
1150             ALTER TABLE task_attachments RENAME TO task_description_attachments;
1151             CREATE INDEX task_description_attachments_by_attachment
1152                 ON task_description_attachments(attachment_id);",
1153        )?;
1154    }
1155    tx.execute_batch(
1156        "
1157        CREATE TABLE IF NOT EXISTS metadata (
1158            key TEXT PRIMARY KEY,
1159            value TEXT NOT NULL
1160        ) STRICT;
1161        CREATE TABLE IF NOT EXISTS app_state (
1162            id INTEGER PRIMARY KEY CHECK (id = 1),
1163            revision INTEGER NOT NULL CHECK (revision >= 0),
1164            settings_json TEXT NOT NULL
1165        ) STRICT;
1166        CREATE TABLE IF NOT EXISTS categories (
1167            id TEXT PRIMARY KEY,
1168            position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1169            name TEXT NOT NULL CHECK (length(trim(name)) > 0),
1170            name_key TEXT NOT NULL UNIQUE CHECK (length(name_key) > 0),
1171            description TEXT NOT NULL
1172        ) STRICT;
1173        CREATE TABLE IF NOT EXISTS tasks (
1174            id TEXT PRIMARY KEY,
1175            position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1176            title TEXT NOT NULL CHECK (length(trim(title)) > 0),
1177            description_json TEXT NOT NULL,
1178            due TEXT NOT NULL,
1179            created TEXT NOT NULL,
1180            done INTEGER NOT NULL CHECK (done IN (0, 1)),
1181            importance INTEGER NOT NULL CHECK (importance BETWEEN 0 AND 3),
1182            category_id TEXT REFERENCES categories(id) ON DELETE SET NULL
1183        ) STRICT;
1184        CREATE TABLE IF NOT EXISTS attachments (
1185            id TEXT PRIMARY KEY,
1186            sha256 TEXT NOT NULL UNIQUE CHECK (sha256 = id),
1187            media_type TEXT NOT NULL,
1188            byte_len INTEGER NOT NULL CHECK (byte_len > 0),
1189            storage_name TEXT NOT NULL UNIQUE
1190        ) STRICT;
1191        CREATE TABLE IF NOT EXISTS task_description_attachments (
1192            task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1193            block_index INTEGER NOT NULL CHECK (block_index >= 0),
1194            attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE RESTRICT,
1195            PRIMARY KEY (task_id, block_index)
1196        ) STRICT;
1197        CREATE TABLE IF NOT EXISTS attachment_gc (
1198            storage_name TEXT PRIMARY KEY
1199        ) STRICT;
1200        CREATE INDEX IF NOT EXISTS task_description_attachments_by_attachment
1201            ON task_description_attachments(attachment_id);
1202        ",
1203    )?;
1204    let settings = serde_json::to_string(&Settings::default()).map_err(|source| {
1205        StoreError::Corrupt(format!("could not encode default settings: {source}"))
1206    })?;
1207    tx.execute(
1208        "INSERT OR IGNORE INTO app_state(id, revision, settings_json) VALUES (1, 0, ?1)",
1209        [settings],
1210    )?;
1211    if version != DATABASE_SCHEMA_VERSION {
1212        tx.pragma_update(None, "user_version", DATABASE_SCHEMA_VERSION)?;
1213    }
1214    tx.commit()?;
1215    Ok(())
1216}
1217
1218pub(crate) fn sqlite_quick_check(
1219    connection: &Connection,
1220    error_prefix: &str,
1221) -> Result<(), StoreError> {
1222    let result: String = connection.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?;
1223    if result != "ok" {
1224        return Err(StoreError::Corrupt(format!(
1225            "{error_prefix}SQLite quick check failed: {result}"
1226        )));
1227    }
1228    Ok(())
1229}
1230
1231fn migration_complete(connection: &Connection) -> Result<bool, StoreError> {
1232    let value: Option<String> = connection
1233        .query_row(
1234            "SELECT value FROM metadata WHERE key = ?1",
1235            [LEGACY_MIGRATION_KEY],
1236            |row| row.get(0),
1237        )
1238        .optional()?;
1239    Ok(value.as_deref() == Some("1"))
1240}
1241
1242fn read_revision(connection: &Connection) -> Result<u64, StoreError> {
1243    let value: i64 =
1244        connection.query_row("SELECT revision FROM app_state WHERE id = 1", [], |row| {
1245            row.get(0)
1246        })?;
1247    u64::try_from(value).map_err(|_| StoreError::Corrupt(format!("negative revision {value}")))
1248}
1249
1250fn load_snapshot(connection: &Connection) -> Result<StoreData, StoreError> {
1251    let (revision, settings_json): (i64, String) = connection.query_row(
1252        "SELECT revision, settings_json FROM app_state WHERE id = 1",
1253        [],
1254        |row| Ok((row.get(0)?, row.get(1)?)),
1255    )?;
1256    let revision = u64::try_from(revision)
1257        .map_err(|_| StoreError::Corrupt(format!("negative revision {revision}")))?;
1258    let settings: Settings = serde_json::from_str(&settings_json)
1259        .map_err(|error| StoreError::Corrupt(format!("invalid settings JSON: {error}")))?;
1260
1261    let mut attachment_statement = connection.prepare(
1262        "SELECT id, sha256, media_type, byte_len, storage_name FROM attachments ORDER BY id",
1263    )?;
1264    let attachment_rows = attachment_statement.query_map([], |row| {
1265        Ok((
1266            row.get::<_, String>(0)?,
1267            row.get::<_, String>(1)?,
1268            row.get::<_, String>(2)?,
1269            row.get::<_, i64>(3)?,
1270            row.get::<_, String>(4)?,
1271        ))
1272    })?;
1273    let mut attachments = Vec::new();
1274    for row in attachment_rows {
1275        let (id, sha256, media_type, byte_len, storage_name) = row?;
1276        attachments.push(Attachment {
1277            id,
1278            sha256,
1279            media_type,
1280            byte_len: u64::try_from(byte_len).map_err(|_| {
1281                StoreError::Corrupt(format!("attachment has invalid byte length {byte_len}"))
1282            })?,
1283            storage_name,
1284        });
1285    }
1286
1287    let mut categories_statement = connection.prepare(
1288        "SELECT position, id, name, name_key, description FROM categories ORDER BY position",
1289    )?;
1290    let category_rows = categories_statement.query_map([], |row| {
1291        Ok((
1292            row.get::<_, i64>(0)?,
1293            Category {
1294                id: row.get(1)?,
1295                name: row.get(2)?,
1296                description: row.get(4)?,
1297            },
1298            row.get::<_, String>(3)?,
1299        ))
1300    })?;
1301    let mut categories = Vec::new();
1302    for (expected_position, row) in category_rows.enumerate() {
1303        if expected_position >= MAX_CATEGORY_COUNT {
1304            return Err(StoreError::Corrupt(format!(
1305                "category count exceeds {MAX_CATEGORY_COUNT}"
1306            )));
1307        }
1308        let (stored_position, category, stored_name_key) = row?;
1309        validate_stored_position(stored_position, expected_position, "category")?;
1310        let expected_name_key = category_name_key(&category.name);
1311        if stored_name_key != expected_name_key {
1312            return Err(StoreError::Corrupt(format!(
1313                "category {:?} has identity key {stored_name_key:?}, expected {expected_name_key:?}",
1314                category.id
1315            )));
1316        }
1317        categories.push(category);
1318    }
1319
1320    let mut tasks_statement = connection.prepare(
1321        "SELECT position, id, title, description_json, due, created, done, importance, category_id
1322         FROM tasks ORDER BY position",
1323    )?;
1324    let rows = tasks_statement.query_map([], |row| {
1325        Ok((
1326            row.get::<_, i64>(0)?,
1327            row.get::<_, String>(1)?,
1328            row.get::<_, String>(2)?,
1329            row.get::<_, String>(3)?,
1330            row.get::<_, String>(4)?,
1331            row.get::<_, String>(5)?,
1332            row.get::<_, i64>(6)?,
1333            row.get::<_, i64>(7)?,
1334            row.get::<_, Option<String>>(8)?,
1335        ))
1336    })?;
1337    let mut tasks = Vec::new();
1338    for (expected_position, row) in rows.enumerate() {
1339        if expected_position >= MAX_TASK_COUNT {
1340            return Err(StoreError::Corrupt(format!(
1341                "task count exceeds {MAX_TASK_COUNT}"
1342            )));
1343        }
1344        let (
1345            stored_position,
1346            id,
1347            title,
1348            description_json,
1349            due,
1350            created,
1351            done,
1352            importance,
1353            category_id,
1354        ) = row?;
1355        validate_stored_position(stored_position, expected_position, "task")?;
1356        let description =
1357            serde_json::from_str::<Vec<Block>>(&description_json).map_err(|error| {
1358                StoreError::Corrupt(format!("task {id:?} has invalid description JSON: {error}"))
1359            })?;
1360        let importance = u8::try_from(importance).map_err(|_| {
1361            StoreError::Corrupt(format!("task {id:?} has invalid importance {importance}"))
1362        })?;
1363        tasks.push(Task {
1364            id,
1365            title,
1366            description,
1367            due,
1368            created,
1369            done: done != 0,
1370            importance,
1371            category_id,
1372        });
1373    }
1374    validate_task_attachment_rows(connection, &tasks)?;
1375    let mut data = StoreData {
1376        revision,
1377        categories,
1378        tasks,
1379        settings,
1380        attachments,
1381    };
1382    data.validate_as_stored().map_err(|error| match error {
1383        StoreError::Validation(message) => StoreError::Corrupt(message),
1384        other => other,
1385    })?;
1386    Ok(data)
1387}
1388
1389fn validate_task_attachment_rows(
1390    connection: &Connection,
1391    tasks: &[Task],
1392) -> Result<(), StoreError> {
1393    let expected: HashSet<(String, usize, String)> = tasks
1394        .iter()
1395        .flat_map(|task| {
1396            task.description
1397                .iter()
1398                .enumerate()
1399                .filter_map(|(block_index, block)| match block {
1400                    Block::Image { attachment_id } => {
1401                        Some((task.id.clone(), block_index, attachment_id.clone()))
1402                    }
1403                    _ => None,
1404                })
1405        })
1406        .collect();
1407    let mut statement = connection.prepare(
1408        "SELECT task_id, block_index, attachment_id
1409         FROM task_description_attachments ORDER BY task_id, block_index",
1410    )?;
1411    let rows = statement.query_map([], |row| {
1412        Ok((
1413            row.get::<_, String>(0)?,
1414            row.get::<_, i64>(1)?,
1415            row.get::<_, String>(2)?,
1416        ))
1417    })?;
1418    let mut stored = HashSet::new();
1419    for row in rows {
1420        let (task_id, block_index, attachment_id) = row?;
1421        let block_index = usize::try_from(block_index).map_err(|_| {
1422            StoreError::Corrupt(format!(
1423                "task {task_id:?} has invalid attachment reference index {block_index}"
1424            ))
1425        })?;
1426        stored.insert((task_id, block_index, attachment_id));
1427    }
1428    if stored != expected {
1429        return Err(StoreError::Corrupt(
1430            "task attachment reference rows do not match task description JSON".into(),
1431        ));
1432    }
1433    Ok(())
1434}
1435
1436fn validate_stored_position(stored: i64, expected: usize, entity: &str) -> Result<(), StoreError> {
1437    let expected = i64::try_from(expected)
1438        .map_err(|_| StoreError::Corrupt(format!("{entity} position exceeds integer range")))?;
1439    if stored != expected {
1440        return Err(StoreError::Corrupt(format!(
1441            "{entity} position {stored} is not contiguous (expected {expected})"
1442        )));
1443    }
1444    Ok(())
1445}
1446
1447/// Persist only rows whose identity, content, or position changed.
1448///
1449/// Positions and category names have UNIQUE constraints. Rows that move or
1450/// change names are first assigned transaction-private values outside the
1451/// validated application domain, which makes swaps and insertions safe without
1452/// deleting and recreating unrelated rows.
1453fn persist_diff(
1454    tx: &Transaction<'_>,
1455    before: &StoreData,
1456    after: &StoreData,
1457) -> Result<(), StoreError> {
1458    let before_categories: HashMap<&str, (usize, &Category)> = before
1459        .categories
1460        .iter()
1461        .enumerate()
1462        .map(|(position, category)| (category.id.as_str(), (position, category)))
1463        .collect();
1464    let after_categories: HashMap<&str, (usize, &Category)> = after
1465        .categories
1466        .iter()
1467        .enumerate()
1468        .map(|(position, category)| (category.id.as_str(), (position, category)))
1469        .collect();
1470    let before_tasks: HashMap<&str, (usize, &Task)> = before
1471        .tasks
1472        .iter()
1473        .enumerate()
1474        .map(|(position, task)| (task.id.as_str(), (position, task)))
1475        .collect();
1476    let after_tasks: HashMap<&str, (usize, &Task)> = after
1477        .tasks
1478        .iter()
1479        .enumerate()
1480        .map(|(position, task)| (task.id.as_str(), (position, task)))
1481        .collect();
1482    let before_attachments: HashMap<&str, &Attachment> = before
1483        .attachments
1484        .iter()
1485        .map(|attachment| (attachment.id.as_str(), attachment))
1486        .collect();
1487    let after_attachments: HashMap<&str, &Attachment> = after
1488        .attachments
1489        .iter()
1490        .map(|attachment| (attachment.id.as_str(), attachment))
1491        .collect();
1492
1493    for attachment in &before.attachments {
1494        if after_attachments
1495            .get(attachment.id.as_str())
1496            .is_some_and(|current| *current != attachment)
1497        {
1498            return Err(StoreError::Validation(format!(
1499                "attachment {:?} metadata is immutable",
1500                attachment.id
1501            )));
1502        }
1503    }
1504    for attachment in &after.attachments {
1505        if !before_attachments.contains_key(attachment.id.as_str()) {
1506            tx.execute(
1507                "INSERT INTO attachments(id, sha256, media_type, byte_len, storage_name)
1508                 VALUES (?1, ?2, ?3, ?4, ?5)",
1509                params![
1510                    attachment.id,
1511                    attachment.sha256,
1512                    attachment.media_type,
1513                    sqlite_attachment_size(attachment.byte_len)?,
1514                    attachment.storage_name,
1515                ],
1516            )?;
1517            tx.execute(
1518                "DELETE FROM attachment_gc WHERE storage_name = ?1",
1519                [&attachment.storage_name],
1520            )?;
1521        }
1522    }
1523
1524    // Remove tasks first so deleting a task and its category does not produce
1525    // an unnecessary ON DELETE SET NULL update.
1526    for task in &before.tasks {
1527        if !after_tasks.contains_key(task.id.as_str()) {
1528            execute_one(
1529                tx,
1530                "DELETE FROM tasks WHERE id = ?1",
1531                [task.id.as_str()],
1532                "task",
1533                &task.id,
1534            )?;
1535        }
1536    }
1537
1538    // Free every old identity key that may be replaced. Control characters are
1539    // rejected by validation, so these temporary values cannot collide with
1540    // application data and are never visible outside this transaction.
1541    let mut temporary_name_index = 0usize;
1542    for category in &before.categories {
1543        let name_changed_or_removed = after_categories
1544            .get(category.id.as_str())
1545            .is_none_or(|(_, current)| current.name != category.name);
1546        if name_changed_or_removed {
1547            let temporary_name = format!("\u{1f}mach-category-{temporary_name_index}");
1548            temporary_name_index += 1;
1549            execute_one(
1550                tx,
1551                "UPDATE categories SET name = ?1, name_key = ?1 WHERE id = ?2",
1552                params![temporary_name, category.id],
1553                "category",
1554                &category.id,
1555            )?;
1556        }
1557    }
1558
1559    let category_position_base = before.categories.len().max(after.categories.len());
1560    let mut category_position_offset = 0usize;
1561    for (old_position, category) in before.categories.iter().enumerate() {
1562        if let Some((new_position, _)) = after_categories.get(category.id.as_str()).copied()
1563            && new_position != old_position
1564        {
1565            let temporary = temporary_position(
1566                category_position_base,
1567                category_position_offset,
1568                "categories",
1569            )?;
1570            category_position_offset += 1;
1571            execute_one(
1572                tx,
1573                "UPDATE categories SET position = ?1 WHERE id = ?2",
1574                params![temporary, category.id],
1575                "category",
1576                &category.id,
1577            )?;
1578        }
1579    }
1580    for category in &after.categories {
1581        if !before_categories.contains_key(category.id.as_str()) {
1582            let temporary = temporary_position(
1583                category_position_base,
1584                category_position_offset,
1585                "categories",
1586            )?;
1587            category_position_offset += 1;
1588            tx.execute(
1589                "INSERT INTO categories(id, position, name, name_key, description)
1590                 VALUES (?1, ?2, ?3, ?4, ?5)",
1591                params![
1592                    category.id,
1593                    temporary,
1594                    category.name,
1595                    category_name_key(&category.name),
1596                    category.description
1597                ],
1598            )?;
1599        }
1600    }
1601
1602    let task_position_base = before.tasks.len().max(after.tasks.len());
1603    let mut task_position_offset = 0usize;
1604    for (old_position, task) in before.tasks.iter().enumerate() {
1605        if let Some((new_position, _)) = after_tasks.get(task.id.as_str()).copied()
1606            && new_position != old_position
1607        {
1608            let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1609            task_position_offset += 1;
1610            execute_one(
1611                tx,
1612                "UPDATE tasks SET position = ?1 WHERE id = ?2",
1613                params![temporary, task.id],
1614                "task",
1615                &task.id,
1616            )?;
1617        }
1618    }
1619
1620    // New categories now exist, so task foreign keys can safely move to them
1621    // before obsolete categories are deleted.
1622    for task in &after.tasks {
1623        if let Some((_, previous)) = before_tasks.get(task.id.as_str()).copied()
1624            && previous != task
1625        {
1626            if previous.description == task.description {
1627                execute_one(
1628                    tx,
1629                    "UPDATE tasks SET
1630                        title = ?1, due = ?2, created = ?3, done = ?4,
1631                        importance = ?5, category_id = ?6
1632                     WHERE id = ?7",
1633                    params![
1634                        task.title,
1635                        task.due,
1636                        task.created,
1637                        i64::from(task.done),
1638                        i64::from(task.importance),
1639                        task.category_id,
1640                        task.id,
1641                    ],
1642                    "task",
1643                    &task.id,
1644                )?;
1645            } else {
1646                let description_json = encode_task_description(task)?;
1647                execute_one(
1648                    tx,
1649                    "UPDATE tasks SET
1650                        title = ?1, description_json = ?2, due = ?3, created = ?4,
1651                        done = ?5, importance = ?6, category_id = ?7
1652                     WHERE id = ?8",
1653                    params![
1654                        task.title,
1655                        description_json,
1656                        task.due,
1657                        task.created,
1658                        i64::from(task.done),
1659                        i64::from(task.importance),
1660                        task.category_id,
1661                        task.id,
1662                    ],
1663                    "task",
1664                    &task.id,
1665                )?;
1666            }
1667        }
1668    }
1669    for task in &after.tasks {
1670        if !before_tasks.contains_key(task.id.as_str()) {
1671            let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1672            task_position_offset += 1;
1673            let description_json = encode_task_description(task)?;
1674            tx.execute(
1675                "INSERT INTO tasks(
1676                    id, position, title, description_json, due, created, done, importance, category_id
1677                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1678                params![
1679                    task.id,
1680                    temporary,
1681                    task.title,
1682                    description_json,
1683                    task.due,
1684                    task.created,
1685                    i64::from(task.done),
1686                    i64::from(task.importance),
1687                    task.category_id,
1688                ],
1689            )?;
1690        }
1691    }
1692
1693    for task in &after.tasks {
1694        let description_changed_or_new = before_tasks
1695            .get(task.id.as_str())
1696            .is_none_or(|(_, previous)| previous.description != task.description);
1697        if description_changed_or_new {
1698            tx.execute(
1699                "DELETE FROM task_description_attachments WHERE task_id = ?1",
1700                [&task.id],
1701            )?;
1702            insert_task_attachment_rows(tx, task)?;
1703        }
1704    }
1705
1706    for attachment in &before.attachments {
1707        if !after_attachments.contains_key(attachment.id.as_str()) {
1708            tx.execute(
1709                "INSERT OR IGNORE INTO attachment_gc(storage_name) VALUES (?1)",
1710                [&attachment.storage_name],
1711            )?;
1712            execute_one(
1713                tx,
1714                "DELETE FROM attachments WHERE id = ?1",
1715                [attachment.id.as_str()],
1716                "attachment",
1717                &attachment.id,
1718            )?;
1719        }
1720    }
1721
1722    for category in &before.categories {
1723        if !after_categories.contains_key(category.id.as_str()) {
1724            execute_one(
1725                tx,
1726                "DELETE FROM categories WHERE id = ?1",
1727                [category.id.as_str()],
1728                "category",
1729                &category.id,
1730            )?;
1731        }
1732    }
1733
1734    for category in &after.categories {
1735        if let Some((_, previous)) = before_categories.get(category.id.as_str()).copied()
1736            && previous != category
1737        {
1738            execute_one(
1739                tx,
1740                "UPDATE categories
1741                 SET name = ?1, name_key = ?2, description = ?3
1742                 WHERE id = ?4",
1743                params![
1744                    category.name,
1745                    category_name_key(&category.name),
1746                    category.description,
1747                    category.id
1748                ],
1749                "category",
1750                &category.id,
1751            )?;
1752        }
1753    }
1754
1755    // All rows whose final slots changed are currently at unique temporary
1756    // positions. Rows omitted here kept the same slot, so final assignment
1757    // cannot collide with them.
1758    for (position, category) in after.categories.iter().enumerate() {
1759        let moved_or_new = before_categories
1760            .get(category.id.as_str())
1761            .is_none_or(|(old_position, _)| *old_position != position);
1762        if moved_or_new {
1763            let position = sqlite_position(position, "categories")?;
1764            execute_one(
1765                tx,
1766                "UPDATE categories SET position = ?1 WHERE id = ?2",
1767                params![position, category.id],
1768                "category",
1769                &category.id,
1770            )?;
1771        }
1772    }
1773    for (position, task) in after.tasks.iter().enumerate() {
1774        let moved_or_new = before_tasks
1775            .get(task.id.as_str())
1776            .is_none_or(|(old_position, _)| *old_position != position);
1777        if moved_or_new {
1778            let position = sqlite_position(position, "tasks")?;
1779            execute_one(
1780                tx,
1781                "UPDATE tasks SET position = ?1 WHERE id = ?2",
1782                params![position, task.id],
1783                "task",
1784                &task.id,
1785            )?;
1786        }
1787    }
1788
1789    let settings = (before.settings != after.settings).then_some(&after.settings);
1790    persist_app_state(tx, after.revision, settings)?;
1791    Ok(())
1792}
1793
1794fn execute_one<P: rusqlite::Params>(
1795    tx: &Transaction<'_>,
1796    sql: &str,
1797    params: P,
1798    entity: &str,
1799    id: &str,
1800) -> Result<(), StoreError> {
1801    let changed = tx.execute(sql, params)?;
1802    if changed != 1 {
1803        return Err(StoreError::Corrupt(format!(
1804            "expected to change one {entity} {id:?}, changed {changed}"
1805        )));
1806    }
1807    Ok(())
1808}
1809
1810fn temporary_position(base: usize, offset: usize, entity: &str) -> Result<i64, StoreError> {
1811    let position = base
1812        .checked_add(offset)
1813        .ok_or_else(|| StoreError::Validation(format!("too many {entity}")))?;
1814    sqlite_position(position, entity)
1815}
1816
1817fn sqlite_position(position: usize, entity: &str) -> Result<i64, StoreError> {
1818    i64::try_from(position).map_err(|_| StoreError::Validation(format!("too many {entity}")))
1819}
1820
1821fn sqlite_attachment_size(byte_len: u64) -> Result<i64, StoreError> {
1822    i64::try_from(byte_len)
1823        .map_err(|_| StoreError::Validation("attachment byte length exceeds integer range".into()))
1824}
1825
1826fn insert_task_attachment_rows(tx: &Transaction<'_>, task: &Task) -> Result<(), StoreError> {
1827    let mut statement = tx.prepare(
1828        "INSERT INTO task_description_attachments(task_id, block_index, attachment_id)
1829         VALUES (?1, ?2, ?3)",
1830    )?;
1831    for (block_index, block) in task.description.iter().enumerate() {
1832        let Block::Image { attachment_id } = block else {
1833            continue;
1834        };
1835        statement.execute(params![
1836            task.id,
1837            sqlite_position(block_index, "task attachment blocks")?,
1838            attachment_id,
1839        ])?;
1840    }
1841    Ok(())
1842}
1843
1844fn encode_task_description(task: &Task) -> Result<String, StoreError> {
1845    serde_json::to_string(&task.description).map_err(|error| {
1846        StoreError::Corrupt(format!("could not encode task {:?}: {error}", task.id))
1847    })
1848}
1849
1850fn persist_app_state(
1851    tx: &Transaction<'_>,
1852    revision: u64,
1853    settings: Option<&Settings>,
1854) -> Result<(), StoreError> {
1855    let revision = i64::try_from(revision)
1856        .map_err(|_| StoreError::Corrupt("revision exceeds SQLite integer range".into()))?;
1857    let changed = if let Some(settings) = settings {
1858        let settings_json = serde_json::to_string(settings)
1859            .map_err(|error| StoreError::Corrupt(format!("could not encode settings: {error}")))?;
1860        tx.execute(
1861            "UPDATE app_state SET revision = ?1, settings_json = ?2 WHERE id = 1",
1862            params![revision, settings_json],
1863        )?
1864    } else {
1865        tx.execute(
1866            "UPDATE app_state SET revision = ?1 WHERE id = 1",
1867            [revision],
1868        )?
1869    };
1870    if changed != 1 {
1871        return Err(StoreError::Corrupt(format!(
1872            "expected to update app state, changed {changed} rows"
1873        )));
1874    }
1875    Ok(())
1876}
1877
1878fn import_task_description_attachments(
1879    data: &mut StoreData,
1880    images_root: Option<&Path>,
1881    staged_attachments: &[StagedAttachment],
1882    installed_attachments: &mut Vec<InstalledAttachmentFile>,
1883) -> Result<(), StoreError> {
1884    let mut known: HashMap<String, usize> = data
1885        .attachments
1886        .iter()
1887        .enumerate()
1888        .map(|(index, attachment)| (attachment.id.clone(), index))
1889        .collect();
1890    let mut staged_by_id = HashMap::with_capacity(staged_attachments.len());
1891    for staged in staged_attachments {
1892        if staged_by_id
1893            .insert(staged.metadata.id.as_str(), staged)
1894            .is_some()
1895        {
1896            return Err(StoreError::Validation(format!(
1897                "staged attachment {:?} is duplicated",
1898                staged.metadata.id
1899            )));
1900        }
1901    }
1902
1903    for task in &mut data.tasks {
1904        let mut owner = None;
1905        for block in &mut task.description {
1906            let Block::Image { attachment_id } = block else {
1907                continue;
1908            };
1909            if known.contains_key(attachment_id) {
1910                continue;
1911            }
1912            let owner = owner.get_or_insert_with(|| format!("task {:?}", task.id));
1913            *attachment_id = import_attachment_reference(
1914                attachment_id,
1915                owner,
1916                images_root,
1917                &staged_by_id,
1918                &mut known,
1919                &mut data.attachments,
1920                installed_attachments,
1921            )?;
1922        }
1923    }
1924    data.attachments
1925        .sort_by(|left, right| left.id.cmp(&right.id));
1926    Ok(())
1927}
1928
1929fn import_attachment_reference(
1930    reference: &str,
1931    owner: &str,
1932    images_root: Option<&Path>,
1933    staged_by_id: &HashMap<&str, &StagedAttachment>,
1934    known: &mut HashMap<String, usize>,
1935    attachments: &mut Vec<Attachment>,
1936    installed_attachments: &mut Vec<InstalledAttachmentFile>,
1937) -> Result<String, StoreError> {
1938    let Some(images_root) = images_root else {
1939        return Err(StoreError::Validation(
1940            "image attachments require a persistent store".into(),
1941        ));
1942    };
1943    let (source_path, expected) = if is_attachment_id(reference) {
1944        let staged = staged_by_id.get(reference).ok_or_else(|| {
1945            StoreError::Validation(format!(
1946                "{owner} refers to unknown attachment {reference:?}"
1947            ))
1948        })?;
1949        (staged.path.clone(), Some(&staged.metadata))
1950    } else {
1951        (crate::image::expand_in(reference, images_root), None)
1952    };
1953    let ImportedAttachmentFile {
1954        metadata,
1955        installed,
1956    } = import_attachment_from_path(&source_path, images_root)?;
1957    if let Some(installed) = installed {
1958        installed_attachments.push(installed);
1959    }
1960    if expected.is_some_and(|expected| expected != &metadata) {
1961        return Err(StoreError::Validation(format!(
1962            "staged attachment {reference:?} does not match its verified metadata"
1963        )));
1964    }
1965    if let Some(&index) = known.get(&metadata.id) {
1966        if attachments[index] != metadata {
1967            return Err(StoreError::Corrupt(format!(
1968                "attachment {:?} metadata does not match imported content",
1969                metadata.id
1970            )));
1971        }
1972        return Ok(metadata.id);
1973    }
1974    let id = metadata.id.clone();
1975    known.insert(id.clone(), attachments.len());
1976    attachments.push(metadata);
1977    Ok(id)
1978}
1979
1980#[derive(Debug, Clone)]
1981struct InstalledAttachmentFile {
1982    id: String,
1983    path: PathBuf,
1984}
1985
1986struct ImportedAttachmentFile {
1987    metadata: Attachment,
1988    installed: Option<InstalledAttachmentFile>,
1989}
1990
1991fn import_attachment_from_path(
1992    source_path: &Path,
1993    images_root: &Path,
1994) -> Result<ImportedAttachmentFile, StoreError> {
1995    let mut source = fs::File::open(source_path)
1996        .map_err(|error| StoreError::io("open image attachment", source_path, error))?;
1997    let metadata = source
1998        .metadata()
1999        .map_err(|error| StoreError::io("inspect image attachment", source_path, error))?;
2000    if !metadata.is_file() {
2001        return Err(StoreError::Validation(format!(
2002            "image attachment {} is not a regular file",
2003            source_path.display()
2004        )));
2005    }
2006
2007    ensure_private_directory(images_root)?;
2008    let temp_path = images_root.join(format!(".mach-attachment-{}.tmp", uuid::Uuid::new_v4()));
2009    let mut temp = open_private_attachment_temp(&temp_path)?;
2010    let mut installed_path = None;
2011    let result = (|| {
2012        let mut hasher = Sha256::new();
2013        let mut byte_len = 0_u64;
2014        let mut prefix = [0_u8; 32];
2015        let mut prefix_len = 0usize;
2016        let mut buffer = [0_u8; 64 * 1024];
2017        loop {
2018            let read = source
2019                .read(&mut buffer)
2020                .map_err(|error| StoreError::io("read image attachment", source_path, error))?;
2021            if read == 0 {
2022                break;
2023            }
2024            byte_len = byte_len
2025                .checked_add(read as u64)
2026                .ok_or_else(|| StoreError::Validation("image attachment is too large".into()))?;
2027            if byte_len > MAX_ATTACHMENT_BYTES {
2028                return Err(StoreError::Validation(format!(
2029                    "image attachment {} exceeds the {} MiB safety limit",
2030                    source_path.display(),
2031                    MAX_ATTACHMENT_BYTES / 1024 / 1024
2032                )));
2033            }
2034            if prefix_len < prefix.len() {
2035                let copy = (prefix.len() - prefix_len).min(read);
2036                prefix[prefix_len..prefix_len + copy].copy_from_slice(&buffer[..copy]);
2037                prefix_len += copy;
2038            }
2039            hasher.update(&buffer[..read]);
2040            temp.write_all(&buffer[..read]).map_err(|error| {
2041                StoreError::io("write managed image attachment", &temp_path, error)
2042            })?;
2043        }
2044        if byte_len == 0 {
2045            return Err(StoreError::Validation(format!(
2046                "image attachment {} is empty",
2047                source_path.display()
2048            )));
2049        }
2050        let format = image::guess_format(&prefix[..prefix_len]).map_err(|_| {
2051            StoreError::Validation(format!(
2052                "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
2053                source_path.display()
2054            ))
2055        })?;
2056        let format = crate::image::managed_attachment_format(format).ok_or_else(|| {
2057            StoreError::Validation(format!(
2058                "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
2059                source_path.display()
2060            ))
2061        })?;
2062        temp.sync_all()
2063            .map_err(|error| StoreError::io("sync managed image attachment", &temp_path, error))?;
2064        drop(temp);
2065        crate::image::load_dynamic(&temp_path).map_err(StoreError::Validation)?;
2066
2067        let id = format!("{:x}", hasher.finalize());
2068        let storage_name = format!("{id}.{}", format.extension);
2069        let destination = images_root.join(&storage_name);
2070        if destination.exists() {
2071            let (stored_hash, stored_len) = hash_attachment_file(&destination)?;
2072            if stored_hash != id || stored_len != byte_len {
2073                return Err(StoreError::Corrupt(format!(
2074                    "managed attachment {} does not match its content address",
2075                    destination.display()
2076                )));
2077            }
2078            fs::remove_file(&temp_path).map_err(|error| {
2079                StoreError::io("remove duplicate image attachment", &temp_path, error)
2080            })?;
2081        } else {
2082            fs::rename(&temp_path, &destination).map_err(|error| {
2083                StoreError::io("install managed image attachment", &destination, error)
2084            })?;
2085            installed_path = Some(destination.clone());
2086            set_private_file(&destination)?;
2087            fs::File::open(images_root)
2088                .and_then(|directory| directory.sync_all())
2089                .map_err(|error| StoreError::io("sync image directory", images_root, error))?;
2090        }
2091        Ok(Attachment {
2092            id: id.clone(),
2093            sha256: id,
2094            media_type: format.media_type.into(),
2095            byte_len,
2096            storage_name,
2097        })
2098    })();
2099    if result.is_err() {
2100        let _ = fs::remove_file(&temp_path);
2101        if let Some(path) = installed_path.as_deref() {
2102            let _ = fs::remove_file(path);
2103        }
2104    }
2105    let metadata = result?;
2106    let installed = installed_path.map(|path| InstalledAttachmentFile {
2107        id: metadata.id.clone(),
2108        path,
2109    });
2110    Ok(ImportedAttachmentFile {
2111        metadata,
2112        installed,
2113    })
2114}
2115
2116fn open_private_attachment_temp(path: &Path) -> Result<fs::File, StoreError> {
2117    let mut options = fs::OpenOptions::new();
2118    options.write(true).create_new(true);
2119    #[cfg(unix)]
2120    {
2121        use std::os::unix::fs::OpenOptionsExt;
2122        options.mode(0o600);
2123    }
2124    options
2125        .open(path)
2126        .map_err(|error| StoreError::io("create managed image attachment", path, error))
2127}
2128
2129fn hash_attachment_file(path: &Path) -> Result<(String, u64), StoreError> {
2130    let mut file = fs::File::open(path)
2131        .map_err(|error| StoreError::io("open managed image attachment", path, error))?;
2132    let mut hasher = Sha256::new();
2133    let mut byte_len = 0_u64;
2134    let mut buffer = [0_u8; 64 * 1024];
2135    loop {
2136        let read = file
2137            .read(&mut buffer)
2138            .map_err(|error| StoreError::io("read managed image attachment", path, error))?;
2139        if read == 0 {
2140            break;
2141        }
2142        byte_len = byte_len
2143            .checked_add(read as u64)
2144            .ok_or_else(|| StoreError::Corrupt("managed attachment is too large".into()))?;
2145        if byte_len > MAX_ATTACHMENT_BYTES {
2146            return Err(StoreError::Corrupt(format!(
2147                "managed attachment {} exceeds the safety limit",
2148                path.display()
2149            )));
2150        }
2151        hasher.update(&buffer[..read]);
2152    }
2153    Ok((format!("{:x}", hasher.finalize()), byte_len))
2154}
2155
2156pub(crate) fn is_attachment_id(value: &str) -> bool {
2157    value.len() == ATTACHMENT_ID_LEN
2158        && value
2159            .bytes()
2160            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2161}
2162
2163pub(crate) fn is_managed_attachment_name(value: &str) -> bool {
2164    let Some((id, extension)) = value.rsplit_once('.') else {
2165        return false;
2166    };
2167    is_attachment_id(id) && crate::image::is_managed_attachment_extension(extension)
2168}
2169
2170fn is_managed_attachment_temp_name(value: &str) -> bool {
2171    value
2172        .strip_prefix(".mach-attachment-")
2173        .and_then(|value| value.strip_suffix(".tmp"))
2174        .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok())
2175}
2176
2177fn prune_unreferenced_attachments(data: &mut StoreData) {
2178    let referenced: HashSet<_> = data
2179        .tasks
2180        .iter()
2181        .flat_map(|task| {
2182            task.description.iter().filter_map(|block| match block {
2183                Block::Image { attachment_id } => Some(attachment_id.as_str()),
2184                _ => None,
2185            })
2186        })
2187        .collect();
2188    data.attachments
2189        .retain(|attachment| referenced.contains(attachment.id.as_str()));
2190}
2191
2192fn remove_managed_attachment_file(path: &Path) -> Result<bool, StoreError> {
2193    match fs::remove_file(path) {
2194        Ok(()) => Ok(true),
2195        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
2196        Err(error) => Err(StoreError::io(
2197            "remove managed image attachment",
2198            path,
2199            error,
2200        )),
2201    }
2202}
2203
2204fn sync_images_directory(images_root: &Path) -> Result<(), StoreError> {
2205    if !images_root.exists() {
2206        return Ok(());
2207    }
2208    fs::File::open(images_root)
2209        .and_then(|directory| directory.sync_all())
2210        .map_err(|error| StoreError::io("sync image directory", images_root, error))
2211}
2212
2213fn remove_installed_attachment_files(
2214    installed_attachments: &[InstalledAttachmentFile],
2215) -> Result<(), StoreError> {
2216    let mut directory = None;
2217    let mut removed = false;
2218    for attachment in installed_attachments {
2219        removed |= remove_managed_attachment_file(&attachment.path)?;
2220        directory = attachment.path.parent();
2221    }
2222    if removed && let Some(directory) = directory {
2223        sync_images_directory(directory)?;
2224    }
2225    Ok(())
2226}
2227
2228fn cleanup_attachments_after_failed_commit(
2229    connection: &mut Connection,
2230    images_root: &Path,
2231    installed_attachments: &[InstalledAttachmentFile],
2232) -> Result<(), StoreError> {
2233    if installed_attachments.is_empty() {
2234        return Ok(());
2235    }
2236    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2237    let mut unowned = Vec::new();
2238    for attachment in installed_attachments {
2239        let owned: bool = tx.query_row(
2240            "SELECT EXISTS(SELECT 1 FROM attachments WHERE id = ?1)",
2241            [&attachment.id],
2242            |row| row.get(0),
2243        )?;
2244        if !owned {
2245            unowned.push(attachment.clone());
2246        }
2247    }
2248    remove_installed_attachment_files(&unowned)?;
2249    tx.commit()?;
2250    sync_images_directory(images_root)?;
2251    Ok(())
2252}
2253
2254fn cleanup_pending_attachment_files(
2255    connection: &mut Connection,
2256    images_root: &Path,
2257) -> Result<(), StoreError> {
2258    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2259    let pending = {
2260        let mut statement =
2261            tx.prepare("SELECT storage_name FROM attachment_gc ORDER BY storage_name")?;
2262        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
2263        rows.collect::<Result<Vec<_>, _>>()?
2264    };
2265    let mut removed = false;
2266    for storage_name in pending {
2267        if !is_managed_attachment_name(&storage_name) {
2268            return Err(StoreError::Corrupt(format!(
2269                "attachment cleanup entry has invalid storage name {storage_name:?}"
2270            )));
2271        }
2272        let owned: bool = tx.query_row(
2273            "SELECT EXISTS(SELECT 1 FROM attachments WHERE storage_name = ?1)",
2274            [&storage_name],
2275            |row| row.get(0),
2276        )?;
2277        if !owned {
2278            removed |= remove_managed_attachment_file(&images_root.join(&storage_name))?;
2279        }
2280        tx.execute(
2281            "DELETE FROM attachment_gc WHERE storage_name = ?1",
2282            [&storage_name],
2283        )?;
2284    }
2285    if removed {
2286        sync_images_directory(images_root)?;
2287    }
2288    tx.commit()?;
2289    Ok(())
2290}
2291
2292fn reconcile_attachment_files(
2293    connection: &mut Connection,
2294    images_root: &Path,
2295) -> Result<(), StoreError> {
2296    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2297    let unreferenced = {
2298        let mut statement = tx.prepare(
2299            "SELECT attachments.id, attachments.storage_name
2300             FROM attachments
2301             WHERE NOT EXISTS (
2302                 SELECT 1 FROM task_description_attachments
2303                 WHERE task_description_attachments.attachment_id = attachments.id
2304             )
2305             ORDER BY attachments.id",
2306        )?;
2307        let rows = statement.query_map([], |row| {
2308            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2309        })?;
2310        rows.collect::<Result<Vec<_>, _>>()?
2311    };
2312    for (id, storage_name) in unreferenced {
2313        if !is_managed_attachment_name(&storage_name)
2314            || !storage_name.starts_with(&format!("{id}."))
2315        {
2316            return Err(StoreError::Corrupt(format!(
2317                "attachment {id:?} has invalid storage name {storage_name:?}"
2318            )));
2319        }
2320        tx.execute(
2321            "INSERT OR IGNORE INTO attachment_gc(storage_name) VALUES (?1)",
2322            [&storage_name],
2323        )?;
2324        tx.execute("DELETE FROM attachments WHERE id = ?1", [&id])?;
2325    }
2326
2327    let owned = {
2328        let mut statement = tx.prepare("SELECT storage_name FROM attachments")?;
2329        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
2330        rows.collect::<Result<HashSet<_>, _>>()?
2331    };
2332    if let Some(invalid) = owned
2333        .iter()
2334        .find(|storage_name| !is_managed_attachment_name(storage_name))
2335    {
2336        return Err(StoreError::Corrupt(format!(
2337            "attachment has invalid storage name {invalid:?}"
2338        )));
2339    }
2340    let pending = {
2341        let mut statement = tx.prepare("SELECT storage_name FROM attachment_gc")?;
2342        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
2343        rows.collect::<Result<Vec<_>, _>>()?
2344    };
2345    if let Some(invalid) = pending
2346        .iter()
2347        .find(|storage_name| !is_managed_attachment_name(storage_name))
2348    {
2349        return Err(StoreError::Corrupt(format!(
2350            "attachment cleanup entry has invalid storage name {invalid:?}"
2351        )));
2352    }
2353
2354    let mut removed = false;
2355    if images_root.exists() {
2356        let entries = fs::read_dir(images_root)
2357            .map_err(|error| StoreError::io("read image directory", images_root, error))?;
2358        for entry in entries {
2359            let entry = entry
2360                .map_err(|error| StoreError::io("read image directory", images_root, error))?;
2361            let name = entry.file_name();
2362            let Some(name) = name.to_str() else {
2363                continue;
2364            };
2365            let stale_temp = is_managed_attachment_temp_name(name);
2366            let orphaned_managed = is_managed_attachment_name(name) && !owned.contains(name);
2367            if !stale_temp && !orphaned_managed {
2368                continue;
2369            }
2370            let file_type = entry.file_type().map_err(|error| {
2371                StoreError::io("inspect image directory entry", &entry.path(), error)
2372            })?;
2373            if file_type.is_file() || file_type.is_symlink() {
2374                removed |= remove_managed_attachment_file(&entry.path())?;
2375            }
2376        }
2377    }
2378    if removed {
2379        sync_images_directory(images_root)?;
2380    }
2381    tx.execute("DELETE FROM attachment_gc", [])?;
2382    tx.commit()?;
2383    Ok(())
2384}
2385
2386#[derive(Clone, Copy)]
2387enum DueMode {
2388    NewWrite,
2389    LegacyMigration,
2390    Stored,
2391}
2392
2393#[derive(Clone, Copy, PartialEq, Eq)]
2394enum AttachmentMode {
2395    Draft,
2396    Persisted,
2397}
2398
2399fn normalize_and_validate(
2400    data: &mut StoreData,
2401    now: NaiveDateTime,
2402    due_mode: DueMode,
2403    attachment_mode: AttachmentMode,
2404) -> Result<(), StoreError> {
2405    // This compatibility-only field moved to the application-level updater
2406    // store. Never let it re-enter persisted task settings.
2407    data.settings.last_update_check_at = None;
2408    if data.categories.len() > MAX_CATEGORY_COUNT {
2409        return Err(StoreError::Validation(format!(
2410            "category limit is {MAX_CATEGORY_COUNT}"
2411        )));
2412    }
2413    if data.tasks.len() > MAX_TASK_COUNT {
2414        return Err(StoreError::Validation(format!(
2415            "task limit is {MAX_TASK_COUNT}"
2416        )));
2417    }
2418
2419    let attachment_ids = validate_attachments(&data.attachments)?;
2420
2421    let mut category_ids = HashSet::new();
2422    let mut category_names = HashSet::new();
2423    for category in &data.categories {
2424        validate_single_line(&category.id, "category id")?;
2425        validate_byte_limit(&category.id, ID_MAX_BYTES, "category id")?;
2426        if category.is_all() {
2427            return Err(StoreError::Validation(
2428                "real category id cannot be empty".into(),
2429            ));
2430        }
2431        if !category_ids.insert(category.id.as_str()) {
2432            return Err(StoreError::Validation(format!(
2433                "category id {:?} must be unique",
2434                category.id
2435            )));
2436        }
2437        validate_single_line(&category.name, "category name")?;
2438        validate_byte_limit(
2439            &category.name,
2440            text_byte_limit(MAX_CATEGORY_NAME_LEN),
2441            "category name",
2442        )?;
2443        let name = category.name.trim();
2444        if name.is_empty() {
2445            return Err(StoreError::Validation(
2446                "category name cannot be empty".into(),
2447            ));
2448        }
2449        if name.graphemes(true).count() > MAX_CATEGORY_NAME_LEN {
2450            return Err(StoreError::Validation(format!(
2451                "category name {:?} exceeds {MAX_CATEGORY_NAME_LEN} characters",
2452                category.name
2453            )));
2454        }
2455        if !category_names.insert(category_name_key(name)) {
2456            return Err(StoreError::Validation(format!(
2457                "category names must be unique (duplicate {:?})",
2458                category.name
2459            )));
2460        }
2461        validate_multiline(
2462            &category.description,
2463            MAX_CATEGORY_DESC_LINES,
2464            MAX_CATEGORY_DESC_LINE_LEN,
2465            "category description",
2466        )?;
2467    }
2468
2469    let mut task_ids = HashSet::new();
2470    for task in &mut data.tasks {
2471        validate_single_line(&task.id, "task id")?;
2472        validate_byte_limit(&task.id, ID_MAX_BYTES, "task id")?;
2473        if task.id.is_empty() || !task_ids.insert(task.id.as_str()) {
2474            return Err(StoreError::Validation(format!(
2475                "task id {:?} must be nonempty and unique",
2476                task.id
2477            )));
2478        }
2479        validate_single_line(&task.title, "task title")?;
2480        validate_byte_limit(&task.title, text_byte_limit(MAX_TITLE_LEN), "task title")?;
2481        if task.title.trim().is_empty() {
2482            return Err(StoreError::Validation(format!(
2483                "task {:?} title cannot be empty",
2484                task.id
2485            )));
2486        }
2487        if task.title.graphemes(true).count() > MAX_TITLE_LEN {
2488            return Err(StoreError::Validation(format!(
2489                "task {:?} title exceeds {MAX_TITLE_LEN} characters",
2490                task.id
2491            )));
2492        }
2493        if task.importance > MAX_IMPORTANCE {
2494            return Err(StoreError::Validation(format!(
2495                "task {:?} importance must be 0-{MAX_IMPORTANCE}",
2496                task.id
2497            )));
2498        }
2499        if task.description.len() > MAX_DESCRIPTION_LINES {
2500            return Err(StoreError::Validation(format!(
2501                "task {:?} description exceeds {MAX_DESCRIPTION_LINES} blocks",
2502                task.id
2503            )));
2504        }
2505        for block in &task.description {
2506            validate_block(block, &task.id)?;
2507            if let Block::Image { attachment_id } = block {
2508                let known = attachment_ids.contains(attachment_id.as_str());
2509                if attachment_mode == AttachmentMode::Persisted && !known {
2510                    return Err(StoreError::Validation(format!(
2511                        "task {:?} refers to unknown attachment {attachment_id:?}",
2512                        task.id
2513                    )));
2514                }
2515                if attachment_mode == AttachmentMode::Draft
2516                    && is_attachment_id(attachment_id)
2517                    && !known
2518                {
2519                    return Err(StoreError::Validation(format!(
2520                        "task {:?} refers to unknown attachment {attachment_id:?}",
2521                        task.id
2522                    )));
2523                }
2524            }
2525        }
2526        if let Some(category_id) = task.category_id.as_deref() {
2527            validate_single_line(category_id, "task category id")?;
2528            validate_byte_limit(category_id, ID_MAX_BYTES, "task category id")?;
2529            if !category_ids.contains(category_id) {
2530                return Err(StoreError::Validation(format!(
2531                    "task {:?} refers to unknown category {category_id:?}",
2532                    task.id
2533                )));
2534            }
2535        }
2536        validate_single_line(&task.due, "task due")?;
2537        validate_byte_limit(&task.due, DUE_MAX_BYTES, "task due")?;
2538        let normalized_due = match due_mode {
2539            DueMode::NewWrite | DueMode::Stored => due::normalize_for_write_at(&task.due, now),
2540            DueMode::LegacyMigration => due::normalize_legacy_at(&task.due, now),
2541        }
2542        .map_err(|error| StoreError::Validation(format!("task {:?} has {error}", task.id)))?;
2543        if matches!(due_mode, DueMode::Stored) && normalized_due != task.due {
2544            return Err(StoreError::Validation(format!(
2545                "task {:?} has noncanonical due value {:?}",
2546                task.id, task.due
2547            )));
2548        }
2549        task.due = normalized_due;
2550        validate_single_line(&task.created, "task creation timestamp")?;
2551        validate_byte_limit(&task.created, CREATED_MAX_BYTES, "task creation timestamp")?;
2552        NaiveDateTime::parse_from_str(&task.created, "%Y-%m-%d %H:%M:%S").map_err(|_| {
2553            StoreError::Validation(format!(
2554                "task {:?} has invalid creation timestamp {:?}",
2555                task.id, task.created
2556            ))
2557        })?;
2558    }
2559    validate_settings(&data.settings)
2560}
2561
2562fn validate_block(block: &Block, task_id: &str) -> Result<(), StoreError> {
2563    let (kind, value) = match block {
2564        Block::Text { text } => ("text", text),
2565        Block::Todo { text, .. } => ("subtask", text),
2566        Block::Bullet { text } => ("bullet", text),
2567        Block::Number { text } => ("number", text),
2568        Block::Link { url } => ("link", url),
2569        Block::Image { attachment_id } => ("image attachment", attachment_id),
2570    };
2571    validate_single_line(value, kind)?;
2572    validate_byte_limit(value, text_byte_limit(MAX_NOTES_LINE_LEN), kind)?;
2573    if value.graphemes(true).count() > MAX_NOTES_LINE_LEN {
2574        return Err(StoreError::Validation(format!(
2575            "task {task_id:?} {kind} exceeds {MAX_NOTES_LINE_LEN} characters"
2576        )));
2577    }
2578    Ok(())
2579}
2580
2581fn validate_attachments(attachments: &[Attachment]) -> Result<HashSet<&str>, StoreError> {
2582    let mut ids = HashSet::new();
2583    let mut storage_names = HashSet::new();
2584    for attachment in attachments {
2585        if !is_attachment_id(&attachment.id) || attachment.sha256 != attachment.id {
2586            return Err(StoreError::Validation(format!(
2587                "attachment {:?} has an invalid content address",
2588                attachment.id
2589            )));
2590        }
2591        if !ids.insert(attachment.id.as_str()) {
2592            return Err(StoreError::Validation(format!(
2593                "attachment id {:?} must be unique",
2594                attachment.id
2595            )));
2596        }
2597        if attachment.byte_len == 0 || attachment.byte_len > MAX_ATTACHMENT_BYTES {
2598            return Err(StoreError::Validation(format!(
2599                "attachment {:?} has invalid byte length {}",
2600                attachment.id, attachment.byte_len
2601            )));
2602        }
2603        let format = crate::image::managed_attachment_format_for_media_type(&attachment.media_type)
2604            .ok_or_else(|| {
2605                StoreError::Validation(format!(
2606                    "attachment {:?} has unsupported media type {:?}",
2607                    attachment.id, attachment.media_type
2608                ))
2609            })?;
2610        let expected_storage_name = format!("{}.{}", attachment.id, format.extension);
2611        if attachment.storage_name != expected_storage_name {
2612            return Err(StoreError::Validation(format!(
2613                "attachment {:?} has invalid storage name {:?}",
2614                attachment.id, attachment.storage_name
2615            )));
2616        }
2617        if !storage_names.insert(attachment.storage_name.as_str()) {
2618            return Err(StoreError::Validation(format!(
2619                "attachment storage name {:?} must be unique",
2620                attachment.storage_name
2621            )));
2622        }
2623    }
2624    Ok(ids)
2625}
2626
2627fn validate_multiline(
2628    value: &str,
2629    max_lines: usize,
2630    max_line_len: usize,
2631    label: &str,
2632) -> Result<(), StoreError> {
2633    let max_line_bytes = text_byte_limit(max_line_len);
2634    let max_total_bytes = max_lines.saturating_mul(max_line_bytes.saturating_add(1));
2635    validate_byte_limit(value, max_total_bytes, label)?;
2636    if value
2637        .chars()
2638        .any(|character| character.is_control() && character != '\n')
2639    {
2640        return Err(StoreError::Validation(format!(
2641            "{label} contains a control character"
2642        )));
2643    }
2644    for (index, line) in value.split('\n').enumerate() {
2645        if index >= max_lines {
2646            return Err(StoreError::Validation(format!(
2647                "{label} exceeds {max_lines} lines"
2648            )));
2649        }
2650        if line.len() > max_line_bytes {
2651            return Err(StoreError::Validation(format!(
2652                "{label} line exceeds {max_line_bytes} bytes"
2653            )));
2654        }
2655        if line.graphemes(true).count() > max_line_len {
2656            return Err(StoreError::Validation(format!(
2657                "{label} line exceeds {max_line_len} characters"
2658            )));
2659        }
2660    }
2661    Ok(())
2662}
2663
2664fn validate_single_line(value: &str, label: &str) -> Result<(), StoreError> {
2665    if value.chars().any(char::is_control) {
2666        return Err(StoreError::Validation(format!(
2667            "{label} contains a control character"
2668        )));
2669    }
2670    Ok(())
2671}
2672
2673fn validate_byte_limit(value: &str, max_bytes: usize, label: &str) -> Result<(), StoreError> {
2674    if value.len() > max_bytes {
2675        return Err(StoreError::Validation(format!(
2676            "{label} exceeds {max_bytes} bytes"
2677        )));
2678    }
2679    Ok(())
2680}
2681
2682fn category_name_has_prefix(name: &str, folded_query: &str) -> bool {
2683    let normalized: String = name.trim().nfkc().collect();
2684    normalized
2685        .char_indices()
2686        .skip(1)
2687        .map(|(index, _)| index)
2688        .chain(std::iter::once(normalized.len()))
2689        .any(|end| category_name_key(&normalized[..end]) == folded_query)
2690}
2691
2692fn validate_settings(settings: &Settings) -> Result<(), StoreError> {
2693    validate_single_line(&settings.date_format, "date format")?;
2694    validate_byte_limit(
2695        &settings.date_format,
2696        SETTINGS_VALUE_MAX_BYTES,
2697        "date format",
2698    )?;
2699    validate_single_line(&settings.selected_color, "theme")?;
2700    validate_byte_limit(&settings.selected_color, SETTINGS_VALUE_MAX_BYTES, "theme")?;
2701    validate_single_line(&settings.sort, "sort")?;
2702    validate_byte_limit(&settings.sort, SETTINGS_VALUE_MAX_BYTES, "sort")?;
2703    validate_single_line(&settings.preview_position, "preview position")?;
2704    validate_byte_limit(
2705        &settings.preview_position,
2706        SETTINGS_VALUE_MAX_BYTES,
2707        "preview position",
2708    )?;
2709    if let Some(version) = settings.last_run_version.as_deref() {
2710        validate_single_line(version, "last-run version")?;
2711        validate_byte_limit(version, SETTINGS_VALUE_MAX_BYTES, "last-run version")?;
2712    }
2713    if !DATE_FORMATS.contains(&settings.date_format.as_str()) {
2714        return Err(StoreError::Validation(format!(
2715            "unknown date format {:?}",
2716            settings.date_format
2717        )));
2718    }
2719    if !THEMES.contains(&settings.selected_color.as_str()) {
2720        return Err(StoreError::Validation(format!(
2721            "unknown theme {:?}",
2722            settings.selected_color
2723        )));
2724    }
2725    if !SORTS.contains(&settings.sort.as_str()) {
2726        return Err(StoreError::Validation(format!(
2727            "unknown sort {:?}",
2728            settings.sort
2729        )));
2730    }
2731    if !PREVIEW_POSITIONS.contains(&settings.preview_position.as_str()) {
2732        return Err(StoreError::Validation(format!(
2733            "unknown preview position {:?}",
2734            settings.preview_position
2735        )));
2736    }
2737    Ok(())
2738}
2739
2740fn read_optional_json<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, StoreError> {
2741    let file = match fs::File::open(path) {
2742        Ok(file) => file,
2743        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2744        Err(error) => return Err(StoreError::io("read", path, error)),
2745    };
2746    let size = file
2747        .metadata()
2748        .map_err(|error| StoreError::io("inspect", path, error))?
2749        .len();
2750    if size > MAX_LEGACY_JSON_BYTES {
2751        return Err(StoreError::Validation(format!(
2752            "legacy file {} is larger than the {} MiB safety limit",
2753            path.display(),
2754            MAX_LEGACY_JSON_BYTES / 1024 / 1024
2755        )));
2756    }
2757    serde_json::from_reader(std::io::BufReader::new(file))
2758        .map(Some)
2759        .map_err(|source| StoreError::Json {
2760            path: path.to_path_buf(),
2761            source,
2762        })
2763}
2764
2765fn validate_legacy_schema(path: &Path, schema: Option<u32>) -> Result<(), StoreError> {
2766    if let Some(found) = schema
2767        && found != SCHEMA_VERSION
2768    {
2769        return Err(StoreError::UnsupportedLegacySchema {
2770            path: path.to_path_buf(),
2771            found,
2772            expected: SCHEMA_VERSION,
2773        });
2774    }
2775    Ok(())
2776}
2777
2778#[derive(Debug, Deserialize)]
2779struct TasksFile {
2780    schema: u32,
2781    tasks: Vec<Task>,
2782}
2783
2784#[derive(Debug, Deserialize)]
2785struct CategoriesFile {
2786    schema: u32,
2787    categories: Vec<Category>,
2788}
2789
2790pub(crate) fn ensure_private_directory(path: &Path) -> Result<(), StoreError> {
2791    let created = match fs::create_dir(path) {
2792        Ok(()) => true,
2793        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => false,
2794        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2795            if let Some(parent) = path
2796                .parent()
2797                .filter(|parent| !parent.as_os_str().is_empty())
2798            {
2799                fs::create_dir_all(parent).map_err(|parent_error| {
2800                    StoreError::io("create parent directory", parent, parent_error)
2801                })?;
2802            }
2803            match fs::create_dir(path) {
2804                Ok(()) => true,
2805                Err(retry)
2806                    if retry.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() =>
2807                {
2808                    false
2809                }
2810                Err(retry) => {
2811                    return Err(StoreError::io("create directory", path, retry));
2812                }
2813            }
2814        }
2815        Err(error) => return Err(StoreError::io("create directory", path, error)),
2816    };
2817    if created {
2818        #[cfg(unix)]
2819        {
2820            use std::os::unix::fs::PermissionsExt;
2821            fs::set_permissions(path, fs::Permissions::from_mode(0o700))
2822                .map_err(|error| StoreError::io("set permissions on", path, error))?;
2823        }
2824    }
2825    Ok(())
2826}
2827
2828pub(crate) fn prepare_private_database_file(path: &Path) -> Result<(), StoreError> {
2829    #[cfg(unix)]
2830    {
2831        use std::os::unix::fs::OpenOptionsExt;
2832        match fs::OpenOptions::new()
2833            .write(true)
2834            .create_new(true)
2835            .mode(0o600)
2836            .open(path)
2837        {
2838            Ok(file) => drop(file),
2839            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
2840            Err(error) => return Err(StoreError::io("create database", path, error)),
2841        }
2842    }
2843    #[cfg(not(unix))]
2844    let _ = path;
2845    Ok(())
2846}
2847
2848pub(crate) fn set_private_file(path: &Path) -> Result<(), StoreError> {
2849    #[cfg(unix)]
2850    {
2851        use std::os::unix::fs::PermissionsExt;
2852        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
2853            .map_err(|error| StoreError::io("set permissions on", path, error))?;
2854    }
2855    Ok(())
2856}
2857
2858#[cfg(test)]
2859mod tests {
2860    use super::*;
2861
2862    #[test]
2863    fn default_directory_requires_a_home_when_no_path_is_configured() {
2864        let error = resolve_data_dir_from(None, None, None)
2865            .expect_err("missing home must not silently select the working directory");
2866        assert!(matches!(error, StoreError::Validation(_)));
2867
2868        assert_eq!(
2869            resolve_data_dir_from(Some(PathBuf::from("/tmp/mach")), None, None).unwrap(),
2870            PathBuf::from("/tmp/mach")
2871        );
2872        assert_eq!(
2873            resolve_data_dir_from(None, Some(PathBuf::from("/tmp/configured")), None).unwrap(),
2874            PathBuf::from("/tmp/configured")
2875        );
2876        assert!(resolve_data_dir_from(Some(PathBuf::from("~/.mach")), None, None).is_err());
2877    }
2878}