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