diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 798a948..9c5ff10 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -16,6 +16,8 @@ fn main() { tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::AppManifest::new().commands(&[ "host_capabilities", + "record_get", + "record_write", "sync_login", "sync_vaults", "sync_create_vault", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 2b7cc1c..a80de52 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -47,6 +47,8 @@ "allow-sync-resolve", "allow-sync-logout", "allow-sync-run", - "allow-sync-preview" + "allow-sync-preview", + "allow-record-get", + "allow-record-write" ] } diff --git a/frontend/src-tauri/permissions/autogenerated/record_get.toml b/frontend/src-tauri/permissions/autogenerated/record_get.toml new file mode 100644 index 0000000..381a8c9 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/record_get.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-record-get" +description = "Enables the record_get command without any pre-configured scope." +commands.allow = ["record_get"] + +[[permission]] +identifier = "deny-record-get" +description = "Denies the record_get command without any pre-configured scope." +commands.deny = ["record_get"] diff --git a/frontend/src-tauri/permissions/autogenerated/record_write.toml b/frontend/src-tauri/permissions/autogenerated/record_write.toml new file mode 100644 index 0000000..aba89fa --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/record_write.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-record-write" +description = "Enables the record_write command without any pre-configured scope." +commands.allow = ["record_write"] + +[[permission]] +identifier = "deny-record-write" +description = "Denies the record_write command without any pre-configured scope." +commands.deny = ["record_write"] diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index c3a4698..348cf1c 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod core; pub mod credentials; mod payloads; +mod preference_records; pub mod recent; pub mod records; #[cfg(feature = "desktop")] diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index b2a4e6b..feb8111 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -2,6 +2,8 @@ //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 +mod record_commands; +use record_commands::*; mod sync_commands; use sync_commands::*; @@ -831,6 +833,8 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ host_capabilities, + record_get, + record_write, sync_login, sync_vaults, sync_create_vault, diff --git a/frontend/src-tauri/src/preference_records.rs b/frontend/src-tauri/src/preference_records.rs new file mode 100644 index 0000000..ce862dd --- /dev/null +++ b/frontend/src-tauri/src/preference_records.rs @@ -0,0 +1,180 @@ +//! Preference schemas contain portable values only; no paths, permissions, providers or secrets. +use crate::workspace::{HostError, Result}; +use serde::Deserialize; +use serde_json::Value; +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Level { + size: f64, + weight: u16, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Headings { + custom: bool, + family: String, + levels: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Theme { + theme_id: String, + font_editor_size: f64, + font_editor_family: String, + line_height: f64, + code_block_theme: String, + headings: Headings, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Markdown { + heading: String, + bullet: String, + increment_list: bool, + fence: String, + math: bool, + callouts: bool, + diagrams: bool, + auto_links: bool, + line_numbers: bool, + wrap_code: bool, + indent: u8, + default_language: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Preset { + name: String, + preferences: Markdown, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Preferences { + restore_last_vault: bool, + auto_save_interval: u32, + language: String, + default_editor_mode: String, + editor_line_width: u16, + spell_check: bool, + markdown: Markdown, + presets: Vec, +} +fn decode(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID")) +} +fn markdown(value: &Markdown) -> bool { + let _ = ( + value.increment_list, + value.math, + value.callouts, + value.diagrams, + value.auto_links, + value.line_numbers, + value.wrap_code, + ); + matches!(value.heading.as_str(), "atx" | "setext") + && matches!(value.bullet.as_str(), "-" | "*" | "+") + && matches!(value.fence.as_str(), "`" | "~") + && [2, 4, 8].contains(&value.indent) + && value.default_language.len() <= 40 + && value + .default_language + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b)) +} +pub fn validate(kind: &str, value: &Value) -> Result<()> { + let valid = match kind { + "theme_settings" => { + let value: Theme = decode(value)?; + let _ = value.headings.custom; + !value.theme_id.is_empty() + && value.theme_id.len() <= 128 + && value + .theme_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + && (6.0..=72.0).contains(&value.font_editor_size) + && (1.0..=3.0).contains(&value.line_height) + && !value.font_editor_family.is_empty() + && value.font_editor_family.len() <= 256 + && !value + .font_editor_family + .chars() + .any(|v| v.is_control() || ";{}\\".contains(v)) + && matches!( + value.code_block_theme.as_str(), + "auto" | "github-light" | "github-dark" + ) + && matches!( + value.headings.family.as_str(), + "inherit" | "serif" | "sans-serif" | "monospace" + ) + && value.headings.levels.len() == 6 + && value.headings.levels.iter().all(|level| { + (12.0..=72.0).contains(&level.size) + && [400, 500, 600, 700, 800].contains(&level.weight) + }) + } + "preferences" => { + let value: Preferences = decode(value)?; + let _ = (value.restore_last_vault, value.spell_check); + (50..=60000).contains(&value.auto_save_interval) + && matches!(value.language.as_str(), "zh-CN" | "en") + && matches!(value.default_editor_mode.as_str(), "source" | "wysiwyg") + && (40..=200).contains(&value.editor_line_width) + && markdown(&value.markdown) + && value.presets.len() <= 20 + && value.presets.iter().all(|p| { + !p.name.trim().is_empty() + && p.name.chars().count() <= 40 + && markdown(&p.preferences) + }) + } + _ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")), + }; + if valid { + Ok(()) + } else { + Err(HostError::new("RECORD_DATA_INVALID")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn portable_preferences_reject_unowned_nested_fields_and_invalid_values() { + let theme = json!({"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}}); + validate("theme_settings", &theme).unwrap(); + let markdown = json!({"heading":"atx","bullet":"-","incrementList":true,"fence":"`","math":true,"callouts":true,"diagrams":true,"autoLinks":true,"lineNumbers":true,"wrapCode":false,"indent":4,"defaultLanguage":""}); + let preferences = json!({"restoreLastVault":true,"autoSaveInterval":1500,"language":"zh-CN","defaultEditorMode":"wysiwyg","editorLineWidth":80,"spellCheck":false,"markdown":markdown,"presets":[]}); + validate("preferences", &preferences).unwrap(); + for field in [ + "apiKey", + "permissions", + "environment", + "vaultPath", + "provider", + ] { + let mut bad = preferences.clone(); + bad[field] = json!("planted-secret"); + assert_eq!( + validate("preferences", &bad).unwrap_err().code, + "RECORD_SCHEMA_INVALID" + ); + } + let mut bad = preferences.clone(); + bad["markdown"]["apiKey"] = json!("planted-secret"); + assert_eq!( + validate("preferences", &bad).unwrap_err().code, + "RECORD_SCHEMA_INVALID" + ); + let mut bad = theme; + bad["fontEditorFamily"] = json!("x;url(secret)"); + assert_eq!( + validate("theme_settings", &bad).unwrap_err().code, + "RECORD_DATA_INVALID" + ); + } +} diff --git a/frontend/src-tauri/src/record_commands.rs b/frontend/src-tauri/src/record_commands.rs new file mode 100644 index 0000000..53e49df --- /dev/null +++ b/frontend/src-tauri/src/record_commands.rs @@ -0,0 +1,61 @@ +//! Main-window preference records; no generic credential or application-state accessor. +use super::{with_workspace, Host}; +use notesagent_host::{records, workspace::HostError}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tauri::State; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Get { + vault_id: String, + kind: String, + id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Write { + vault_id: String, + record: Value, + expected: String, + operation_id: String, +} +fn preference(kind: &str) -> Result<(), String> { + if matches!(kind, "theme_settings" | "preferences") { + Ok(()) + } else { + Err("RECORD_SCOPE_DENIED".into()) + } +} +#[tauri::command] +pub fn record_get(host: State<'_, Host>, request: Get) -> Result, String> { + preference(&request.kind)?; + with_workspace(&host, |ws| { + if ws.vault_id != request.vault_id { + return Err(HostError::new("VAULT_CHANGED")); + } + ws.record_get_kind(&request.kind, &request.id) + }) +} +#[tauri::command] +pub fn record_write(host: State<'_, Host>, request: Write) -> Result { + let kind = request.record["kind"] + .as_str() + .ok_or("RECORD_SCHEMA_INVALID")?; + preference(kind)?; + let path = + records::path_for(kind, request.record["id"].as_str().unwrap_or("")).map_err(|e| e.code)?; + let bytes = serde_json::to_vec(&request.record).map_err(|_| "RECORD_SCHEMA_INVALID")?; + with_workspace(&host, |ws| { + if ws.vault_id != request.vault_id { + return Err(HostError::new("VAULT_CHANGED")); + } + let entry = ws.write_operation( + &path, + &request.expected, + &bytes, + "local", + &request.operation_id, + )?; + Ok(json!({"record":request.record,"hash":entry.hash,"file_id":entry.file_id})) + }) +} diff --git a/frontend/src-tauri/src/records.rs b/frontend/src-tauri/src/records.rs index 39c2e2a..dad7fd1 100644 --- a/frontend/src-tauri/src/records.rs +++ b/frontend/src-tauri/src/records.rs @@ -19,7 +19,7 @@ pub struct Record { pub schema: u32, pub kind: String, pub id: String, - pub data: TaskData, + pub data: Value, } pub fn path(id: &str) -> Result { if !id.starts_with("task_") @@ -32,10 +32,27 @@ pub fn path(id: &str) -> Result { } Ok(format!("opennexus-records/v1/tasks/{id}.json")) } +pub fn path_for(kind: &str, id: &str) -> Result { + match (kind, id) { + ("task", id) => path(id), + ("theme_settings", "appearance") => { + Ok("opennexus-records/v1/theme-settings/appearance.json".into()) + } + ("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()), + _ => Err(HostError::new("RECORD_ID_INVALID")), + } +} pub fn is_record(path: &str) -> bool { path.starts_with("opennexus-records/") } pub fn allowed(path_value: &str) -> bool { + if matches!( + path_value, + "opennexus-records/v1/theme-settings/appearance.json" + | "opennexus-records/v1/preferences/editor.json" + ) { + return true; + } path_value .strip_prefix("opennexus-records/v1/tasks/") .and_then(|v| v.strip_suffix(".json")) @@ -48,10 +65,15 @@ pub fn validate(path_value: &str, content: &[u8]) -> Result { let record: Record = serde_json::from_slice(content).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?; let time = |value: i64| (0..=253402300799999).contains(&value); - if record.schema != 1 || record.kind != "task" || path(&record.id)? != path_value { + if record.schema != 1 || path_for(&record.kind, &record.id)? != path_value { return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")); } - let data = &record.data; + if record.kind != "task" { + crate::preference_records::validate(&record.kind, &record.data)?; + return Ok(record); + } + let data: TaskData = serde_json::from_value(record.data.clone()) + .map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?; if data.title.trim().is_empty() || data.title.len() > 4096 || data.description.len() > 262144 @@ -76,15 +98,19 @@ pub fn validate(path_value: &str, content: &[u8]) -> Result { } impl Workspace { pub(crate) fn normalize_record_links(&mut self) -> Result<()> { - for path in self.sync_paths()?.into_iter().filter(|v| allowed(v)) { + for path in self + .sync_paths()? + .into_iter() + .filter(|v| v.starts_with("opennexus-records/v1/tasks/") && allowed(v)) + { let bytes = std::fs::read(self.resolve(&path)?)?; let original = validate(&path, &bytes)?; - if let Some(note_id) = original.data.note_id.as_ref() { + if let Some(note_id) = original.data["note_id"].as_str() { if let Ok(note_path) = self.path_for_id(note_id) { if let Some(entry) = self.entry(¬e_path)? { - if &entry.file_id != note_id { + if entry.file_id != note_id { let mut record = original; - record.data.note_id = Some(entry.file_id); + record.data["note_id"] = json!(entry.file_id); self.write( &path, &crate::workspace::hash(&bytes), @@ -100,7 +126,10 @@ impl Workspace { Ok(()) } pub fn record_get(&mut self, id: &str) -> Result> { - let path = path(id)?; + self.record_get_kind("task", id) + } + pub fn record_get_kind(&mut self, kind: &str, id: &str) -> Result> { + let path = path_for(kind, id)?; if !self.resolve(&path)?.is_file() { return Ok(None); } @@ -109,10 +138,10 @@ impl Workspace { } let document = self.read(&path)?; let mut record = validate(&path, document.content.as_bytes())?; - if let Some(note_id) = record.data.note_id.as_ref() { + if let Some(note_id) = record.data["note_id"].as_str() { if let Ok(path) = self.path_for_id(note_id) { if let Some(entry) = self.entry(&path)? { - record.data.note_id = Some(entry.file_id); + record.data["note_id"] = json!(entry.file_id); } } } @@ -127,7 +156,7 @@ impl Workspace { let paths = self .sync_paths()? .into_iter() - .filter(|path| allowed(path)) + .filter(|path| path.starts_with("opennexus-records/v1/tasks/") && allowed(path)) .collect::>(); let total = paths.len(); let mut items = Vec::new(); diff --git a/frontend/src-tauri/src/sync_initial.rs b/frontend/src-tauri/src/sync_initial.rs index ea73026..4e61eb2 100644 --- a/frontend/src-tauri/src/sync_initial.rs +++ b/frontend/src-tauri/src/sync_initial.rs @@ -40,6 +40,9 @@ impl Workspace { return Err(HostError::new("FILE_TOO_LARGE")); } let bytes = fs::read(source)?; + if crate::records::is_record(&path) { + crate::records::validate(&path, &bytes)?; + } Ok(Local { path, hash: hash(&bytes), diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index fe1ef67..d80ff95 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -484,6 +484,57 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate .record_get(task_id) .unwrap() .is_none()); + + let preference_path = "opennexus-records/v1/theme-settings/appearance.json"; + let preference_record = json!({"schema":1,"kind":"theme_settings","id":"appearance","data":{"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}}}); + workspace + .lock() + .unwrap() + .write( + preference_path, + "", + &serde_json::to_vec(&preference_record).unwrap(), + "local", + ) + .unwrap(); + client.push_one(&workspace, &binding).await.unwrap(); + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); + assert_eq!( + workspace_b + .lock() + .unwrap() + .record_get_kind("theme_settings", "appearance") + .unwrap() + .unwrap()["record"], + preference_record + ); + { + let mut ws = workspace_b.lock().unwrap(); + let stored = ws + .record_get_kind("theme_settings", "appearance") + .unwrap() + .unwrap(); + let mut record = stored["record"].clone(); + record["data"]["fontEditorSize"] = json!(24); + ws.write( + preference_path, + stored["hash"].as_str().unwrap(), + &serde_json::to_vec(&record).unwrap(), + "local", + ) + .unwrap(); + } + client_b.push_one(&workspace_b, &binding_b).await.unwrap(); + client.pull_page(&workspace, &binding).await.unwrap(); + assert_eq!( + workspace + .lock() + .unwrap() + .record_get_kind("theme_settings", "appearance") + .unwrap() + .unwrap()["record"]["data"]["fontEditorSize"], + 24 + ); // Kill the actual client process after each durable 10 MiB server offset, // before its response reaches the client. The next process must query offset. use sha2::{Digest, Sha256}; diff --git a/frontend/src/features/settings/SyncSettings.spec.ts b/frontend/src/features/settings/SyncSettings.spec.ts index 8097fe2..8a9b6aa 100644 --- a/frontend/src/features/settings/SyncSettings.spec.ts +++ b/frontend/src/features/settings/SyncSettings.spec.ts @@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { afterEach, expect, it, vi } from 'vitest' import { hostInvoke } from '@/services/platform/desktop' import SyncSettings from './SyncSettings.vue' +vi.mock('@/services/platform/preferenceSync', () => ({ preferenceSyncIssues: [], resolvePreferenceDraft: vi.fn(), seedCurrentPreferences: vi.fn() })) vi.mock('@/services/platform/desktop', () => ({ hostInvoke: vi.fn() })) const confirm = vi.hoisted(() => vi.fn()) vi.mock('@/composables/useActionDialog', () => ({ useActionDialog: () => ({ actionDialog: null, resolveAction: vi.fn(), askConfirm: confirm }) })) diff --git a/frontend/src/features/settings/SyncSettings.vue b/frontend/src/features/settings/SyncSettings.vue index 9fa5874..30fc5b6 100644 --- a/frontend/src/features/settings/SyncSettings.vue +++ b/frontend/src/features/settings/SyncSettings.vue @@ -1,5 +1,6 @@