Merge pull request #6747 from dotCooCoo/fix/plugin-grant-sealed-api

Plug-ins: only an approval records a grant, and installs need one
This commit is contained in:
BFlat
2026-11-12 12:41:15 +07:00
committed by GitHub
12 changed files with 339 additions and 42 deletions
+8 -8
View File
@@ -552,7 +552,7 @@ mod tests {
// Traps on a long input.
let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))";
let src = crate::wat::with_imports(&manifest, "", first, r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap();
let doc = Document::new(100.0, 100.0);
let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::<Vec<_>>(), false));
let draw =
@@ -583,7 +583,7 @@ mod tests {
// Traps on a long input.
let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))";
let src = crate::wat::with_imports(&manifest, "", first, &output);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap();
let doc = Document::new(100.0, 100.0);
let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::<Vec<_>>(), false));
let draw =
@@ -647,7 +647,7 @@ mod tests {
crate::safe_mode::set_active(None);
const ID: &str = "test.effect.budget";
let src = crate::wat::returning(&crate::wat::manifest(ID, "effect", "{}"), r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap();
// Each run takes 900 ms by this clock (it is read before and after each run).
let view = crate::deferred::Deferrals::with_clock(stepping(450));
let ran = view.view(|| (0..4).map(|i| drawn(ID, &line(i + 2), i)).collect::<Vec<_>>());
@@ -677,7 +677,7 @@ mod tests {
const ID: &str = "test.effect.deferfailed";
// Runs to its instruction budget.
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap();
let view = crate::deferred::Deferrals::with_clock(stepping(1));
assert!(!view.view(|| drawn(ID, &line(2), 0)));
assert!(last_error(ID).is_some());
@@ -712,9 +712,9 @@ mod tests {
const FAST: &str = "test.effect.cutshort.fast";
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 10_000_000, r#"{"objects":[]}"#);
let limits = Limits { fuel_base: 1_000_000_000, ..Limits::default() };
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap());
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap()).unwrap();
let fast = crate::wat::returning(&crate::wat::manifest(FAST, "effect", "{}"), r#"{"objects":[]}"#);
registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap());
registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap()).unwrap();
// The first run starts the budget at 0 ms and ends at 1,999: the next has 1 ms left, and
// ten million steps take longer.
let reads = Arc::new(std::sync::atomic::AtomicU64::new(0));
@@ -737,7 +737,7 @@ mod tests {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
let src = crate::wat::spinning(&crate::wat::manifest("test.effect.cancelled", "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#);
let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap();
let ran_first =
|| RUNS.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|r| matches!(r.get(&plugin.serial()), Some(Runs::Done)));
let draw = || {
@@ -777,7 +777,7 @@ mod tests {
crate::safe_mode::parse_marker(&std::fs::read(&json[0]).unwrap())
};
let src = crate::wat::returning(&crate::wat::manifest("test.effect.overlap", "effect", "{}"), r#"{"objects":[]}"#);
let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap();
let named = Some(crate::safe_mode::Item::of(&plugin));
assert_eq!(marker(), None);
+4
View File
@@ -89,6 +89,10 @@ pub enum Error {
/// Safe Mode is on ([`safe_mode`]): no plug-in loads or runs.
#[error("Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally")]
SafeMode,
/// The module lists permissions that no recorded grant gives this very module
/// ([`registry::approved`]): it names them, comma-separated.
#[error("needs approval: it asks for {0}, which the user grants when installing it on its own")]
NeedsApproval(String),
#[error("{0}")]
Io(String),
}
+26
View File
@@ -275,6 +275,32 @@ impl Grant {
}
}
/// A [`Grant`] that [`crate::registry::grant`] records. The engine makes one where it records what
/// a person approved and where it loads the grants kept in its plug-in folder; tests that set up a
/// grant use the same constructors. `tests/grant_sites.rs` fails when other shipped code makes one.
/// Native code linked into the app can still call these constructors, so the token does not
/// protect against such code.
#[derive(Debug)]
pub struct GrantToken(Grant);
impl GrantToken {
/// The grant the engine records after a person approved the module it covers, or in a session
/// whose starter allowed requests to grant permissions (`--allow-plugin-grants`).
pub fn after_engine_approval(grant: Grant) -> Self {
Self(grant)
}
/// A grant the engine read from the grants file of its plug-in folder, for a module it loads.
pub fn from_engine_grants_file(grant: Grant) -> Self {
Self(grant)
}
/// The grant it holds.
pub fn grant(&self) -> &Grant {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
+117 -17
View File
@@ -7,9 +7,8 @@ use std::collections::{BTreeMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
#[cfg(not(target_arch = "wasm32"))]
use crate::Error;
use crate::permissions::Grant;
use crate::permissions::{Grant, GrantToken};
use crate::storage::Storage;
use crate::{Limits, Plugin, Result};
@@ -35,8 +34,11 @@ pub fn storage(id: &str) -> Option<Arc<dyn Storage>> {
STORAGE.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
}
/// Records what the user approved for a plug-in, replacing an earlier grant for its id.
pub fn grant(g: Grant) {
/// Records what the user approved for a plug-in, replacing an earlier grant for its id. Only a
/// [`GrantToken`] is recorded, which the engine makes after an approval or when it loads its grants
/// file.
pub fn grant(token: GrantToken) {
let g = token.grant().clone();
GRANTS.write().unwrap_or_else(|e| e.into_inner()).insert(g.id.clone(), g);
}
@@ -63,20 +65,27 @@ pub fn approved(plugin: &Plugin) -> bool {
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions()))
}
/// Installs `plugin`, replacing one with the same id.
pub fn install(mut plugin: Plugin) -> Arc<Plugin> {
/// Installs `plugin`, replacing one with the same id, when it is [`approved`]: a module that lists
/// permissions no recorded grant gives this very module is refused ([`Error::NeedsApproval`]), and
/// the plug-in installed under its id, the grants and the revision stay as they were.
pub fn install(mut plugin: Plugin) -> Result<Arc<Plugin>> {
// The revision moves while the write lock is held, so a cache that reads the new revision and
// then the plug-ins finds the new plug-in among them.
// then the plug-ins finds the new plug-in among them. The approval is checked while it is held
// too.
let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner());
if !approved(&plugin) {
return Err(Error::NeedsApproval(crate::permissions::names(&plugin.manifest().permissions()).join(", ")));
}
plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1);
let p = Arc::new(plugin);
plugins.insert(p.id().to_string(), p.clone());
p
Ok(p)
}
/// Loads `bytes` with the default limits and installs the plug-in.
/// Loads `bytes` with the default limits and installs the plug-in ([`install`]: one that lists
/// permissions installs only when they were granted to this very module).
pub fn install_bytes(bytes: &[u8]) -> Result<Arc<Plugin>> {
Ok(install(Plugin::load(bytes, Limits::default())?))
install(Plugin::load(bytes, Limits::default())?)
}
pub fn get(id: &str) -> Option<Arc<Plugin>> {
@@ -162,7 +171,8 @@ pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
}
/// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list
/// permissions only those `approve` accepts; the others are reported as failed with the reason. A plug-in the user disabled ([`crate::safe_mode::skipped`]) is reported and
/// permissions only those `approve` accepts and a recorded grant covers ([`install`]); the others
/// are reported as failed with the reason. A plug-in the user disabled ([`crate::safe_mode::skipped`]) is reported and
/// skipped, by its file before it loads or by its id after (its file is then disabled too, so the
/// next load skips it before it runs); the launch marker names each file while it loads
/// ([`crate::safe_mode::loading`]). In Safe Mode nothing loads ([`Error::SafeMode`]). When the
@@ -206,12 +216,14 @@ pub fn load_folder_where(
}
Ok(p) if p.manifest().asks_permissions() && !approve(&p) => {
let asked = crate::permissions::names(&p.manifest().permissions()).join(", ");
report.failed.push((
f.display().to_string(),
format!("needs approval: it asks for {asked}, which the user grants when installing it on its own"),
));
report.failed.push((f.display().to_string(), Error::NeedsApproval(asked).to_string()));
}
Ok(p) => report.loaded.push(install(p).id().to_string()),
// `approve` only narrows what installs: `install` still refuses a module that no
// recorded grant covers.
Ok(p) => match install(p) {
Ok(p) => report.loaded.push(p.id().to_string()),
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
},
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
}
}
@@ -231,6 +243,7 @@ mod tests {
/// waiting for the set leaves the revision alone until it holds the set.
#[test]
fn an_install_changes_the_revision_only_while_it_holds_the_set() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let plugin = Plugin::load(include_bytes!("../tests/fixtures/desaturate.wasm"), Limits::default()).unwrap();
let id = plugin.id().to_string();
let held = PLUGINS.read().unwrap_or_else(|e| e.into_inner());
@@ -241,8 +254,95 @@ mod tests {
revision() != before
});
drop(held);
installing.join().unwrap();
installing.join().unwrap().unwrap();
assert!(!moved, "the revision changed while the install waited for the set");
assert!(revision() > before && remove(&id));
}
/// A module of a filter `id` whose manifest lists `permissions` (a JSON list, or nothing), with
/// `marker` in its code so that two markers give two SHA-256s.
fn module(id: &str, permissions: Option<&str>, marker: i64) -> Vec<u8> {
let listed = permissions.map(|p| format!(r#","permissions":{p}"#)).unwrap_or_default();
let manifest = format!(r#"{{"id":"{id}","name":"T","kind":"filter"{listed}}}"#);
wat::parse_str(crate::wat::module(&manifest, &format!("(i64.const {marker}) (drop) (i64.const 0)"), "", 1)).unwrap()
}
fn grant_for(id: &str, bytes: &[u8], permissions: Vec<crate::permissions::Permission>) -> Grant {
Grant { id: id.into(), hash: crate::permissions::sha256_hex(bytes), permissions }
}
/// A module that lists permissions installs only once a recorded grant gives this very module
/// all of them: refused, nothing changes, neither the revision nor the plug-in under its id.
#[test]
fn install_bytes_refuses_unapproved_manifest_permissions() {
// The other tests that install or remove plug-ins hold it, so the revision moves only here.
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let id = "test.registry.unapproved";
let plain = install_bytes(&module(id, None, 1)).unwrap();
let before = revision();
let e = install_bytes(&module(id, Some(r#"["doc.read"]"#), 2)).unwrap_err();
assert_eq!(e, Error::NeedsApproval("doc.read".into()));
assert_eq!(revision(), before, "nothing installed");
assert_eq!(get(id).map(|p| p.hash().to_string()), Some(plain.hash().to_string()), "the plug-in before it stays");
assert!(granted(id).is_none());
assert!(remove(id));
}
/// `install` itself and a folder load whose `approve` accepts everything refuse the module too.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn install_and_folder_callback_cannot_bypass_approval() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
let id = "test.registry.bypass";
let plain = install_bytes(&module(id, Some("[]"), 3)).unwrap();
let asking = module(id, Some(r#"["doc.read","doc.write"]"#), 4);
let e = install(Plugin::load(&asking, Limits::default()).unwrap()).unwrap_err();
assert_eq!(e, Error::NeedsApproval("doc.read, doc.write".into()));
let dir = std::env::temp_dir().join(format!("vc-registry-bypass-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("asking.wasm"), &asking).unwrap();
let r = load_folder_where(&dir, |_| Ok(()), |_| true).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(r.loaded.is_empty(), "{r:?}");
assert_eq!(r.failed.len(), 1);
assert!(r.failed[0].1.starts_with("needs approval: it asks for doc.read, doc.write"), "{r:?}");
assert_eq!(get(id).map(|p| p.hash().to_string()), Some(plain.hash().to_string()), "the plug-in before it stays");
assert!(remove(id));
}
/// A recorded grant lets in only the module with its id and SHA-256 that lists nothing it
/// doesn't grant; a module whose manifest lists no permissions, or an empty list, needs none.
#[test]
fn matching_grant_allows_only_exact_bytes() {
use crate::permissions::Permission::{DocRead, DocWrite};
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let id = "test.registry.exact";
let bytes = module(id, Some(r#"["doc.read","doc.write"]"#), 5);
let plugin = Plugin::load(&bytes, Limits::default()).unwrap();
assert_eq!(plugin.hash(), crate::permissions::sha256_hex(&bytes), "a grant's hash is the loaded module's");
let refused = |g: Grant, why: &str| {
let other = g.id.clone();
revoke(id);
grant(GrantToken::after_engine_approval(g));
assert!(matches!(install_bytes(&bytes), Err(Error::NeedsApproval(_))), "{why}");
assert!(get(id).is_none(), "{why}");
revoke(&other);
};
refused(grant_for(id, &module(id, Some(r#"["doc.read","doc.write"]"#), 6), vec![DocRead, DocWrite]), "other bytes");
refused(grant_for("test.registry.other", &bytes, vec![DocRead, DocWrite]), "another id");
refused(Grant { id: id.into(), hash: "not a hash".into(), permissions: vec![DocRead, DocWrite] }, "a malformed hash");
refused(
Grant { id: id.into(), hash: crate::permissions::sha256_hex(&bytes).to_uppercase(), permissions: vec![DocRead, DocWrite] },
"an upper-case hash",
);
refused(grant_for(id, &bytes, vec![DocRead]), "a missing permission");
grant(GrantToken::from_engine_grants_file(grant_for(id, &bytes, vec![DocRead, DocWrite])));
assert_eq!(install_bytes(&bytes).unwrap().hash(), plugin.hash());
assert!(remove(id) && revoke(id));
for listed in [None, Some("[]")] {
assert!(install_bytes(&module(id, listed, 7)).is_ok(), "{listed:?}");
assert!(granted(id).is_none() && remove(id), "{listed:?}");
}
}
}
+82
View File
@@ -0,0 +1,82 @@
//! The shipped code that records a plug-in grant: only the engine's install approval and its load
//! of the plug-in folder's grants file make a `GrantToken` and call `registry::grant`. A new call
//! site fails this test until it is added to the list. It checks the source, not what native code
//! can do at run time.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// `(text, file, how many times)` for every place shipped code may name it.
const EXPECTED: &[(&str, &str, usize)] = &[
("GrantToken::after_engine_approval", "crates/engine/src/cmd/plugin.rs", 1),
("GrantToken::from_engine_grants_file", "crates/engine/src/store.rs", 2),
("registry::grant", "crates/engine/src/cmd/plugin.rs", 1),
("registry::grant", "crates/engine/src/store.rs", 2),
];
/// Every `.rs` file under `dir`, without test files and folders.
fn sources(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if !matches!(name.as_str(), "tests" | "benches" | "examples" | "target" | "fuzz") {
sources(&path, out);
}
} else if name.ends_with(".rs") && name != "tests.rs" && !name.starts_with("tests_") && !name.ends_with("_tests.rs") {
out.push(path);
}
}
}
/// The lines of `text` that ship: not comments, and nothing from a top-level `#[cfg(test)] mod x {`
/// on.
fn shipped(text: &str) -> Vec<&str> {
let lines: Vec<&str> = text.lines().collect();
let opens_test_module = |i: usize| {
lines[i] == "#[cfg(test)]"
&& lines[i + 1..].iter().find(|l| !l.trim().is_empty()).is_some_and(|l| l.starts_with("mod ") && l.trim_end().ends_with('{'))
};
let end = (0..lines.len()).find(|&i| opens_test_module(i)).unwrap_or(lines.len());
lines[..end].iter().copied().filter(|l| !l.trim_start().starts_with("//")).collect()
}
/// How many times `name` occurs in `line` as a whole path (`registry::grant`, not `registry::granted`).
fn count(line: &str, name: &str) -> usize {
line.match_indices(name).filter(|(at, _)| !line[at + name.len()..].starts_with(|c: char| c.is_alphanumeric() || c == '_')).count()
}
#[test]
fn grant_token_call_sites_are_limited() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut files = Vec::new();
for top in ["crates", "apps", "xtask"] {
sources(&root.join(top), &mut files);
}
assert!(files.len() > 100, "found only {} source files under {}", files.len(), root.display());
let mut found: BTreeMap<(&str, String), usize> = BTreeMap::new();
for file in &files {
let text = std::fs::read_to_string(file).unwrap();
let rel = file.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/");
for line in shipped(&text) {
for name in ["GrantToken::after_engine_approval", "GrantToken::from_engine_grants_file", "registry::grant"] {
let n = count(line, name);
if n > 0 {
*found.entry((name, rel.clone())).or_default() += n;
}
}
}
}
let expected: BTreeMap<(&str, String), usize> = EXPECTED.iter().map(|&(name, file, n)| ((name, file.to_string()), n)).collect();
assert_eq!(found, expected, "shipped code that makes a grant token or records a grant");
}
/// The audit's own filters: a test module and comments are left out, a longer name isn't counted.
#[test]
fn the_audit_skips_test_code_and_comments() {
let text = "fn a() { registry::grant(t); }\n// registry::grant(t)\nfn b() { registry::granted(id); }\n\n#[cfg(test)]\n\nmod tests {\n registry::grant(t);\n}\n";
let lines = shipped(text);
assert_eq!(lines.iter().map(|l| count(l, "registry::grant")).sum::<usize>(), 1, "{lines:?}");
}
+2 -2
View File
@@ -58,9 +58,9 @@ fn temp_dir(name: &str) -> std::path::PathBuf {
fn in_safe_mode_nothing_loads_or_runs() {
let _f = fresh();
// Installed before Safe Mode was turned on.
let before = registry::install(Plugin::load(&filter("test.safe.before"), Limits::default()).unwrap());
let before = registry::install(Plugin::load(&filter("test.safe.before"), Limits::default()).unwrap()).unwrap();
let fx = build(&tpl::returning(&tpl::manifest("test.safe.effect", "effect", "{}"), r#"{"objects":[]}"#));
registry::install(Plugin::load(&fx, Limits::default()).unwrap());
registry::install(Plugin::load(&fx, Limits::default()).unwrap()).unwrap();
let dir = temp_dir("gate");
std::fs::write(dir.join("a.wasm"), filter("test.safe.folder")).unwrap();