feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -252,3 +252,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 在线及底层准备切换均检查持久拒绝。重启后仍拒绝,发行撤回不影响其他版本,旧键撤销不自动禁止另一个显式确认的新公钥,但新公钥也不能复活已撤回版本。
|
- 在线及底层准备切换均检查持久拒绝。重启后仍拒绝,发行撤回不影响其他版本,旧键撤销不自动禁止另一个显式确认的新公钥,但新公钥也不能复活已撤回版本。
|
||||||
- 30 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-revocation-tests.log`。测试覆盖持久性、信任确认后仍拒绝、不同版本/来源/公钥范围和网络失败不污染拒绝记录。
|
- 30 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-revocation-tests.log`。测试覆盖持久性、信任确认后仍拒绝、不同版本/来源/公钥范围和网络失败不污染拒绝记录。
|
||||||
- 尚未接入运行中实例和工具注册表的撤销轮询/停止,不能据此声明 D-04 的 5 秒撤销目标通过。恢复旧指针仍可保留数据,但不授予运行许可。
|
- 尚未接入运行中实例和工具注册表的撤销轮询/停止,不能据此声明 D-04 的 5 秒撤销目标通过。恢复旧指针仍可保留数据,但不授予运行许可。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:完整安装预览与持久确认计划
|
||||||
|
|
||||||
|
- installation_preview 组合确定性依赖锁、全部包权限、配置、准备目录/展开摘要、信任设置摘要、目标 Vault/应用/平台/架构及当前活动 revision。未信任、已撤销、错误配置、非计划包配置或待健康检查事务均拒绝,预览不切换活动指针或授予许可。
|
||||||
|
- install_confirmed 首次执行重新生成预览并比较确认摘要,再进入在线复核。schema 6 保存操作 ID 对应的请求摘要、确认摘要和冻结切换组,网络或进程中断重试沿用原组;改变请求/确认并重用 ID 拒绝。重试仍经过来源/撤销/归档/配置检查和活动 revision CAS。
|
||||||
|
- 31 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-install-preview-tests.log`。新增预览重开摘要稳定、应用版本/活动状态变化使预览失效、错误确认在联网前失败、未知配置拒绝,以及确认计划重开重放不重新选择内容的测试。
|
||||||
|
- 真实主窗口确认 UI、Host 注入的应用/平台信息、旧实例停机、沙箱迁移和健康探测仍待接入。确认摘要 API 本身不证明用户授权;本轮没有执行第三方包或宣称整体生产化完成。
|
||||||
|
|||||||
@@ -84,6 +84,22 @@ impl TrustSetting {
|
|||||||
Ok(hash(&serde_json::to_vec(self).unwrap()))
|
Ok(hash(&serde_json::to_vec(self).unwrap()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct InstallRequest {
|
||||||
|
pub root_key: String,
|
||||||
|
pub vault_id: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub architecture: String,
|
||||||
|
pub configurations: std::collections::BTreeMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct InstallPreview {
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub dependencies: crate::extension_dependencies::Plan,
|
||||||
|
pub changes: Vec<crate::extension_transaction::Change>,
|
||||||
|
}
|
||||||
pub struct ExtensionStore {
|
pub struct ExtensionStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
db: Connection,
|
db: Connection,
|
||||||
@@ -272,10 +288,10 @@ impl ExtensionStore {
|
|||||||
let mut db = Connection::open(database)?;
|
let mut db = Connection::open(database)?;
|
||||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||||
if version > 5 {
|
if version > 6 {
|
||||||
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
|
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
|
||||||
}
|
}
|
||||||
if (1..5).contains(&version) {
|
if (1..6).contains(&version) {
|
||||||
let backup = root.join(format!(
|
let backup = root.join(format!(
|
||||||
"extensions.schema{version}.{}.sqlite3",
|
"extensions.schema{version}.{}.sqlite3",
|
||||||
Uuid::new_v4()
|
Uuid::new_v4()
|
||||||
@@ -291,7 +307,8 @@ impl ExtensionStore {
|
|||||||
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_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_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id));
|
CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id));
|
||||||
CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL);
|
||||||
PRAGMA user_version=5; COMMIT;")?;
|
CREATE TABLE IF NOT EXISTS extension_confirmations(operation_id TEXT PRIMARY KEY,request_hash TEXT NOT NULL,review_hash TEXT NOT NULL,changes TEXT NOT NULL);
|
||||||
|
PRAGMA user_version=6; COMMIT;")?;
|
||||||
crate::extension_transaction::recover(&mut db)?;
|
crate::extension_transaction::recover(&mut db)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
root,
|
root,
|
||||||
@@ -431,6 +448,158 @@ impl ExtensionStore {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
/// Builds the complete consent payload; staging work is allowed, but active
|
||||||
|
/// pointers, running instances and permissions are untouched.
|
||||||
|
pub fn installation_preview(&mut self, request: &InstallRequest) -> Result<InstallPreview> {
|
||||||
|
use crate::extension_transaction::{Change, Target};
|
||||||
|
let vault = Uuid::parse_str(&request.vault_id)
|
||||||
|
.map_err(|_| HostError::new("VAULT_INVALID"))?
|
||||||
|
.to_string();
|
||||||
|
if vault != request.vault_id || request.configurations.len() > 200 {
|
||||||
|
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
|
||||||
|
}
|
||||||
|
let dependencies = self.dependency_plan(
|
||||||
|
&request.root_key,
|
||||||
|
&request.app_version,
|
||||||
|
&request.platform,
|
||||||
|
&request.architecture,
|
||||||
|
)?;
|
||||||
|
for key in request.configurations.keys() {
|
||||||
|
if !dependencies.packages.iter().any(|p| &p.package_key == key) {
|
||||||
|
return Err(HostError::new("EXTENSION_CONFIG_INVALID"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
let mut trust_revisions = Vec::new();
|
||||||
|
for locked in &dependencies.packages {
|
||||||
|
let (json, key): (String, Vec<u8>) = self.db.query_row(
|
||||||
|
"SELECT release,signer FROM versions WHERE package_key=?1",
|
||||||
|
[&locked.package_key],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
let release: Release = serde_json::from_str(&json)
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||||
|
let key: [u8; 32] = key
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||||
|
self.check_not_revoked(&locked.source, &release, &key)?;
|
||||||
|
let trusted = self
|
||||||
|
.trust_setting(&locked.source, &release.namespace, &release.key_id)?
|
||||||
|
.ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?;
|
||||||
|
if !trusted.enabled || trusted.public_key != key {
|
||||||
|
return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED"));
|
||||||
|
}
|
||||||
|
trust_revisions.push(trusted.fingerprint()?);
|
||||||
|
let configuration = request
|
||||||
|
.configurations
|
||||||
|
.get(&locked.package_key)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
let archive = self.archive(&locked.package_key)?;
|
||||||
|
let (_, manifest) = release.verify_package(
|
||||||
|
&key,
|
||||||
|
&release.key_id,
|
||||||
|
&release.namespace,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
&archive,
|
||||||
|
)?;
|
||||||
|
crate::extension_config::validate(&manifest, &configuration)?;
|
||||||
|
let prepared = self.prepare(
|
||||||
|
&locked.package_key,
|
||||||
|
Signer {
|
||||||
|
public_key: &key,
|
||||||
|
key_id: &release.key_id,
|
||||||
|
namespace: &release.namespace,
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
let slot = hash(
|
||||||
|
&serde_json::to_vec(&(
|
||||||
|
&vault,
|
||||||
|
&locked.source,
|
||||||
|
&release.namespace,
|
||||||
|
&release.package_id,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let current = self.active_installation(&slot)?;
|
||||||
|
if current
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.pending_operation.is_some())
|
||||||
|
{
|
||||||
|
return Err(HostError::new("EXTENSION_TRANSACTION_BUSY"));
|
||||||
|
}
|
||||||
|
changes.push(Change {
|
||||||
|
target: Target {
|
||||||
|
slot,
|
||||||
|
package_key: locked.package_key.clone(),
|
||||||
|
directory: prepared.directory,
|
||||||
|
tree_sha256: prepared.tree_sha256,
|
||||||
|
configuration,
|
||||||
|
},
|
||||||
|
expected_revision: current.map(|p| p.revision),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let bytes =
|
||||||
|
serde_json::to_vec(&(request, &dependencies, &changes, trust_revisions)).unwrap();
|
||||||
|
if bytes.len() > 4 * 1024 * 1024 {
|
||||||
|
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
|
||||||
|
}
|
||||||
|
Ok(InstallPreview {
|
||||||
|
fingerprint: hash(&bytes),
|
||||||
|
dependencies,
|
||||||
|
changes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Recompute the exact reviewed payload before online checks. The main-window
|
||||||
|
/// confirmation UI must supply this digest; this method alone is not consent.
|
||||||
|
pub async fn install_confirmed(
|
||||||
|
&mut self,
|
||||||
|
operation: &str,
|
||||||
|
request: &InstallRequest,
|
||||||
|
confirmed_fingerprint: &str,
|
||||||
|
) -> Result<crate::extension_transaction::Receipt> {
|
||||||
|
let changes = self.lock_confirmed_plan(operation, request, confirmed_fingerprint)?;
|
||||||
|
self.switch_online(operation, &request.vault_id, &changes)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
fn lock_confirmed_plan(
|
||||||
|
&mut self,
|
||||||
|
operation: &str,
|
||||||
|
request: &InstallRequest,
|
||||||
|
confirmed_fingerprint: &str,
|
||||||
|
) -> Result<Vec<crate::extension_transaction::Change>> {
|
||||||
|
Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?;
|
||||||
|
let request_bytes = serde_json::to_vec(request).unwrap();
|
||||||
|
if request_bytes.len() > 4 * 1024 * 1024 {
|
||||||
|
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
|
||||||
|
}
|
||||||
|
let request_hash = hash(&request_bytes);
|
||||||
|
let existing:Option<(String,String,String)>=self.db.query_row("SELECT request_hash,review_hash,changes FROM extension_confirmations WHERE operation_id=?1",[operation],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?;
|
||||||
|
if let Some((old_request, old_review, json)) = existing {
|
||||||
|
if old_request != request_hash || old_review != confirmed_fingerprint {
|
||||||
|
return Err(HostError::new("OPERATION_REUSED"));
|
||||||
|
}
|
||||||
|
return serde_json::from_str(&json)
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"));
|
||||||
|
}
|
||||||
|
let preview = self.installation_preview(request)?;
|
||||||
|
if preview.fingerprint != confirmed_fingerprint {
|
||||||
|
return Err(HostError::new("EXTENSION_INSTALL_REVIEW_CHANGED"));
|
||||||
|
}
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO extension_confirmations VALUES (?1,?2,?3,?4)",
|
||||||
|
params![
|
||||||
|
operation,
|
||||||
|
request_hash,
|
||||||
|
confirmed_fingerprint,
|
||||||
|
serde_json::to_string(&preview.changes).unwrap()
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(preview.changes)
|
||||||
|
}
|
||||||
/// Online installation gate, using confirmed Host trust settings only.
|
/// Online installation gate, using confirmed Host trust settings only.
|
||||||
pub async fn switch_online(
|
pub async fn switch_online(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -862,6 +1031,107 @@ mod tests {
|
|||||||
archive,
|
archive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn installation_preview_binds_target_state_and_rejects_stale_confirmation_before_network()
|
||||||
|
{
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (release, archive, key) = fixture();
|
||||||
|
let mut store = ExtensionStore::open(temp.path()).unwrap();
|
||||||
|
let staged = store
|
||||||
|
.stage(request(
|
||||||
|
&Uuid::new_v4().to_string(),
|
||||||
|
&release,
|
||||||
|
&archive,
|
||||||
|
&key,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let setting = TrustSetting {
|
||||||
|
source: "https://catalog.example/".into(),
|
||||||
|
source_id: "catalog".into(),
|
||||||
|
namespace: release.namespace.clone(),
|
||||||
|
key_id: release.key_id.clone(),
|
||||||
|
public_key: key,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.confirm_trust(&setting, None, &setting.fingerprint().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let mut req = InstallRequest {
|
||||||
|
root_key: staged.package_key,
|
||||||
|
vault_id: Uuid::new_v4().to_string(),
|
||||||
|
app_version: "1.0.0".into(),
|
||||||
|
platform: "windows".into(),
|
||||||
|
architecture: "x86_64".into(),
|
||||||
|
configurations: Default::default(),
|
||||||
|
};
|
||||||
|
let preview = store.installation_preview(&req).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
preview.fingerprint,
|
||||||
|
store.installation_preview(&req).unwrap().fingerprint
|
||||||
|
);
|
||||||
|
drop(store);
|
||||||
|
let mut store = ExtensionStore::open(temp.path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
preview.fingerprint,
|
||||||
|
store.installation_preview(&req).unwrap().fingerprint
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.install_confirmed(&Uuid::new_v4().to_string(), &req, "stale")
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"EXTENSION_INSTALL_REVIEW_CHANGED"
|
||||||
|
);
|
||||||
|
req.app_version = "1.0.1".into();
|
||||||
|
assert_ne!(
|
||||||
|
preview.fingerprint,
|
||||||
|
store.installation_preview(&req).unwrap().fingerprint
|
||||||
|
);
|
||||||
|
req.app_version = "1.0.0".into();
|
||||||
|
let confirmed_operation = Uuid::new_v4().to_string();
|
||||||
|
let locked = store
|
||||||
|
.lock_confirmed_plan(&confirmed_operation, &req, &preview.fingerprint)
|
||||||
|
.unwrap();
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
store
|
||||||
|
.switch_prepared(&id, &req.vault_id, &preview.changes)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.installation_preview(&req).is_err());
|
||||||
|
store.finish_installation(&id, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
locked,
|
||||||
|
store
|
||||||
|
.lock_confirmed_plan(&confirmed_operation, &req, &preview.fingerprint)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
drop(store);
|
||||||
|
let mut store = ExtensionStore::open(temp.path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
locked,
|
||||||
|
store
|
||||||
|
.lock_confirmed_plan(&confirmed_operation, &req, &preview.fingerprint)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(store
|
||||||
|
.lock_confirmed_plan(&confirmed_operation, &req, "different")
|
||||||
|
.is_err());
|
||||||
|
assert_ne!(
|
||||||
|
preview.fingerprint,
|
||||||
|
store.installation_preview(&req).unwrap().fingerprint
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.install_confirmed(&Uuid::new_v4().to_string(), &req, &preview.fingerprint)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"EXTENSION_INSTALL_REVIEW_CHANGED"
|
||||||
|
);
|
||||||
|
req.configurations
|
||||||
|
.insert("unknown-package".into(), serde_json::json!({}));
|
||||||
|
assert!(store.installation_preview(&req).is_err());
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn revocations_survive_restart_and_consent_without_overblocking_other_releases() {
|
fn revocations_survive_restart_and_consent_without_overblocking_other_releases() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user