diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index e1f3162..e4307d3 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -477,21 +477,19 @@ impl SqliteStore { .fetch_one(&self.pool) .await?; - let transcript_text = - std::fs::read_to_string(paths::meeting_dir(id).join("transcript.json")) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .map(|t| { - t.segments - .iter() - .map(|s| s.text.as_str()) - .collect::>() - .join(" ") - }) - .unwrap_or_default(); + let transcript_text = read_artifact(&paths::meeting_dir(id).join("transcript.json")) + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|t| { + t.segments + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); let notes_text = - std::fs::read_to_string(paths::meeting_dir(id).join("notes.md")).unwrap_or_default(); + read_artifact(&paths::meeting_dir(id).join("notes.md")).unwrap_or_default(); sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?") .bind(id) @@ -664,7 +662,10 @@ impl Store for SqliteStore { }; let json = serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?; - std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?; + write_artifact( + &paths::meeting_dir(id).join("transcript.json"), + json.as_bytes(), + )?; self.reindex_fts(id).await?; let template_id: Option = sqlx::query_scalar("SELECT template_id FROM meetings WHERE id = ?") @@ -778,8 +779,7 @@ impl Store for SqliteStore { .collect(); let folder = paths::meeting_dir(id); - let mut segments = std::fs::read_to_string(folder.join("transcript.json")) - .ok() + let mut segments = read_artifact(&folder.join("transcript.json")) .and_then(|s| serde_json::from_str::(&s).ok()) .map(|t| t.segments) .unwrap_or_default(); @@ -791,9 +791,8 @@ impl Store for SqliteStore { seg.speaker = resolve_canonical(&seg.speaker, &merge_map).to_string(); } } - let notes_markdown = std::fs::read_to_string(folder.join("notes.md")).unwrap_or_default(); - let summary = std::fs::read_to_string(folder.join("summary.json")) - .ok() + let notes_markdown = read_artifact(&folder.join("notes.md")).unwrap_or_default(); + let summary = read_artifact(&folder.join("summary.json")) .and_then(|s| serde_json::from_str::(&s).ok()); let tags = self.tags_for_meeting(id).await?; @@ -837,7 +836,10 @@ impl Store for SqliteStore { } async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> { - std::fs::write(paths::meeting_dir(id).join("notes.md"), markdown)?; + write_artifact( + &paths::meeting_dir(id).join("notes.md"), + markdown.as_bytes(), + )?; sqlx::query("UPDATE meetings SET updated_at = ? WHERE id = ?") .bind(now_unix()) .bind(id) @@ -1450,6 +1452,24 @@ impl Store for SqliteStore { } } +/// Read a derived artifact, transparently decrypting it if the vault sealed it +/// (T8.8, FR-SEC-3). Returns `None` if the file is missing or can't be +/// decrypted (vault locked / wrong key) — callers already treat a missing +/// artifact as empty, so a locked vault degrades to "no content" rather than a +/// crash. +fn read_artifact(path: &std::path::Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let plain = crate::vault::open(&bytes).ok()?; + Some(String::from_utf8_lossy(&plain).into_owned()) +} + +/// Write a derived artifact, sealing it when the vault is unlocked (a no-op +/// passthrough otherwise). Audio is not sealed here — it's handled separately. +fn write_artifact(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { + let sealed = crate::vault::seal(contents).map_err(|e| std::io::Error::other(e.to_string()))?; + std::fs::write(path, sealed) +} + fn dir_size(path: &str) -> u64 { std::fs::read_dir(path) .into_iter()