use crate::config::{device_node_id, Config};
use anyhow::{bail, Context, Result};
use chrono::{DateTime, NaiveDate, Utc};
use note_to_self_lib::model::normalize_tag;
use note_to_self_lib::{
checksum, decode_bytes, derive_journal_key, dirty_entries, encode_bytes, open, seal,
Attachment, Entry, EntryKind, HlcState, Manifest, ManifestEntry, Priority, SyncBlob,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug)]
pub struct LocalStore {
root: PathBuf,
journal: String,
encryption_key: [u8; 32],
pub entries: BTreeMap<Uuid, Entry>,
pub manifest: Manifest,
hlc_state: HlcState,
}
const LOCK_VERIFIER_PLAINTEXT: &[u8] = b"note-to-self locked journal v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct JournalMetadata {
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub verifier: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ExportFile {
journal: String,
exported_at: DateTime<Utc>,
entries: Vec<Entry>,
}
impl LocalStore {
pub fn open(
root: &Path,
journal: &str,
config: &Config,
encryption_key: &[u8; 32],
) -> Result<Self> {
create_journal(root, journal)?;
let manifest = read_json(&manifest_path(root, journal))?.unwrap_or_default();
let hlc_state = read_json(&hlc_state_path(root, journal))?
.unwrap_or_else(|| HlcState::new(device_node_id(config.device_id)));
let entries = load_entries(root, journal, encryption_key)?;
Ok(Self {
root: root.to_path_buf(),
journal: journal.to_string(),
encryption_key: *encryption_key,
entries,
manifest,
hlc_state,
})
}
pub fn journal(&self) -> &str {
&self.journal
}
pub fn add_journal_entry(
&mut self,
body: String,
tags: &[String],
starred: bool,
) -> Result<Uuid> {
self.add_journal_entry_with_attachments(body, tags, starred, Vec::new())
}
pub fn add_journal_entry_with_attachments(
&mut self,
body: String,
tags: &[String],
starred: bool,
attachments: Vec<Attachment>,
) -> Result<Uuid> {
if body.trim().is_empty() {
bail!("entry body cannot be empty");
}
let hlc = self.hlc_state.next();
let entry = Entry::new_journal_with_attachments(body, tags, starred, attachments, hlc);
let id = entry.id;
self.entries.insert(id, entry);
self.save_entry(id)?;
self.save_hlc()?;
Ok(id)
}
pub fn add_todo(
&mut self,
body: String,
tags: &[String],
priority: Priority,
due: Option<NaiveDate>,
) -> Result<Uuid> {
if body.trim().is_empty() {
bail!("todo body cannot be empty");
}
let hlc = self.hlc_state.next();
let entry = Entry::new_todo(body, tags, priority, due, hlc);
let id = entry.id;
self.entries.insert(id, entry);
self.save_entry(id)?;
self.save_hlc()?;
Ok(id)
}
pub fn edit_body(&mut self, prefix: &str, body: String) -> Result<Uuid> {
let id = self.resolve_id(prefix)?;
let hlc = self.hlc_state.next();
let entry = self.entries.get_mut(&id).context("entry disappeared")?;
if body.trim().is_empty() && entry.attachments.is_empty() {
bail!("entry body cannot be empty");
}
let existing_tags = entry.tags.clone();
entry.body = body;
entry.touch(hlc);
merge_explicit_tags(entry, &existing_tags);
self.save_entry(id)?;
self.save_hlc()?;
Ok(id)
}
pub fn soft_delete(&mut self, prefix: &str) -> Result<Uuid> {
let id = self.resolve_id(prefix)?;
let hlc = self.hlc_state.next();
let entry = self.entries.get_mut(&id).context("entry disappeared")?;
entry.deleted = true;
entry.touch(hlc);
self.save_entry(id)?;
self.save_hlc()?;
Ok(id)
}
pub fn set_todo_completed(&mut self, prefix: &str, completed: bool) -> Result<Uuid> {
self.update_todo(prefix, |meta| {
meta.completed = completed;
meta.completed_at = completed.then(Utc::now);
})
}
pub fn set_todo_priority(&mut self, prefix: &str, priority: Priority) -> Result<Uuid> {
self.update_todo(prefix, |meta| {
meta.priority = priority;
})
}
pub fn set_todo_due(&mut self, prefix: &str, due: Option<NaiveDate>) -> Result<Uuid> {
self.update_todo(prefix, |meta| {
meta.due = due;
})
}
fn update_todo(
&mut self,
prefix: &str,
update: impl FnOnce(&mut note_to_self_lib::TodoMeta),
) -> Result<Uuid> {
let id = self.resolve_id(prefix)?;
let hlc = self.hlc_state.next();
let entry = self.entries.get_mut(&id).context("entry disappeared")?;
let EntryKind::Todo(meta) = &mut entry.kind else {
bail!("entry {prefix} is not a todo");
};
update(meta);
entry.touch(hlc);
self.save_entry(id)?;
self.save_hlc()?;
Ok(id)
}
pub fn resolve_id(&self, prefix: &str) -> Result<Uuid> {
let prefix = prefix.to_ascii_lowercase();
let matches: Vec<Uuid> = self
.entries
.keys()
.copied()
.filter(|id| id.to_string().starts_with(&prefix))
.collect();
match matches.as_slice() {
[id] => Ok(*id),
[] => bail!("no entry matches id prefix {prefix}"),
_ => bail!("id prefix {prefix} is ambiguous"),
}
}
pub fn get(&self, prefix: &str) -> Result<&Entry> {
let id = self.resolve_id(prefix)?;
self.entries.get(&id).context("entry disappeared")
}
pub fn upload_blobs(&self) -> Result<Vec<SyncBlob>> {
dirty_entries(self.entries.values(), &self.manifest)
.into_iter()
.map(|entry| {
let raw = fs::read(entry_path(&self.root, &self.journal, entry.id))
.with_context(|| format!("failed to read blob for {}", entry.id))?;
Ok(SyncBlob::from_raw(entry.id, &raw, entry.version, entry.hlc))
})
.collect()
}
pub fn client_manifest(&self) -> Result<Manifest> {
let mut manifest = Manifest::new();
for entry in self.entries.values() {
let raw = fs::read(entry_path(&self.root, &self.journal, entry.id))
.with_context(|| format!("failed to read blob for {}", entry.id))?;
manifest.insert(
entry.id,
ManifestEntry {
version: entry.version,
hlc: entry.hlc,
checksum: checksum(&raw),
},
);
}
Ok(manifest)
}
pub fn apply_download(&mut self, blob: &SyncBlob) -> Result<()> {
let raw = blob.raw().context("server returned invalid base64 blob")?;
if checksum(&raw) != blob.checksum {
bail!("server returned blob with invalid checksum for {}", blob.id);
}
let plaintext = open(&self.encryption_key, &raw)
.with_context(|| format!("failed to decrypt downloaded entry {}", blob.id))?;
let remote: Entry = serde_json::from_slice(&plaintext)
.with_context(|| format!("failed to decode downloaded entry {}", blob.id))?;
self.hlc_state.observe(remote.hlc);
match self.entries.get(&remote.id).cloned() {
Some(local) if local.hlc > remote.hlc => {
self.write_conflict(&remote)?;
}
Some(local) if local.hlc < remote.hlc => {
self.write_conflict(&local)?;
fs::write(entry_path(&self.root, &self.journal, remote.id), raw)
.with_context(|| format!("failed to save downloaded entry {}", remote.id))?;
self.entries.insert(remote.id, remote);
}
Some(local) if local.version > remote.version => {
self.write_conflict(&remote)?;
}
Some(local) if local.version < remote.version => {
self.write_conflict(&local)?;
fs::write(entry_path(&self.root, &self.journal, remote.id), raw)
.with_context(|| format!("failed to save downloaded entry {}", remote.id))?;
self.entries.insert(remote.id, remote);
}
Some(_) => {}
None => {
fs::write(entry_path(&self.root, &self.journal, remote.id), raw)
.with_context(|| format!("failed to save downloaded entry {}", remote.id))?;
self.entries.insert(remote.id, remote);
}
}
self.save_hlc()?;
Ok(())
}
pub fn adopt_server_manifest(&mut self, server_manifest: Manifest) -> Result<()> {
let mut next = self.manifest.clone();
for (id, server) in server_manifest {
match self.entries.get(&id) {
Some(local) if local.hlc > server.hlc => {}
Some(local) if local.hlc == server.hlc && local.version > server.version => {}
_ => {
next.insert(id, server);
}
}
}
self.manifest = next;
self.save_manifest()
}
pub fn export_json(&self) -> Result<String> {
let entries = self.entries.values().cloned().collect();
let export = ExportFile {
journal: self.journal.clone(),
exported_at: Utc::now(),
entries,
};
serde_json::to_string_pretty(&export).context("failed to serialize export")
}
pub fn import_json(&mut self, raw: &str) -> Result<usize> {
let export: ExportFile = serde_json::from_str(raw).context("failed to parse import")?;
let mut imported = 0;
for entry in export.entries {
let should_insert = match self.entries.get(&entry.id) {
Some(existing) => entry.hlc > existing.hlc,
None => true,
};
if should_insert {
let id = entry.id;
self.hlc_state.observe(entry.hlc);
self.entries.insert(id, entry);
self.save_entry(id)?;
imported += 1;
}
}
self.save_hlc()?;
Ok(imported)
}
pub fn reencrypt_all(&mut self, new_encryption_key: &[u8; 32]) -> Result<usize> {
self.encryption_key = *new_encryption_key;
let ids: Vec<Uuid> = self.entries.keys().copied().collect();
for id in &ids {
self.save_entry(*id)?;
}
self.manifest.clear();
self.save_manifest()?;
Ok(ids.len())
}
fn save_entry(&self, id: Uuid) -> Result<()> {
let entry = self.entries.get(&id).context("entry disappeared")?;
let plaintext = serde_json::to_vec(entry).context("failed to serialize entry")?;
let sealed = seal(&self.encryption_key, &plaintext)?;
fs::write(entry_path(&self.root, &self.journal, id), sealed)
.with_context(|| format!("failed to write entry {id}"))?;
Ok(())
}
fn save_manifest(&self) -> Result<()> {
write_json(&manifest_path(&self.root, &self.journal), &self.manifest)
}
fn save_hlc(&self) -> Result<()> {
write_json(&hlc_state_path(&self.root, &self.journal), &self.hlc_state)
}
fn write_conflict(&self, entry: &Entry) -> Result<()> {
let dir = self.root.join("conflicts");
fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
let file = format!(
"{}_{}_{}_{}.json",
entry.id, entry.hlc.wall_ms, entry.hlc.counter, entry.hlc.node_id
);
write_json(&dir.join(file), entry)
}
}
pub fn list_journals(root: &Path) -> Result<Vec<String>> {
let dir = root.join("journals");
if !dir.exists() {
return Ok(vec![]);
}
let mut journals = Vec::new();
for entry in fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))? {
let entry = entry?;
if entry.file_type()?.is_dir() {
journals.push(entry.file_name().to_string_lossy().to_string());
}
}
journals.sort();
Ok(journals)
}
pub fn create_journal(root: &Path, journal: &str) -> Result<()> {
validate_journal_name(journal)?;
fs::create_dir_all(entries_dir(root, journal))?;
fs::create_dir_all(sync_dir(root, journal))?;
if !metadata_path(root, journal).exists() {
write_journal_metadata(root, journal, &JournalMetadata::default())?;
}
Ok(())
}
pub fn delete_journal(root: &Path, journal: &str) -> Result<()> {
validate_journal_name(journal)?;
let dir = journal_dir(root, journal);
if dir.exists() {
fs::remove_dir_all(&dir).with_context(|| format!("failed to delete {}", dir.display()))?;
}
Ok(())
}
pub fn journal_dir(root: &Path, journal: &str) -> PathBuf {
root.join("journals").join(journal)
}
pub fn journal_metadata(root: &Path, journal: &str) -> Result<JournalMetadata> {
validate_journal_name(journal)?;
Ok(read_json(&metadata_path(root, journal))?.unwrap_or_default())
}
pub fn is_journal_locked(root: &Path, journal: &str) -> Result<bool> {
Ok(journal_metadata(root, journal)?.locked)
}
pub fn lock_journal_metadata(
root: &Path,
journal: &str,
username: &str,
password: &str,
) -> Result<[u8; 32]> {
create_journal(root, journal)?;
let key = derive_journal_key(username, journal, password)?;
write_locked_journal_metadata(root, journal, &key)?;
Ok(key)
}
pub fn write_locked_journal_metadata(root: &Path, journal: &str, key: &[u8; 32]) -> Result<()> {
create_journal(root, journal)?;
let verifier = seal(&key, LOCK_VERIFIER_PLAINTEXT)?;
write_locked_journal_metadata_with_verifier(root, journal, encode_bytes(&verifier))
}
pub fn write_locked_journal_metadata_with_verifier(
root: &Path,
journal: &str,
verifier: String,
) -> Result<()> {
create_journal(root, journal)?;
write_journal_metadata(
root,
journal,
&JournalMetadata {
locked: true,
verifier: Some(verifier),
},
)?;
Ok(())
}
pub fn locked_journal_verifier(root: &Path, journal: &str) -> Result<Option<String>> {
Ok(journal_metadata(root, journal)?.verifier)
}
pub fn unlock_journal_metadata(root: &Path, journal: &str) -> Result<()> {
create_journal(root, journal)?;
write_journal_metadata(root, journal, &JournalMetadata::default())
}
pub fn locked_journal_key(
root: &Path,
journal: &str,
username: &str,
password: &str,
) -> Result<[u8; 32]> {
let key = derive_journal_key(username, journal, password)?;
verify_journal_key(root, journal, &key)?;
Ok(key)
}
pub fn verify_journal_key(root: &Path, journal: &str, key: &[u8; 32]) -> Result<()> {
let metadata = journal_metadata(root, journal)?;
if !metadata.locked {
return Ok(());
}
let verifier = metadata
.verifier
.as_deref()
.context("locked journal is missing its verifier")?;
let sealed = decode_bytes(verifier)?;
let plaintext = open(key, &sealed).context("invalid journal password")?;
if plaintext != LOCK_VERIFIER_PLAINTEXT {
bail!("invalid journal password");
}
Ok(())
}
fn write_journal_metadata(root: &Path, journal: &str, metadata: &JournalMetadata) -> Result<()> {
write_json(&metadata_path(root, journal), metadata)
}
fn metadata_path(root: &Path, journal: &str) -> PathBuf {
journal_dir(root, journal).join("metadata.json")
}
fn entries_dir(root: &Path, journal: &str) -> PathBuf {
journal_dir(root, journal).join("entries")
}
fn sync_dir(root: &Path, journal: &str) -> PathBuf {
journal_dir(root, journal).join("sync")
}
fn entry_path(root: &Path, journal: &str, id: Uuid) -> PathBuf {
entries_dir(root, journal).join(format!("{id}.bin"))
}
fn manifest_path(root: &Path, journal: &str) -> PathBuf {
sync_dir(root, journal).join("manifest.json")
}
fn hlc_state_path(root: &Path, journal: &str) -> PathBuf {
sync_dir(root, journal).join("hlc_state.json")
}
fn load_entries(
root: &Path,
journal: &str,
encryption_key: &[u8; 32],
) -> Result<BTreeMap<Uuid, Entry>> {
let mut entries = BTreeMap::new();
let dir = entries_dir(root, journal);
if !dir.exists() {
return Ok(entries);
}
for entry in fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("bin") {
continue;
}
let raw = fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
let plaintext = open(encryption_key, &raw)
.with_context(|| format!("failed to decrypt {}", path.display()))?;
let entry: Entry = serde_json::from_slice(&plaintext)
.with_context(|| format!("failed to decode {}", path.display()))?;
entries.insert(entry.id, entry);
}
Ok(entries)
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
if !path.exists() {
return Ok(None);
}
let raw =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let value = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Some(value))
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = serde_json::to_string_pretty(value).context("failed to serialize JSON")?;
fs::write(path, raw).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn validate_journal_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("journal name cannot be empty");
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
bail!("journal names may only contain letters, numbers, '-' and '_'");
}
Ok(())
}
fn merge_explicit_tags(entry: &mut Entry, explicit_tags: &[String]) {
let mut tags = entry.tags.clone();
for tag in explicit_tags {
if let Some(tag) = normalize_tag(tag) {
if !tags.contains(&tag) {
tags.push(tag);
}
}
}
tags.sort();
entry.tags = tags;
}
#[cfg(test)]
mod tests {
use super::*;
use note_to_self_lib::derive_keys;
#[test]
fn locked_metadata_verifies_password() {
let root = tempfile::tempdir().unwrap();
lock_journal_metadata(root.path(), "private", "alice", "secret").unwrap();
assert!(is_journal_locked(root.path(), "private").unwrap());
assert!(locked_journal_key(root.path(), "private", "alice", "secret").is_ok());
assert!(locked_journal_key(root.path(), "private", "alice", "wrong").is_err());
}
#[test]
fn reencrypting_journal_requires_new_key_to_open_entries() {
let root = tempfile::tempdir().unwrap();
let config = Config::new(Some("alice".to_string()), "private".to_string(), None);
let account_keys = derive_keys("alice", "account").unwrap();
let mut store = LocalStore::open(
root.path(),
"private",
&config,
&account_keys.encryption_key,
)
.unwrap();
let id = store
.add_journal_entry("locked body".to_string(), &[], false)
.unwrap();
let journal_key = lock_journal_metadata(root.path(), "private", "alice", "secret").unwrap();
assert_eq!(store.reencrypt_all(&journal_key).unwrap(), 1);
let err = LocalStore::open(
root.path(),
"private",
&config,
&account_keys.encryption_key,
)
.unwrap_err();
assert!(format!("{err:#}").contains("failed to decrypt"));
let store = LocalStore::open(root.path(), "private", &config, &journal_key).unwrap();
assert_eq!(store.get(&id.to_string()).unwrap().body, "locked body");
assert_eq!(store.manifest.len(), 0);
}
}