feat(community): 通过已验证的 Host 下载暂存桌面包

This commit is contained in:
2026-09-08 22:00:18 +08:00
parent 86f59d463e
commit 6566490416
11 changed files with 278 additions and 11 deletions
+1
View File
@@ -19,6 +19,7 @@ fn main() {
"extension_trust_review",
"extension_trust_confirm",
"extension_install_preview",
"extension_stage",
"record_get",
"record_write",
"sync_login",
+2 -1
View File
@@ -52,6 +52,7 @@
"allow-record-write",
"allow-extension-trust-review",
"allow-extension-trust-confirm",
"allow-extension-install-preview"
"allow-extension-install-preview",
"allow-extension-stage"
]
}
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-extension-stage"
description = "Enables the extension_stage command without any pre-configured scope."
commands.allow = ["extension_stage"]
[[permission]]
identifier = "deny-extension-stage"
description = "Denies the extension_stage command without any pre-configured scope."
commands.deny = ["extension_stage"]
@@ -146,6 +146,36 @@ pub async fn extension_install_preview(
.map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())?
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StageRequest {
operation_id: String,
source: String,
release: notesagent_host::extension_package::Release,
}
#[tauri::command]
pub async fn extension_stage(
window: WebviewWindow,
host: State<'_, Host>,
request: StageRequest,
) -> Result<Value, String> {
main_window(&window)?;
let extensions = host.extensions.clone();
tauri::async_runtime::spawn_blocking(move || {
let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?;
let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?;
let receipt = tauri::async_runtime::block_on(store.stage_online(
&request.operation_id,
&request.source,
&request.release,
))
.map_err(|e| e.code)?;
serde_json::to_value(receipt).map_err(|_| "EXTENSION_STAGE_FAILED".into())
})
.await
.map_err(|_| "EXTENSION_STAGE_FAILED".to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
+51
View File
@@ -600,6 +600,57 @@ impl ExtensionStore {
)?;
Ok(preview.changes)
}
pub async fn stage_online(
&mut self,
operation: &str,
source_url: &str,
release: &Release,
) -> Result<Receipt> {
Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?;
let origin = source(source_url)?;
let trusted = self
.trust_setting(&origin, &release.namespace, &release.key_id)?
.ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?;
if !trusted.enabled {
return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED"));
}
self.check_not_revoked(&origin, release, &trusted.public_key)?;
let client = crate::extension_trust::Client::new(&origin)?;
let result = client
.check(
crate::extension_trust::Pin {
source_id: &trusted.source_id,
key_id: &trusted.key_id,
namespace: &trusted.namespace,
public_key: &trusted.public_key,
},
release,
)
.await;
let checked = match result {
Ok(checked) => checked,
Err(error) => {
self.remember_revocation(&origin, release, &trusted.public_key, &error.code)?;
return Err(error);
}
};
let archive = client
.download(&checked, release, &trusted.public_key)
.await?;
self.stage(Stage {
operation_id: operation,
source: &origin,
release,
archive: &archive,
withdrawn: false,
signer: Signer {
public_key: &trusted.public_key,
key_id: &trusted.key_id,
namespace: &trusted.namespace,
revoked: false,
},
})
}
/// Online installation gate, using confirmed Host trust settings only.
pub async fn switch_online(
&mut self,
+139 -9
View File
@@ -23,6 +23,7 @@ pub struct Checked {
release_hash: String,
key_hash: String,
checked_at: Instant,
archive_path: String,
}
impl Checked {
/// Monotonic freshness avoids a wall-clock rollback extending validity.
@@ -112,6 +113,55 @@ impl Client {
}
serde_json::from_slice(&bytes).map_err(|_| unavailable())
}
pub async fn download(
&self,
checked: &Checked,
release: &Release,
key: &[u8; 32],
) -> Result<Vec<u8>> {
checked.matches(self.source.as_str(), release, key)?;
let limit = if release.kind == "theme" {
5 * 1024 * 1024
} else {
10 * 1024 * 1024
};
if release.size > limit {
return Err(HostError::new("EXTENSION_ARCHIVE_LIMIT"));
}
let url = self
.source
.join(&checked.archive_path)
.map_err(|_| unavailable())?;
let mut response = self
.http
.get(url)
.header("Cache-Control", "no-cache, no-store")
.send()
.await
.map_err(|_| unavailable())?;
if response.status() != reqwest::StatusCode::OK
|| response.content_length().is_some_and(|n| n != release.size)
{
return Err(unavailable());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| unavailable())? {
if bytes.len() as u64 + chunk.len() as u64 > release.size {
return Err(HostError::new("EXTENSION_ARCHIVE_LIMIT"));
}
bytes.extend_from_slice(&chunk);
}
release.verify_package(
key,
&release.key_id,
&release.namespace,
false,
false,
&bytes,
)?;
checked.matches(self.source.as_str(), release, key)?;
Ok(bytes)
}
pub async fn check(&self, pin: Pin<'_>, release: &Release) -> Result<Checked> {
release.validate()?;
let started = Instant::now();
@@ -163,17 +213,33 @@ impl Client {
.remove("withdrawn")
.and_then(|v| v.as_bool())
.ok_or_else(unavailable)?;
for field in ["release_id", "download_path"] {
if map
.remove(field)
.and_then(|v| v.as_str().map(str::to_owned))
.is_none()
{
return Err(unavailable());
}
let release_id = map
.remove("release_id")
.and_then(|v| v.as_str().map(str::to_owned))
.ok_or_else(unavailable)?;
if release_id.is_empty()
|| release_id.len() > 128
|| !release_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(unavailable());
}
let archive_path = format!("catalog/v1/releases/{release_id}/archive");
if map
.remove("download_path")
.and_then(|v| v.as_str().map(str::to_owned))
.as_deref()
!= Some(format!("/{archive_path}").as_str())
{
return Err(unavailable());
}
let remote: Release = serde_json::from_value(item).map_err(|_| unavailable())?;
found.push((hash(&serde_json::to_vec(&remote).unwrap()), withdrawn));
found.push((
hash(&serde_json::to_vec(&remote).unwrap()),
withdrawn,
archive_path,
));
}
let release_hash = hash(&serde_json::to_vec(release).unwrap());
if found.len() != 1 || found[0].0 != release_hash {
@@ -187,6 +253,7 @@ impl Client {
release_hash,
key_hash: hash(pin.public_key),
checked_at: started,
archive_path: found[0].2.clone(),
};
checked.matches(self.source.as_str(), release, pin.public_key)?;
Ok(checked)
@@ -304,6 +371,69 @@ mod tests {
}
assert!(Client::new("http://catalog.example").is_err());
}
#[tokio::test]
async fn archive_download_verifies_real_fixture_and_enforces_stream_size() {
let mut fixture: Value = serde_json::from_str(include_str!(
"../../src/services/fixtures/community-python-vector.json"
))
.unwrap();
let key: [u8; 32] = STANDARD
.decode(fixture["key"]["public_key"].as_str().unwrap())
.unwrap()
.try_into()
.unwrap();
let bytes = STANDARD
.decode(fixture["archive_base64"].as_str().unwrap())
.unwrap();
for field in ["release_id", "withdrawn", "download_path"] {
fixture["release"].as_object_mut().unwrap().remove(field);
}
let release: Release = serde_json::from_value(fixture["release"].clone()).unwrap();
for case in ["ok", "corrupt", "overlong", "redirect"] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let source = format!("http://{}/", listener.local_addr().unwrap());
let mut body = bytes.clone();
if case == "corrupt" {
body[0] ^= 1;
}
if case == "overlong" {
body.push(0);
}
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0; 8192];
let mut count = 0;
while !buffer[..count].windows(4).any(|b| b == b"\r\n\r\n") {
let n = stream.read(&mut buffer[count..]).unwrap();
assert!(n > 0);
count += n;
}
let status = if case == "redirect" {
"302 Found"
} else {
"200 OK"
};
// No content length: exercise the streaming cap independently.
write!(stream, "HTTP/1.1 {status}\r\nConnection: close\r\n\r\n").unwrap();
stream.write_all(&body).unwrap();
});
let mut client = Client::new("https://catalog.example/").unwrap();
client.source = reqwest::Url::parse(&source).unwrap();
let checked = Checked {
source,
release_hash: hash(&serde_json::to_vec(&release).unwrap()),
key_hash: hash(&key),
checked_at: Instant::now(),
archive_path: "catalog/v1/releases/fixture/archive".into(),
};
let result = client.download(&checked, &release, &key).await;
assert_eq!(result.is_ok(), case == "ok", "{case}");
if let Ok(actual) = result {
assert_eq!(actual, bytes);
}
worker.join().unwrap();
}
}
fn json_source(public: &[u8; 32]) -> Value {
serde_json::json!({"schema_version":1,"source_id":"fixture","keys":[{"key_id":"test-key","namespace":"examples","public_key":STANDARD.encode(public),"revoked":false}]})
}
+1
View File
@@ -850,6 +850,7 @@ fn main() {
extension_trust_review,
extension_trust_confirm,
extension_install_preview,
extension_stage,
record_get,
record_write,
sync_login,