//! Versioned logical records: explicit fields only, never raw application databases/config. use crate::workspace::{HostError, Result, Workspace}; use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TaskData { pub title: String, pub description: String, pub status: String, pub note_id: Option, pub due_at_ms: Option, pub created_at_ms: i64, pub updated_at_ms: i64, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Record { pub schema: u32, pub kind: String, pub id: String, pub data: Value, } pub fn path(id: &str) -> Result { if !id.starts_with("task_") || id.len() != 37 || !id[5..] .bytes() .all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v)) { return Err(HostError::new("RECORD_ID_INVALID")); } Ok(format!("opennexus-records/v1/tasks/{id}.json")) } pub fn user_skill_path(id: &str) -> Result { if !id.starts_with("user_skill_") || id.len() != 43 || !id[11..] .bytes() .all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v)) { return Err(HostError::new("RECORD_ID_INVALID")); } Ok(format!("opennexus-records/v1/user-skills/{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()) } ("persona", "default") => Ok("opennexus-records/v1/persona/default.json".into()), ("layout", "sidebars") => Ok("opennexus-records/v1/layout/sidebars.json".into()), ("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()), ("user_skill", id) => user_skill_path(id), _ => 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" | "opennexus-records/v1/layout/sidebars.json" | "opennexus-records/v1/persona/default.json" ) { return true; } if path_value .strip_prefix("opennexus-records/v1/tasks/") .and_then(|v| v.strip_suffix(".json")) .is_some_and(|id| path(id).is_ok()) { return true; } path_value .strip_prefix("opennexus-records/v1/user-skills/") .and_then(|v| v.strip_suffix(".json")) .is_some_and(|id| user_skill_path(id).is_ok()) } pub fn validate(path_value: &str, content: &[u8]) -> Result { if content.len() > 1024 * 1024 { return Err(HostError::new("RECORD_TOO_LARGE")); } 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 || path_for(&record.kind, &record.id)? != path_value { return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")); } 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 || !matches!( data.status.as_str(), "todo" | "in_progress" | "done" | "cancelled" ) || !time(data.created_at_ms) || !time(data.updated_at_ms) || data.due_at_ms.is_some_and(|v| !time(v)) || data.note_id.as_ref().is_some_and(|v| { v.is_empty() || v.len() > 128 || !v .bytes() .all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) }) { return Err(HostError::new("RECORD_DATA_INVALID")); } Ok(record) } impl Workspace { pub(crate) fn normalize_record_links(&mut self) -> Result<()> { 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_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 { let mut record = original; record.data["note_id"] = json!(entry.file_id); self.write( &path, &crate::workspace::hash(&bytes), &serde_json::to_vec(&record) .map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?, "local", )?; } } } } } Ok(()) } pub fn record_get(&mut self, id: &str) -> Result> { 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); } if std::fs::metadata(self.resolve(&path)?)?.len() > 1024 * 1024 { return Err(HostError::new("RECORD_TOO_LARGE")); } let document = self.read(&path)?; let mut record = validate(&path, document.content.as_bytes())?; 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"] = json!(entry.file_id); } } } Ok(Some( json!({"record":record,"hash":document.entry.hash,"file_id":document.entry.file_id}), )) } pub fn record_list(&mut self, offset: usize, limit: usize) -> Result { self.record_list_kind("task", offset, limit) } pub fn record_list_kind(&mut self, kind: &str, offset: usize, limit: usize) -> Result { if limit == 0 || limit > 1000 { return Err(HostError::new("RECORD_LIMIT_INVALID")); } let prefix = match kind { "task" => "opennexus-records/v1/tasks/", "user_skill" => "opennexus-records/v1/user-skills/", _ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")), }; let paths = self .sync_paths()? .into_iter() .filter(|path| path.starts_with(prefix) && allowed(path)) .collect::>(); let total = paths.len(); let mut items = Vec::new(); let mut bytes = 0; for path in paths.into_iter().skip(offset).take(limit) { let id = path .strip_prefix(prefix) .and_then(|v| v.strip_suffix(".json")) .ok_or_else(|| HostError::new("RECORD_ID_INVALID"))?; if let Some(value) = self.record_get_kind(kind, id)? { let size = serde_json::to_vec(&value) .map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))? .len(); if bytes + size > 4 * 1024 * 1024 { break; } bytes += size; items.push(value); } } Ok(json!({"items":items,"total":total})) } pub fn record_operation(&mut self, operation: &str) -> Result> { self.recover()?; let Some(receipt) = self.operation(operation)? else { return Ok(None); }; let path = receipt["result"]["path"] .as_str() .ok_or_else(|| HostError::new("RECORD_OPERATION_PENDING"))?; if !allowed(path) { return Err(HostError::new("RECORD_OPERATION_DENIED")); } let bytes = self.payload(operation, &[])?; let record = validate(path, &bytes)?; let expected = receipt["result"]["expected"] .as_str() .map(str::to_owned) .or(self .db .query_row( "SELECT expected FROM journal WHERE operation_id=?1", [operation], |row| row.get::<_, String>(0), ) .optional()?) .or(self .db .query_row( "SELECT hash FROM file_ops WHERE id=?1", [operation], |row| row.get::<_, String>(0), ) .optional()?); Ok(Some( json!({"record":record,"hash":crate::workspace::hash(&bytes),"expected":expected,"deleted":receipt["result"]["deleted"],"state":receipt["state"]}), )) } } #[cfg(test)] mod tests { use super::*; fn fixture() -> Value { json!({"schema":1,"kind":"task","id":"task_00000000000000000000000000000001","data":{"title":"Task","description":"","status":"todo","note_id":null,"due_at_ms":null,"created_at_ms":0,"updated_at_ms":0}}) } #[test] fn whitelist_rejects_secret_unknown_fields_and_future_schema_before_journal() { let root = tempfile::tempdir().unwrap(); let mut ws = Workspace::open(root.path()).unwrap(); let value = fixture(); let path = path(value["id"].as_str().unwrap()).unwrap(); for field in ["api_key", "token", "environment", "permissions", "extra"] { let mut value = value.clone(); value["data"][field] = json!("planted-secret"); assert_eq!( ws.write(&path, "", &serde_json::to_vec(&value).unwrap(), "local") .unwrap_err() .code, "RECORD_SCHEMA_INVALID" ); } let mut future = value.clone(); future["schema"] = json!(2); assert_eq!( ws.write(&path, "", &serde_json::to_vec(&future).unwrap(), "remote") .unwrap_err() .code, "RECORD_SCHEMA_UNSUPPORTED" ); assert!(!root.path().join(&path).exists()); assert_eq!(ws.pending_count().unwrap(), 0); let operation = uuid::Uuid::new_v4().to_string(); ws.write_operation( &path, "", &serde_json::to_vec(&value).unwrap(), "local", &operation, ) .unwrap(); assert_eq!( ws.record_operation(&operation).unwrap().unwrap()["record"], value ); } }