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