Files
NotesAgentic/frontend/src-tauri/src/extension_transaction.rs
T

643 lines
26 KiB
Rust

//! 原子包/配置指针。指针从来都不是运行时权限。
use crate::workspace::{hash, HostError, Result};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Target {
pub slot: String,
pub package_key: String,
pub directory: String,
pub tree_sha256: String,
pub configuration: serde_json::Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Change {
pub target: Target,
pub expected_revision: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Active {
pub target: Target,
pub revision: String,
pub pending_operation: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Receipt {
pub operation_id: String,
pub state: String,
}
pub fn schema(db: &Connection) -> Result<()> {
db.execute_batch("CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT);
CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?;
Ok(())
}
pub fn active(db: &Connection, slot: &str) -> Result<Option<Active>> {
let row: Option<(String, String, Option<String>)> = db
.query_row(
"SELECT target,revision,pending_operation FROM extension_active WHERE slot=?1",
[slot],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
row.map(|(target, revision, pending_operation)| {
Ok(Active {
target: serde_json::from_str(&target)
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?,
revision,
pending_operation,
})
})
.transpose()
}
fn encoded<T: Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|_| HostError::new("EXTENSION_TRANSACTION_INVALID"))
}
pub fn switch(db: &mut Connection, operation: &str, changes: &[Change]) -> Result<Receipt> {
switch_inner(db, operation, changes, |_| Ok(()))
}
fn switch_inner(
db: &mut Connection,
operation: &str,
changes: &[Change],
mut checkpoint: impl FnMut(&str) -> Result<()>,
) -> Result<Receipt> {
if uuid::Uuid::parse_str(operation).is_err() || changes.is_empty() || changes.len() > 200 {
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
}
let serialized = encoded(&changes)?;
if serialized.len() > 4 * 1024 * 1024 {
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
}
let fingerprint = hash(serialized.as_bytes());
let transaction = db.transaction()?;
let prior: Option<(String, String)> = transaction
.query_row(
"SELECT fingerprint,state FROM extension_transactions WHERE id=?1",
[operation],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some((previous, state)) = prior {
if previous != fingerprint {
return Err(HostError::new("OPERATION_REUSED"));
}
return Ok(Receipt {
operation_id: operation.into(),
state,
});
}
let mut slots = BTreeSet::new();
let mut before = Vec::new();
for change in changes {
let target = &change.target;
for digest in [&target.slot, &target.package_key, &target.tree_sha256] {
if digest.len() != 64
|| !digest
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
}
}
if uuid::Uuid::parse_str(&target.directory)
.map(|v| v.to_string())
.ok()
.as_ref()
!= Some(&target.directory)
|| !target.configuration.is_object()
|| !slots.insert(&target.slot)
{
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
}
let previous = active(&transaction, &target.slot)?;
if previous
.as_ref()
.is_some_and(|p| p.pending_operation.is_some())
{
return Err(HostError::new("EXTENSION_TRANSACTION_BUSY"));
}
if previous.as_ref().map(|p| &p.revision) != change.expected_revision.as_ref() {
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
}
before.push(previous);
}
transaction.execute(
"INSERT INTO extension_transactions VALUES (?1,?2,?3,?4,'checking')",
params![operation, fingerprint, encoded(&before)?, serialized],
)?;
checkpoint("journal_recorded")?;
for change in changes {
let target = encoded(&change.target)?;
transaction.execute("INSERT INTO extension_active VALUES (?1,?2,?3,?4) ON CONFLICT(slot) DO UPDATE SET target=excluded.target,revision=excluded.revision,pending_operation=excluded.pending_operation",
params![change.target.slot,target,hash(target.as_bytes()),operation])?;
checkpoint("pointer_recorded")?;
}
transaction.commit()?;
checkpoint("switch_committed")?;
Ok(Receipt {
operation_id: operation.into(),
state: "checking".into(),
})
}
/// “healthy”必须来自 Host 的匹配包/配置运行状况探测。恢复称其为 false;它从不重新签发任何执行许可证。
pub fn finish(db: &mut Connection, operation: &str, healthy: bool) -> Result<Receipt> {
let tx = db.transaction()?;
let (before, after, state): (String, String, String) = tx.query_row(
"SELECT before_state,after_state,state FROM extension_transactions WHERE id=?1",
[operation],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
if state != "checking" {
return Ok(Receipt {
operation_id: operation.into(),
state,
});
}
let changes: Vec<Change> =
serde_json::from_str(&after).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
let previous: Vec<Option<Active>> =
serde_json::from_str(&before).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
if previous.len() != changes.len() {
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
}
for (change, old) in changes.iter().zip(previous) {
let current = active(&tx, &change.target.slot)?
.ok_or_else(|| HostError::new("EXTENSION_STORE_CORRUPT"))?;
if current.pending_operation.as_deref() != Some(operation)
|| current.target != change.target
{
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
}
if healthy {
tx.execute(
"UPDATE extension_active SET pending_operation=NULL WHERE slot=?1",
[&change.target.slot],
)?;
} else if let Some(old) = old {
tx.execute("UPDATE extension_active SET target=?2,revision=?3,pending_operation=NULL WHERE slot=?1",
params![change.target.slot,encoded(&old.target)?,old.revision])?;
} else {
tx.execute(
"DELETE FROM extension_active WHERE slot=?1",
[&change.target.slot],
)?;
}
}
let state = if healthy { "complete" } else { "rolled_back" };
tx.execute(
"UPDATE extension_transactions SET state=?2 WHERE id=?1",
params![operation, state],
)?;
tx.commit()?;
Ok(Receipt {
operation_id: operation.into(),
state: state.into(),
})
}
pub fn recover(db: &mut Connection) -> Result<usize> {
let ids: Vec<String> = db
.prepare("SELECT id FROM extension_transactions WHERE state='checking' ORDER BY id")?
.query_map([], |r| r.get(0))?
.collect::<std::result::Result<_, _>>()?;
for id in &ids {
finish(db, id, false)?;
}
Ok(ids.len())
}
/// 为已完成的升级生成回滚变更;回滚本身仍以新的 operation_id 执行和记录。
pub fn rollback_changes(db: &Connection, operation: &str) -> Result<Vec<Change>> {
let (before, after, state): (String, String, String) = db
.query_row(
"SELECT before_state,after_state,state FROM extension_transactions WHERE id=?1",
[operation],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|_| HostError::new("EXTENSION_ROLLBACK_UNKNOWN"))?;
if state != "complete" {
return Err(HostError::new("EXTENSION_ROLLBACK_INVALID"));
}
let old: Vec<Option<Active>> =
serde_json::from_str(&before).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
let installed: Vec<Change> =
serde_json::from_str(&after).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
if old.len() != installed.len() || old.iter().any(Option::is_none) {
return Err(HostError::new("EXTENSION_ROLLBACK_INVALID"));
}
old.into_iter()
.zip(installed)
.map(|(previous, installed)| {
let previous = previous.unwrap();
let current = active(db, &installed.target.slot)?
.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?;
if current.pending_operation.is_some() || current.target != installed.target {
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
}
Ok(Change {
target: previous.target,
expected_revision: Some(current.revision),
})
})
.collect()
}
/// 实例停止后原子移除活动指针。保留已验证对象,以便审计或显式回滚;不会触碰外部目录。
pub fn uninstall(
db: &mut Connection,
operation: &str,
slot: &str,
expected_revision: &str,
) -> Result<Receipt> {
if uuid::Uuid::parse_str(operation).is_err()
|| slot.len() != 64
|| expected_revision.len() != 64
|| [slot, expected_revision].iter().any(|value| {
!value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
{
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
}
db.execute_batch("CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?;
let transaction = db.transaction()?;
let prior: Option<(String, String, String)> = transaction
.query_row(
"SELECT slot,expected_revision,state FROM extension_uninstalls WHERE id=?1",
[operation],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()?;
if let Some((old_slot, old_revision, state)) = prior {
if old_slot != slot || old_revision != expected_revision {
return Err(HostError::new("OPERATION_REUSED"));
}
return Ok(Receipt {
operation_id: operation.into(),
state,
});
}
let current =
active(&transaction, slot)?.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?;
if current.pending_operation.is_some() || current.revision != expected_revision {
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
}
transaction.execute(
"INSERT INTO extension_uninstalls VALUES (?1,?2,?3,'complete')",
params![operation, slot, expected_revision],
)?;
transaction.execute("DELETE FROM extension_active WHERE slot=?1", [slot])?;
transaction.commit()?;
Ok(Receipt {
operation_id: operation.into(),
state: "complete".into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
path::Path,
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
fn open(path: &std::path::Path) -> Connection {
let db = Connection::open(path).unwrap();
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")
.unwrap();
schema(&db).unwrap();
db
}
fn change(slot: char, version: u8, previous: Option<String>) -> Change {
Change {
target: Target {
slot: slot.to_string().repeat(64),
package_key: format!("{version:x}").repeat(64),
directory: uuid::Uuid::new_v4().to_string(),
tree_sha256: "a".repeat(64),
configuration: serde_json::json!({"version": version}),
},
expected_revision: previous,
}
}
fn seeded(path: &Path) -> (Vec<Change>, Vec<Change>) {
let mut db = open(path);
let old = vec![change('a', 1, None), change('b', 1, None)];
let id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &id, &old).unwrap();
finish(&mut db, &id, true).unwrap();
let next = old
.iter()
.map(|current| {
change(
current.target.slot.chars().next().unwrap(),
2,
Some(active(&db, &current.target.slot).unwrap().unwrap().revision),
)
})
.collect();
(old, next)
}
fn assert_complete_generation(db: &Connection) {
let active: Vec<_> = ['a', 'b']
.into_iter()
.map(|slot| active(db, &slot.to_string().repeat(64)).unwrap().unwrap())
.collect();
let versions: BTreeSet<_> = active
.iter()
.map(|item| item.target.configuration["version"].as_u64().unwrap())
.collect();
assert_eq!(versions.len(), 1, "package/config generations were mixed");
let version = *versions.first().unwrap();
assert!(matches!(version, 1 | 2));
for item in active {
assert_eq!(item.target.package_key, format!("{version:x}").repeat(64));
assert!(item.pending_operation.is_none());
}
}
#[test]
fn completed_upgrade_can_rollback_then_uninstall_idempotently() {
let temp = tempfile::tempdir().unwrap();
let mut db = open(&temp.path().join("state.sqlite3"));
let initial = change('a', 1, None);
let install = uuid::Uuid::new_v4().to_string();
switch(&mut db, &install, std::slice::from_ref(&initial)).unwrap();
finish(&mut db, &install, true).unwrap();
let installed = active(&db, &initial.target.slot).unwrap().unwrap();
let upgrade = change('a', 2, Some(installed.revision));
let upgrade_id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &upgrade_id, &[upgrade]).unwrap();
finish(&mut db, &upgrade_id, true).unwrap();
let rollback = rollback_changes(&db, &upgrade_id).unwrap();
let rollback_id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &rollback_id, &rollback).unwrap();
finish(&mut db, &rollback_id, true).unwrap();
let restored = active(&db, &initial.target.slot).unwrap().unwrap();
assert_eq!(restored.target, initial.target);
let uninstall_id = uuid::Uuid::new_v4().to_string();
let receipt = uninstall(
&mut db,
&uninstall_id,
&restored.target.slot,
&restored.revision,
)
.unwrap();
assert_eq!(receipt.state, "complete");
assert_eq!(
uninstall(
&mut db,
&uninstall_id,
&restored.target.slot,
&restored.revision,
)
.unwrap(),
receipt
);
assert!(active(&db, &restored.target.slot).unwrap().is_none());
}
#[test]
fn group_switch_crashes_recover_matching_packages_and_configuration() {
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
for _ in 0..20 {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.sqlite3");
let mut db = open(&path);
let old = vec![change('a', 1, None), change('b', 1, None)];
let id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &id, &old).unwrap();
finish(&mut db, &id, true).unwrap();
let next: Vec<_> = old
.iter()
.map(|c| {
change(
c.target.slot.chars().next().unwrap(),
2,
Some(active(&db, &c.target.slot).unwrap().unwrap().revision),
)
})
.collect();
let update = uuid::Uuid::new_v4().to_string();
assert!(
switch_inner(&mut db, &update, &next, |at| if at == boundary {
Err(HostError::new("INJECTED"))
} else {
Ok(())
})
.is_err()
);
drop(db);
let mut db = open(&path);
recover(&mut db).unwrap();
for original in &old {
let current = active(&db, &original.target.slot).unwrap().unwrap();
assert_eq!(current.target, original.target);
assert!(current.pending_operation.is_none());
}
assert_eq!(recover(&mut db).unwrap(), 0);
}
}
}
#[test]
#[ignore = "parent acceptance oracle hard-terminates this helper at a durable boundary"]
fn power_cut_worker() {
let Some(path) = std::env::var_os("OPENNEXUS_D03_DATABASE") else {
return;
};
let boundary = std::env::var("OPENNEXUS_D03_BOUNDARY").unwrap();
let marker = std::path::PathBuf::from(std::env::var_os("OPENNEXUS_D03_MARKER").unwrap());
let mut db = open(Path::new(&path));
let old: Vec<_> = ['a', 'b']
.into_iter()
.map(|slot| {
let current = active(&db, &slot.to_string().repeat(64)).unwrap().unwrap();
Change {
target: current.target.clone(),
expected_revision: Some(current.revision),
}
})
.collect();
let next: Vec<_> = old
.iter()
.map(|current| {
change(
current.target.slot.chars().next().unwrap(),
2,
current.expected_revision.clone(),
)
})
.collect();
let operation = uuid::Uuid::new_v4().to_string();
let _ = switch_inner(&mut db, &operation, &next, |at| {
if at == boundary {
let file = std::fs::File::create(&marker).unwrap();
file.sync_all().unwrap();
loop {
thread::sleep(Duration::from_secs(60));
}
}
Ok(())
});
panic!("power-cut helper passed the requested boundary");
}
#[test]
fn group_switch_survives_hard_termination_twenty_times_per_boundary() {
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
for round in 0..20 {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.sqlite3");
seeded(&path);
let marker = temp.path().join(format!("{boundary}-{round}.ready"));
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--ignored",
"--exact",
"extension_transaction::tests::power_cut_worker",
"--nocapture",
])
.env("OPENNEXUS_D03_DATABASE", &path)
.env("OPENNEXUS_D03_BOUNDARY", boundary)
.env("OPENNEXUS_D03_MARKER", &marker)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let started = Instant::now();
while !marker.is_file() {
assert!(
child.try_wait().unwrap().is_none(),
"helper exited before {boundary}"
);
assert!(
started.elapsed() < Duration::from_secs(10),
"helper did not reach {boundary}"
);
thread::sleep(Duration::from_millis(5));
}
child.kill().unwrap();
assert!(!child.wait().unwrap().success());
let mut db = open(&path);
recover(&mut db).unwrap();
assert_complete_generation(&db);
assert_eq!(recover(&mut db).unwrap(), 0);
}
}
}
#[test]
fn disk_full_and_configuration_migration_failures_cover_every_boundary() {
let mapped: HostError = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL),
None,
)
.into();
assert_eq!(mapped.code, "QUOTA_EXCEEDED");
for failure in ["QUOTA_EXCEEDED", "EXTENSION_CONFIG_MIGRATION_FAILED"] {
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
for _ in 0..20 {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.sqlite3");
let (_, next) = seeded(&path);
let mut db = open(&path);
let update = uuid::Uuid::new_v4().to_string();
let error = switch_inner(&mut db, &update, &next, |at| {
if at == boundary {
Err(HostError::new(failure))
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error.code, failure);
drop(db);
let mut db = open(&path);
recover(&mut db).unwrap();
assert_complete_generation(&db);
assert_eq!(recover(&mut db).unwrap(), 0);
}
}
}
}
#[test]
fn failed_switch_cannot_expand_an_existing_execution_permit() {
use crate::extension_permit::{Authority, Claims, Environment, ExecutionKind};
use std::collections::BTreeMap;
let authority = Authority::default();
let mut claims = Claims {
kind: ExecutionKind::Mcp,
source: "https://catalog.example/".into(),
namespace: "examples".into(),
package_id: "note-reviewer".into(),
version: "1.0.0".into(),
archive_sha256: "a".repeat(64),
tree_sha256: "b".repeat(64),
signer_sha256: "c".repeat(64),
entry: "entry.exe".into(),
arguments: vec!["--stdio".into()],
environment: BTreeMap::from([(
"MODE".into(),
Environment::Literal("production".into()),
)]),
permissions: BTreeSet::from(["notes.read".into()]),
vault_id: uuid::Uuid::new_v4().to_string(),
platform: "windows".into(),
policy_version: "1".into(),
expires_at_ms: 10_000,
};
let permit = authority.issue(&claims, 1).unwrap();
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.sqlite3");
let (_, next) = seeded(&path);
let mut db = open(&path);
let error = switch_inner(&mut db, &uuid::Uuid::new_v4().to_string(), &next, |at| {
if at == "switch_committed" {
Err(HostError::new("EXTENSION_CONFIG_MIGRATION_FAILED"))
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error.code, "EXTENSION_CONFIG_MIGRATION_FAILED");
recover(&mut db).unwrap();
authority.verify(&permit, &claims, 2).unwrap();
claims.permissions.insert("notes.write".into());
assert_eq!(
authority.verify(&permit, &claims, 2).unwrap_err().code,
"PERMISSION_CHANGED"
);
assert_complete_generation(&db);
}
#[test]
fn cas_busy_replay_and_failed_first_install() {
let mut db = Connection::open_in_memory().unwrap();
schema(&db).unwrap();
let c = vec![change('a', 1, None)];
let id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &id, &c).unwrap();
assert!(switch(&mut db, &uuid::Uuid::new_v4().to_string(), &c).is_err());
assert_eq!(switch(&mut db, &id, &c).unwrap().state, "checking");
finish(&mut db, &id, false).unwrap();
assert!(active(&db, &c[0].target.slot).unwrap().is_none());
assert_eq!(switch(&mut db, &id, &c).unwrap().state, "rolled_back");
assert_eq!(finish(&mut db, &id, true).unwrap().state, "rolled_back");
let id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &id, &c).unwrap();
finish(&mut db, &id, true).unwrap();
assert!(switch(&mut db, &uuid::Uuid::new_v4().to_string(), &c).is_err());
let mut different = c.clone();
different[0].target.configuration = serde_json::json!({"changed":true});
assert!(switch(&mut db, &id, &different).is_err());
}
}