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