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 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 fn normalize_and_validate_new_write(&mut self) -> Result<(), StoreError> {
777 normalize_and_validate(
778 self,
779 Local::now().naive_local(),
780 DueMode::NewWrite,
781 AttachmentMode::Draft,
782 )
783 }
784}
785
786fn field_conflicts<T: PartialEq>(desired: Option<&T>, current: &T, expected: &T) -> bool {
789 desired.is_some_and(|desired| current != expected && current != desired)
790}
791
792#[derive(Debug, Clone)]
793pub struct Paths {
794 pub dir: PathBuf,
795 pub database: PathBuf,
796 pub tasks: PathBuf,
797 pub categories: PathBuf,
798 pub settings: PathBuf,
799 pub images: PathBuf,
800}
801
802impl Paths {
803 fn new(dir: PathBuf) -> Self {
804 Self {
805 database: dir.join(DATABASE_FILE),
806 tasks: dir.join("tasks.json"),
807 categories: dir.join("categories.json"),
808 settings: dir.join("settings.json"),
809 images: dir.join("images"),
810 dir,
811 }
812 }
813}
814
815pub struct Store {
816 connection: Connection,
817 paths: Paths,
818 persistent_attachments: bool,
819}
820
821impl Store {
822 pub fn open(dir: impl AsRef<Path>) -> Result<Self, StoreError> {
823 let paths = Paths::new(expand_user(dir.as_ref().to_path_buf())?);
824 ensure_private_directory(&paths.dir)?;
825 prepare_private_database_file(&paths.database)?;
826 let mut connection = Connection::open(&paths.database)?;
827 set_private_file(&paths.database)?;
828 connection.busy_timeout(BUSY_TIMEOUT)?;
829 configure_resource_limits(&connection)?;
830 initialize_schema(&mut connection, &paths.database)?;
831 configure_connection(&connection)?;
832 quick_check(&connection)?;
833 let mut store = Self {
834 connection,
835 paths,
836 persistent_attachments: true,
837 };
838 store.migrate_legacy_json()?;
839 Ok(store)
840 }
841
842 pub fn open_in_memory_with_paths(data_dir: impl AsRef<Path>) -> Result<Self, StoreError> {
847 let paths = Paths::new(expand_user(data_dir.as_ref().to_path_buf())?);
848 let mut connection = Connection::open_in_memory()?;
849 connection.busy_timeout(BUSY_TIMEOUT)?;
850 configure_resource_limits(&connection)?;
851 initialize_schema(&mut connection, Path::new(":memory:"))?;
852 configure_in_memory_connection(&connection)?;
853 quick_check(&connection)?;
854 connection.execute(
855 "INSERT INTO metadata(key, value) VALUES (?1, '1')",
856 [LEGACY_MIGRATION_KEY],
857 )?;
858 Ok(Self {
859 connection,
860 paths,
861 persistent_attachments: false,
862 })
863 }
864
865 pub fn open_default(explicit: Option<PathBuf>) -> Result<Self, StoreError> {
866 Self::open(resolve_data_dir(explicit)?)
867 }
868
869 pub fn paths(&self) -> &Paths {
870 &self.paths
871 }
872
873 pub fn data_dir(&self) -> &Path {
874 &self.paths.dir
875 }
876
877 pub fn images_dir(&self) -> &Path {
878 &self.paths.images
879 }
880
881 pub fn database_path(&self) -> &Path {
882 &self.paths.database
883 }
884
885 pub fn revision(&self) -> Result<u64, StoreError> {
887 read_revision(&self.connection)
888 }
889
890 pub fn snapshot(&self) -> Result<StoreData, StoreError> {
891 let tx = self.connection.unchecked_transaction()?;
892 let data = load_snapshot(&tx)?;
893 tx.commit()?;
894 Ok(data)
895 }
896
897 pub fn load_settings(&self) -> Result<Settings, StoreError> {
898 Ok(self.snapshot()?.settings)
899 }
900
901 pub fn save_settings(&mut self, settings: &Settings) -> Result<(), StoreError> {
902 self.update(|data| {
903 data.replace_settings(settings.clone())?;
904 Ok(())
905 })
906 }
907
908 pub fn update<R>(
911 &mut self,
912 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
913 ) -> Result<R, StoreError> {
914 self.update_with_snapshot(operation)
915 .map(|(result, _)| result)
916 }
917
918 pub fn update_with_snapshot<R>(
921 &mut self,
922 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
923 ) -> Result<(R, StoreData), StoreError> {
924 self.update_inner(None, operation)
925 }
926
927 pub fn update_if_revision<R>(
929 &mut self,
930 expected_revision: u64,
931 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
932 ) -> Result<R, StoreError> {
933 self.update_if_revision_with_snapshot(expected_revision, operation)
934 .map(|(result, _)| result)
935 }
936
937 pub fn update_if_revision_with_snapshot<R>(
938 &mut self,
939 expected_revision: u64,
940 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
941 ) -> Result<(R, StoreData), StoreError> {
942 self.update_inner(Some(expected_revision), operation)
943 }
944
945 fn update_inner<R>(
946 &mut self,
947 expected_revision: Option<u64>,
948 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
949 ) -> Result<(R, StoreData), StoreError> {
950 let images_root = self
951 .persistent_attachments
952 .then(|| self.paths.images.clone());
953 let tx = self
954 .connection
955 .transaction_with_behavior(TransactionBehavior::Immediate)?;
956 let before = load_snapshot(&tx)?;
957 let base_revision = before.revision;
958 if let Some(expected) = expected_revision
959 && expected != base_revision
960 {
961 return Err(StoreError::Conflict {
962 expected,
963 actual: base_revision,
964 });
965 }
966 let mut data = before.clone();
967 let result = operation(&mut data)?;
968 import_task_attachments(&mut data, images_root.as_deref())?;
969 normalize_and_validate(
970 &mut data,
971 Local::now().naive_local(),
972 DueMode::NewWrite,
973 AttachmentMode::Persisted,
974 )?;
975 let next_revision = base_revision
976 .checked_add(1)
977 .ok_or_else(|| StoreError::Corrupt("revision overflow".into()))?;
978 data.revision = next_revision;
979 persist_diff(&tx, &before, &data)?;
980 tx.commit()?;
981 Ok((result, data))
982 }
983
984 fn migrate_legacy_json(&mut self) -> Result<(), StoreError> {
985 let images_root = self.paths.images.clone();
986 let tx = self
987 .connection
988 .transaction_with_behavior(TransactionBehavior::Immediate)?;
989 if migration_complete(&tx)? {
990 tx.commit()?;
991 return Ok(());
992 }
993
994 let existing = load_snapshot(&tx)?;
995 if !existing.categories.is_empty()
996 || !existing.tasks.is_empty()
997 || !existing.attachments.is_empty()
998 || existing.revision != 0
999 {
1000 return Err(StoreError::Corrupt(
1001 "database contains data but has no completed legacy migration marker".into(),
1002 ));
1003 }
1004
1005 let categories_file = read_optional_json::<CategoriesFile>(&self.paths.categories)?;
1006 let tasks_file = read_optional_json::<TasksFile>(&self.paths.tasks)?;
1007 let settings = read_optional_json::<Settings>(&self.paths.settings)?;
1008 validate_legacy_schema(
1009 &self.paths.categories,
1010 categories_file.as_ref().map(|f| f.schema),
1011 )?;
1012 validate_legacy_schema(&self.paths.tasks, tasks_file.as_ref().map(|f| f.schema))?;
1013
1014 let has_legacy = categories_file.is_some() || tasks_file.is_some() || settings.is_some();
1015 if has_legacy {
1016 let mut data = StoreData {
1017 revision: 1,
1018 categories: categories_file
1019 .map(|file| {
1020 file.categories
1021 .into_iter()
1022 .filter(|category| !category.is_all())
1023 .collect()
1024 })
1025 .unwrap_or_default(),
1026 tasks: tasks_file.map(|file| file.tasks).unwrap_or_default(),
1027 settings: settings.unwrap_or_default().normalized(),
1028 attachments: Vec::new(),
1029 };
1030 import_task_attachments(&mut data, Some(&images_root))?;
1031 normalize_and_validate(
1034 &mut data,
1035 Local::now().naive_local(),
1036 DueMode::LegacyMigration,
1037 AttachmentMode::Persisted,
1038 )?;
1039 persist_diff(&tx, &existing, &data)?;
1040 }
1041 tx.execute(
1042 "INSERT INTO metadata(key, value) VALUES (?1, '1')",
1043 [LEGACY_MIGRATION_KEY],
1044 )?;
1045 tx.commit()?;
1046 Ok(())
1047 }
1048}
1049
1050pub fn resolve_data_dir(explicit: Option<PathBuf>) -> Result<PathBuf, StoreError> {
1051 resolve_data_dir_from(
1052 explicit,
1053 std::env::var_os("MACH_DIR").map(PathBuf::from),
1054 dirs::home_dir(),
1055 )
1056}
1057
1058fn resolve_data_dir_from(
1059 explicit: Option<PathBuf>,
1060 configured: Option<PathBuf>,
1061 home: Option<PathBuf>,
1062) -> Result<PathBuf, StoreError> {
1063 if let Some(dir) = explicit {
1064 return expand_user_with_home(dir, home.as_deref());
1065 }
1066 if let Some(dir) = configured {
1067 return expand_user_with_home(dir, home.as_deref());
1068 }
1069 home.map(|home| home.join(".mach")).ok_or_else(|| {
1070 StoreError::validation("could not determine the home directory; use --dir or set MACH_DIR")
1071 })
1072}
1073
1074fn expand_user(path: PathBuf) -> Result<PathBuf, StoreError> {
1075 let home = dirs::home_dir();
1076 expand_user_with_home(path, home.as_deref())
1077}
1078
1079fn expand_user_with_home(path: PathBuf, home: Option<&Path>) -> Result<PathBuf, StoreError> {
1080 if path.as_os_str().is_empty() {
1081 return Err(StoreError::validation("data directory cannot be empty"));
1082 }
1083 let text = path.to_string_lossy();
1084 if text == "~" {
1085 return home
1086 .map(Path::to_path_buf)
1087 .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1088 }
1089 if let Some(rest) = text.strip_prefix("~/") {
1090 return home
1091 .map(|home| home.join(rest))
1092 .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1093 }
1094 Ok(path)
1095}
1096
1097fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
1098 connection.pragma_update(None, "foreign_keys", "ON")?;
1099 connection.pragma_update(None, "synchronous", "FULL")?;
1100 let mode: String = connection.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;
1101 if !mode.eq_ignore_ascii_case("wal") {
1102 return Err(StoreError::Corrupt(format!(
1103 "SQLite refused WAL mode (using {mode})"
1104 )));
1105 }
1106 Ok(())
1107}
1108
1109fn configure_resource_limits(connection: &Connection) -> Result<(), StoreError> {
1110 connection.set_limit(Limit::SQLITE_LIMIT_LENGTH, MAX_SQLITE_VALUE_BYTES)?;
1111 Ok(())
1112}
1113
1114fn configure_in_memory_connection(connection: &Connection) -> Result<(), StoreError> {
1115 connection.pragma_update(None, "foreign_keys", "ON")?;
1116 connection.pragma_update(None, "journal_mode", "MEMORY")?;
1117 Ok(())
1118}
1119
1120fn initialize_schema(connection: &mut Connection, path: &Path) -> Result<(), StoreError> {
1121 let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1122 if version != 0 && version != DATABASE_SCHEMA_VERSION {
1123 return Err(StoreError::UnsupportedDatabaseSchema {
1124 path: path.to_path_buf(),
1125 found: version,
1126 expected: DATABASE_SCHEMA_VERSION,
1127 });
1128 }
1129
1130 let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1131 tx.execute_batch(
1132 "
1133 CREATE TABLE IF NOT EXISTS metadata (
1134 key TEXT PRIMARY KEY,
1135 value TEXT NOT NULL
1136 ) STRICT;
1137 CREATE TABLE IF NOT EXISTS app_state (
1138 id INTEGER PRIMARY KEY CHECK (id = 1),
1139 revision INTEGER NOT NULL CHECK (revision >= 0),
1140 settings_json TEXT NOT NULL
1141 ) STRICT;
1142 CREATE TABLE IF NOT EXISTS categories (
1143 id TEXT PRIMARY KEY,
1144 position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1145 name TEXT NOT NULL CHECK (length(trim(name)) > 0),
1146 name_key TEXT NOT NULL UNIQUE CHECK (length(name_key) > 0),
1147 description TEXT NOT NULL
1148 ) STRICT;
1149 CREATE TABLE IF NOT EXISTS tasks (
1150 id TEXT PRIMARY KEY,
1151 position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1152 title TEXT NOT NULL CHECK (length(trim(title)) > 0),
1153 body_json TEXT NOT NULL,
1154 due TEXT NOT NULL,
1155 created TEXT NOT NULL,
1156 done INTEGER NOT NULL CHECK (done IN (0, 1)),
1157 importance INTEGER NOT NULL CHECK (importance BETWEEN 0 AND 3),
1158 category_id TEXT REFERENCES categories(id) ON DELETE SET NULL
1159 ) STRICT;
1160 CREATE TABLE IF NOT EXISTS attachments (
1161 id TEXT PRIMARY KEY,
1162 sha256 TEXT NOT NULL UNIQUE CHECK (sha256 = id),
1163 media_type TEXT NOT NULL,
1164 byte_len INTEGER NOT NULL CHECK (byte_len > 0),
1165 storage_name TEXT NOT NULL UNIQUE
1166 ) STRICT;
1167 CREATE TABLE IF NOT EXISTS task_attachments (
1168 task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1169 block_index INTEGER NOT NULL CHECK (block_index >= 0),
1170 attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE RESTRICT,
1171 PRIMARY KEY (task_id, block_index)
1172 ) STRICT;
1173 CREATE INDEX IF NOT EXISTS task_attachments_by_attachment
1174 ON task_attachments(attachment_id);
1175 ",
1176 )?;
1177 let settings = serde_json::to_string(&Settings::default()).map_err(|source| {
1178 StoreError::Corrupt(format!("could not encode default settings: {source}"))
1179 })?;
1180 tx.execute(
1181 "INSERT OR IGNORE INTO app_state(id, revision, settings_json) VALUES (1, 0, ?1)",
1182 [settings],
1183 )?;
1184 if version == 0 {
1185 tx.pragma_update(None, "user_version", DATABASE_SCHEMA_VERSION)?;
1186 }
1187 tx.commit()?;
1188 Ok(())
1189}
1190
1191fn quick_check(connection: &Connection) -> Result<(), StoreError> {
1192 let result: String = connection.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?;
1193 if result != "ok" {
1194 return Err(StoreError::Corrupt(format!(
1195 "SQLite quick check failed: {result}"
1196 )));
1197 }
1198 Ok(())
1199}
1200
1201fn migration_complete(connection: &Connection) -> Result<bool, StoreError> {
1202 let value: Option<String> = connection
1203 .query_row(
1204 "SELECT value FROM metadata WHERE key = ?1",
1205 [LEGACY_MIGRATION_KEY],
1206 |row| row.get(0),
1207 )
1208 .optional()?;
1209 Ok(value.as_deref() == Some("1"))
1210}
1211
1212fn read_revision(connection: &Connection) -> Result<u64, StoreError> {
1213 let value: i64 =
1214 connection.query_row("SELECT revision FROM app_state WHERE id = 1", [], |row| {
1215 row.get(0)
1216 })?;
1217 u64::try_from(value).map_err(|_| StoreError::Corrupt(format!("negative revision {value}")))
1218}
1219
1220fn load_snapshot(connection: &Connection) -> Result<StoreData, StoreError> {
1221 let (revision, settings_json): (i64, String) = connection.query_row(
1222 "SELECT revision, settings_json FROM app_state WHERE id = 1",
1223 [],
1224 |row| Ok((row.get(0)?, row.get(1)?)),
1225 )?;
1226 let revision = u64::try_from(revision)
1227 .map_err(|_| StoreError::Corrupt(format!("negative revision {revision}")))?;
1228 let settings: Settings = serde_json::from_str(&settings_json)
1229 .map_err(|error| StoreError::Corrupt(format!("invalid settings JSON: {error}")))?;
1230
1231 let mut attachment_statement = connection.prepare(
1232 "SELECT id, sha256, media_type, byte_len, storage_name FROM attachments ORDER BY id",
1233 )?;
1234 let attachment_rows = attachment_statement.query_map([], |row| {
1235 Ok((
1236 row.get::<_, String>(0)?,
1237 row.get::<_, String>(1)?,
1238 row.get::<_, String>(2)?,
1239 row.get::<_, i64>(3)?,
1240 row.get::<_, String>(4)?,
1241 ))
1242 })?;
1243 let mut attachments = Vec::new();
1244 for row in attachment_rows {
1245 let (id, sha256, media_type, byte_len, storage_name) = row?;
1246 attachments.push(Attachment {
1247 id,
1248 sha256,
1249 media_type,
1250 byte_len: u64::try_from(byte_len).map_err(|_| {
1251 StoreError::Corrupt(format!("attachment has invalid byte length {byte_len}"))
1252 })?,
1253 storage_name,
1254 });
1255 }
1256
1257 let mut categories_statement = connection.prepare(
1258 "SELECT position, id, name, name_key, description FROM categories ORDER BY position",
1259 )?;
1260 let category_rows = categories_statement.query_map([], |row| {
1261 Ok((
1262 row.get::<_, i64>(0)?,
1263 Category {
1264 id: row.get(1)?,
1265 name: row.get(2)?,
1266 description: row.get(4)?,
1267 },
1268 row.get::<_, String>(3)?,
1269 ))
1270 })?;
1271 let mut categories = Vec::new();
1272 for (expected_position, row) in category_rows.enumerate() {
1273 if expected_position >= MAX_CATEGORY_COUNT {
1274 return Err(StoreError::Corrupt(format!(
1275 "category count exceeds {MAX_CATEGORY_COUNT}"
1276 )));
1277 }
1278 let (stored_position, category, stored_name_key) = row?;
1279 validate_stored_position(stored_position, expected_position, "category")?;
1280 let expected_name_key = category_name_key(&category.name);
1281 if stored_name_key != expected_name_key {
1282 return Err(StoreError::Corrupt(format!(
1283 "category {:?} has identity key {stored_name_key:?}, expected {expected_name_key:?}",
1284 category.id
1285 )));
1286 }
1287 categories.push(category);
1288 }
1289
1290 let mut tasks_statement = connection.prepare(
1291 "SELECT position, id, title, body_json, due, created, done, importance, category_id
1292 FROM tasks ORDER BY position",
1293 )?;
1294 let rows = tasks_statement.query_map([], |row| {
1295 Ok((
1296 row.get::<_, i64>(0)?,
1297 row.get::<_, String>(1)?,
1298 row.get::<_, String>(2)?,
1299 row.get::<_, String>(3)?,
1300 row.get::<_, String>(4)?,
1301 row.get::<_, String>(5)?,
1302 row.get::<_, i64>(6)?,
1303 row.get::<_, i64>(7)?,
1304 row.get::<_, Option<String>>(8)?,
1305 ))
1306 })?;
1307 let mut tasks = Vec::new();
1308 for (expected_position, row) in rows.enumerate() {
1309 if expected_position >= MAX_TASK_COUNT {
1310 return Err(StoreError::Corrupt(format!(
1311 "task count exceeds {MAX_TASK_COUNT}"
1312 )));
1313 }
1314 let (stored_position, id, title, body_json, due, created, done, importance, category_id) =
1315 row?;
1316 validate_stored_position(stored_position, expected_position, "task")?;
1317 let body = serde_json::from_str::<Vec<Block>>(&body_json).map_err(|error| {
1318 StoreError::Corrupt(format!("task {id:?} has invalid body JSON: {error}"))
1319 })?;
1320 let importance = u8::try_from(importance).map_err(|_| {
1321 StoreError::Corrupt(format!("task {id:?} has invalid importance {importance}"))
1322 })?;
1323 tasks.push(Task {
1324 id,
1325 title,
1326 body,
1327 due,
1328 created,
1329 done: done != 0,
1330 importance,
1331 category_id,
1332 });
1333 }
1334 validate_task_attachment_rows(connection, &tasks)?;
1335 let mut data = StoreData {
1336 revision,
1337 categories,
1338 tasks,
1339 settings,
1340 attachments,
1341 };
1342 normalize_and_validate(
1343 &mut data,
1344 Local::now().naive_local(),
1345 DueMode::Stored,
1346 AttachmentMode::Persisted,
1347 )
1348 .map_err(|error| match error {
1349 StoreError::Validation(message) => StoreError::Corrupt(message),
1350 other => other,
1351 })?;
1352 Ok(data)
1353}
1354
1355fn validate_task_attachment_rows(
1356 connection: &Connection,
1357 tasks: &[Task],
1358) -> Result<(), StoreError> {
1359 let expected: HashSet<(String, usize, String)> = tasks
1360 .iter()
1361 .flat_map(|task| {
1362 task.body
1363 .iter()
1364 .enumerate()
1365 .filter_map(|(block_index, block)| match block {
1366 Block::Image { attachment_id } => {
1367 Some((task.id.clone(), block_index, attachment_id.clone()))
1368 }
1369 _ => None,
1370 })
1371 })
1372 .collect();
1373 let mut statement = connection.prepare(
1374 "SELECT task_id, block_index, attachment_id
1375 FROM task_attachments ORDER BY task_id, block_index",
1376 )?;
1377 let rows = statement.query_map([], |row| {
1378 Ok((
1379 row.get::<_, String>(0)?,
1380 row.get::<_, i64>(1)?,
1381 row.get::<_, String>(2)?,
1382 ))
1383 })?;
1384 let mut stored = HashSet::new();
1385 for row in rows {
1386 let (task_id, block_index, attachment_id) = row?;
1387 let block_index = usize::try_from(block_index).map_err(|_| {
1388 StoreError::Corrupt(format!(
1389 "task {task_id:?} has invalid attachment reference index {block_index}"
1390 ))
1391 })?;
1392 stored.insert((task_id, block_index, attachment_id));
1393 }
1394 if stored != expected {
1395 return Err(StoreError::Corrupt(
1396 "task attachment reference rows do not match task body JSON".into(),
1397 ));
1398 }
1399 Ok(())
1400}
1401
1402fn validate_stored_position(stored: i64, expected: usize, entity: &str) -> Result<(), StoreError> {
1403 let expected = i64::try_from(expected)
1404 .map_err(|_| StoreError::Corrupt(format!("{entity} position exceeds integer range")))?;
1405 if stored != expected {
1406 return Err(StoreError::Corrupt(format!(
1407 "{entity} position {stored} is not contiguous (expected {expected})"
1408 )));
1409 }
1410 Ok(())
1411}
1412
1413fn persist_diff(
1420 tx: &Transaction<'_>,
1421 before: &StoreData,
1422 after: &StoreData,
1423) -> Result<(), StoreError> {
1424 let before_categories: HashMap<&str, (usize, &Category)> = before
1425 .categories
1426 .iter()
1427 .enumerate()
1428 .map(|(position, category)| (category.id.as_str(), (position, category)))
1429 .collect();
1430 let after_categories: HashMap<&str, (usize, &Category)> = after
1431 .categories
1432 .iter()
1433 .enumerate()
1434 .map(|(position, category)| (category.id.as_str(), (position, category)))
1435 .collect();
1436 let before_tasks: HashMap<&str, (usize, &Task)> = before
1437 .tasks
1438 .iter()
1439 .enumerate()
1440 .map(|(position, task)| (task.id.as_str(), (position, task)))
1441 .collect();
1442 let after_tasks: HashMap<&str, (usize, &Task)> = after
1443 .tasks
1444 .iter()
1445 .enumerate()
1446 .map(|(position, task)| (task.id.as_str(), (position, task)))
1447 .collect();
1448 let before_attachments: HashMap<&str, &Attachment> = before
1449 .attachments
1450 .iter()
1451 .map(|attachment| (attachment.id.as_str(), attachment))
1452 .collect();
1453 let after_attachments: HashMap<&str, &Attachment> = after
1454 .attachments
1455 .iter()
1456 .map(|attachment| (attachment.id.as_str(), attachment))
1457 .collect();
1458
1459 for attachment in &before.attachments {
1460 if after_attachments.get(attachment.id.as_str()).copied() != Some(attachment) {
1461 return Err(StoreError::Validation(format!(
1462 "attachment {:?} metadata is immutable",
1463 attachment.id
1464 )));
1465 }
1466 }
1467 for attachment in &after.attachments {
1468 if !before_attachments.contains_key(attachment.id.as_str()) {
1469 tx.execute(
1470 "INSERT INTO attachments(id, sha256, media_type, byte_len, storage_name)
1471 VALUES (?1, ?2, ?3, ?4, ?5)",
1472 params![
1473 attachment.id,
1474 attachment.sha256,
1475 attachment.media_type,
1476 sqlite_attachment_size(attachment.byte_len)?,
1477 attachment.storage_name,
1478 ],
1479 )?;
1480 }
1481 }
1482
1483 for task in &before.tasks {
1486 if !after_tasks.contains_key(task.id.as_str()) {
1487 execute_one(
1488 tx,
1489 "DELETE FROM tasks WHERE id = ?1",
1490 [task.id.as_str()],
1491 "task",
1492 &task.id,
1493 )?;
1494 }
1495 }
1496
1497 let mut temporary_name_index = 0usize;
1501 for category in &before.categories {
1502 let name_changed_or_removed = after_categories
1503 .get(category.id.as_str())
1504 .is_none_or(|(_, current)| current.name != category.name);
1505 if name_changed_or_removed {
1506 let temporary_name = format!("\u{1f}mach-category-{temporary_name_index}");
1507 temporary_name_index += 1;
1508 execute_one(
1509 tx,
1510 "UPDATE categories SET name = ?1, name_key = ?1 WHERE id = ?2",
1511 params![temporary_name, category.id],
1512 "category",
1513 &category.id,
1514 )?;
1515 }
1516 }
1517
1518 let category_position_base = before.categories.len().max(after.categories.len());
1519 let mut category_position_offset = 0usize;
1520 for (old_position, category) in before.categories.iter().enumerate() {
1521 if let Some((new_position, _)) = after_categories.get(category.id.as_str()).copied()
1522 && new_position != old_position
1523 {
1524 let temporary = temporary_position(
1525 category_position_base,
1526 category_position_offset,
1527 "categories",
1528 )?;
1529 category_position_offset += 1;
1530 execute_one(
1531 tx,
1532 "UPDATE categories SET position = ?1 WHERE id = ?2",
1533 params![temporary, category.id],
1534 "category",
1535 &category.id,
1536 )?;
1537 }
1538 }
1539 for category in &after.categories {
1540 if !before_categories.contains_key(category.id.as_str()) {
1541 let temporary = temporary_position(
1542 category_position_base,
1543 category_position_offset,
1544 "categories",
1545 )?;
1546 category_position_offset += 1;
1547 tx.execute(
1548 "INSERT INTO categories(id, position, name, name_key, description)
1549 VALUES (?1, ?2, ?3, ?4, ?5)",
1550 params![
1551 category.id,
1552 temporary,
1553 category.name,
1554 category_name_key(&category.name),
1555 category.description
1556 ],
1557 )?;
1558 }
1559 }
1560
1561 let task_position_base = before.tasks.len().max(after.tasks.len());
1562 let mut task_position_offset = 0usize;
1563 for (old_position, task) in before.tasks.iter().enumerate() {
1564 if let Some((new_position, _)) = after_tasks.get(task.id.as_str()).copied()
1565 && new_position != old_position
1566 {
1567 let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1568 task_position_offset += 1;
1569 execute_one(
1570 tx,
1571 "UPDATE tasks SET position = ?1 WHERE id = ?2",
1572 params![temporary, task.id],
1573 "task",
1574 &task.id,
1575 )?;
1576 }
1577 }
1578
1579 for task in &after.tasks {
1582 if let Some((_, previous)) = before_tasks.get(task.id.as_str()).copied()
1583 && previous != task
1584 {
1585 let body_json = encode_task_body(task)?;
1586 execute_one(
1587 tx,
1588 "UPDATE tasks SET
1589 title = ?1, body_json = ?2, due = ?3, created = ?4,
1590 done = ?5, importance = ?6, category_id = ?7
1591 WHERE id = ?8",
1592 params![
1593 task.title,
1594 body_json,
1595 task.due,
1596 task.created,
1597 i64::from(task.done),
1598 i64::from(task.importance),
1599 task.category_id,
1600 task.id,
1601 ],
1602 "task",
1603 &task.id,
1604 )?;
1605 }
1606 }
1607 for task in &after.tasks {
1608 if !before_tasks.contains_key(task.id.as_str()) {
1609 let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1610 task_position_offset += 1;
1611 let body_json = encode_task_body(task)?;
1612 tx.execute(
1613 "INSERT INTO tasks(
1614 id, position, title, body_json, due, created, done, importance, category_id
1615 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1616 params![
1617 task.id,
1618 temporary,
1619 task.title,
1620 body_json,
1621 task.due,
1622 task.created,
1623 i64::from(task.done),
1624 i64::from(task.importance),
1625 task.category_id,
1626 ],
1627 )?;
1628 }
1629 }
1630
1631 for task in &after.tasks {
1632 let body_changed_or_new = before_tasks
1633 .get(task.id.as_str())
1634 .is_none_or(|(_, previous)| previous.body != task.body);
1635 if body_changed_or_new {
1636 tx.execute(
1637 "DELETE FROM task_attachments WHERE task_id = ?1",
1638 [&task.id],
1639 )?;
1640 insert_task_attachment_rows(tx, task)?;
1641 }
1642 }
1643
1644 for category in &before.categories {
1645 if !after_categories.contains_key(category.id.as_str()) {
1646 execute_one(
1647 tx,
1648 "DELETE FROM categories WHERE id = ?1",
1649 [category.id.as_str()],
1650 "category",
1651 &category.id,
1652 )?;
1653 }
1654 }
1655
1656 for category in &after.categories {
1657 if let Some((_, previous)) = before_categories.get(category.id.as_str()).copied()
1658 && previous != category
1659 {
1660 execute_one(
1661 tx,
1662 "UPDATE categories
1663 SET name = ?1, name_key = ?2, description = ?3
1664 WHERE id = ?4",
1665 params![
1666 category.name,
1667 category_name_key(&category.name),
1668 category.description,
1669 category.id
1670 ],
1671 "category",
1672 &category.id,
1673 )?;
1674 }
1675 }
1676
1677 for (position, category) in after.categories.iter().enumerate() {
1681 let moved_or_new = before_categories
1682 .get(category.id.as_str())
1683 .is_none_or(|(old_position, _)| *old_position != position);
1684 if moved_or_new {
1685 let position = sqlite_position(position, "categories")?;
1686 execute_one(
1687 tx,
1688 "UPDATE categories SET position = ?1 WHERE id = ?2",
1689 params![position, category.id],
1690 "category",
1691 &category.id,
1692 )?;
1693 }
1694 }
1695 for (position, task) in after.tasks.iter().enumerate() {
1696 let moved_or_new = before_tasks
1697 .get(task.id.as_str())
1698 .is_none_or(|(old_position, _)| *old_position != position);
1699 if moved_or_new {
1700 let position = sqlite_position(position, "tasks")?;
1701 execute_one(
1702 tx,
1703 "UPDATE tasks SET position = ?1 WHERE id = ?2",
1704 params![position, task.id],
1705 "task",
1706 &task.id,
1707 )?;
1708 }
1709 }
1710
1711 let settings = (before.settings != after.settings).then_some(&after.settings);
1712 persist_app_state(tx, after.revision, settings)?;
1713 Ok(())
1714}
1715
1716fn execute_one<P: rusqlite::Params>(
1717 tx: &Transaction<'_>,
1718 sql: &str,
1719 params: P,
1720 entity: &str,
1721 id: &str,
1722) -> Result<(), StoreError> {
1723 let changed = tx.execute(sql, params)?;
1724 if changed != 1 {
1725 return Err(StoreError::Corrupt(format!(
1726 "expected to change one {entity} {id:?}, changed {changed}"
1727 )));
1728 }
1729 Ok(())
1730}
1731
1732fn temporary_position(base: usize, offset: usize, entity: &str) -> Result<i64, StoreError> {
1733 let position = base
1734 .checked_add(offset)
1735 .ok_or_else(|| StoreError::Validation(format!("too many {entity}")))?;
1736 sqlite_position(position, entity)
1737}
1738
1739fn sqlite_position(position: usize, entity: &str) -> Result<i64, StoreError> {
1740 i64::try_from(position).map_err(|_| StoreError::Validation(format!("too many {entity}")))
1741}
1742
1743fn sqlite_attachment_size(byte_len: u64) -> Result<i64, StoreError> {
1744 i64::try_from(byte_len)
1745 .map_err(|_| StoreError::Validation("attachment byte length exceeds integer range".into()))
1746}
1747
1748fn insert_task_attachment_rows(tx: &Transaction<'_>, task: &Task) -> Result<(), StoreError> {
1749 let mut statement = tx.prepare(
1750 "INSERT INTO task_attachments(task_id, block_index, attachment_id)
1751 VALUES (?1, ?2, ?3)",
1752 )?;
1753 for (block_index, block) in task.body.iter().enumerate() {
1754 let Block::Image { attachment_id } = block else {
1755 continue;
1756 };
1757 statement.execute(params![
1758 task.id,
1759 sqlite_position(block_index, "task attachment blocks")?,
1760 attachment_id,
1761 ])?;
1762 }
1763 Ok(())
1764}
1765
1766fn encode_task_body(task: &Task) -> Result<String, StoreError> {
1767 serde_json::to_string(&task.body).map_err(|error| {
1768 StoreError::Corrupt(format!("could not encode task {:?}: {error}", task.id))
1769 })
1770}
1771
1772fn persist_app_state(
1773 tx: &Transaction<'_>,
1774 revision: u64,
1775 settings: Option<&Settings>,
1776) -> Result<(), StoreError> {
1777 let revision = i64::try_from(revision)
1778 .map_err(|_| StoreError::Corrupt("revision exceeds SQLite integer range".into()))?;
1779 let changed = if let Some(settings) = settings {
1780 let settings_json = serde_json::to_string(settings)
1781 .map_err(|error| StoreError::Corrupt(format!("could not encode settings: {error}")))?;
1782 tx.execute(
1783 "UPDATE app_state SET revision = ?1, settings_json = ?2 WHERE id = 1",
1784 params![revision, settings_json],
1785 )?
1786 } else {
1787 tx.execute(
1788 "UPDATE app_state SET revision = ?1 WHERE id = 1",
1789 [revision],
1790 )?
1791 };
1792 if changed != 1 {
1793 return Err(StoreError::Corrupt(format!(
1794 "expected to update app state, changed {changed} rows"
1795 )));
1796 }
1797 Ok(())
1798}
1799
1800fn import_task_attachments(
1801 data: &mut StoreData,
1802 images_root: Option<&Path>,
1803) -> Result<(), StoreError> {
1804 let mut known: HashMap<String, Attachment> = data
1805 .attachments
1806 .iter()
1807 .cloned()
1808 .map(|attachment| (attachment.id.clone(), attachment))
1809 .collect();
1810
1811 for task in &mut data.tasks {
1812 for block in &mut task.body {
1813 let Block::Image { attachment_id } = block else {
1814 continue;
1815 };
1816 if known.contains_key(attachment_id) {
1817 continue;
1818 }
1819 if is_attachment_id(attachment_id) {
1820 return Err(StoreError::Validation(format!(
1821 "task {:?} refers to unknown attachment {attachment_id:?}",
1822 task.id
1823 )));
1824 }
1825 let Some(images_root) = images_root else {
1826 return Err(StoreError::Validation(
1827 "image attachments require a persistent store".into(),
1828 ));
1829 };
1830 let imported = import_attachment(attachment_id, images_root)?;
1831 if let Some(existing) = known.get(&imported.id) {
1832 if existing != &imported {
1833 return Err(StoreError::Corrupt(format!(
1834 "attachment {:?} metadata does not match imported content",
1835 imported.id
1836 )));
1837 }
1838 } else {
1839 known.insert(imported.id.clone(), imported.clone());
1840 data.attachments.push(imported.clone());
1841 }
1842 *attachment_id = imported.id;
1843 }
1844 }
1845 data.attachments
1846 .sort_by(|left, right| left.id.cmp(&right.id));
1847 Ok(())
1848}
1849
1850fn import_attachment(reference: &str, images_root: &Path) -> Result<Attachment, StoreError> {
1851 let source_path = crate::image::expand_in(reference, images_root);
1852 let mut source = fs::File::open(&source_path)
1853 .map_err(|error| StoreError::io("open image attachment", &source_path, error))?;
1854 let metadata = source
1855 .metadata()
1856 .map_err(|error| StoreError::io("inspect image attachment", &source_path, error))?;
1857 if !metadata.is_file() {
1858 return Err(StoreError::Validation(format!(
1859 "image attachment {} is not a regular file",
1860 source_path.display()
1861 )));
1862 }
1863
1864 ensure_private_directory(images_root)?;
1865 let temp_path = images_root.join(format!(".mach-attachment-{}.tmp", uuid::Uuid::new_v4()));
1866 let mut temp = open_private_attachment_temp(&temp_path)?;
1867 let result = (|| {
1868 let mut hasher = Sha256::new();
1869 let mut byte_len = 0_u64;
1870 let mut prefix = [0_u8; 32];
1871 let mut prefix_len = 0usize;
1872 let mut buffer = [0_u8; 64 * 1024];
1873 loop {
1874 let read = source
1875 .read(&mut buffer)
1876 .map_err(|error| StoreError::io("read image attachment", &source_path, error))?;
1877 if read == 0 {
1878 break;
1879 }
1880 byte_len = byte_len
1881 .checked_add(read as u64)
1882 .ok_or_else(|| StoreError::Validation("image attachment is too large".into()))?;
1883 if byte_len > MAX_ATTACHMENT_BYTES {
1884 return Err(StoreError::Validation(format!(
1885 "image attachment {} exceeds the {} MiB safety limit",
1886 source_path.display(),
1887 MAX_ATTACHMENT_BYTES / 1024 / 1024
1888 )));
1889 }
1890 if prefix_len < prefix.len() {
1891 let copy = (prefix.len() - prefix_len).min(read);
1892 prefix[prefix_len..prefix_len + copy].copy_from_slice(&buffer[..copy]);
1893 prefix_len += copy;
1894 }
1895 hasher.update(&buffer[..read]);
1896 temp.write_all(&buffer[..read]).map_err(|error| {
1897 StoreError::io("write managed image attachment", &temp_path, error)
1898 })?;
1899 }
1900 if byte_len == 0 {
1901 return Err(StoreError::Validation(format!(
1902 "image attachment {} is empty",
1903 source_path.display()
1904 )));
1905 }
1906 let format = image::guess_format(&prefix[..prefix_len]).map_err(|_| {
1907 StoreError::Validation(format!(
1908 "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
1909 source_path.display()
1910 ))
1911 })?;
1912 let (extension, media_type) = attachment_format(format).ok_or_else(|| {
1913 StoreError::Validation(format!(
1914 "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
1915 source_path.display()
1916 ))
1917 })?;
1918 temp.sync_all()
1919 .map_err(|error| StoreError::io("sync managed image attachment", &temp_path, error))?;
1920 drop(temp);
1921 crate::image::load_dynamic(&temp_path).map_err(StoreError::Validation)?;
1922
1923 let id = format!("{:x}", hasher.finalize());
1924 let storage_name = format!("{id}.{extension}");
1925 let destination = images_root.join(&storage_name);
1926 if destination.exists() {
1927 let (stored_hash, stored_len) = hash_attachment_file(&destination)?;
1928 if stored_hash != id || stored_len != byte_len {
1929 return Err(StoreError::Corrupt(format!(
1930 "managed attachment {} does not match its content address",
1931 destination.display()
1932 )));
1933 }
1934 fs::remove_file(&temp_path).map_err(|error| {
1935 StoreError::io("remove duplicate image attachment", &temp_path, error)
1936 })?;
1937 } else {
1938 fs::rename(&temp_path, &destination).map_err(|error| {
1939 StoreError::io("install managed image attachment", &destination, error)
1940 })?;
1941 set_private_file(&destination)?;
1942 fs::File::open(images_root)
1943 .and_then(|directory| directory.sync_all())
1944 .map_err(|error| StoreError::io("sync image directory", images_root, error))?;
1945 }
1946 Ok(Attachment {
1947 id: id.clone(),
1948 sha256: id,
1949 media_type: media_type.into(),
1950 byte_len,
1951 storage_name,
1952 })
1953 })();
1954 if result.is_err() {
1955 let _ = fs::remove_file(&temp_path);
1956 }
1957 result
1958}
1959
1960fn open_private_attachment_temp(path: &Path) -> Result<fs::File, StoreError> {
1961 let mut options = fs::OpenOptions::new();
1962 options.write(true).create_new(true);
1963 #[cfg(unix)]
1964 {
1965 use std::os::unix::fs::OpenOptionsExt;
1966 options.mode(0o600);
1967 }
1968 options
1969 .open(path)
1970 .map_err(|error| StoreError::io("create managed image attachment", path, error))
1971}
1972
1973fn hash_attachment_file(path: &Path) -> Result<(String, u64), StoreError> {
1974 let mut file = fs::File::open(path)
1975 .map_err(|error| StoreError::io("open managed image attachment", path, error))?;
1976 let mut hasher = Sha256::new();
1977 let mut byte_len = 0_u64;
1978 let mut buffer = [0_u8; 64 * 1024];
1979 loop {
1980 let read = file
1981 .read(&mut buffer)
1982 .map_err(|error| StoreError::io("read managed image attachment", path, error))?;
1983 if read == 0 {
1984 break;
1985 }
1986 byte_len = byte_len
1987 .checked_add(read as u64)
1988 .ok_or_else(|| StoreError::Corrupt("managed attachment is too large".into()))?;
1989 if byte_len > MAX_ATTACHMENT_BYTES {
1990 return Err(StoreError::Corrupt(format!(
1991 "managed attachment {} exceeds the safety limit",
1992 path.display()
1993 )));
1994 }
1995 hasher.update(&buffer[..read]);
1996 }
1997 Ok((format!("{:x}", hasher.finalize()), byte_len))
1998}
1999
2000fn attachment_format(format: ImageFormat) -> Option<(&'static str, &'static str)> {
2001 match format {
2002 ImageFormat::Png => Some(("png", "image/png")),
2003 ImageFormat::Jpeg => Some(("jpg", "image/jpeg")),
2004 ImageFormat::Gif => Some(("gif", "image/gif")),
2005 ImageFormat::WebP => Some(("webp", "image/webp")),
2006 _ => None,
2007 }
2008}
2009
2010fn is_attachment_id(value: &str) -> bool {
2011 value.len() == ATTACHMENT_ID_LEN
2012 && value
2013 .bytes()
2014 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2015}
2016
2017#[derive(Clone, Copy)]
2018enum DueMode {
2019 NewWrite,
2020 LegacyMigration,
2021 Stored,
2022}
2023
2024#[derive(Clone, Copy, PartialEq, Eq)]
2025enum AttachmentMode {
2026 Draft,
2027 Persisted,
2028}
2029
2030fn normalize_and_validate(
2031 data: &mut StoreData,
2032 now: NaiveDateTime,
2033 due_mode: DueMode,
2034 attachment_mode: AttachmentMode,
2035) -> Result<(), StoreError> {
2036 if data.categories.len() > MAX_CATEGORY_COUNT {
2037 return Err(StoreError::Validation(format!(
2038 "category limit is {MAX_CATEGORY_COUNT}"
2039 )));
2040 }
2041 if data.tasks.len() > MAX_TASK_COUNT {
2042 return Err(StoreError::Validation(format!(
2043 "task limit is {MAX_TASK_COUNT}"
2044 )));
2045 }
2046
2047 let attachment_ids = validate_attachments(&data.attachments)?;
2048
2049 let mut category_ids = HashSet::new();
2050 let mut category_names = HashSet::new();
2051 for category in &data.categories {
2052 validate_single_line(&category.id, "category id")?;
2053 validate_byte_limit(&category.id, ID_MAX_BYTES, "category id")?;
2054 if category.is_all() {
2055 return Err(StoreError::Validation(
2056 "real category id cannot be empty".into(),
2057 ));
2058 }
2059 if !category_ids.insert(category.id.as_str()) {
2060 return Err(StoreError::Validation(format!(
2061 "category id {:?} must be unique",
2062 category.id
2063 )));
2064 }
2065 validate_single_line(&category.name, "category name")?;
2066 validate_byte_limit(
2067 &category.name,
2068 text_byte_limit(MAX_CATEGORY_NAME_LEN),
2069 "category name",
2070 )?;
2071 let name = category.name.trim();
2072 if name.is_empty() {
2073 return Err(StoreError::Validation(
2074 "category name cannot be empty".into(),
2075 ));
2076 }
2077 if name.graphemes(true).count() > MAX_CATEGORY_NAME_LEN {
2078 return Err(StoreError::Validation(format!(
2079 "category name {:?} exceeds {MAX_CATEGORY_NAME_LEN} characters",
2080 category.name
2081 )));
2082 }
2083 if !category_names.insert(category_name_key(name)) {
2084 return Err(StoreError::Validation(format!(
2085 "category names must be unique (duplicate {:?})",
2086 category.name
2087 )));
2088 }
2089 validate_multiline(
2090 &category.description,
2091 MAX_CATEGORY_DESC_LINES,
2092 MAX_CATEGORY_DESC_LINE_LEN,
2093 "category description",
2094 )?;
2095 }
2096
2097 let mut task_ids = HashSet::new();
2098 for task in &mut data.tasks {
2099 validate_single_line(&task.id, "task id")?;
2100 validate_byte_limit(&task.id, ID_MAX_BYTES, "task id")?;
2101 if task.id.is_empty() || !task_ids.insert(task.id.as_str()) {
2102 return Err(StoreError::Validation(format!(
2103 "task id {:?} must be nonempty and unique",
2104 task.id
2105 )));
2106 }
2107 validate_single_line(&task.title, "task title")?;
2108 validate_byte_limit(&task.title, text_byte_limit(MAX_TITLE_LEN), "task title")?;
2109 if task.title.trim().is_empty() {
2110 return Err(StoreError::Validation(format!(
2111 "task {:?} title cannot be empty",
2112 task.id
2113 )));
2114 }
2115 if task.title.graphemes(true).count() > MAX_TITLE_LEN {
2116 return Err(StoreError::Validation(format!(
2117 "task {:?} title exceeds {MAX_TITLE_LEN} characters",
2118 task.id
2119 )));
2120 }
2121 if task.importance > MAX_IMPORTANCE {
2122 return Err(StoreError::Validation(format!(
2123 "task {:?} importance must be 0-{MAX_IMPORTANCE}",
2124 task.id
2125 )));
2126 }
2127 if task.body.len() > MAX_BODY_LINES {
2128 return Err(StoreError::Validation(format!(
2129 "task {:?} body exceeds {MAX_BODY_LINES} blocks",
2130 task.id
2131 )));
2132 }
2133 for block in &task.body {
2134 validate_block(block, &task.id)?;
2135 if let Block::Image { attachment_id } = block {
2136 let known = attachment_ids.contains(attachment_id.as_str());
2137 if attachment_mode == AttachmentMode::Persisted && !known {
2138 return Err(StoreError::Validation(format!(
2139 "task {:?} refers to unknown attachment {attachment_id:?}",
2140 task.id
2141 )));
2142 }
2143 if attachment_mode == AttachmentMode::Draft
2144 && is_attachment_id(attachment_id)
2145 && !known
2146 {
2147 return Err(StoreError::Validation(format!(
2148 "task {:?} refers to unknown attachment {attachment_id:?}",
2149 task.id
2150 )));
2151 }
2152 }
2153 }
2154 if let Some(category_id) = task.category_id.as_deref() {
2155 validate_single_line(category_id, "task category id")?;
2156 validate_byte_limit(category_id, ID_MAX_BYTES, "task category id")?;
2157 if !category_ids.contains(category_id) {
2158 return Err(StoreError::Validation(format!(
2159 "task {:?} refers to unknown category {category_id:?}",
2160 task.id
2161 )));
2162 }
2163 }
2164 validate_single_line(&task.due, "task due")?;
2165 validate_byte_limit(&task.due, DUE_MAX_BYTES, "task due")?;
2166 let normalized_due = match due_mode {
2167 DueMode::NewWrite => due::normalize_for_write_at(&task.due, now),
2168 DueMode::LegacyMigration => due::normalize_legacy_at(&task.due, now),
2169 DueMode::Stored => due::normalize_for_write_at(&task.due, now),
2170 }
2171 .map_err(|error| StoreError::Validation(format!("task {:?} has {error}", task.id)))?;
2172 if matches!(due_mode, DueMode::Stored) && normalized_due != task.due {
2173 return Err(StoreError::Validation(format!(
2174 "task {:?} has noncanonical due value {:?}",
2175 task.id, task.due
2176 )));
2177 }
2178 task.due = normalized_due;
2179 validate_single_line(&task.created, "task creation timestamp")?;
2180 validate_byte_limit(&task.created, CREATED_MAX_BYTES, "task creation timestamp")?;
2181 NaiveDateTime::parse_from_str(&task.created, "%Y-%m-%d %H:%M:%S").map_err(|_| {
2182 StoreError::Validation(format!(
2183 "task {:?} has invalid creation timestamp {:?}",
2184 task.id, task.created
2185 ))
2186 })?;
2187 }
2188 validate_settings(&data.settings)
2189}
2190
2191fn validate_block(block: &Block, task_id: &str) -> Result<(), StoreError> {
2192 let (kind, value) = match block {
2193 Block::Text { text } => ("text", text),
2194 Block::Todo { text, .. } => ("subtask", text),
2195 Block::Bullet { text } => ("bullet", text),
2196 Block::Number { text } => ("number", text),
2197 Block::Link { url } => ("link", url),
2198 Block::Image { attachment_id } => ("image attachment", attachment_id),
2199 };
2200 validate_single_line(value, kind)?;
2201 validate_byte_limit(value, text_byte_limit(MAX_NOTES_LINE_LEN), kind)?;
2202 if value.graphemes(true).count() > MAX_NOTES_LINE_LEN {
2203 return Err(StoreError::Validation(format!(
2204 "task {task_id:?} {kind} exceeds {MAX_NOTES_LINE_LEN} characters"
2205 )));
2206 }
2207 Ok(())
2208}
2209
2210fn validate_attachments(attachments: &[Attachment]) -> Result<HashSet<&str>, StoreError> {
2211 let mut ids = HashSet::new();
2212 let mut storage_names = HashSet::new();
2213 for attachment in attachments {
2214 if !is_attachment_id(&attachment.id) || attachment.sha256 != attachment.id {
2215 return Err(StoreError::Validation(format!(
2216 "attachment {:?} has an invalid content address",
2217 attachment.id
2218 )));
2219 }
2220 if !ids.insert(attachment.id.as_str()) {
2221 return Err(StoreError::Validation(format!(
2222 "attachment id {:?} must be unique",
2223 attachment.id
2224 )));
2225 }
2226 if attachment.byte_len == 0 || attachment.byte_len > MAX_ATTACHMENT_BYTES {
2227 return Err(StoreError::Validation(format!(
2228 "attachment {:?} has invalid byte length {}",
2229 attachment.id, attachment.byte_len
2230 )));
2231 }
2232 let extension = match attachment.media_type.as_str() {
2233 "image/png" => "png",
2234 "image/jpeg" => "jpg",
2235 "image/gif" => "gif",
2236 "image/webp" => "webp",
2237 other => {
2238 return Err(StoreError::Validation(format!(
2239 "attachment {:?} has unsupported media type {other:?}",
2240 attachment.id
2241 )));
2242 }
2243 };
2244 let expected_storage_name = format!("{}.{}", attachment.id, extension);
2245 if attachment.storage_name != expected_storage_name {
2246 return Err(StoreError::Validation(format!(
2247 "attachment {:?} has invalid storage name {:?}",
2248 attachment.id, attachment.storage_name
2249 )));
2250 }
2251 if !storage_names.insert(attachment.storage_name.as_str()) {
2252 return Err(StoreError::Validation(format!(
2253 "attachment storage name {:?} must be unique",
2254 attachment.storage_name
2255 )));
2256 }
2257 }
2258 Ok(ids)
2259}
2260
2261fn validate_multiline(
2262 value: &str,
2263 max_lines: usize,
2264 max_line_len: usize,
2265 label: &str,
2266) -> Result<(), StoreError> {
2267 let max_line_bytes = text_byte_limit(max_line_len);
2268 let max_total_bytes = max_lines.saturating_mul(max_line_bytes.saturating_add(1));
2269 validate_byte_limit(value, max_total_bytes, label)?;
2270 if value
2271 .chars()
2272 .any(|character| character.is_control() && character != '\n')
2273 {
2274 return Err(StoreError::Validation(format!(
2275 "{label} contains a control character"
2276 )));
2277 }
2278 for (index, line) in value.split('\n').enumerate() {
2279 if index >= max_lines {
2280 return Err(StoreError::Validation(format!(
2281 "{label} exceeds {max_lines} lines"
2282 )));
2283 }
2284 if line.len() > max_line_bytes {
2285 return Err(StoreError::Validation(format!(
2286 "{label} line exceeds {max_line_bytes} bytes"
2287 )));
2288 }
2289 if line.graphemes(true).count() > max_line_len {
2290 return Err(StoreError::Validation(format!(
2291 "{label} line exceeds {max_line_len} characters"
2292 )));
2293 }
2294 }
2295 Ok(())
2296}
2297
2298fn validate_single_line(value: &str, label: &str) -> Result<(), StoreError> {
2299 if value.chars().any(char::is_control) {
2300 return Err(StoreError::Validation(format!(
2301 "{label} contains a control character"
2302 )));
2303 }
2304 Ok(())
2305}
2306
2307fn validate_byte_limit(value: &str, max_bytes: usize, label: &str) -> Result<(), StoreError> {
2308 if value.len() > max_bytes {
2309 return Err(StoreError::Validation(format!(
2310 "{label} exceeds {max_bytes} bytes"
2311 )));
2312 }
2313 Ok(())
2314}
2315
2316fn category_name_key(value: &str) -> String {
2320 caseless_key(value.trim())
2321}
2322
2323fn category_name_has_prefix(name: &str, folded_query: &str) -> bool {
2324 let normalized: String = name.trim().nfkc().collect();
2325 normalized
2326 .char_indices()
2327 .skip(1)
2328 .map(|(index, _)| index)
2329 .chain(std::iter::once(normalized.len()))
2330 .any(|end| category_name_key(&normalized[..end]) == folded_query)
2331}
2332
2333fn validate_settings(settings: &Settings) -> Result<(), StoreError> {
2334 validate_single_line(&settings.date_format, "date format")?;
2335 validate_byte_limit(
2336 &settings.date_format,
2337 SETTINGS_VALUE_MAX_BYTES,
2338 "date format",
2339 )?;
2340 validate_single_line(&settings.selected_color, "theme")?;
2341 validate_byte_limit(&settings.selected_color, SETTINGS_VALUE_MAX_BYTES, "theme")?;
2342 validate_single_line(&settings.sort, "sort")?;
2343 validate_byte_limit(&settings.sort, SETTINGS_VALUE_MAX_BYTES, "sort")?;
2344 validate_single_line(&settings.preview_position, "preview position")?;
2345 validate_byte_limit(
2346 &settings.preview_position,
2347 SETTINGS_VALUE_MAX_BYTES,
2348 "preview position",
2349 )?;
2350 if let Some(version) = settings.last_run_version.as_deref() {
2351 validate_single_line(version, "last-run version")?;
2352 validate_byte_limit(version, SETTINGS_VALUE_MAX_BYTES, "last-run version")?;
2353 }
2354 if settings
2355 .last_update_check_at
2356 .is_some_and(|timestamp| timestamp < 0)
2357 {
2358 return Err(StoreError::Validation(
2359 "last update check timestamp cannot be negative".into(),
2360 ));
2361 }
2362 if !DATE_FORMATS.contains(&settings.date_format.as_str()) {
2363 return Err(StoreError::Validation(format!(
2364 "unknown date format {:?}",
2365 settings.date_format
2366 )));
2367 }
2368 if !THEMES.contains(&settings.selected_color.as_str()) {
2369 return Err(StoreError::Validation(format!(
2370 "unknown theme {:?}",
2371 settings.selected_color
2372 )));
2373 }
2374 if !SORTS.contains(&settings.sort.as_str()) {
2375 return Err(StoreError::Validation(format!(
2376 "unknown sort {:?}",
2377 settings.sort
2378 )));
2379 }
2380 if !PREVIEW_POSITIONS.contains(&settings.preview_position.as_str()) {
2381 return Err(StoreError::Validation(format!(
2382 "unknown preview position {:?}",
2383 settings.preview_position
2384 )));
2385 }
2386 Ok(())
2387}
2388
2389fn read_optional_json<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, StoreError> {
2390 let file = match fs::File::open(path) {
2391 Ok(file) => file,
2392 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2393 Err(error) => return Err(StoreError::io("read", path, error)),
2394 };
2395 let size = file
2396 .metadata()
2397 .map_err(|error| StoreError::io("inspect", path, error))?
2398 .len();
2399 if size > MAX_LEGACY_JSON_BYTES {
2400 return Err(StoreError::Validation(format!(
2401 "legacy file {} is larger than the {} MiB safety limit",
2402 path.display(),
2403 MAX_LEGACY_JSON_BYTES / 1024 / 1024
2404 )));
2405 }
2406 serde_json::from_reader(std::io::BufReader::new(file))
2407 .map(Some)
2408 .map_err(|source| StoreError::Json {
2409 path: path.to_path_buf(),
2410 source,
2411 })
2412}
2413
2414fn validate_legacy_schema(path: &Path, schema: Option<u32>) -> Result<(), StoreError> {
2415 if let Some(found) = schema
2416 && found != SCHEMA_VERSION
2417 {
2418 return Err(StoreError::UnsupportedLegacySchema {
2419 path: path.to_path_buf(),
2420 found,
2421 expected: SCHEMA_VERSION,
2422 });
2423 }
2424 Ok(())
2425}
2426
2427#[derive(Debug, Deserialize)]
2428struct TasksFile {
2429 schema: u32,
2430 tasks: Vec<Task>,
2431}
2432
2433#[derive(Debug, Deserialize)]
2434struct CategoriesFile {
2435 schema: u32,
2436 categories: Vec<Category>,
2437}
2438
2439fn ensure_private_directory(path: &Path) -> Result<(), StoreError> {
2440 let created = match fs::create_dir(path) {
2441 Ok(()) => true,
2442 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => false,
2443 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2444 if let Some(parent) = path
2445 .parent()
2446 .filter(|parent| !parent.as_os_str().is_empty())
2447 {
2448 fs::create_dir_all(parent).map_err(|parent_error| {
2449 StoreError::io("create parent directory", parent, parent_error)
2450 })?;
2451 }
2452 match fs::create_dir(path) {
2453 Ok(()) => true,
2454 Err(retry)
2455 if retry.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() =>
2456 {
2457 false
2458 }
2459 Err(retry) => {
2460 return Err(StoreError::io("create directory", path, retry));
2461 }
2462 }
2463 }
2464 Err(error) => return Err(StoreError::io("create directory", path, error)),
2465 };
2466 if created {
2467 #[cfg(unix)]
2468 {
2469 use std::os::unix::fs::PermissionsExt;
2470 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
2471 .map_err(|error| StoreError::io("set permissions on", path, error))?;
2472 }
2473 }
2474 Ok(())
2475}
2476
2477fn prepare_private_database_file(path: &Path) -> Result<(), StoreError> {
2478 #[cfg(unix)]
2479 {
2480 use std::os::unix::fs::OpenOptionsExt;
2481 match fs::OpenOptions::new()
2482 .write(true)
2483 .create_new(true)
2484 .mode(0o600)
2485 .open(path)
2486 {
2487 Ok(file) => drop(file),
2488 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
2489 Err(error) => return Err(StoreError::io("create database", path, error)),
2490 }
2491 }
2492 #[cfg(not(unix))]
2493 let _ = path;
2494 Ok(())
2495}
2496
2497fn set_private_file(path: &Path) -> Result<(), StoreError> {
2498 #[cfg(unix)]
2499 {
2500 use std::os::unix::fs::PermissionsExt;
2501 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
2502 .map_err(|error| StoreError::io("set permissions on", path, error))?;
2503 }
2504 Ok(())
2505}
2506
2507#[cfg(test)]
2508mod tests {
2509 use super::*;
2510
2511 #[test]
2512 fn default_directory_requires_a_home_when_no_path_is_configured() {
2513 let error = resolve_data_dir_from(None, None, None)
2514 .expect_err("missing home must not silently select the working directory");
2515 assert!(matches!(error, StoreError::Validation(_)));
2516
2517 assert_eq!(
2518 resolve_data_dir_from(Some(PathBuf::from("/tmp/mach")), None, None).unwrap(),
2519 PathBuf::from("/tmp/mach")
2520 );
2521 assert_eq!(
2522 resolve_data_dir_from(None, Some(PathBuf::from("/tmp/configured")), None).unwrap(),
2523 PathBuf::from("/tmp/configured")
2524 );
2525 assert!(resolve_data_dir_from(Some(PathBuf::from("~/.mach")), None, None).is_err());
2526 }
2527}