feat: 通过 Host 日志同步类型化任务记录
This commit is contained in:
@@ -4,6 +4,7 @@ pub mod core;
|
||||
pub mod credentials;
|
||||
mod payloads;
|
||||
pub mod recent;
|
||||
pub mod records;
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod request_lifecycle;
|
||||
mod runtime_compat;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Versioned logical records: explicit fields only, never raw application databases/config.
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
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<String>,
|
||||
pub due_at_ms: Option<i64>,
|
||||
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: TaskData,
|
||||
}
|
||||
pub fn path(id: &str) -> Result<String> {
|
||||
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 is_record(path: &str) -> bool {
|
||||
path.starts_with("opennexus-records/")
|
||||
}
|
||||
pub fn allowed(path_value: &str) -> bool {
|
||||
path_value
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.is_some_and(|id| path(id).is_ok())
|
||||
}
|
||||
pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
|
||||
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 || record.kind != "task" || path(&record.id)? != path_value {
|
||||
return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED"));
|
||||
}
|
||||
let data = &record.data;
|
||||
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| 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 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 = Some(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<Option<Value>> {
|
||||
let path = path(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_ref() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
if limit == 0 || limit > 1000 {
|
||||
return Err(HostError::new("RECORD_LIMIT_INVALID"));
|
||||
}
|
||||
let paths = self
|
||||
.sync_paths()?
|
||||
.into_iter()
|
||||
.filter(|path| allowed(path))
|
||||
.collect::<Vec<_>>();
|
||||
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("opennexus-records/v1/tasks/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.ok_or_else(|| HostError::new("RECORD_ID_INVALID"))?;
|
||||
if let Some(value) = self.record_get(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<Option<Value>> {
|
||||
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)?;
|
||||
Ok(Some(
|
||||
json!({"record":record,"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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use std::{collections::HashSet, fs, io::Read, path::Path};
|
||||
use uuid::Uuid;
|
||||
/// File transport only. Logical records receive their own versioned whitelist separately.
|
||||
pub fn allowed(path: &str) -> bool {
|
||||
if crate::records::is_record(path) {
|
||||
return crate::records::allowed(path);
|
||||
}
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
if parts.iter().any(|part| {
|
||||
part.starts_with('.')
|
||||
@@ -100,6 +103,9 @@ impl Workspace {
|
||||
if bytes.len() > 104857600 {
|
||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||
}
|
||||
if crate::records::is_record(&path) {
|
||||
crate::records::validate(&path, &bytes)?;
|
||||
}
|
||||
let digest = hash(&bytes);
|
||||
let previous = self.entry(&path)?;
|
||||
let observed: Option<(String, String, bool)> = if let Some(entry) = &previous {
|
||||
|
||||
@@ -140,6 +140,7 @@ impl Workspace {
|
||||
}
|
||||
pub fn sync_capture(&mut self, binding: &str) -> Result<()> {
|
||||
self.check_binding(binding)?;
|
||||
self.normalize_record_links()?;
|
||||
loop {
|
||||
let pending = self.db.query_row("SELECT operation_id,file_id,path,hash,operation,content FROM outbox WHERE state='pending' ORDER BY rowid LIMIT 1", [], |r| {
|
||||
Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Vec<u8>>(5)?))
|
||||
@@ -165,6 +166,9 @@ impl Workspace {
|
||||
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
||||
}
|
||||
crate::payloads::verify(&self.sync_spool(&digest)?, &digest, size as u64)?;
|
||||
if crate::records::is_record(&path) {
|
||||
crate::records::validate(&path, &self.payload(&operation_id, &content)?)?;
|
||||
}
|
||||
size
|
||||
} else {
|
||||
0
|
||||
|
||||
@@ -258,10 +258,11 @@ impl Workspace {
|
||||
.map_err(|_| HostError::new("UNSAFE_PATH"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if relative
|
||||
.split('/')
|
||||
.any(|s| s.eq_ignore_ascii_case(".ainote") || s.eq_ignore_ascii_case(".git"))
|
||||
|| linked(&path)?
|
||||
if relative.split('/').any(|s| {
|
||||
s.eq_ignore_ascii_case(".ainote")
|
||||
|| s.eq_ignore_ascii_case(".git")
|
||||
|| s.eq_ignore_ascii_case("opennexus-records")
|
||||
}) || linked(&path)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -407,6 +408,9 @@ impl Workspace {
|
||||
if Uuid::parse_str(operation_id).is_err() {
|
||||
return Err(HostError::new("OPERATION_ID_INVALID"));
|
||||
}
|
||||
if crate::records::is_record(path) {
|
||||
crate::records::validate(path, content)?;
|
||||
}
|
||||
if content.len() > 100 * 1024 * 1024 {
|
||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||
}
|
||||
@@ -526,6 +530,9 @@ impl Workspace {
|
||||
content: &[u8],
|
||||
origin: &str,
|
||||
) -> Result<()> {
|
||||
if crate::records::is_record(path) {
|
||||
crate::records::validate(path, content)?;
|
||||
}
|
||||
let target = self.resolve(path)?;
|
||||
let digest = hash(content);
|
||||
let current = if target.exists() {
|
||||
|
||||
@@ -42,6 +42,28 @@ struct Mutation {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecordRead {
|
||||
vault_id: String,
|
||||
id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecordDelete {
|
||||
vault_id: String,
|
||||
id: String,
|
||||
expected: String,
|
||||
operation_id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecordWrite {
|
||||
vault_id: String,
|
||||
record: Value,
|
||||
expected: String,
|
||||
operation_id: String,
|
||||
}
|
||||
fn bound(ws: &Workspace, vault_id: &str) -> Result<(), String> {
|
||||
if ws.vault_id != vault_id {
|
||||
return Err("VAULT_PERMISSION_CHANGED".into());
|
||||
@@ -54,6 +76,47 @@ fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T, String> {
|
||||
pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
let params = &request["params"];
|
||||
match request["rpc"].as_str().unwrap_or_default() {
|
||||
"workspace.records.list" => {
|
||||
let p: List = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_list(p.offset, p.limit).map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.get" => {
|
||||
let p: RecordRead = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_get(&p.id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.operation" => {
|
||||
let p: Operation = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.delete" => {
|
||||
let p: RecordDelete = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let path = crate::records::path(&p.id).map_err(|e| e.code)?;
|
||||
ws.mutate_operation("delete", &path, "", &p.expected, &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.write" => {
|
||||
let p: RecordWrite = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let path =
|
||||
crate::records::path(p.record["id"].as_str().unwrap_or("")).map_err(|e| e.code)?;
|
||||
let bytes = serde_json::to_vec(&p.record).map_err(|_| "RECORD_SCHEMA_INVALID")?;
|
||||
ws.write_operation(&path, &p.expected, &bytes, "local", &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.list" => {
|
||||
let p: List = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
|
||||
Reference in New Issue
Block a user