feat: 通过绑定的工作区路由 Core 笔记并保存持久操作回执

This commit is contained in:
2026-09-08 15:02:46 +08:00
parent 3439d1fe3f
commit 044e6d6146
18 changed files with 881 additions and 22 deletions
+7 -3
View File
@@ -363,11 +363,15 @@ impl CoreSupervisor {
let mut reader = BufReader::new(stdout);
loop {
let mut line = Zeroizing::new(Vec::new());
match reader.by_ref().take(131073).read_until(b'\n', &mut line) {
match reader
.by_ref()
.take(8 * 1024 * 1024 + 1)
.read_until(b'\n', &mut line)
{
Ok(0) | Err(_) => break,
_ => {}
}
if line.len() > 131072 {
if line.len() > 8 * 1024 * 1024 {
break;
}
let Ok(message) = serde_json::from_slice::<serde_json::Value>(&line) else {
@@ -393,7 +397,7 @@ impl CoreSupervisor {
break;
};
let mut bytes = Zeroizing::new(bytes);
if bytes.len() > 131072 {
if bytes.len() > 8 * 1024 * 1024 {
break;
}
bytes.push(b'\n');
+1
View File
@@ -9,3 +9,4 @@ mod runtime_compat;
#[cfg(windows)]
pub mod session_lock;
pub mod workspace;
pub mod workspace_broker;
+53 -2
View File
@@ -18,7 +18,7 @@ use zeroize::Zeroizing;
#[derive(Default)]
struct Host {
requests: Requests,
workspace: Mutex<Option<Workspace>>,
workspace: Arc<Mutex<Option<Workspace>>>,
recent: Mutex<Option<RecentVaultStore>>,
core: Arc<Mutex<Option<CoreSupervisor>>>,
credentials: Arc<Mutex<Option<CredentialBroker>>>,
@@ -162,6 +162,12 @@ async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result<Cor
content_type,
idempotency_key,
} = request;
let vault_id = host
.workspace
.lock()
.map_err(|_| "HOST_BUSY")?
.as_ref()
.map(|ws| ws.vault_id.clone());
let mut lease = host.requests.claim(&request_id)?;
let checkpoint = lease.checkpoint();
lease
@@ -212,6 +218,9 @@ async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result<Cor
)
.header("X-Core-Generation", &session.generation)
.header("X-Request-Id", &request_id);
if let Some(vault_id) = &vault_id {
request = request.header("X-OpenNexus-Vault", vault_id);
}
if let Some(value) = body {
request = request.json(&value);
}
@@ -321,6 +330,12 @@ fn core_stream(
return Err("CORE_HEADER_INVALID".into());
}
let core = host.core.clone();
let vault_id = host
.workspace
.lock()
.map_err(|_| "HOST_BUSY")?
.as_ref()
.map(|ws| ws.vault_id.clone());
let streams = host.streams.clone();
let mut running = host.streams.lock().map_err(|_| "HOST_BUSY")?;
if running.len() >= 16 || running.contains_key(&request_id) {
@@ -338,6 +353,7 @@ fn core_stream(
let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?, session.url)
.header("Authorization", session.authorization.as_str())
.header("X-Core-Generation", session.generation).header("Accept", "text/event-stream");
if let Some(vault_id) = vault_id { request = request.header("X-OpenNexus-Vault", vault_id); }
if let Some(body) = body { request = request.json(&body); }
if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); }
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
@@ -619,12 +635,32 @@ fn workspace_write(
path: String,
expected: String,
content: String,
operation_id: Option<String>,
) -> Result<Entry, String> {
with_workspace(&host, |ws| {
ws.write(&path, &expected, content.as_bytes(), "local")
ws.write_operation(
&path,
&expected,
content.as_bytes(),
"local",
&operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
)
})
}
#[tauri::command]
fn workspace_operation(
host: State<'_, Host>,
vault_id: String,
operation_id: String,
) -> Result<Option<serde_json::Value>, String> {
let guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?;
let workspace = guard.as_ref().ok_or("WORKSPACE_NOT_OPEN")?;
if workspace.vault_id != vault_id {
return Err("VAULT_PERMISSION_CHANGED".into());
}
workspace.operation(&operation_id).map_err(|e| e.code)
}
#[tauri::command]
fn workspace_rename(
host: State<'_, Host>,
path: String,
@@ -730,7 +766,21 @@ fn main() {
include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(),
)
};
let workspace_state = app.state::<Host>().workspace.clone();
let core = core.with_broker(Arc::new(move |request| {
if request["rpc"]
.as_str()
.is_some_and(|method| method.starts_with("workspace."))
{
return notesagent_host::workspace_broker::dispatch(
workspace_state
.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("WORKSPACE_NOT_OPEN")?,
request,
);
}
credential_state
.lock()
.map_err(|_| "HOST_BUSY")?
@@ -786,6 +836,7 @@ fn main() {
workspace_tree,
workspace_read,
workspace_write,
workspace_operation,
workspace_rename,
workspace_delete,
workspace_mkdir
+285 -13
View File
@@ -135,16 +135,22 @@ impl Workspace {
let db = Connection::open(db_path)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 1 {
if version > 2 {
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
}
if version == 1 {
// Independent, complete SQLite backup before the schema ownership change.
let backup = managed.join(format!("host-schema1-{}.sqlite3", Uuid::new_v4()));
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
}
db.execute_batch("BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS identity (id TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY,path TEXT UNIQUE NOT NULL,hash TEXT NOT NULL,revision INTEGER NOT NULL,deleted INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS journal (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,path TEXT NOT NULL,expected TEXT NOT NULL,content BLOB NOT NULL,origin TEXT NOT NULL,state TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS file_ops (id TEXT PRIMARY KEY,kind TEXT NOT NULL,path TEXT NOT NULL,destination TEXT NOT NULL,hash TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
CREATE TABLE IF NOT EXISTS outbox (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,revision INTEGER NOT NULL,path TEXT NOT NULL,hash TEXT NOT NULL,operation TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
PRAGMA user_version=1; COMMIT;")?;
CREATE TABLE IF NOT EXISTS operations (operation_id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT);
PRAGMA user_version=2; COMMIT;")?;
let vault_id: String = db
.query_row("SELECT id FROM identity", [], |r| r.get(0))
.optional()?
@@ -281,17 +287,36 @@ impl Workspace {
}
pub fn read(&mut self, path: &str) -> Result<Document> {
self.scan()?;
let content = fs::read_to_string(self.resolve(path)?)?;
let digest = hash(content.as_bytes());
let previous = self.entry(path)?;
if previous
.as_ref()
.is_none_or(|entry| entry.hash != digest || entry.deleted)
{
let id = previous.map_or_else(|| Uuid::new_v4().to_string(), |entry| entry.file_id);
self.db.execute("INSERT INTO files VALUES (?1,?2,?3,1,0) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,revision=files.revision+1,deleted=0", params![id,path,digest])?;
}
let entry = self
.entry(path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
let content = fs::read_to_string(self.resolve(path)?)?;
if hash(content.as_bytes()) != entry.hash {
return Err(HostError::new("REVISION_CONFLICT"));
}
Ok(Document { entry, content })
}
pub fn path_for_id(&self, file_id: &str) -> Result<String> {
self.db
.query_row(
"SELECT path FROM files WHERE id=?1 AND deleted=0",
[file_id],
|row| row.get(0),
)
.optional()?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
}
pub fn write(
&mut self,
path: &str,
@@ -299,12 +324,73 @@ impl Workspace {
content: &[u8],
origin: &str,
) -> Result<Entry> {
self.write_operation(path, expected, content, origin, &Uuid::new_v4().to_string())
}
pub fn operation(&self, operation_id: &str) -> Result<Option<serde_json::Value>> {
let value: Option<(String, Option<String>)> = self
.db
.query_row(
"SELECT state,result FROM operations WHERE operation_id=?1",
[operation_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
value
.map(|(state, result)| {
let result: Option<Entry> = result
.map(|value| {
serde_json::from_str(&value).map_err(|_| HostError::new("DATABASE_ERROR"))
})
.transpose()?;
Ok(serde_json::json!({"operation_id":operation_id,"state":state,"result":result}))
})
.transpose()
}
pub fn write_operation(
&mut self,
path: &str,
expected: &str,
content: &[u8],
origin: &str,
operation_id: &str,
) -> Result<Entry> {
if Uuid::parse_str(operation_id).is_err() {
return Err(HostError::new("OPERATION_ID_INVALID"));
}
if content.len() > 100 * 1024 * 1024 {
return Err(HostError::new("FILE_TOO_LARGE"));
}
if origin != "local" && origin != "remote" {
return Err(HostError::new("INVALID_ORIGIN"));
}
let fingerprint = hash(
&serde_json::to_vec(&(path, expected, hash(content), origin))
.map_err(|_| HostError::new("INVALID_OPERATION"))?,
);
let previous: Option<String> = self
.db
.query_row(
"SELECT fingerprint FROM operations WHERE operation_id=?1",
[operation_id],
|row| row.get(0),
)
.optional()?;
if let Some(previous) = previous {
if previous != fingerprint {
return Err(HostError::new("OPERATION_PAYLOAD_CONFLICT"));
}
self.recover()?;
let receipt = self
.operation(operation_id)?
.ok_or_else(|| HostError::new("DATABASE_ERROR"))?;
if receipt["state"] != "committed" {
return Err(HostError::new("RECOVERY_CONFLICT"));
}
return serde_json::from_value(receipt["result"].clone())
.map_err(|_| HostError::new("DATABASE_ERROR"));
}
let target = self.resolve(path)?;
let current = if target.exists() {
hash(&fs::read(&target)?)
@@ -317,12 +403,17 @@ impl Workspace {
let file_id = self
.entry(path)?
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
let operation_id = Uuid::new_v4().to_string();
self.db.execute(
let tx = self.db.transaction()?;
tx.execute(
"INSERT INTO operations VALUES (?1,?2,'pending',NULL)",
params![operation_id, fingerprint],
)?;
tx.execute(
"INSERT INTO journal VALUES (?1,?2,?3,?4,?5,?6,'pending')",
params![operation_id, file_id, path, expected, content, origin],
)?;
self.apply_journal(&operation_id, &file_id, path, expected, content, origin)?;
tx.commit()?;
self.apply_journal(operation_id, &file_id, path, expected, content, origin)?;
self.entry(path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
}
@@ -344,10 +435,16 @@ impl Workspace {
String::new()
};
if current != expected && current != digest {
self.db.execute(
let tx = self.db.transaction()?;
tx.execute(
"UPDATE journal SET state='conflict' WHERE operation_id=?1",
[operation_id],
)?;
tx.execute(
"UPDATE operations SET state='conflict' WHERE operation_id=?1",
[operation_id],
)?;
tx.commit()?;
return Err(HostError::new("RECOVERY_CONFLICT"));
}
if current != digest {
@@ -369,6 +466,25 @@ impl Workspace {
if origin == "local" {
tx.execute("INSERT OR IGNORE INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE path=?3", params![operation_id,content,path])?;
}
let entry = tx.query_row(
"SELECT id,path,hash,revision,deleted FROM files WHERE path=?1",
[path],
|row| {
Ok(Entry {
file_id: row.get(0)?,
path: row.get(1)?,
hash: row.get(2)?,
revision: row.get(3)?,
deleted: row.get(4)?,
is_folder: false,
})
},
)?;
let result = serde_json::to_string(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?;
tx.execute(
"UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1",
params![operation_id, result],
)?;
tx.execute("DELETE FROM journal WHERE operation_id=?1", [operation_id])?;
tx.commit()?;
Ok(())
@@ -451,6 +567,76 @@ impl Workspace {
destination: &str,
expected: &str,
) -> Result<String> {
self.prepare_file_op_with_id(
kind,
path,
destination,
expected,
&Uuid::new_v4().to_string(),
)
}
pub fn mutate_operation(
&mut self,
kind: &str,
path: &str,
destination: &str,
expected: &str,
operation_id: &str,
) -> Result<serde_json::Value> {
let id = self.prepare_file_op_with_id(kind, path, destination, expected, operation_id)?;
if self
.operation(&id)?
.is_some_and(|v| v["state"] == "committed")
{
return self
.operation(&id)?
.ok_or_else(|| HostError::new("DATABASE_ERROR"));
}
self.apply_file_op(&id)?;
self.operation(&id)?
.ok_or_else(|| HostError::new("DATABASE_ERROR"))
}
fn prepare_file_op_with_id(
&mut self,
kind: &str,
path: &str,
destination: &str,
expected: &str,
id: &str,
) -> Result<String> {
if !matches!(kind, "rename" | "delete") || Uuid::parse_str(id).is_err() {
return Err(HostError::new("INVALID_OPERATION"));
}
let fingerprint = hash(
&serde_json::to_vec(&(kind, path, destination, expected))
.map_err(|_| HostError::new("INVALID_OPERATION"))?,
);
let previous: Option<String> = self
.db
.query_row(
"SELECT fingerprint FROM operations WHERE operation_id=?1",
[id],
|row| row.get(0),
)
.optional()?;
if let Some(previous) = previous {
if previous != fingerprint {
return Err(HostError::new("OPERATION_PAYLOAD_CONFLICT"));
}
self.recover()?;
if self
.operation(id)?
.is_some_and(|v| v["state"] == "conflict")
{
return Err(HostError::new("RECOVERY_CONFLICT"));
}
return Ok(id.to_owned());
}
if kind == "rename" && self.resolve(destination)?.exists() {
return Err(HostError::new("PATH_CONFLICT"));
}
let source = self.resolve(path)?;
if !source.is_file() {
return Err(HostError::new("FILE_NOT_FOUND"));
@@ -461,12 +647,17 @@ impl Workspace {
}
self.entry(path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
let id = Uuid::new_v4().to_string();
self.db.execute(
let tx = self.db.transaction()?;
tx.execute(
"INSERT INTO operations VALUES (?1,?2,'pending',NULL)",
params![id, fingerprint],
)?;
tx.execute(
"INSERT INTO file_ops VALUES (?1,?2,?3,?4,?5,?6,'pending')",
params![id, kind, path, destination, expected, content],
)?;
Ok(id)
tx.commit()?;
Ok(id.to_owned())
}
fn apply_file_op(&mut self, id: &str) -> Result<()> {
@@ -499,8 +690,13 @@ impl Workspace {
let target_conflict =
target.exists() && (linked(&target)? || hash(&fs::read(&target)?) != expected);
if source_conflict || target_conflict {
self.db
.execute("UPDATE file_ops SET state='conflict' WHERE id=?1", [id])?;
let tx = self.db.transaction()?;
tx.execute("UPDATE file_ops SET state='conflict' WHERE id=?1", [id])?;
tx.execute(
"UPDATE operations SET state='conflict' WHERE operation_id=?1",
[id],
)?;
tx.commit()?;
return Err(HostError::new("RECOVERY_CONFLICT"));
}
// journal 保留完整内容,目标刷盘后才删除来源;两处崩溃均可幂等重放。
@@ -533,6 +729,20 @@ impl Workspace {
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,'','delete',X'','pending' FROM files WHERE id=?2", params![id,previous.file_id])?;
}
tx.execute("DELETE FROM file_ops WHERE id=?1", [id])?;
let mut result = previous;
result.revision += 1;
if kind == "rename" {
result.path = destination;
} else {
result.deleted = true;
}
tx.execute(
"UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1",
params![
id,
serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?
],
)?;
tx.commit()?;
Ok(())
}
@@ -599,6 +809,68 @@ mod tests {
assert_eq!(ws.pending_count().unwrap(), 2);
}
#[test]
fn operation_receipt_survives_reopen_and_replay_after_later_edit() {
let dir = tempfile::tempdir().unwrap();
let operation = Uuid::new_v4().to_string();
let mut ws = Workspace::open(dir.path()).unwrap();
let first = ws
.write_operation("a.md", "", b"first", "local", &operation)
.unwrap();
ws.write("a.md", &first.hash, b"second", "local").unwrap();
drop(ws);
let mut ws = Workspace::open(dir.path()).unwrap();
for _ in 0..100 {
let replay = ws
.write_operation("a.md", "", b"first", "local", &operation)
.unwrap();
assert_eq!(replay.revision, first.revision);
assert_eq!(replay.hash, first.hash);
}
assert_eq!(ws.read("a.md").unwrap().content, "second");
assert_eq!(ws.pending_count().unwrap(), 2);
assert_eq!(
ws.operation(&operation).unwrap().unwrap()["state"],
"committed"
);
assert_eq!(
ws.write_operation("a.md", "", b"changed-payload", "local", &operation)
.unwrap_err()
.code,
"OPERATION_PAYLOAD_CONFLICT"
);
}
#[test]
fn schema_upgrade_preserves_a_readable_previous_database() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
ws.write("a.md", "", b"old-data", "local").unwrap();
ws.db
.execute_batch("DROP TABLE operations; PRAGMA user_version=1;")
.unwrap();
drop(ws);
let ws = Workspace::open(dir.path()).unwrap();
assert_eq!(ws.pending_count().unwrap(), 1);
let backup = fs::read_dir(dir.path().join(".ainote"))
.unwrap()
.filter_map(|e| e.ok())
.find(|e| e.file_name().to_string_lossy().starts_with("host-schema1-"))
.unwrap();
let old = Connection::open(backup.path()).unwrap();
assert_eq!(
old.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
old.query_row("SELECT COUNT(*) FROM outbox", [], |row| row
.get::<_, i64>(0))
.unwrap(),
1
);
}
#[test]
fn unsafe_paths_external_change_and_remote_origin() {
let dir = tempfile::tempdir().unwrap();
+161
View File
@@ -0,0 +1,161 @@
//! Narrow Core RPC. Every request is bound to the Vault captured by the Host transport.
use crate::workspace::Workspace;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Read {
vault_id: String,
file_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct List {
vault_id: String,
offset: usize,
limit: usize,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Write {
vault_id: String,
path: String,
expected: String,
content: String,
operation_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Operation {
vault_id: String,
operation_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Mutation {
vault_id: String,
path: String,
destination: String,
expected: String,
operation_id: String,
kind: String,
}
fn bound(ws: &Workspace, vault_id: &str) -> Result<(), String> {
if ws.vault_id != vault_id {
return Err("VAULT_PERMISSION_CHANGED".into());
}
Ok(())
}
fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T, String> {
serde_json::from_value(value.clone()).map_err(|_| "WORKSPACE_REQUEST_INVALID".into())
}
pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
let params = &request["params"];
match request["rpc"].as_str().unwrap_or_default() {
"workspace.list" => {
let p: List = decode(params)?;
bound(ws, &p.vault_id)?;
if !(1..=1000).contains(&p.limit) {
return Err("WORKSPACE_REQUEST_INVALID".into());
}
let mut entries = ws.scan().map_err(|e| e.code)?;
entries.retain(|e| !e.deleted && !e.is_folder);
entries.sort_by(|a, b| a.path.cmp(&b.path));
let total = entries.len();
Ok(
json!({"items":entries.into_iter().skip(p.offset).take(p.limit).collect::<Vec<_>>(),"total":total}),
)
}
"workspace.read" => {
let p: Read = decode(params)?;
bound(ws, &p.vault_id)?;
let relative = ws.path_for_id(&p.file_id).map_err(|e| e.code)?;
let path = ws.resolve(&relative).map_err(|e| e.code)?;
let metadata = std::fs::metadata(path).map_err(|_| "FILESYSTEM_ERROR")?;
if metadata.len() > 1024 * 1024 {
return Err("CORE_NOTE_TOO_LARGE".into());
}
let document = ws.read(&relative).map_err(|e| e.code)?;
let timestamp = |value: std::io::Result<std::time::SystemTime>| {
value
.ok()
.and_then(|v| v.duration_since(std::time::UNIX_EPOCH).ok())
.map(|v| v.as_secs())
.unwrap_or(0)
};
let mut value = serde_json::to_value(document).map_err(|_| "HOST_SERIALIZE_FAILED")?;
value["created_at"] = json!(timestamp(metadata.created()));
value["updated_at"] = json!(timestamp(metadata.modified()));
Ok(value)
}
"workspace.write" => {
let p: Write = decode(params)?;
bound(ws, &p.vault_id)?;
if p.content.len() > 1024 * 1024 || !p.path.to_ascii_lowercase().ends_with(".md") {
return Err("CORE_NOTE_TOO_LARGE".into());
}
let entry = ws
.write_operation(
&p.path,
&p.expected,
p.content.as_bytes(),
"local",
&p.operation_id,
)
.map_err(|e| e.code)?;
Ok(json!({"operation_id":p.operation_id,"state":"committed","result":entry}))
}
"workspace.operation" => {
let p: Operation = decode(params)?;
bound(ws, &p.vault_id)?;
ws.operation(&p.operation_id)
.map_err(|e| e.code)?
.ok_or("OPERATION_NOT_FOUND".into())
}
"workspace.mutate" => {
let p: Mutation = decode(params)?;
bound(ws, &p.vault_id)?;
if !p.path.to_ascii_lowercase().ends_with(".md")
|| (p.kind == "rename" && !p.destination.to_ascii_lowercase().ends_with(".md"))
{
return Err("WORKSPACE_REQUEST_INVALID".into());
}
ws.mutate_operation(
&p.kind,
&p.path,
&p.destination,
&p.expected,
&p.operation_id,
)
.map_err(|e| e.code)
}
_ => Err("HOST_METHOD_DENIED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_stale_vault_and_unowned_fields_before_writes() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
let mut request = json!({"rpc":"workspace.write","params":{"vault_id":"another-vault","path":"a.md","expected":"","content":"test","operation_id":uuid::Uuid::new_v4().to_string()}});
assert_eq!(
dispatch(&mut ws, &request).unwrap_err(),
"VAULT_PERMISSION_CHANGED"
);
request["params"]["vault_id"] = json!(ws.vault_id);
request["params"]["origin"] = json!("remote");
assert_eq!(
dispatch(&mut ws, &request).unwrap_err(),
"WORKSPACE_REQUEST_INVALID"
);
assert!(!root.path().join("a.md").exists());
request["params"].as_object_mut().unwrap().remove("origin");
assert_eq!(dispatch(&mut ws, &request).unwrap()["state"], "committed");
assert_eq!(ws.pending_count().unwrap(), 1);
}
}