>(), [Risk::High, Risk::Medium, Risk::Low]);
}
+
+ #[test]
+ fn hashes_paths_with_forward_slashes_on_every_os() {
+ assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
+ assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
+ #[cfg(windows)]
+ assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::create_dir(tmp.path().join("web")).unwrap();
+ std::fs::write(tmp.path().join("web").join("index.html"), "").unwrap();
+ // A walked path (native separators) hashes like the manifest's `/` spelling.
+ assert_eq!(
+ package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
+ package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
+ );
+ }
}
diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs
index 9089067a..fdf63d95 100644
--- a/crates/ide-extension/src/lib.rs
+++ b/crates/ide-extension/src/lib.rs
@@ -27,8 +27,8 @@ mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
- ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative,
- engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
+ ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
+ Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs
index 99820d5f..d079cff2 100644
--- a/crates/ide-extension/src/manifest.rs
+++ b/crates/ide-extension/src/manifest.rs
@@ -35,6 +35,11 @@ pub const MANIFEST_FILE: &str = "plugin.toml";
pub const ENGINE_VERSION: &str = "1.0.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
+/// Marketplace categories, in the order they are shown.
+pub const CATEGORIES: &[&str] =
+ &["languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other"];
+/// Categories a plugin is listed under; later ones are ignored.
+pub const MAX_CATEGORIES: usize = 3;
#[derive(Clone, Debug, PartialEq)]
pub struct Manifest {
@@ -47,6 +52,8 @@ pub struct Manifest {
pub license: Option,
/// Release notes of this version (Settings › Plugins › What's New).
pub change_notes: Option,
+ /// Marketplace categories as declared (see [`Manifest::categories`]).
+ pub declared_categories: Vec,
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
pub engine: String,
/// Written in the 0.1 format.
@@ -166,6 +173,8 @@ struct Raw {
#[serde(default)]
change_notes: Option,
#[serde(default)]
+ categories: Vec,
+ #[serde(default)]
engines: Option,
/// 0.1 format.
#[serde(default)]
@@ -243,6 +252,7 @@ impl Manifest {
homepage: raw.homepage,
license: raw.license,
change_notes: raw.change_notes,
+ declared_categories: raw.categories,
engine,
legacy,
wasm: raw.wasm,
@@ -290,6 +300,9 @@ impl Manifest {
return Err(format!("duplicate activation event {event}"));
}
}
+ for c in &self.declared_categories {
+ validate_item_id(c, "category")?;
+ }
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
@@ -325,6 +338,39 @@ impl Manifest {
}
}
+impl Manifest {
+ /// Its marketplace categories, in [`CATEGORIES`] order: the first
+ /// [`MAX_CATEGORIES`] declared that this IDE knows; without any, those its
+ /// contributions imply (themes, icon themes, languages), else `other`.
+ pub fn categories(&self) -> Vec {
+ let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect();
+ let mut known: Vec<&str> = Vec::new();
+ for d in &self.declared_categories {
+ if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES {
+ known.push(d);
+ }
+ }
+ let mut out: Vec = pick(&|c| known.contains(&c));
+ if out.is_empty() {
+ out = pick(&|c| match c {
+ "themes" => self.contributes.contains_key("themes"),
+ "icon-themes" => self.contributes.contains_key("iconThemes"),
+ "languages" => self.contributes.contains_key("languages"),
+ _ => false,
+ });
+ }
+ if out.is_empty() {
+ out.push("other".into());
+ }
+ out
+ }
+
+ /// Declared categories this IDE doesn't know (ignored).
+ pub fn unknown_categories(&self) -> Vec<&str> {
+ self.declared_categories.iter().map(String::as_str).filter(|c| !CATEGORIES.contains(c)).collect()
+ }
+}
+
/// Whether this host can load a plugin written for `engine`.
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
if legacy {
@@ -417,6 +463,26 @@ mod tests {
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
}
+ #[test]
+ fn categories_are_declared_or_implied() {
+ let head = "id = \"acme.x\"\nname = \"X\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
+ let m = |rest: &str| Manifest::parse(&format!("{head}{rest}"));
+ // Declared, in the catalogue's order; unknown ones (a newer IDE's) are ignored.
+ let declared = m("categories = [\"tools\", \"vcs\", \"robots\"]\n").unwrap();
+ assert_eq!(declared.categories(), ["vcs", "tools"]);
+ assert_eq!(declared.unknown_categories(), ["robots"]);
+ // None declared: implied by the contributions, else `other`.
+ let theme = m("[[contributes.themes]]\nid = \"t\"\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
+ assert_eq!(theme.categories(), ["languages", "themes"]);
+ let declared_wins = m("categories = [\"tools\"]\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
+ assert_eq!(declared_wins.categories(), ["tools"]);
+ assert_eq!(m("").unwrap().categories(), ["other"]);
+ // At most three: the first ones declared.
+ let many = m("categories = [\"vcs\", \"ai\", \"vcs\", \"tools\", \"themes\"]\n").unwrap();
+ assert_eq!(many.categories(), ["vcs", "ai", "tools"]);
+ assert!(m("categories = [\"Bad Name\"]\n").is_err());
+ }
+
#[test]
fn parses_the_02_format() {
let m = Manifest::parse(FULL).unwrap();
diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs
index b8d3988d..f1bc9c04 100644
--- a/crates/ide-extension/src/package.rs
+++ b/crates/ide-extension/src/package.rs
@@ -9,9 +9,19 @@
//! proves who published the bytes; whether to trust that publisher is the
//! index's call.
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
-//! the published plugins, and revocations. A publisher owns the id prefix
-//! `.`; a revoked key invalidates everything it signed, a
-//! revoked plugin version is disabled where it is installed.
+//! the published plugin versions, and revocations. A publisher owns the id
+//! prefix `.`; a revoked key invalidates everything it signed, a
+//! revoked plugin version is disabled where it is installed. A plugin may be
+//! listed in several versions, each with the plugin API it needs: the IDE
+//! offers the newest one it can load.
+//! - **Key continuity**: an installed plugin updates silently only to a
+//! package signed with the same key, or with a key the installed one
+//! endorsed ([`Endorsement`], a publisher rotating keys); any other signer
+//! is the user's call. An IDE can pin a publisher to keys it was built with
+//! ([`Index::pin_publisher`]): the index then can't replace them.
+//! - **Format**: fields are only ever added (IDEs ignore those they don't
+//! know); `format` grows only for changes older IDEs can't read, and they
+//! still apply such an index's revocations ([`Index::parse`]).
use crate::MANIFEST_FILE;
use base64::Engine as _;
@@ -40,6 +50,24 @@ fn signed_message(id: &str, version: &str, digest: &str) -> Vec {
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
}
+fn endorsement_message(publisher: &str, key: &str) -> Vec {
+ format!("openidea-key/1\n{publisher}\n{key}").into_bytes()
+}
+
+/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of
+/// `message`; malformed keys and signatures are errors.
+fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result {
+ let key: [u8; 32] = B64
+ .decode(key)
+ .ok()
+ .and_then(|b| b.try_into().ok())
+ .ok_or_else(|| "signature: malformed public key".to_owned())?;
+ let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
+ let sig: [u8; 64] =
+ B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?;
+ Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok())
+}
+
/// `SIGNATURE.json` of a signed package.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -74,6 +102,12 @@ impl PublisherKey {
B64.encode(self.0.verifying_key().to_bytes())
}
+ /// Vouches for `publisher`'s new key `key` (base64), the one that follows this one.
+ pub fn endorse(&self, publisher: &str, key: &str) -> Endorsement {
+ let sig = self.0.sign(&endorsement_message(publisher, key));
+ Endorsement { key: self.public_base64(), signature: B64.encode(sig.to_bytes()) }
+ }
+
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
let sig = self.0.sign(&signed_message(id, version, digest));
PackageSignature {
@@ -88,19 +122,11 @@ impl PackageSignature {
/// Whether the signature is valid for `id`, `version` and `digest` (the
/// key's validity is checked against an [`Index`]).
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
- let key: [u8; 32] = B64
- .decode(&self.key)
- .ok()
- .and_then(|b| b.try_into().ok())
- .ok_or_else(|| "signature: malformed public key".to_owned())?;
- let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
- let sig: [u8; 64] = B64
- .decode(&self.signature)
- .ok()
- .and_then(|b| b.try_into().ok())
- .ok_or_else(|| "signature: malformed".to_owned())?;
- key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
- .map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
+ if verify_ed25519(&self.key, &signed_message(id, version, digest), &self.signature)? {
+ Ok(())
+ } else {
+ Err("the signature does not match the package (it was changed after signing)".to_owned())
+ }
}
}
@@ -242,6 +268,10 @@ pub struct Index {
pub plugins: Vec,
#[serde(default)]
pub revoked: Vec,
+ /// The marketplace moved: its index is at this URL (relative to this
+ /// one, or absolute), and the IDE reads that one instead.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub moved: Option,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
@@ -253,7 +283,7 @@ pub struct Publisher {
pub keys: Vec,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct KeyEntry {
/// Ed25519 public key, base64.
@@ -261,6 +291,27 @@ pub struct KeyEntry {
/// Packages signed with it are no longer trusted.
#[serde(default)]
pub revoked: bool,
+ /// The publisher's earlier key vouching for this one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub endorsement: Option,
+}
+
+impl KeyEntry {
+ pub fn new(key: String) -> Self {
+ Self { key, ..Self::default() }
+ }
+}
+
+/// A publisher's earlier key vouching for a newer one: plugins installed from
+/// packages signed with the earlier key update to ones signed with the newer
+/// key without asking the user.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
+#[serde(rename_all = "camelCase")]
+pub struct Endorsement {
+ /// The endorsing key, base64.
+ pub key: String,
+ /// Its Ed25519 signature of `openidea-key/1\n\n`, base64.
+ pub signature: String,
}
/// A published plugin version.
@@ -281,6 +332,34 @@ pub struct IndexEntry {
pub digest: String,
#[serde(default)]
pub capabilities: Vec,
+ /// The plugin API versions it loads in (a semver requirement): its
+ /// manifest's `engines.openidea` and the API its component was built for.
+ /// Required: an entry without one is offered to no IDE.
+ #[serde(default)]
+ pub engine: String,
+ /// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
+ #[serde(default)]
+ pub categories: Vec,
+}
+
+impl IndexEntry {
+ /// Whether this IDE can load the version.
+ pub fn compatible(&self) -> bool {
+ crate::engine_compatible(&self.engine, false)
+ }
+
+ fn semver(&self) -> Option {
+ semver::Version::parse(&self.version).ok()
+ }
+}
+
+/// `engines.openidea` of a manifest narrowed to the API its component was built
+/// for (`major.minor`, if it has code): the requirement an index lists.
+pub fn engine_requirement(manifest_engine: &str, built_for: Option<&semver::Version>) -> String {
+ match built_for {
+ Some(v) => format!("{manifest_engine}, ^{}.{}", v.major, v.minor),
+ None => manifest_engine.to_owned(),
+ }
}
/// A plugin (all versions when `versions` is empty) that must not run.
@@ -294,12 +373,71 @@ pub struct Revocation {
}
impl Index {
+ /// Parses an index. One in a newer format keeps what must reach every IDE —
+ /// revocations, publisher keys (revoked ones) and `moved` — and lists no
+ /// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
pub fn parse(text: &str) -> Result {
- let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
- if index.format != INDEX_FORMAT {
- return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
+ let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
+ let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
+ if format == u64::from(INDEX_FORMAT) {
+ return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
}
- Ok(index)
+ if format < u64::from(INDEX_FORMAT) {
+ return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
+ }
+ fn each(value: &serde_json::Value, field: &str) -> Vec {
+ let items = value.get(field).and_then(serde_json::Value::as_array);
+ items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
+ }
+ Ok(Index {
+ format: u32::try_from(format).unwrap_or(u32::MAX),
+ publishers: each(&value, "publishers"),
+ plugins: Vec::new(),
+ revoked: each(&value, "revoked"),
+ moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
+ })
+ }
+
+ /// Written in a format newer than this IDE reads: it lists no plugins.
+ pub fn is_newer(&self) -> bool {
+ self.format > INDEX_FORMAT
+ }
+
+ /// The version of each plugin to offer, and whether this IDE can load it:
+ /// the newest it can load, else the newest. Revoked versions are skipped;
+ /// plugins come in the order they are first listed.
+ pub fn offered(&self) -> Vec<(&IndexEntry, bool)> {
+ let mut ids: Vec<&str> = Vec::new();
+ for e in &self.plugins {
+ if !ids.contains(&e.id.as_str()) {
+ ids.push(&e.id);
+ }
+ }
+ ids.into_iter()
+ .filter_map(|id| {
+ let versions: Vec<&IndexEntry> =
+ self.plugins.iter().filter(|e| e.id == id && self.revocation(id, &e.version).is_none()).collect();
+ let newest = |loadable: bool| {
+ versions.iter().copied().filter(|e| !loadable || e.compatible()).max_by_key(|e| e.semver())
+ };
+ newest(true).map(|e| (e, true)).or_else(|| newest(false).map(|e| (e, false)))
+ })
+ .collect()
+ }
+
+ /// Drops the versions no IDE would pick: revoked ones, and of each plugin
+ /// all but the newest version per API requirement.
+ pub fn prune_versions(&mut self) {
+ let plugins = std::mem::take(&mut self.plugins);
+ let mut kept: Vec = Vec::new();
+ for e in plugins.into_iter().filter(|e| self.revocation(&e.id, &e.version).is_none()) {
+ match kept.iter_mut().find(|k| k.id == e.id && k.engine == e.engine) {
+ Some(k) if e.semver() > k.semver() => *k = e,
+ Some(_) => {}
+ None => kept.push(e),
+ }
+ }
+ self.plugins = kept;
}
/// The revocation that applies to `id` at `version`.
@@ -335,6 +473,127 @@ impl Index {
sig.check(id, version, digest)?;
Ok(publisher.id.clone())
}
+
+ /// Whether `to` is `from`, or a key of `publisher` that `from` endorsed,
+ /// directly or through keys it endorsed. Endorsements by revoked keys don't
+ /// count: whoever stole a key could vouch for their own.
+ pub fn succeeds(&self, publisher: &str, from: &str, to: &str) -> bool {
+ let keys = self.publishers.iter().find(|p| p.id == publisher).map_or(&[][..], |p| &p.keys[..]);
+ let mut key = to;
+ // Each step moves to an earlier key; more steps than keys is a loop.
+ for _ in 0..=keys.len() {
+ if key == from {
+ return true;
+ }
+ let Some(e) = keys.iter().find(|k| k.key == key).and_then(|k| k.endorsement.as_ref()) else {
+ return false;
+ };
+ let endorser_valid = keys.iter().any(|k| k.key == e.key && !k.revoked);
+ if !endorser_valid || verify_ed25519(&e.key, &endorsement_message(publisher, key), &e.signature) != Ok(true)
+ {
+ return false;
+ }
+ key = &e.key;
+ }
+ false
+ }
+
+ /// Makes `roots` (base64) the keys `publisher` signs with, whatever this
+ /// index says: another key it lists stays only when a root endorsed it (a
+ /// rotation the roots vouched for). Revocations of the keys are kept.
+ pub fn pin_publisher(&mut self, publisher: &str, name: &str, roots: &[String]) {
+ if roots.is_empty() {
+ return;
+ }
+ let i = match self.publishers.iter().position(|p| p.id == publisher) {
+ Some(i) => i,
+ None => {
+ self.publishers.push(Publisher { id: publisher.into(), name: name.into(), keys: Vec::new() });
+ self.publishers.len() - 1
+ }
+ };
+ for root in roots {
+ if !self.publishers[i].keys.iter().any(|k| &k.key == root) {
+ self.publishers[i].keys.push(KeyEntry::new(root.clone()));
+ }
+ }
+ let keys = self.publishers[i].keys.clone();
+ let trusted = keys
+ .into_iter()
+ .filter(|k| roots.iter().any(|r| self.succeeds(publisher, r, &k.key)))
+ .map(|k| if roots.contains(&k.key) { KeyEntry { endorsement: None, ..k } } else { k })
+ .collect();
+ self.publishers[i].keys = trusted;
+ }
+
+ /// Replaces what this index lists for every publisher but `keep` with what
+ /// `other` lists: its publishers, their plugins and their revocations
+ /// (`other`'s downloads should already be absolute). `other` may only
+ /// speak for its own publishers, and none of them may be in `keep`.
+ pub fn include(&mut self, other: Index, keep: &[&str]) -> Result<(), String> {
+ let mut theirs: Vec<&str> = Vec::new();
+ for p in &other.publishers {
+ if !publisher_id_ok(&p.id) {
+ return Err(format!("invalid publisher id {:?}: use a-z, 0-9, '-' and '_'", p.id));
+ }
+ if keep.contains(&p.id.as_str()) {
+ return Err(format!("may not list publisher {:?}", p.id));
+ }
+ if theirs.contains(&p.id.as_str()) {
+ return Err(format!("publisher {:?} is listed twice", p.id));
+ }
+ theirs.push(&p.id);
+ }
+ let owned_by = |id: &str| theirs.iter().any(|p| owns(p, id));
+ for e in &other.plugins {
+ if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) {
+ return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher));
+ }
+ if semver::VersionReq::parse(&e.engine).is_err() {
+ return Err(format!("{} {}: `engine` must be the plugin API it needs", e.id, e.version));
+ }
+ }
+ if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) {
+ return Err(format!("{}: revoked by an index that doesn't publish it", r.id));
+ }
+ let kept = |id: &str| keep.iter().any(|p| owns(p, id));
+ self.publishers.retain(|p| keep.contains(&p.id.as_str()));
+ self.plugins.retain(|e| keep.contains(&e.publisher.as_str()));
+ self.revoked.retain(|r| kept(&r.id));
+ self.publishers.extend(other.publishers);
+ self.plugins.extend(other.plugins);
+ self.revoked.extend(other.revoked);
+ Ok(())
+ }
+}
+
+/// Whether a package signed `new` (`None`: unsigned) may replace an installed
+/// one signed `installed` without asking the user: signed with the same key,
+/// or with one the installed key endorsed (per `index`). An unsigned installed
+/// copy pins nothing.
+pub fn signer_continues(
+ index: Option<&Index>,
+ installed: Option<&PackageSignature>,
+ new: Option<&PackageSignature>,
+) -> bool {
+ match (installed, new) {
+ (None, _) => true,
+ (Some(_), None) => false,
+ (Some(old), Some(new)) => {
+ old.publisher == new.publisher
+ && (old.key == new.key || index.is_some_and(|i| i.succeeds(&new.publisher, &old.key, &new.key)))
+ }
+ }
+}
+
+/// Whether the plugin `id` is in `publisher`'s namespace.
+fn owns(publisher: &str, id: &str) -> bool {
+ id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.'))
+}
+
+/// A publisher id is a single segment of a plugin id.
+pub fn publisher_id_ok(id: &str) -> bool {
+ !id.contains('.') && crate::validate_plugin_id(id).is_ok()
}
#[cfg(test)]
@@ -354,7 +613,7 @@ mod tests {
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
- keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
+ keys: vec![KeyEntry::new(key.public_base64())],
}],
plugins: Vec::new(),
revoked: vec![Revocation {
@@ -362,6 +621,7 @@ mod tests {
versions: vec!["1.0.0".into()],
reason: "malware".into(),
}],
+ moved: None,
}
}
@@ -399,6 +659,138 @@ mod tests {
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
}
+ fn entry(id: &str, publisher: &str) -> IndexEntry {
+ IndexEntry {
+ id: id.into(),
+ name: id.into(),
+ version: "1.0.0".into(),
+ description: String::new(),
+ authors: Vec::new(),
+ publisher: publisher.into(),
+ download: format!("https://example.com/{id}.oiplugin"),
+ digest: "d".into(),
+ capabilities: Vec::new(),
+ engine: "^1.0".into(),
+ categories: Vec::new(),
+ }
+ }
+
+ fn version(id: &str, version: &str, engine: &str) -> IndexEntry {
+ IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") }
+ }
+
+ #[test]
+ fn the_ide_is_offered_the_newest_version_it_can_load() {
+ let host = crate::ENGINE_VERSION;
+ let newer = semver::Version::parse(host).unwrap().major + 1;
+ let mut index = Index {
+ format: INDEX_FORMAT,
+ plugins: vec![
+ version("acme.a", "1.0.0", &format!("^{host}")),
+ version("acme.a", "1.2.0", &format!("^{host}")),
+ version("acme.a", "2.0.0", &format!("^{newer}.0")),
+ version("acme.b", "0.1.0", &format!("^{newer}.0")),
+ version("acme.b", "0.2.0", &format!("^{newer}.1")),
+ version("acme.c", "1.0.0", ""),
+ version("acme.c", "1.1.0", ""),
+ ],
+ revoked: vec![Revocation { id: "acme.c".into(), versions: vec!["1.1.0".into()], reason: "r".into() }],
+ ..Index::default()
+ };
+ let offered = |index: &Index| {
+ index.offered().into_iter().map(|(e, ok)| (e.id.clone(), e.version.clone(), ok)).collect::>()
+ };
+ let expected = [
+ ("acme.a".to_owned(), "1.2.0".to_owned(), true),
+ ("acme.b".to_owned(), "0.2.0".to_owned(), false),
+ // No `engine`: listed, but no IDE installs it.
+ ("acme.c".to_owned(), "1.0.0".to_owned(), false),
+ ];
+ assert_eq!(offered(&index), expected);
+
+ // Pruning keeps the newest version per requirement, which changes nothing offered.
+ index.prune_versions();
+ let left: Vec<_> = index.plugins.iter().map(|e| format!("{} {}", e.id, e.version)).collect();
+ assert_eq!(left, ["acme.a 1.2.0", "acme.a 2.0.0", "acme.b 0.1.0", "acme.b 0.2.0", "acme.c 1.0.0"]);
+ assert_eq!(offered(&index), expected);
+
+ let built_for = semver::Version::new(1, 3, 0);
+ assert_eq!(engine_requirement("^1.0", Some(&built_for)), "^1.0, ^1.3");
+ assert_eq!(engine_requirement("^1.0", None), "^1.0");
+ assert!(semver::VersionReq::parse(&engine_requirement("^1.0", Some(&built_for))).is_ok());
+ }
+
+ fn publisher(id: &str) -> Publisher {
+ Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry::new(format!("{id}-key"))] }
+ }
+
+ fn revocation(id: &str) -> Revocation {
+ Revocation { id: id.into(), versions: Vec::new(), reason: "r".into() }
+ }
+
+ #[test]
+ fn including_an_index_replaces_everything_but_the_kept_publishers() {
+ let mut official = Index {
+ format: INDEX_FORMAT,
+ publishers: vec![publisher("openidea"), publisher("gone")],
+ plugins: vec![entry("openidea.a", "openidea"), entry("gone.x", "gone")],
+ revoked: vec![revocation("openidea.old"), revocation("gone.x")],
+ moved: None,
+ };
+ let community = Index {
+ format: INDEX_FORMAT,
+ publishers: vec![publisher("alice")],
+ plugins: vec![entry("alice.arthas", "alice")],
+ revoked: vec![revocation("alice.bad")],
+ moved: None,
+ };
+ official.include(community.clone(), &["openidea"]).unwrap();
+ let ids = |v: &[IndexEntry]| v.iter().map(|e| e.id.clone()).collect::>();
+ assert_eq!(official.publishers.iter().map(|p| p.id.as_str()).collect::>(), ["openidea", "alice"]);
+ assert_eq!(ids(&official.plugins), ["openidea.a", "alice.arthas"]);
+ assert_eq!(official.revoked.iter().map(|r| r.id.as_str()).collect::>(), ["openidea.old", "alice.bad"]);
+
+ // Including again is idempotent.
+ let once = official.clone();
+ official.include(community.clone(), &["openidea"]).unwrap();
+ assert_eq!(official, once);
+ }
+
+ #[test]
+ fn an_included_index_speaks_only_for_its_own_publishers() {
+ let base = Index { format: INDEX_FORMAT, publishers: vec![publisher("openidea")], ..Index::default() };
+ let with = |publishers: Vec, plugins: Vec, revoked: Vec| Index {
+ format: INDEX_FORMAT,
+ publishers,
+ plugins,
+ revoked,
+ moved: None,
+ };
+ let cases = [
+ (with(vec![publisher("openidea")], vec![], vec![]), "may not list"),
+ (with(vec![publisher("alice"), publisher("alice")], vec![], vec![]), "listed twice"),
+ (with(vec![publisher("openidea.x")], vec![], vec![]), "invalid publisher"),
+ (with(vec![publisher("alice")], vec![entry("openidea.a", "alice")], vec![]), "not a plugin of"),
+ (with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"),
+ (with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"),
+ (with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"),
+ (
+ with(
+ vec![publisher("alice")],
+ vec![IndexEntry { engine: String::new(), ..entry("alice.a", "alice") }],
+ vec![],
+ ),
+ "`engine`",
+ ),
+ ];
+ for (other, error) in cases {
+ let mut index = base.clone();
+ let err = index.include(other, &["openidea"]).unwrap_err();
+ assert!(err.contains(error), "{err}");
+ assert_eq!(index, base);
+ }
+ }
+
#[test]
fn unpacking_refuses_paths_leaving_the_package() {
let tmp = tempfile::tempdir().unwrap();
@@ -413,7 +805,7 @@ mod tests {
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
assert!(err.contains("invalid path"), "{err}");
assert!(!tmp.path().join("outside.txt").exists());
- assert!(Index::parse(r#"{"format": 2}"#).is_err());
+ assert!(Index::parse(r#"{"format": 2}"#).unwrap().is_newer());
}
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
@@ -462,4 +854,102 @@ mod tests {
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
}
}
+
+ #[test]
+ fn a_newer_index_still_revokes_and_moves() {
+ let newer = format!(
+ r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed",
+ "publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}],
+ "revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}],
+ "moved": "https://example.com/next.json"}}"#,
+ INDEX_FORMAT + 1
+ );
+ let index = Index::parse(&newer).unwrap();
+ assert!(index.is_newer());
+ assert!(index.plugins.is_empty() && index.offered().is_empty());
+ assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware");
+ assert!(index.key_revoked("k1"));
+ assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json"));
+ // Fields this IDE doesn't know are ignored in its own format.
+ let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#);
+ assert!(!Index::parse(¤t).unwrap().is_newer());
+ assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported"));
+ assert!(Index::parse(r#"{"plugins": []}"#).is_err());
+ }
+
+ fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec {
+ &mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys
+ }
+
+ #[test]
+ fn rotated_keys_continue_through_endorsements() {
+ let (k1, k2, k3) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
+ let mut index = index(&k1);
+ let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry {
+ endorsement: Some(by.endorse("acme", &key.public_base64())),
+ ..KeyEntry::new(key.public_base64())
+ };
+ keys(&mut index, "acme").push(endorsed(&k1, &k2));
+ keys(&mut index, "acme").push(endorsed(&k2, &k3));
+ let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64());
+ assert!(index.succeeds("acme", &p1, &p1));
+ assert!(index.succeeds("acme", &p1, &p3), "through k2");
+ assert!(!index.succeeds("acme", &p3, &p1), "not backwards");
+ assert!(!index.succeeds("other", &p1, &p2));
+ // An endorsement for another publisher, or by a revoked key, doesn't count.
+ let mut foreign = index.clone();
+ keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2));
+ assert!(!foreign.succeeds("acme", &p1, &p2));
+ let mut stolen = index.clone();
+ keys(&mut stolen, "acme")[0].revoked = true;
+ assert!(!stolen.succeeds("acme", &p1, &p2));
+
+ let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d");
+ assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3))));
+ assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1))));
+ assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch");
+ let stranger = PublisherKey::from_seed([4; 32]);
+ assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger))));
+ assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned");
+ assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing");
+ }
+
+ #[test]
+ fn pinned_publishers_keep_their_built_in_keys() {
+ let (root, next, intruder) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32]));
+ let mut index = index(&intruder);
+ index.publishers[0].id = "openidea".into();
+ keys(&mut index, "openidea").push(KeyEntry {
+ endorsement: Some(root.endorse("openidea", &next.public_base64())),
+ ..KeyEntry::new(next.public_base64())
+ });
+ let roots = [root.public_base64()];
+ let listed = |index: &Index| -> Vec<(String, bool)> {
+ let mut index = index.clone();
+ index.pin_publisher("openidea", "OpenIDEA", &roots);
+ keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect()
+ };
+ // The intruder's key goes, the successor the root endorsed stays, the root is added.
+ assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]);
+ let mut pinned = index.clone();
+ pinned.pin_publisher("openidea", "OpenIDEA", &roots);
+ let digest = "d";
+ let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest);
+ assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err());
+ assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok());
+ // A revoked root stays revoked, and what it endorsed is no longer trusted.
+ keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) });
+ assert_eq!(listed(&index), [(root.public_base64(), true)]);
+
+ // A publisher missing from the index is added with its roots.
+ let mut empty = Index { format: INDEX_FORMAT, ..Index::default() };
+ empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]);
+ assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea");
+ // No roots: the index decides.
+ let mut open = index.clone();
+ open.pin_publisher("openidea", "OpenIDEA", &[]);
+ assert_eq!(open, index);
+ }
}
diff --git a/crates/ide-fonts/src/lib.rs b/crates/ide-fonts/src/lib.rs
index 341febde..e52a9396 100644
--- a/crates/ide-fonts/src/lib.rs
+++ b/crates/ide-fonts/src/lib.rs
@@ -21,6 +21,18 @@ pub struct FontFamily {
pub monospaced: bool,
}
+/// PANOSE bProportion value 9 means "Monospaced"; DirectWrite's
+/// `IsMonospacedFont` alone misses fonts that only carry this classification.
+const PANOSE_MONOSPACED: u8 = 9;
+
+/// One face counts as monospaced when the font system flags it fixed pitch, its
+/// PANOSE proportion is "Monospaced", or its glyph advances are uniform
+/// (fonts with wrong/missing metadata, e.g. bitmap-style TTFs).
+#[cfg_attr(not(windows), allow(dead_code))]
+fn face_is_mono(is_monospaced_font: bool, panose_proportion: u8, advances_equal: bool) -> bool {
+ is_monospaced_font || panose_proportion == PANOSE_MONOSPACED || advances_equal
+}
+
type Cache = Mutex