Add a schema to extensions, to prevent installing extensions on too old of a Zed version (#9599)

Max Brunsfeld and Marshall created

Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>

Change summary

Cargo.lock                                                               |  3 
crates/client/src/telemetry.rs                                           |  6 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql           |  1 
crates/collab/migrations/20240320124800_add_extension_schema_version.sql |  2 
crates/collab/src/api/extensions.rs                                      | 20 
crates/collab/src/db.rs                                                  |  1 
crates/collab/src/db/queries/extensions.rs                               | 18 
crates/collab/src/db/tables/extension_version.rs                         |  1 
crates/collab/src/db/tests/extension_tests.rs                            | 29 
crates/extension/Cargo.toml                                              |  1 
crates/extension/src/extension_manifest.rs                               |  1 
crates/extension/src/extension_store.rs                                  | 44 
crates/extension/src/extension_store_test.rs                             |  3 
crates/extension_api/Cargo.toml                                          |  6 
crates/extension_api/README.md                                           | 56 
crates/extension_cli/src/main.rs                                         |  1 
crates/live_kit_client/src/prod.rs                                       |  2 
crates/rpc/src/extension.rs                                              |  1 
crates/util/src/http.rs                                                  |  7 
crates/zed/src/main.rs                                                   |  4 
extensions/gleam/extension.toml                                          |  1 
extensions/uiua/extension.toml                                           |  1 
22 files changed, 165 insertions(+), 44 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3482,6 +3482,7 @@ dependencies = [
  "settings",
  "theme",
  "toml 0.8.10",
+ "url",
  "util",
  "wasm-encoder",
  "wasmparser",
@@ -12630,7 +12631,7 @@ dependencies = [
 
 [[package]]
 name = "zed_extension_api"
-version = "0.1.0"
+version = "0.0.1"
 dependencies = [
  "wit-bindgen",
 ]

crates/client/src/telemetry.rs 🔗

@@ -470,7 +470,11 @@ impl Telemetry {
 
                     let request = http::Request::builder()
                         .method(Method::POST)
-                        .uri(this.http_client.build_zed_api_url("/telemetry/events"))
+                        .uri(
+                            this.http_client
+                                .build_zed_api_url("/telemetry/events", &[])?
+                                .as_ref(),
+                        )
                         .header("Content-Type", "text/plain")
                         .header("x-zed-checksum", checksum)
                         .body(json_bytes.into());

crates/collab/src/api/extensions.rs 🔗

@@ -12,6 +12,7 @@ use axum::{
     Extension, Json, Router,
 };
 use collections::HashMap;
+use rpc::ExtensionApiManifest;
 use serde::{Deserialize, Serialize};
 use std::{sync::Arc, time::Duration};
 use time::PrimitiveDateTime;
@@ -33,6 +34,8 @@ pub fn router() -> Router {
 #[derive(Debug, Deserialize)]
 struct GetExtensionsParams {
     filter: Option<String>,
+    #[serde(default)]
+    max_schema_version: i32,
 }
 
 #[derive(Debug, Deserialize)]
@@ -51,20 +54,14 @@ struct GetExtensionsResponse {
     pub data: Vec<ExtensionMetadata>,
 }
 
-#[derive(Deserialize)]
-struct ExtensionManifest {
-    name: String,
-    version: String,
-    description: Option<String>,
-    authors: Vec<String>,
-    repository: String,
-}
-
 async fn get_extensions(
     Extension(app): Extension<Arc<AppState>>,
     Query(params): Query<GetExtensionsParams>,
 ) -> Result<Json<GetExtensionsResponse>> {
-    let extensions = app.db.get_extensions(params.filter.as_deref(), 500).await?;
+    let extensions = app
+        .db
+        .get_extensions(params.filter.as_deref(), params.max_schema_version, 500)
+        .await?;
     Ok(Json(GetExtensionsResponse { data: extensions }))
 }
 
@@ -267,7 +264,7 @@ async fn fetch_extension_manifest(
         })?
         .to_vec();
     let manifest =
-        serde_json::from_slice::<ExtensionManifest>(&manifest_bytes).with_context(|| {
+        serde_json::from_slice::<ExtensionApiManifest>(&manifest_bytes).with_context(|| {
             format!(
                 "invalid manifest for extension {extension_id} version {version}: {}",
                 String::from_utf8_lossy(&manifest_bytes)
@@ -287,6 +284,7 @@ async fn fetch_extension_manifest(
         description: manifest.description.unwrap_or_default(),
         authors: manifest.authors,
         repository: manifest.repository,
+        schema_version: manifest.schema_version.unwrap_or(0),
         published_at,
     })
 }

crates/collab/src/db.rs 🔗

@@ -731,6 +731,7 @@ pub struct NewExtensionVersion {
     pub description: String,
     pub authors: Vec<String>,
     pub repository: String,
+    pub schema_version: i32,
     pub published_at: PrimitiveDateTime,
 }
 

crates/collab/src/db/queries/extensions.rs 🔗

@@ -4,27 +4,28 @@ impl Database {
     pub async fn get_extensions(
         &self,
         filter: Option<&str>,
+        max_schema_version: i32,
         limit: usize,
     ) -> Result<Vec<ExtensionMetadata>> {
         self.transaction(|tx| async move {
-            let mut condition = Condition::all();
+            let mut condition = Condition::all().add(
+                extension::Column::LatestVersion
+                    .into_expr()
+                    .eq(extension_version::Column::Version.into_expr()),
+            );
             if let Some(filter) = filter {
                 let fuzzy_name_filter = Self::fuzzy_like_string(filter);
                 condition = condition.add(Expr::cust_with_expr("name ILIKE $1", fuzzy_name_filter));
             }
 
             let extensions = extension::Entity::find()
+                .inner_join(extension_version::Entity)
+                .select_also(extension_version::Entity)
                 .filter(condition)
+                .filter(extension_version::Column::SchemaVersion.lte(max_schema_version))
                 .order_by_desc(extension::Column::TotalDownloadCount)
                 .order_by_asc(extension::Column::Name)
                 .limit(Some(limit as u64))
-                .filter(
-                    extension::Column::LatestVersion
-                        .into_expr()
-                        .eq(extension_version::Column::Version.into_expr()),
-                )
-                .inner_join(extension_version::Entity)
-                .select_also(extension_version::Entity)
                 .all(&*tx)
                 .await?;
 
@@ -170,6 +171,7 @@ impl Database {
                         authors: ActiveValue::Set(version.authors.join(", ")),
                         repository: ActiveValue::Set(version.repository.clone()),
                         description: ActiveValue::Set(version.description.clone()),
+                        schema_version: ActiveValue::Set(version.schema_version),
                         download_count: ActiveValue::NotSet,
                     }
                 }))

crates/collab/src/db/tests/extension_tests.rs 🔗

@@ -16,7 +16,7 @@ async fn test_extensions(db: &Arc<Database>) {
     let versions = db.get_known_extension_versions().await.unwrap();
     assert!(versions.is_empty());
 
-    let extensions = db.get_extensions(None, 5).await.unwrap();
+    let extensions = db.get_extensions(None, 1, 5).await.unwrap();
     assert!(extensions.is_empty());
 
     let t0 = OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
@@ -33,6 +33,7 @@ async fn test_extensions(db: &Arc<Database>) {
                         description: "an extension".into(),
                         authors: vec!["max".into()],
                         repository: "ext1/repo".into(),
+                        schema_version: 1,
                         published_at: t0,
                     },
                     NewExtensionVersion {
@@ -41,6 +42,7 @@ async fn test_extensions(db: &Arc<Database>) {
                         description: "a good extension".into(),
                         authors: vec!["max".into(), "marshall".into()],
                         repository: "ext1/repo".into(),
+                        schema_version: 1,
                         published_at: t0,
                     },
                 ],
@@ -53,6 +55,7 @@ async fn test_extensions(db: &Arc<Database>) {
                     description: "a great extension".into(),
                     authors: vec!["marshall".into()],
                     repository: "ext2/repo".into(),
+                    schema_version: 0,
                     published_at: t0,
                 }],
             ),
@@ -75,7 +78,7 @@ async fn test_extensions(db: &Arc<Database>) {
     );
 
     // The latest version of each extension is returned.
-    let extensions = db.get_extensions(None, 5).await.unwrap();
+    let extensions = db.get_extensions(None, 1, 5).await.unwrap();
     assert_eq!(
         extensions,
         &[
@@ -102,6 +105,22 @@ async fn test_extensions(db: &Arc<Database>) {
         ]
     );
 
+    // Extensions with too new of a schema version are excluded.
+    let extensions = db.get_extensions(None, 0, 5).await.unwrap();
+    assert_eq!(
+        extensions,
+        &[ExtensionMetadata {
+            id: "ext2".into(),
+            name: "Extension Two".into(),
+            version: "0.2.0".into(),
+            authors: vec!["marshall".into()],
+            description: "a great extension".into(),
+            repository: "ext2/repo".into(),
+            published_at: t0,
+            download_count: 0
+        },]
+    );
+
     // Record extensions being downloaded.
     for _ in 0..7 {
         assert!(db.record_extension_download("ext2", "0.0.2").await.unwrap());
@@ -122,7 +141,7 @@ async fn test_extensions(db: &Arc<Database>) {
         .unwrap());
 
     // Extensions are returned in descending order of total downloads.
-    let extensions = db.get_extensions(None, 5).await.unwrap();
+    let extensions = db.get_extensions(None, 1, 5).await.unwrap();
     assert_eq!(
         extensions,
         &[
@@ -161,6 +180,7 @@ async fn test_extensions(db: &Arc<Database>) {
                     description: "a real good extension".into(),
                     authors: vec!["max".into(), "marshall".into()],
                     repository: "ext1/repo".into(),
+                    schema_version: 1,
                     published_at: t0,
                 }],
             ),
@@ -172,6 +192,7 @@ async fn test_extensions(db: &Arc<Database>) {
                     description: "an old extension".into(),
                     authors: vec!["marshall".into()],
                     repository: "ext2/repo".into(),
+                    schema_version: 0,
                     published_at: t0,
                 }],
             ),
@@ -196,7 +217,7 @@ async fn test_extensions(db: &Arc<Database>) {
         .collect()
     );
 
-    let extensions = db.get_extensions(None, 5).await.unwrap();
+    let extensions = db.get_extensions(None, 1, 5).await.unwrap();
     assert_eq!(
         extensions,
         &[

crates/extension/Cargo.toml 🔗

@@ -37,6 +37,7 @@ serde_json.workspace = true
 settings.workspace = true
 theme.workspace = true
 toml.workspace = true
+url.workspace = true
 util.workspace = true
 wasm-encoder.workspace = true
 wasmtime.workspace = true

crates/extension/src/extension_manifest.rs 🔗

@@ -29,6 +29,7 @@ pub struct ExtensionManifest {
     pub id: Arc<str>,
     pub name: String,
     pub version: Arc<str>,
+    pub schema_version: i32,
 
     #[serde(default)]
     pub description: Option<String>,

crates/extension/src/extension_store.rs 🔗

@@ -35,6 +35,7 @@ use std::{
     time::{Duration, Instant},
 };
 use theme::{ThemeRegistry, ThemeSettings};
+use url::Url;
 use util::{
     http::{AsyncBody, HttpClient, HttpClientWithUrl},
     paths::EXTENSIONS_DIR,
@@ -49,6 +50,8 @@ pub use extension_manifest::{
 const RELOAD_DEBOUNCE_DURATION: Duration = Duration::from_millis(200);
 const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
 
+const CURRENT_SCHEMA_VERSION: i64 = 1;
+
 #[derive(Deserialize)]
 pub struct ExtensionsApiResponse {
     pub data: Vec<ExtensionApiResponse>,
@@ -377,15 +380,18 @@ impl ExtensionStore {
         search: Option<&str>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Vec<ExtensionApiResponse>>> {
-        let url = self.http_client.build_zed_api_url(&format!(
-            "/extensions{query}",
-            query = search
-                .map(|search| format!("?filter={search}"))
-                .unwrap_or_default()
-        ));
+        let version = CURRENT_SCHEMA_VERSION.to_string();
+        let mut query = vec![("max_schema_version", version.as_str())];
+        if let Some(search) = search {
+            query.push(("filter", search));
+        }
+
+        let url = self.http_client.build_zed_api_url("/extensions", &query);
         let http_client = self.http_client.clone();
         cx.spawn(move |_, _| async move {
-            let mut response = http_client.get(&url, AsyncBody::empty(), true).await?;
+            let mut response = http_client
+                .get(&url?.as_ref(), AsyncBody::empty(), true)
+                .await?;
 
             let mut body = Vec::new();
             response
@@ -420,7 +426,7 @@ impl ExtensionStore {
     fn install_or_upgrade_extension_at_endpoint(
         &mut self,
         extension_id: Arc<str>,
-        url: String,
+        url: Url,
         operation: ExtensionOperation,
         cx: &mut ModelContext<Self>,
     ) {
@@ -447,7 +453,7 @@ impl ExtensionStore {
             });
 
             let mut response = http_client
-                .get(&url, Default::default(), true)
+                .get(&url.as_ref(), Default::default(), true)
                 .await
                 .map_err(|err| anyhow!("error downloading extension: {}", err))?;
             let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
@@ -482,9 +488,13 @@ impl ExtensionStore {
     ) {
         log::info!("installing extension {extension_id} latest version");
 
-        let url = self
+        let Some(url) = self
             .http_client
-            .build_zed_api_url(&format!("/extensions/{extension_id}/download"));
+            .build_zed_api_url(&format!("/extensions/{extension_id}/download"), &[])
+            .log_err()
+        else {
+            return;
+        };
 
         self.install_or_upgrade_extension_at_endpoint(
             extension_id,
@@ -511,9 +521,16 @@ impl ExtensionStore {
         cx: &mut ModelContext<Self>,
     ) {
         log::info!("installing extension {extension_id} {version}");
-        let url = self
+        let Some(url) = self
             .http_client
-            .build_zed_api_url(&format!("/extensions/{extension_id}/{version}/download"));
+            .build_zed_api_url(
+                &format!("/extensions/{extension_id}/{version}/download"),
+                &[],
+            )
+            .log_err()
+        else {
+            return;
+        };
 
         self.install_or_upgrade_extension_at_endpoint(extension_id, url, operation, cx);
     }
@@ -1104,6 +1121,7 @@ fn manifest_from_old_manifest(
         description: manifest_json.description,
         repository: manifest_json.repository,
         authors: manifest_json.authors,
+        schema_version: 0,
         lib: Default::default(),
         themes: {
             let mut themes = manifest_json.themes.into_values().collect::<Vec<_>>();

crates/extension/src/extension_store_test.rs 🔗

@@ -145,6 +145,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         id: "zed-ruby".into(),
                         name: "Zed Ruby".into(),
                         version: "1.0.0".into(),
+                        schema_version: 0,
                         description: None,
                         authors: Vec::new(),
                         repository: None,
@@ -169,6 +170,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         id: "zed-monokai".into(),
                         name: "Zed Monokai".into(),
                         version: "2.0.0".into(),
+                        schema_version: 0,
                         description: None,
                         authors: vec![],
                         repository: None,
@@ -324,6 +326,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                 id: "zed-gruvbox".into(),
                 name: "Zed Gruvbox".into(),
                 version: "1.0.0".into(),
+                schema_version: 0,
                 description: None,
                 authors: vec![],
                 repository: None,

crates/extension_api/Cargo.toml 🔗

@@ -1,6 +1,10 @@
 [package]
 name = "zed_extension_api"
-version = "0.1.0"
+version = "0.0.1"
+description = "APIs for creating Zed extensions in Rust"
+repository = "https://github.com/zed-industries/zed"
+documentation = "https://docs.rs/zed_extension_api"
+keywords = ["zed", "extension"]
 edition = "2021"
 license = "Apache-2.0"
 

crates/extension_api/README.md 🔗

@@ -0,0 +1,56 @@
+# The Zed Rust Extension API
+
+This crate lets you write extensions for Zed in Rust.
+
+## Extension Manifest
+
+You'll need an `extension.toml` file at the root of your extension directory, with the following structure:
+
+```toml
+id = "my-extension"
+name = "My Extension"
+description = "..."
+version = "0.0.1"
+schema_version = 1
+authors = ["Your Name <you@example.com>"]
+repository = "https://github.com/your/extension-repository"
+```
+
+## Cargo metadata
+
+Zed extensions are packaged as WebAssembly files. In your Cargo.toml, you'll
+need to set your `crate-type` accordingly:
+
+```toml
+[dependencies]
+zed_extension_api = "0.0.1"
+
+[lib]
+crate-type = ["cdylib"]
+```
+
+## Implementing an Extension
+
+To define your extension, create a type that implements the `Extension` trait, and register it.
+
+```rust
+use zed_extension_api as zed;
+
+struct MyExtension {
+    // ... state
+}
+
+impl zed::Extension for MyExtension {
+    // ...
+}
+
+zed::register_extension!(MyExtension);
+```
+
+## Testing your extension
+
+To run your extension in Zed as you're developing it:
+
+- Open the extensions view using the `zed: extensions` action in the command palette.
+- Click the `Add Dev Extension` button in the top right
+- Choose the path to your extension directory.

crates/extension_cli/src/main.rs 🔗

@@ -94,6 +94,7 @@ async fn main() -> Result<()> {
         version: manifest.version.to_string(),
         description: manifest.description,
         authors: manifest.authors,
+        schema_version: Some(manifest.schema_version),
         repository: manifest
             .repository
             .ok_or_else(|| anyhow!("missing repository in extension manifest"))?,

crates/live_kit_client/src/prod.rs 🔗

@@ -684,7 +684,7 @@ impl Drop for RoomDelegate {
     fn drop(&mut self) {
         unsafe {
             CFRelease(self.native_delegate.0);
-            let _ = Weak::from_raw(self.weak_room);
+            let _ = Weak::from_raw(self.weak_room as *mut Room);
         }
     }
 }

crates/rpc/src/extension.rs 🔗

@@ -7,4 +7,5 @@ pub struct ExtensionApiManifest {
     pub description: Option<String>,
     pub authors: Vec<String>,
     pub repository: String,
+    pub schema_version: Option<i32>,
 }

crates/util/src/http.rs 🔗

@@ -54,7 +54,7 @@ impl HttpClientWithUrl {
     }
 
     /// Builds a Zed API URL using the given path.
-    pub fn build_zed_api_url(&self, path: &str) -> String {
+    pub fn build_zed_api_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> {
         let base_url = self.base_url();
         let base_api_url = match base_url.as_ref() {
             "https://zed.dev" => "https://api.zed.dev",
@@ -63,7 +63,10 @@ impl HttpClientWithUrl {
             other => other,
         };
 
-        format!("{}{}", base_api_url, path)
+        Ok(Url::parse_with_params(
+            &format!("{}{}", base_api_url, path),
+            query,
+        )?)
     }
 }
 

crates/zed/src/main.rs 🔗

@@ -783,7 +783,7 @@ async fn upload_previous_crashes(
         .unwrap_or("zed-2024-01-17-221900.ips".to_string()); // don't upload old crash reports from before we had this.
     let mut uploaded = last_uploaded.clone();
 
-    let crash_report_url = http.build_zed_api_url("/telemetry/crashes");
+    let crash_report_url = http.build_zed_api_url("/telemetry/crashes", &[])?;
 
     for dir in [&*CRASHES_DIR, &*CRASHES_RETIRED_DIR] {
         let mut children = smol::fs::read_dir(&dir).await?;
@@ -809,7 +809,7 @@ async fn upload_previous_crashes(
                 .await
                 .context("error reading crash file")?;
 
-            let mut request = Request::post(&crash_report_url)
+            let mut request = Request::post(&crash_report_url.to_string())
                 .redirect_policy(isahc::config::RedirectPolicy::Follow)
                 .header("Content-Type", "text/plain");
 

extensions/gleam/extension.toml 🔗

@@ -2,6 +2,7 @@ id = "gleam"
 name = "Gleam"
 description = "Gleam support for Zed"
 version = "0.0.1"
+schema_version = 1
 authors = ["Marshall Bowers <elliott.codes@gmail.com>"]
 repository = "https://github.com/zed-industries/zed"
 

extensions/uiua/extension.toml 🔗

@@ -2,6 +2,7 @@ id = "uiua"
 name = "Uiua"
 description = "Uiua support for Zed"
 version = "0.0.1"
+schema_version = 1
 authors = ["Max Brunsfeld <max@zed.dev>"]
 repository = "https://github.com/zed-industries/zed"