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