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