Skip to main content

scriv/
storage.rs

1//! Notes file path resolution and persistence logic.
2
3use crate::crypto::{ENCRYPTED_MAGIC, decrypt_notes, encrypt_notes, is_encrypted_data};
4use crate::model::Note;
5use once_cell::sync::Lazy;
6use std::fs;
7use std::io::{BufRead, BufReader, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::Mutex;
10use zeroize::Zeroizing;
11
12static NOTES_PATH_OVERRIDE: Lazy<Mutex<Option<PathBuf>>> = Lazy::new(|| Mutex::new(None));
13static ACTIVE_PASSWORD: Lazy<Mutex<Zeroizing<String>>> =
14    Lazy::new(|| Mutex::new(Zeroizing::new(String::new())));
15
16/// Override notes path for tests and controlled environments.
17pub fn set_notes_path_override(path: Option<PathBuf>) {
18    let mut guard = NOTES_PATH_OVERRIDE
19        .lock()
20        .expect("notes path override lock poisoned");
21    *guard = path;
22}
23
24/// Set in-memory password used for decrypting/encrypting notes.
25pub fn set_active_password(password: String) {
26    let mut guard = ACTIVE_PASSWORD
27        .lock()
28        .expect("active password lock poisoned");
29    *guard = Zeroizing::new(password);
30}
31
32/// Get current active password value (zeroized on drop).
33pub(crate) fn active_password_zeroized() -> Zeroizing<String> {
34    let guard = ACTIVE_PASSWORD
35        .lock()
36        .expect("active password lock poisoned");
37    guard.clone()
38}
39
40/// Get current active password value.
41pub fn active_password() -> String {
42    let guard = ACTIVE_PASSWORD
43        .lock()
44        .expect("active password lock poisoned");
45    String::clone(&guard)
46}
47
48/// Resolve the platform-specific notes file path.
49pub fn notes_path() -> PathBuf {
50    if let Some(p) = NOTES_PATH_OVERRIDE
51        .lock()
52        .expect("notes path override lock poisoned")
53        .clone()
54    {
55        return p;
56    }
57
58    let data_dir = if cfg!(target_os = "windows") {
59        std::env::var("APPDATA").unwrap_or_default()
60    } else if cfg!(target_os = "macos") {
61        let home = std::env::var("HOME").unwrap_or_default();
62        Path::new(&home)
63            .join("Library")
64            .join("Application Support")
65            .to_string_lossy()
66            .into_owned()
67    } else {
68        let xdg = std::env::var("XDG_DATA_HOME").unwrap_or_default();
69        if !xdg.is_empty() {
70            xdg
71        } else {
72            let home = std::env::var("HOME").unwrap_or_default();
73            Path::new(&home)
74                .join(".local")
75                .join("share")
76                .to_string_lossy()
77                .into_owned()
78        }
79    };
80
81    let base = if data_dir.is_empty() {
82        PathBuf::from(".")
83    } else {
84        PathBuf::from(data_dir)
85    };
86
87    base.join("scriv").join("notes.json")
88}
89
90/// Return true when the on-disk notes file starts with the encrypted magic header.
91pub fn notes_file_is_encrypted() -> bool {
92    let path = notes_path();
93    let file = fs::File::open(path);
94    let mut file = match file {
95        Ok(f) => f,
96        Err(_) => return false,
97    };
98
99    let mut header = [0_u8; ENCRYPTED_MAGIC.len()];
100    match file.read_exact(&mut header) {
101        Ok(()) => header == *ENCRYPTED_MAGIC,
102        Err(_) => false,
103    }
104}
105
106/// Load notes from disk. Missing files are treated as an empty dataset.
107pub fn load_notes() -> Result<Vec<Note>, String> {
108    let path = notes_path();
109    let mut data = match fs::read(&path) {
110        Ok(b) => b,
111        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
112        Err(e) => return Err(format!("cannot read from {}: {}", path.display(), e)),
113    };
114
115    if is_encrypted_data(&data) {
116        data = decrypt_notes(&data, &active_password_zeroized())?;
117    }
118
119    let reader = BufReader::new(data.as_slice());
120    let mut notes = Vec::new();
121
122    for line in reader.lines() {
123        let line = line.map_err(|e| format!("cannot read from {}: {}", path.display(), e))?;
124        let trimmed = line.trim();
125        if trimmed.is_empty() {
126            continue;
127        }
128        let note: Note = serde_json::from_str(trimmed).map_err(|_| {
129            "notes file is corrupted. Run 'scriv clear --force' to reset.".to_string()
130        })?;
131        notes.push(note);
132    }
133
134    Ok(notes)
135}
136
137/// Persist notes to disk using atomic replacement via a temporary file to reduce corruption risk.
138pub fn save_notes(notes: &[Note]) -> Result<(), String> {
139    let path = notes_path();
140    let dir = path
141        .parent()
142        .ok_or_else(|| format!("cannot write to {}", path.display()))?
143        .to_path_buf();
144
145    fs::create_dir_all(&dir).map_err(|e| format!("cannot write to {}: {}", dir.display(), e))?;
146
147    let mut ndjson = Vec::new();
148    for note in notes {
149        let line = serde_json::to_string(note).map_err(|e| e.to_string())?;
150        ndjson.extend_from_slice(line.as_bytes());
151        ndjson.push(b'\n');
152    }
153
154    let pw = active_password_zeroized();
155    let payload = if pw.is_empty() {
156        ndjson
157    } else {
158        encrypt_notes(&ndjson, &pw).map_err(|e| format!("cannot encrypt notes: {}", e))?
159    };
160
161    let mut tmp = tempfile::NamedTempFile::new_in(&dir)
162        .map_err(|e| format!("cannot write to {}: {}", dir.display(), e))?;
163
164    #[cfg(unix)]
165    {
166        use std::os::unix::fs::PermissionsExt;
167        let perms = std::fs::Permissions::from_mode(0o600);
168        tmp.as_file()
169            .set_permissions(perms)
170            .map_err(|e| format!("cannot set permissions on {}: {}", path.display(), e))?;
171    }
172
173    tmp.write_all(&payload)
174        .map_err(|e| format!("cannot write to {}: {}", path.display(), e))?;
175    tmp.persist(&path)
176        .map_err(|e| format!("cannot write to {}: {}", path.display(), e.error))?;
177
178    Ok(())
179}