feat(extensions): 绑定安装审阅并持久化已确认依赖计划

This commit is contained in:
2026-09-08 21:46:32 +08:00
parent 0757279f44
commit e18fb8faee
2 changed files with 281 additions and 3 deletions
@@ -252,3 +252,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 在线及底层准备切换均检查持久拒绝。重启后仍拒绝,发行撤回不影响其他版本,旧键撤销不自动禁止另一个显式确认的新公钥,但新公钥也不能复活已撤回版本。
- 30 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-revocation-tests.log`。测试覆盖持久性、信任确认后仍拒绝、不同版本/来源/公钥范围和网络失败不污染拒绝记录。
- 尚未接入运行中实例和工具注册表的撤销轮询/停止,不能据此声明 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 本身不证明用户授权;本轮没有执行第三方包或宣称整体生产化完成。
+273 -3
View File
@@ -84,6 +84,22 @@ impl TrustSetting {
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 {
root: PathBuf,
db: Connection,
@@ -272,10 +288,10 @@ impl ExtensionStore {
let mut db = Connection::open(database)?;
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 > 5 {
if version > 6 {
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
}
if (1..5).contains(&version) {
if (1..6).contains(&version) {
let backup = root.join(format!(
"extensions.schema{version}.{}.sqlite3",
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_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);
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)?;
Ok(Self {
root,
@@ -431,6 +448,158 @@ impl ExtensionStore {
)?;
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.
pub async fn switch_online(
&mut self,
@@ -862,6 +1031,107 @@ mod tests {
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]
fn revocations_survive_restart_and_consent_without_overblocking_other_releases() {
let temp = tempfile::tempdir().unwrap();