Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions krillnotes-core/src/core/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ use std::hash::{Hash, Hasher};
pub fn get_device_id() -> Result<String> {
match mac_address::get_mac_address() {
Ok(Some(mac)) => {
// DefaultHasher is not guaranteed stable across Rust versions, but this
// derivation is battle-tested for multi-device sync. Do NOT change
// without extensive cross-device migration testing.
let mut hasher = DefaultHasher::new();
mac.bytes().hash(&mut hasher);
let hash = hasher.finish();
Expand Down
12 changes: 12 additions & 0 deletions krillnotes-core/src/core/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub struct WorkspaceMetadata {
/// Workspace-level taxonomy tags for gallery discovery (distinct from per-note tags).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_pubkey: Option<String>,
}

/// Result returned after reading an export archive's metadata.
Expand Down Expand Up @@ -257,6 +259,7 @@ pub fn export_workspace<W: Write + Seek>(
.get_workspace_metadata()
.map_err(|e| ExportError::Database(e.to_string()))?;
ws_meta.version = 1;
ws_meta.owner_pubkey = Some(workspace.owner_pubkey().to_string());
zip.start_file("workspace.json", options)?;
serde_json::to_writer_pretty(&mut zip, &ws_meta)?;

Expand Down Expand Up @@ -550,6 +553,15 @@ pub fn import_workspace<R: Read + Seek>(
.map_err(|e| ExportError::Database(e.to_string()))?;
}

// Restore the original owner_pubkey from the archive, overriding the
// importer's key that Workspace::open() inserted.
if let Some(ref meta) = workspace_metadata {
if let Some(ref original_owner) = meta.owner_pubkey {
workspace.set_owner_pubkey(original_owner)
.map_err(|e| ExportError::Database(e.to_string()))?;
}
}

Ok(ImportResult {
app_version: export_notes.app_version,
note_count: export_notes.notes.len(),
Expand Down
35 changes: 35 additions & 0 deletions krillnotes-core/src/core/export_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@
license_url: Some("https://mit-license.org".to_string()),
language: Some("en".to_string()),
tags: vec!["notes".to_string(), "template".to_string()],
owner_pubkey: None,
};
ws.set_workspace_metadata(&meta).unwrap();

Expand Down Expand Up @@ -603,6 +604,7 @@
license_url: None,
language: Some("en".to_string()),
tags: vec!["test".to_string(), "demo".to_string()],
owner_pubkey: None,
};
ws.set_workspace_metadata(&meta).unwrap();

Expand Down Expand Up @@ -642,3 +644,36 @@
let result = peek_import(Cursor::new(&buf), None).unwrap();
assert!(result.metadata.is_none(), "old archives without workspace.json should return None metadata");
}

#[test]
fn test_m7_import_preserves_original_owner_pubkey() {
// Create original workspace with key A
let key_a = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]);
let pubkey_a = {
use base64::Engine as _;
let vk = ed25519_dalek::VerifyingKey::from(&key_a);
base64::engine::general_purpose::STANDARD.encode(vk.as_bytes())
};
let temp_src = NamedTempFile::new().unwrap();
let ws = Workspace::create(temp_src.path(), "", "identity-a", key_a.clone(), test_gate(), None).unwrap();
assert_eq!(ws.owner_pubkey(), pubkey_a);

// Export
let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();

// Import with a DIFFERENT key (key B)
let key_b = ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]);
let pubkey_b = {
use base64::Engine as _;
let vk = ed25519_dalek::VerifyingKey::from(&key_b);
base64::engine::general_purpose::STANDARD.encode(vk.as_bytes())
};
let temp_dst = NamedTempFile::new().unwrap();
import_workspace(Cursor::new(&buf), temp_dst.path(), None, "", "identity-b", key_b.clone()).unwrap();

// Re-open and verify that owner is still key A, NOT key B
let imported_ws = Workspace::open(temp_dst.path(), "", "identity-b", key_b, test_gate(), None).unwrap();
assert_eq!(imported_ws.owner_pubkey(), pubkey_a, "imported workspace must preserve original owner, not importer");
assert_ne!(imported_ws.owner_pubkey(), pubkey_b);
}
2 changes: 1 addition & 1 deletion krillnotes-core/src/core/hlc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ pub fn node_id_from_device(device_id: &Uuid) -> u32 {
fn wall_clock_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_millis() as u64
}

Expand Down
5 changes: 5 additions & 0 deletions krillnotes-core/src/core/operation_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use rusqlite::Transaction;
const SECONDS_PER_DAY: i64 = 86_400;

/// Controls which old operations are removed from the log.
#[derive(Debug, PartialEq)]
pub enum PurgeStrategy {
/// Retain only the most recent `keep_last` operations.
///
Expand Down Expand Up @@ -51,6 +52,10 @@ impl OperationLog {
Self { strategy }
}

pub fn purge_strategy(&self) -> &PurgeStrategy {
&self.strategy
}

/// Serialises `op` and appends it to the `operations` table within `tx`.
///
/// # Errors
Expand Down
11 changes: 11 additions & 0 deletions krillnotes-core/src/core/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ impl Storage {
}

fn run_migrations(conn: &Connection) -> Result<()> {
conn.execute_batch("BEGIN IMMEDIATE;")?;
let result = Self::run_migrations_inner(conn);
if result.is_ok() {
conn.execute_batch("COMMIT;")?;
} else {
let _ = conn.execute_batch("ROLLBACK;");
}
result
}

fn run_migrations_inner(conn: &Connection) -> Result<()> {
// Migration: add is_expanded column if absent.
let column_exists: bool = conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='is_expanded'",
Expand Down
6 changes: 6 additions & 0 deletions krillnotes-core/src/core/undo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ use crate::{AttachmentMeta, FieldValue, Note};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

fn default_script_category() -> String {
"library".to_string()
}

/// The inverse data needed to reverse one or more workspace mutations.
///
/// Applied by [`crate::Workspace::undo`] to restore previous state.
Expand Down Expand Up @@ -61,6 +65,8 @@ pub enum RetractInverse {
source_code: String,
load_order: i32,
enabled: bool,
#[serde(default = "default_script_category")]
category: String,
},

/// Inverse of `DeleteAttachment` — restores a soft-deleted attachment.
Expand Down
18 changes: 16 additions & 2 deletions krillnotes-core/src/core/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ impl Workspace {
) -> Result<Self> {
let mut storage = Storage::create(&path, password)?;
let mut script_registry = ScriptRegistry::new()?;
let operation_log = OperationLog::new(PurgeStrategy::LocalOnly { keep_last: 100 });
let purge_limit = Self::read_purge_limit_from_meta(storage.connection());
let operation_log = OperationLog::new(PurgeStrategy::LocalOnly { keep_last: purge_limit });

let device_id = if let Some(dir) = identity_dir {
let device_uuid = crate::core::identity::ensure_device_uuid(dir)?;
Expand Down Expand Up @@ -458,7 +459,8 @@ impl Workspace {
pub fn open<P: AsRef<Path>>(path: P, password: &str, identity_uuid: &str, signing_key: ed25519_dalek::SigningKey, mut permission_gate: Box<dyn crate::core::permission::PermissionGate>, identity_dir: Option<&Path>) -> Result<Self> {
let storage = Storage::open(&path, password)?;
let script_registry = ScriptRegistry::new()?;
let operation_log = OperationLog::new(PurgeStrategy::LocalOnly { keep_last: 100 });
let purge_limit = Self::read_purge_limit_from_meta(storage.connection());
let operation_log = OperationLog::new(PurgeStrategy::LocalOnly { keep_last: purge_limit });

// Read metadata from database
let mut device_id: String = storage.connection()
Expand Down Expand Up @@ -1030,6 +1032,18 @@ impl Workspace {
Ok(())
}

const DEFAULT_PURGE_LIMIT: usize = 1000;

fn read_purge_limit_from_meta(conn: &rusqlite::Connection) -> usize {
conn.query_row(
"SELECT value FROM workspace_meta WHERE key = 'purge_limit'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(Self::DEFAULT_PURGE_LIMIT)
}
}

// ── Domain sub-modules (split from this file for readability) ──────
Expand Down
40 changes: 17 additions & 23 deletions krillnotes-core/src/core/workspace/notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -887,9 +887,9 @@ impl Workspace {
};
self.authorize(&auth_op)?;

let now = chrono::Utc::now().timestamp();
let ts = self.advance_hlc();
let signing_key = self.signing_key.clone();
let now = ts.wall_ms as i64;

let tx = self.storage.connection_mut().transaction()?;
tx.execute(
Expand Down Expand Up @@ -1297,6 +1297,9 @@ impl Workspace {
self.clear_links_to(id)?;
}

let ts = self.advance_hlc();
let signing_key = self.signing_key.clone();

let tx = self.storage.connection_mut().transaction()?;

// Clean up any permission grants anchored on deleted notes.
Expand All @@ -1315,29 +1318,20 @@ impl Workspace {
);

let result = Self::delete_recursive_in_tx(&tx, note_id)?;
tx.commit()?;

// Log a DeleteNote operation for the root of the deleted subtree.
// Uses a separate transaction since the deletion tx was already committed.
// Advance HLC and capture signing key before the second transaction borrows self.storage.
let ts = self.advance_hlc();
let signing_key = self.signing_key.clone();
{
let tx = self.storage.connection_mut().transaction()?;
Self::save_hlc(&ts, &tx)?;
let mut op = Operation::DeleteNote {
operation_id: op_id.clone(),
timestamp: ts,
device_id: self.device_id.clone(),
note_id: note_id.to_string(),
deleted_by: String::new(),
signature: String::new(),
};
Self::sign_op_with(&signing_key, &mut op);
Self::log_op(&self.operation_log, &tx, &op)?;
Self::purge_ops_if_needed(&self.operation_log, &tx)?;
tx.commit()?;
}
Self::save_hlc(&ts, &tx)?;
let mut op = Operation::DeleteNote {
operation_id: op_id.clone(),
timestamp: ts,
device_id: self.device_id.clone(),
note_id: note_id.to_string(),
deleted_by: String::new(),
signature: String::new(),
};
Self::sign_op_with(&signing_key, &mut op);
Self::log_op(&self.operation_log, &tx, &op)?;
Self::purge_ops_if_needed(&self.operation_log, &tx)?;
tx.commit()?;

self.push_undo(UndoEntry {
retracted_ids: vec![op_id],
Expand Down
2 changes: 2 additions & 0 deletions krillnotes-core/src/core/workspace/scripts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ impl Workspace {
source_code: old_script.source_code,
load_order: old_script.load_order,
enabled: old_script.enabled,
category: old_script.category,
},
propagate: true,
});
Expand Down Expand Up @@ -325,6 +326,7 @@ impl Workspace {
source_code: old_script.source_code,
load_order: old_script.load_order,
enabled: old_script.enabled,
category: old_script.category,
},
propagate: true,
});
Expand Down
76 changes: 76 additions & 0 deletions krillnotes-core/src/core/workspace/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,7 @@ register_menu("Add Item", ["TAFolder"], |note| {
license_url: None,
language: Some("en".to_string()),
tags: vec!["productivity".to_string()],
owner_pubkey: None,
};
ws.set_workspace_metadata(&meta).unwrap();

Expand Down Expand Up @@ -3808,3 +3809,78 @@ schema("SameVerType", #{
let after_undo = ws.get_note(&root.id).unwrap();
assert!(after_undo.is_checked, "undo should restore checked state to true");
}

// ── Batch 2: Data Integrity Fix Tests ──────────────────────────────

#[test]
fn test_c5_delete_note_logs_operation_atomically() {
let temp = NamedTempFile::new().unwrap();
let mut ws = Workspace::create(temp.path(), "", "test-identity", ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), test_gate(), None).unwrap();

let root = ws.list_all_notes().unwrap()[0].clone();
let child_id = ws.create_note(&root.id, AddPosition::AsChild, "TextNote").unwrap();

ws.delete_note_recursive(&child_id).unwrap();

let ops = ws.list_operations(Some("DeleteNote"), None, None).unwrap();
assert!(!ops.is_empty(), "DeleteNote operation must be logged after recursive delete");
}

#[test]
fn test_m4_undo_delete_schema_script_restores_category() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.krillnotes");
let mut ws = Workspace::create(&path, "", "test-identity", ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), test_gate(), None).unwrap();

let src = "// @name: MySchema\n// @description: test schema\nschema(\"MySchema\", #{ version: 1, fields: [] });";
let (script, _) = ws.create_user_script(src).unwrap();
assert_eq!(script.category, "schema");
ws.script_undo_stack.clear();

ws.delete_user_script(&script.id).unwrap();
assert!(ws.can_script_undo());

ws.script_undo().unwrap();

let restored = ws.get_user_script(&script.id).unwrap();
assert_eq!(restored.category, "schema", "restored script must retain original category, not default to 'library'");
}

#[test]
fn test_m5_purge_retains_more_than_100_ops() {
let temp = NamedTempFile::new().unwrap();
let mut ws = Workspace::create(temp.path(), "", "test-identity", ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), test_gate(), None).unwrap();

let root = ws.list_all_notes().unwrap()[0].clone();
// Generate 120 operations (each title update = 1 op).
for i in 0..120 {
ws.update_note_title(&root.id, format!("Title {i}")).unwrap();
}

// Old limit (100) would purge ops down to 100; new limit (1000) keeps all.
// The workspace starts with a CreateNote op, so total should be 1 + 120 = 121.
let ops = ws.list_operations(None, None, None).unwrap();
assert!(
ops.len() > 100,
"purge limit should be higher than 100, but only {} ops remain",
ops.len()
);
}

#[test]
fn test_m6_set_note_checked_stores_seconds_timestamp() {
let temp = NamedTempFile::new().unwrap();
let mut ws = Workspace::create(temp.path(), "", "test-identity", ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), test_gate(), None).unwrap();

let root = ws.list_all_notes().unwrap()[0].clone();
ws.set_note_checked(&root.id, true).unwrap();

let note = ws.get_note(&root.id).unwrap();
// A seconds-range timestamp (10 digits) is < 10_000_000_000.
// A milliseconds-range timestamp (13 digits) is > 1_000_000_000_000.
assert!(
note.modified_at < 10_000_000_000,
"modified_at should be seconds, not milliseconds: got {}",
note.modified_at
);
}
Loading