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