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