From 28a9b2321472c14e4c1eb422e927a835248a9a72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 07:09:39 +0000 Subject: [PATCH] Describe each format in Rust and remove plugin.toml Each format now describes itself with a 'static Descriptor that its FormatPlugin::descriptor returns: id, name, version (the crate's, env!("CARGO_PKG_VERSION")), description, mount, repository kinds, storage and asset names. Descriptor, Storage, Assets (with Assets::DEFAULT) and DEFAULT_MOUNT live in forge-plugin-api next to RepositoryKind. NuGet and raw define theirs (DESCRIPTOR) next to their implementations, so a typo in a key or a kind fails to compile. Plugin::builtin takes only the format, Plugin::descriptor replaces Plugin::manifest, and Plugin::id returns the descriptor's id without leaking a copy. What the startup parser checked (the id's spelling and the reserved ids, the mount, the kinds, the asset names) is now forge_plugin::descriptor::check, which a test over plugins::builtin() runs, together with the check that no two formats share an id: a bad descriptor fails cargo test instead of startup. Gone: plugin.toml from both crates, plugin_toml! and PLUGIN_TOML, Manifest::parse, PluginError, SUPPORTED_APIS, forge_plugin_api::API and the api key, the plugin_toml_names_this_crate tests, the "keep it equal" comments, and forge-plugin's toml dependency (and its unused serde and thiserror). The crates' README and docs/format-plugins.md show the descriptor instead of plugin.toml. GET /admin/formats answers as before. Fixes #444 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CxwRK8xYPsMDBiMkySxcHf --- Cargo.lock | 69 +--- crates/core/tests/group_read.rs | 6 +- crates/core/tests/hosted_read.rs | 6 +- crates/core/tests/hosted_write.rs | 6 +- crates/core/tests/proxy_read.rs | 6 +- crates/core/tests/retention_worker.rs | 6 +- crates/formats/README.md | 18 +- crates/formats/nuget/Cargo.toml | 2 +- crates/formats/nuget/plugin.toml | 17 - crates/formats/nuget/src/lib.rs | 52 +-- crates/formats/raw/Cargo.toml | 2 +- crates/formats/raw/plugin.toml | 13 - crates/formats/raw/src/lib.rs | 34 +- crates/plugin-api/src/lib.rs | 27 +- crates/plugin-api/src/types.rs | 110 ++++++ crates/plugin/Cargo.toml | 3 - crates/plugin/src/descriptor.rs | 298 ++++++++++++++++ crates/plugin/src/format.rs | 42 ++- crates/plugin/src/lib.rs | 48 +-- crates/plugin/src/manifest.rs | 453 ------------------------- crates/server/src/admin/formats.rs | 12 +- crates/server/src/browse.rs | 2 +- crates/server/src/files/mod.rs | 4 +- crates/server/src/plugins.rs | 61 +++- crates/server/src/state.rs | 14 +- crates/server/tests/plugin_contract.rs | 50 +-- docs/format-plugins.md | 96 +++--- 27 files changed, 680 insertions(+), 777 deletions(-) delete mode 100644 crates/formats/nuget/plugin.toml delete mode 100644 crates/formats/raw/plugin.toml create mode 100644 crates/plugin/src/descriptor.rs delete mode 100644 crates/plugin/src/manifest.rs diff --git a/Cargo.lock b/Cargo.lock index 30c49c25..bd9a47f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,7 +1634,7 @@ dependencies = [ "pear", "serde", "tempfile", - "toml 0.8.23", + "toml", "uncased", "version_check", ] @@ -1881,10 +1881,7 @@ version = "1.1.0" dependencies = [ "forge-core", "forge-plugin-api", - "serde", "serde_json", - "thiserror", - "toml 1.1.8+spec-1.1.0", "tracing", ] @@ -4238,15 +4235,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4999,26 +4987,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_edit", ] -[[package]] -name = "toml" -version = "1.1.8+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" -dependencies = [ - "indexmap 2.14.2", - "serde_core", - "serde_spanned 1.1.2", - "toml_datetime 1.1.2+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.4", -] - [[package]] name = "toml_datetime" version = "0.6.11" @@ -5028,15 +5001,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -5045,19 +5009,10 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.2", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_parser" -version = "1.1.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" -dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] @@ -5066,12 +5021,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "toml_writer" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" - [[package]] name = "tonic" version = "0.14.6" @@ -5754,12 +5703,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" - [[package]] name = "wiremock" version = "0.6.5" diff --git a/crates/core/tests/group_read.rs b/crates/core/tests/group_read.rs index 0a8f1e57..ca52d087 100644 --- a/crates/core/tests/group_read.rs +++ b/crates/core/tests/group_read.rs @@ -40,11 +40,7 @@ use sqlx::PgPool; /// NuGet, the built-in format plugin, as the `Format` the repository code /// works with. fn nuget() -> forge_plugin::PluginFormat { - let plugin = forge_plugin::Plugin::builtin( - forge_format_nuget::PLUGIN_TOML, - forge_format_nuget::Nuget::default(), - ) - .expect("crates/formats/nuget/plugin.toml is valid"); + let plugin = forge_plugin::Plugin::builtin(forge_format_nuget::Nuget::default()); forge_plugin::PluginFormat::new(&plugin) } diff --git a/crates/core/tests/hosted_read.rs b/crates/core/tests/hosted_read.rs index f8c3cc80..eb5f1cdc 100644 --- a/crates/core/tests/hosted_read.rs +++ b/crates/core/tests/hosted_read.rs @@ -21,11 +21,7 @@ use sqlx::PgPool; /// NuGet, the built-in format plugin, as the `Format` the repository code /// works with. fn nuget() -> forge_plugin::PluginFormat { - let plugin = forge_plugin::Plugin::builtin( - forge_format_nuget::PLUGIN_TOML, - forge_format_nuget::Nuget::default(), - ) - .expect("crates/formats/nuget/plugin.toml is valid"); + let plugin = forge_plugin::Plugin::builtin(forge_format_nuget::Nuget::default()); forge_plugin::PluginFormat::new(&plugin) } diff --git a/crates/core/tests/hosted_write.rs b/crates/core/tests/hosted_write.rs index 976b5e83..34d6deea 100644 --- a/crates/core/tests/hosted_write.rs +++ b/crates/core/tests/hosted_write.rs @@ -27,11 +27,7 @@ use tokio::sync::Mutex; /// NuGet, the built-in format plugin, as the `Format` the repository code /// works with. fn nuget() -> forge_plugin::PluginFormat { - let plugin = forge_plugin::Plugin::builtin( - forge_format_nuget::PLUGIN_TOML, - forge_format_nuget::Nuget::default(), - ) - .expect("crates/formats/nuget/plugin.toml is valid"); + let plugin = forge_plugin::Plugin::builtin(forge_format_nuget::Nuget::default()); forge_plugin::PluginFormat::new(&plugin) } diff --git a/crates/core/tests/proxy_read.rs b/crates/core/tests/proxy_read.rs index 6bc924b4..158d0b85 100644 --- a/crates/core/tests/proxy_read.rs +++ b/crates/core/tests/proxy_read.rs @@ -57,11 +57,7 @@ use tokio::sync::Notify; /// NuGet, the built-in format plugin, as the `Format` the repository code /// works with. fn nuget() -> forge_plugin::PluginFormat { - let plugin = forge_plugin::Plugin::builtin( - forge_format_nuget::PLUGIN_TOML, - forge_format_nuget::Nuget::default(), - ) - .expect("crates/formats/nuget/plugin.toml is valid"); + let plugin = forge_plugin::Plugin::builtin(forge_format_nuget::Nuget::default()); forge_plugin::PluginFormat::new(&plugin) } diff --git a/crates/core/tests/retention_worker.rs b/crates/core/tests/retention_worker.rs index cdfcc80a..a7006bfc 100644 --- a/crates/core/tests/retention_worker.rs +++ b/crates/core/tests/retention_worker.rs @@ -36,11 +36,7 @@ use common::{pool, unique}; /// NuGet, the built-in format plugin, as the `Format` the repository code /// works with. fn nuget() -> forge_plugin::PluginFormat { - let plugin = forge_plugin::Plugin::builtin( - forge_format_nuget::PLUGIN_TOML, - forge_format_nuget::Nuget::default(), - ) - .expect("crates/formats/nuget/plugin.toml is valid"); + let plugin = forge_plugin::Plugin::builtin(forge_format_nuget::Nuget::default()); forge_plugin::PluginFormat::new(&plugin) } diff --git a/crates/formats/README.md b/crates/formats/README.md index f545f4d9..0df60094 100644 --- a/crates/formats/README.md +++ b/crates/formats/README.md @@ -12,15 +12,15 @@ the design, the contract and how to write one. A plugin directory holds: -- `Cargo.toml`: a member of the workspace; -- `plugin.toml`: the format id (also the directory's name), display name, - version, the `forge:format` contract version it targets (`api`), and - optionally its mount, repository kinds (`proxy` when it implements - `upstream`), its `storage` (`packages`, or `files` for a files format such - as raw) and `[assets]` names; -- `src/`: `impl FormatPlugin`, and `pub const PLUGIN_TOML: &str = - forge_plugin_api::plugin_toml!();`. +- `Cargo.toml`: a member of the workspace; its `version` is the plugin's; +- `src/`: `impl FormatPlugin`, and the format's `pub const DESCRIPTOR: + Descriptor` that its `descriptor()` returns: the format id (also the + directory's name), display name, version (`env!("CARGO_PKG_VERSION")`), + description, mount, repository kinds (`Proxy` when it implements + `upstream`), its `storage` (`Packages`, or `Files` for a files format such + as raw) and its asset names. The server installs it in `plugins::builtin()` -([`crates/server/src/plugins.rs`](../server/src/plugins.rs)). The +([`crates/server/src/plugins.rs`](../server/src/plugins.rs)), whose tests +check every built-in format's descriptor (`forge_plugin::descriptor`). The workspace builds, lints and tests the plugins like every other crate. diff --git a/crates/formats/nuget/Cargo.toml b/crates/formats/nuget/Cargo.toml index c91b5f52..e1a35ac3 100644 --- a/crates/formats/nuget/Cargo.toml +++ b/crates/formats/nuget/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-format-nuget" -# The plugin's own version: keep it equal to `version` in plugin.toml. +# The plugin's own version, its descriptor's. version = "1.0.0" edition.workspace = true rust-version.workspace = true diff --git a/crates/formats/nuget/plugin.toml b/crates/formats/nuget/plugin.toml deleted file mode 100644 index 8c01953d..00000000 --- a/crates/formats/nuget/plugin.toml +++ /dev/null @@ -1,17 +0,0 @@ -# A Forge format plugin: NuGet V3 feeds (see crates/formats/README.md). -[plugin] -id = "nuget" -name = "NuGet" -version = "1.0.0" -api = "0.4.0" -description = "NuGet V3 feeds: push, restore and search with dotnet, nuget.exe and Visual Studio; proxies of nuget.org and other V3 feeds." -mount = "/v3" -# Proxies mirror another V3 feed through the plugin's upstream side. -kinds = ["hosted", "proxy", "group"] - -# The asset names and content types NuGet has always stored. -[assets] -package = "package.nupkg" -package_type = "application/octet-stream" -manifest = "package.nuspec" -manifest_type = "application/xml" diff --git a/crates/formats/nuget/src/lib.rs b/crates/formats/nuget/src/lib.rs index 7c618432..9e2cabf5 100644 --- a/crates/formats/nuget/src/lib.rs +++ b/crates/formats/nuget/src/lib.rs @@ -1,6 +1,6 @@ //! NuGet V3 feeds for Forge, as a format plugin. //! -//! Served under `/v3/{repository}` (its `plugin.toml` names the mount): +//! Served under `/v3/{repository}` (its [`DESCRIPTOR`] names the mount): //! //! * `GET /index.json` — the service index; //! * `GET /flat/{id}/index.json`, `/flat/{id}/{version}/{file}` — the flat @@ -17,7 +17,7 @@ //! Ids are matched case-insensitively (`ToLowerInvariant`); versions are //! NuGet versions, normalized (`1.0` → `1.0.0`). //! -//! Forge links this plugin in ([`Nuget`], [`PLUGIN_TOML`]). +//! Forge links this plugin in ([`Nuget`], [`DESCRIPTOR`]). pub mod model; pub mod nuspec; @@ -28,14 +28,35 @@ pub mod version; use std::cmp::Ordering; use forge_plugin_api::{ - Action, FetchResult, FormatError, FormatPlugin, Loaded, PackageSource, ParsedPackage, Remote, - Repository, Request, UpstreamOp, UpstreamStep, + Action, Assets, Descriptor, FetchResult, FormatError, FormatPlugin, Loaded, PackageSource, + ParsedPackage, Remote, Repository, RepositoryKind, Request, Storage, UpstreamOp, UpstreamStep, }; pub use model::NupkgLimits; -/// This plugin's `plugin.toml`: its format id, name, version and mount. -pub const PLUGIN_TOML: &str = forge_plugin_api::plugin_toml!(); +/// What NuGet says about itself: its format id, name, version and mount. +pub const DESCRIPTOR: Descriptor = Descriptor { + id: "nuget", + name: "NuGet", + version: env!("CARGO_PKG_VERSION"), + description: "NuGet V3 feeds: push, restore and search with dotnet, nuget.exe and Visual \ + Studio; proxies of nuget.org and other V3 feeds.", + mount: "/v3", + // Proxies mirror another V3 feed through the plugin's upstream side. + kinds: &[ + RepositoryKind::Hosted, + RepositoryKind::Proxy, + RepositoryKind::Group, + ], + storage: Storage::Packages, + // The asset names and content types NuGet has always stored. + assets: Assets { + package: "package.nupkg", + package_type: "application/octet-stream", + manifest: "package.nuspec", + manifest_type: "application/xml", + }, +}; /// The NuGet format, with the zip-bomb limits applied while parsing packages. #[derive(Debug, Clone, Default)] @@ -51,6 +72,10 @@ impl Nuget { } impl FormatPlugin for Nuget { + fn descriptor(&self) -> &'static Descriptor { + &DESCRIPTOR + } + fn canonical_id(&self, id: &str) -> String { nuspec::lower_id(id) } @@ -100,21 +125,6 @@ impl FormatPlugin for Nuget { mod tests { use super::*; - #[test] - fn plugin_toml_names_this_crate() { - let value = |key: &str| { - PLUGIN_TOML - .lines() - .filter_map(|line| line.split_once('=')) - .find(|(k, _)| k.trim() == key) - .map(|(_, v)| v.trim().trim_matches('"').to_string()) - }; - assert_eq!(value("id").as_deref(), Some("nuget")); - assert_eq!(value("version").as_deref(), Some(env!("CARGO_PKG_VERSION"))); - assert_eq!(value("api").as_deref(), Some(forge_plugin_api::API)); - assert_eq!(value("mount").as_deref(), Some("/v3")); - } - #[test] fn the_rules_are_nugets() { let nuget = Nuget::default(); diff --git a/crates/formats/raw/Cargo.toml b/crates/formats/raw/Cargo.toml index 948b6082..31af06d2 100644 --- a/crates/formats/raw/Cargo.toml +++ b/crates/formats/raw/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-format-raw" -# The plugin's own version: keep it equal to `version` in plugin.toml. +# The plugin's own version, its descriptor's. version = "1.0.0" edition.workspace = true rust-version.workspace = true diff --git a/crates/formats/raw/plugin.toml b/crates/formats/raw/plugin.toml deleted file mode 100644 index b26655d9..00000000 --- a/crates/formats/raw/plugin.toml +++ /dev/null @@ -1,13 +0,0 @@ -# A Forge format plugin: raw file repositories (see crates/formats/README.md). -[plugin] -id = "raw" -name = "Raw files" -version = "1.0.0" -api = "0.4.0" -description = "Arbitrary files under arbitrary paths, like Nexus' raw repositories: uploads with PUT or a form, downloads with ranges and checksum sidecars, directory listings; proxies of any HTTP(S) base URL." -mount = "/raw" -# Proxies cache the files under an HTTP(S) base URL through the plugin's -# upstream side. -kinds = ["hosted", "proxy", "group"] -# Its repositories hold files under paths, in the host's file store. -storage = "files" diff --git a/crates/formats/raw/src/lib.rs b/crates/formats/raw/src/lib.rs index 7a2e0fc7..928e4c58 100644 --- a/crates/formats/raw/src/lib.rs +++ b/crates/formats/raw/src/lib.rs @@ -21,7 +21,7 @@ //! paths, groups, and proxies, whose file `` is the upstream's //! `/` ([`upstream_url`]). //! -//! Forge links this plugin in ([`Raw`], [`PLUGIN_TOML`]), like NuGet. +//! Forge links this plugin in ([`Raw`], [`DESCRIPTOR`]), like NuGet. pub mod form; pub mod listing; @@ -29,19 +29,41 @@ pub mod sidecar; use forge_plugin_api::files::{self, Target}; use forge_plugin_api::{ - Action, Answer, DirectoryQuery, FetchResult, FileSource, FormatError, FormatPlugin, Loaded, - NewFile, Query, Remote, Repository, RepositoryKind, Request, Response, StoreStatus, - UpstreamAnswer, UpstreamOp, UpstreamStep, + Action, Answer, Assets, Descriptor, DirectoryQuery, FetchResult, FileSource, FormatError, + FormatPlugin, Loaded, NewFile, Query, Remote, Repository, RepositoryKind, Request, Response, + Storage, StoreStatus, UpstreamAnswer, UpstreamOp, UpstreamStep, }; -/// This plugin's `plugin.toml`: its format id, name and version. -pub const PLUGIN_TOML: &str = forge_plugin_api::plugin_toml!(); +/// What raw says about itself: its format id, name, version and mount. +pub const DESCRIPTOR: Descriptor = Descriptor { + id: "raw", + name: "Raw files", + version: env!("CARGO_PKG_VERSION"), + description: "Arbitrary files under arbitrary paths, like Nexus' raw repositories: uploads \ + with PUT or a form, downloads with ranges and checksum sidecars, directory \ + listings; proxies of any HTTP(S) base URL.", + mount: "/raw", + // Proxies cache the files under an HTTP(S) base URL through the + // plugin's upstream side. + kinds: &[ + RepositoryKind::Hosted, + RepositoryKind::Proxy, + RepositoryKind::Group, + ], + // Its repositories hold files under paths, in the host's file store. + storage: Storage::Files, + assets: Assets::DEFAULT, +}; /// The raw format. #[derive(Debug, Clone, Copy, Default)] pub struct Raw; impl FormatPlugin for Raw { + fn descriptor(&self) -> &'static Descriptor { + &DESCRIPTOR + } + fn handle(&self, repo: &Repository, req: &Request) -> Action { let target = match target(req) { Ok(Some(target)) => target, diff --git a/crates/plugin-api/src/lib.rs b/crates/plugin-api/src/lib.rs index 563dba70..636a15e6 100644 --- a/crates/plugin-api/src/lib.rs +++ b/crates/plugin-api/src/lib.rs @@ -8,14 +8,14 @@ //! storage key, or a whole package in memory. //! //! A format stores package versions, or files under paths (raw): its -//! `plugin.toml` says which (`storage`). A files format has no identity +//! [`Descriptor`] says which (`storage`). A files format has no identity //! rules and no packages to read: it keeps the defaults of those methods and //! implements the protocol over the host's file store ([`Query::File`] … //! [`Query::Remove`], [`Action::ServeFile`]). //! //! A plugin is a crate under `crates/formats//` implementing -//! [`FormatPlugin`], with a `plugin.toml` naming it, linked into the server -//! and called directly (NuGet, raw). +//! [`FormatPlugin`], describing itself with a [`Descriptor`], linked into +//! the server and called directly (NuGet, raw). pub mod files; mod types; @@ -29,20 +29,6 @@ use std::path::Path; pub use types::*; -/// The `forge:format` contract version this crate implements: what a plugin -/// built against it declares as `api` in its `plugin.toml`. -pub const API: &str = "0.4.0"; - -/// The `plugin.toml` of the crate this is expanded in: how a built-in plugin -/// hands the server its id and name (`pub const PLUGIN_TOML: &str = -/// forge_plugin_api::plugin_toml!();`). -#[macro_export] -macro_rules! plugin_toml { - () => { - include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/plugin.toml")) - }; -} - /// A package the host staged, lent to [`FormatPlugin::parse_package`]. pub trait PackageSource: Read + Seek {} @@ -61,6 +47,11 @@ impl PackageSource for T {} /// files format has neither ids nor versions nor packages: it keeps their /// defaults, which refuse every id, version and package. pub trait FormatPlugin: Send + Sync + 'static { + /// What the format says about itself: its id, name, version, mount, + /// repository kinds, storage and asset names. A `'static` value the + /// crate defines next to its implementation (`pub const DESCRIPTOR`). + fn descriptor(&self) -> &'static Descriptor; + /// The canonical form of a package id, used for lookups and uniqueness /// (e.g. lowercased). The default keeps the id as it is. fn canonical_id(&self, id: &str) -> String { @@ -138,7 +129,7 @@ pub trait FormatPlugin: Send + Sync + 'static { /// only to the upstream's own origin; bounded in size and time, cached /// as [`Fetch::freshness`] says), downloads and verifies packages, and /// keeps the package delay, the not-found markers and the offline mode. - /// Only a plugin whose `plugin.toml` lists the `proxy` kind is asked; + /// Only a plugin whose descriptor lists the `proxy` kind is asked; /// the default says so. fn upstream( &self, diff --git a/crates/plugin-api/src/types.rs b/crates/plugin-api/src/types.rs index f1f7e023..4986fd1a 100644 --- a/crates/plugin-api/src/types.rs +++ b/crates/plugin-api/src/types.rs @@ -79,6 +79,116 @@ pub enum RepositoryKind { Group, } +impl RepositoryKind { + /// The name the admin API uses: `hosted`, `proxy` or `group`. + pub fn as_str(self) -> &'static str { + match self { + RepositoryKind::Hosted => "hosted", + RepositoryKind::Proxy => "proxy", + RepositoryKind::Group => "group", + } + } +} + +/// What a format says about itself ([`FormatPlugin::descriptor`]): a +/// `'static` value next to its `impl FormatPlugin`. +/// +/// ```no_run +/// use forge_plugin_api::{Assets, Descriptor, RepositoryKind, Storage}; +/// +/// pub const DESCRIPTOR: Descriptor = Descriptor { +/// id: "example", +/// name: "Example packages", +/// version: env!("CARGO_PKG_VERSION"), +/// description: "Example packages: push and pull with the example client.", +/// mount: "/repository", +/// kinds: &[RepositoryKind::Hosted, RepositoryKind::Group], +/// storage: Storage::Packages, +/// assets: Assets::DEFAULT, +/// }; +/// ``` +/// +/// [`FormatPlugin::descriptor`]: crate::FormatPlugin::descriptor +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Descriptor { + /// The format id repositories are created with (`repository.format`): + /// 2–32 lowercase letters, digits and `-`, starting with a letter. + pub id: &'static str, + /// Display name. + pub name: &'static str, + /// The format's own version, its crate's (`env!("CARGO_PKG_VERSION")`). + pub version: &'static str, + /// One line on what the format serves. + pub description: &'static str, + /// Where its repositories are served, `{mount}/{repository}/…`: + /// [`DEFAULT_MOUNT`], or `/` and one lowercase word of its own. + pub mount: &'static str, + /// The repository kinds it supports, each once. `Proxy` only when the + /// format implements [`FormatPlugin::upstream`]. + /// + /// [`FormatPlugin::upstream`]: crate::FormatPlugin::upstream + pub kinds: &'static [RepositoryKind], + /// What its repositories hold. + pub storage: Storage, + /// How a version's files are stored (a package format). + pub assets: Assets, +} + +/// Where a format's repositories are served unless it names another mount. +pub const DEFAULT_MOUNT: &str = "/repository"; + +/// What a format's repositories hold ([`Descriptor::storage`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Storage { + /// Package versions: an id, a version, a package file and its manifest + /// (NuGet). + #[default] + Packages, + /// Files under paths, with their digests (raw). + Files, +} + +impl Storage { + /// The name the admin API uses: `packages` or `files`. + pub fn as_str(self) -> &'static str { + match self { + Storage::Packages => "packages", + Storage::Files => "files", + } + } +} + +/// The names (within a version) and content types of a version's package +/// and manifest ([`Descriptor::assets`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Assets { + /// A plain file name: 1–64 letters, digits, `.`, `_` or `-`, not + /// starting with `.`. + pub package: &'static str, + /// `type/subtype`, parameters allowed. + pub package_type: &'static str, + /// A plain file name other than [`Self::package`]. + pub manifest: &'static str, + /// `type/subtype`, parameters allowed. + pub manifest_type: &'static str, +} + +impl Assets { + /// `package` and `manifest`, both `application/octet-stream`. + pub const DEFAULT: Assets = Assets { + package: "package", + package_type: "application/octet-stream", + manifest: "manifest", + manifest_type: "application/octet-stream", + }; +} + +impl Default for Assets { + fn default() -> Self { + Assets::DEFAULT + } +} + /// The repository a request is for. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Repository { diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index 087ad2da..f4d44c10 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -10,8 +10,5 @@ publish.workspace = true forge-core = { path = "../core" } forge-plugin-api = { path = "../plugin-api" } -serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } tracing = { workspace = true } -toml = "1.1" diff --git a/crates/plugin/src/descriptor.rs b/crates/plugin/src/descriptor.rs new file mode 100644 index 00000000..8600e8ee --- /dev/null +++ b/crates/plugin/src/descriptor.rs @@ -0,0 +1,298 @@ +//! What a format's [`Descriptor`] may say: a well-formed id that no other +//! format and none of the server's names takes, a mount the server leaves +//! free, its repository kinds once each, plain asset names and content +//! types. A test over the server's built-in formats +//! (`plugins::builtin`) checks them, so a bad descriptor fails `cargo test`. + +use forge_plugin_api::{Assets, Descriptor}; + +/// Format ids a plugin can never take: the built-in Docker registry's, and +/// names the server routes or reserves. +const RESERVED_IDS: &[&str] = &[ + "docker", + "v2", + "v3", + "admin", + "browse", + "auth", + "repository", +]; + +/// Mounts a plugin can never take: the server's own routes, the web UI's +/// pages (the first segment of every route in `web/src/routes.ts`) and the +/// Docker registry. +const RESERVED_MOUNTS: &[&str] = &[ + "admin", + "api", + "assets", + "auth", + "browse", + "healthz", + "images", + "invite", + "login", + "manage", + "packages", + "readyz", + "realms", + "repos", + "repositories", + "tokens", + "users", + "v2", + "version", +]; + +/// Check the descriptors of the formats one server holds: each on its own +/// ([`check_one`]), and no two with the same id. +pub fn check(formats: &[&Descriptor]) -> Result<(), String> { + for (i, format) in formats.iter().enumerate() { + check_one(format)?; + if formats[..i].iter().any(|other| other.id == format.id) { + return Err(format!("two formats have the id {:?}", format.id)); + } + } + Ok(()) +} + +/// Check one format's descriptor. +pub fn check_one(descriptor: &Descriptor) -> Result<(), String> { + let id = descriptor.id; + validate_id(id).map_err(|e| format!("format {id:?}: {e}"))?; + if descriptor.name.trim().is_empty() { + return Err(format!("format {id:?}: the name must not be empty")); + } + validate_mount(descriptor.mount).map_err(|e| format!("format {id:?}: {e}"))?; + let kinds = descriptor.kinds; + if kinds.is_empty() { + return Err(format!("format {id:?}: kinds must not be empty")); + } + let twice = kinds + .iter() + .enumerate() + .find_map(|(i, kind)| kinds[..i].contains(kind).then_some(kind)); + if let Some(kind) = twice { + return Err(format!("format {id:?}: kind {kind:?} is listed twice")); + } + validate_assets(&descriptor.assets).map_err(|e| format!("format {id:?}: {e}")) +} + +/// A format id: 2–32 characters of lowercase ASCII letters, digits and `-`, +/// starting with a letter, and not one of the reserved names. +pub fn validate_id(id: &str) -> Result<(), String> { + let well_formed = (2..=32).contains(&id.len()) + && id.starts_with(|c: char| c.is_ascii_lowercase()) + && id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + if !well_formed { + return Err(format!( + "id {id:?} must be 2-32 lowercase letters, digits or '-', starting with a letter" + )); + } + if RESERVED_IDS.contains(&id) { + return Err(format!("id {id:?} is reserved")); + } + Ok(()) +} + +/// A mount: `/` and one path segment of 2–32 lowercase letters, digits and +/// `-`, starting with a letter, and not one of the reserved names. +pub fn validate_mount(mount: &str) -> Result<(), String> { + let name = mount.strip_prefix('/').unwrap_or(""); + let well_formed = (2..=32).contains(&name.len()) + && name.starts_with(|c: char| c.is_ascii_lowercase()) + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + if !well_formed { + return Err(format!( + "mount {mount:?} must be '/' and 2-32 lowercase letters, digits or '-', \ + starting with a letter" + )); + } + if RESERVED_MOUNTS.contains(&name) { + return Err(format!("mount {mount:?} is reserved")); + } + Ok(()) +} + +/// Asset names are plain file names; content types are `type/subtype` +/// (parameters allowed) in printable ASCII. +fn validate_assets(assets: &Assets) -> Result<(), String> { + for name in [assets.package, assets.manifest] { + let ok = (1..=64).contains(&name.len()) + && !name.starts_with('.') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + if !ok { + return Err(format!( + "asset name {name:?} must be 1-64 letters, digits, '.', '_' or '-', \ + not starting with '.'" + )); + } + } + if assets.package == assets.manifest { + return Err("the package and the manifest need different asset names".into()); + } + for content_type in [assets.package_type, assets.manifest_type] { + let essence = content_type.split(';').next().unwrap_or("").trim(); + let ok = content_type.len() <= 127 + && content_type + .chars() + .all(|c| c.is_ascii_graphic() || c == ' ') + && essence + .split_once('/') + .is_some_and(|(t, s)| !t.is_empty() && !s.is_empty()); + if !ok { + return Err(format!( + "content type {content_type:?} must be type/subtype" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use forge_plugin_api::{RepositoryKind, Storage, DEFAULT_MOUNT}; + + use super::*; + + const GOOD: Descriptor = Descriptor { + id: "helm", + name: "Helm charts", + version: "0.1.0", + description: "Charts.", + mount: DEFAULT_MOUNT, + kinds: &[RepositoryKind::Hosted, RepositoryKind::Group], + storage: Storage::Packages, + assets: Assets::DEFAULT, + }; + + #[test] + fn accepts_a_good_descriptor() { + assert_eq!(check(&[&GOOD]), Ok(())); + let with_proxies = Descriptor { + id: "npm", + kinds: &[RepositoryKind::Hosted, RepositoryKind::Proxy], + mount: "/npm", + storage: Storage::Files, + ..GOOD + }; + assert_eq!(check(&[&GOOD, &with_proxies]), Ok(())); + assert!(validate_id("go-modules").is_ok()); + } + + #[test] + fn refuses_bad_ids_and_reserved_names() { + for id in [ + "", + "h", + "Helm", + "1helm", + "helm_charts", + "docker", + "v2", + "v3", + "admin", + "repository", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + let bad = Descriptor { id, ..GOOD }; + assert!(check(&[&bad]).is_err(), "{id:?} accepted"); + } + let unnamed = Descriptor { name: " ", ..GOOD }; + assert!(check(&[&unnamed]).is_err()); + } + + #[test] + fn refuses_two_formats_with_one_id() { + let twin = Descriptor { + name: "Other charts", + mount: "/charts", + ..GOOD + }; + let err = check(&[&GOOD, &twin]).unwrap_err(); + assert!(err.contains("\"helm\""), "{err}"); + } + + #[test] + fn refuses_bad_mounts_and_kinds() { + for mount in [ + "charts", "/", "/c", "/Charts", "/a/b", "/admin", "/v2", "/manage", "/images", + ] { + let bad = Descriptor { mount, ..GOOD }; + assert!(check(&[&bad]).is_err(), "{mount:?} accepted"); + } + for kinds in [ + &[][..], + &[RepositoryKind::Hosted, RepositoryKind::Hosted][..], + ] { + let bad = Descriptor { kinds, ..GOOD }; + assert!(check(&[&bad]).is_err(), "{kinds:?} accepted"); + } + } + + #[test] + fn refuses_invalid_asset_names_and_types() { + let with = |assets: Assets| Descriptor { assets, ..GOOD }; + for assets in [ + Assets { + package: "", + ..Assets::DEFAULT + }, + Assets { + package: "../x", + ..Assets::DEFAULT + }, + Assets { + package: ".hidden", + ..Assets::DEFAULT + }, + Assets { + package: "manifest", + ..Assets::DEFAULT + }, + Assets { + package_type: "binary", + ..Assets::DEFAULT + }, + Assets { + manifest_type: "text/\u{1}", + ..Assets::DEFAULT + }, + ] { + assert!(check(&[&with(assets)]).is_err(), "{assets:?} accepted"); + } + let named = Assets { + package: "chart.tgz", + manifest_type: "application/yaml", + ..Assets::DEFAULT + }; + assert_eq!(check(&[&with(named)]), Ok(())); + } + + /// Every top-level page of the web UI is reserved, so a plugin cannot + /// take a page's deep links from the SPA fallback. + #[test] + fn reserves_the_web_uis_pages() { + let tree = include_str!("../../../web/src/routeTree.gen.ts"); + let mut pages: Vec<&str> = tree + .lines() + .filter_map(|line| line.trim().strip_prefix("fullPath: '/")) + .filter_map(|path| path.split(['/', '\'']).next()) + .filter(|page| !page.is_empty()) + .collect(); + pages.sort_unstable(); + pages.dedup(); + assert!(pages.contains(&"images"), "no pages read: {pages:?}"); + for page in pages { + assert!( + RESERVED_MOUNTS.contains(&page), + "the web UI's page /{page} is not in RESERVED_MOUNTS" + ); + } + } +} diff --git a/crates/plugin/src/format.rs b/crates/plugin/src/format.rs index 60178b6a..bd0ab34d 100644 --- a/crates/plugin/src/format.rs +++ b/crates/plugin/src/format.rs @@ -6,9 +6,8 @@ use std::sync::Arc; use forge_core::domain::{Dependency, DependencyGroup, FormatError, ParsedUpload}; use forge_core::format::{Format, ReadSeek}; -use forge_plugin_api::{self as api, FormatPlugin}; +use forge_plugin_api::{self as api, Assets, FormatPlugin, Storage}; -use crate::manifest::{Assets, Storage}; use crate::Plugin; /// The longest version the host takes from a format plugin, in bytes: a @@ -44,8 +43,8 @@ impl PluginFormat { PluginFormat { id: plugin.id(), plugin: plugin.implementation().clone(), - assets: plugin.manifest().assets.clone(), - storage: plugin.manifest().storage, + assets: plugin.descriptor().assets, + storage: plugin.descriptor().storage, } } } @@ -135,19 +134,19 @@ impl Format for PluginFormat { } fn package_asset_path(&self) -> &str { - &self.assets.package + self.assets.package } fn package_content_type(&self) -> &str { - &self.assets.package_type + self.assets.package_type } fn manifest_asset_path(&self) -> &str { - &self.assets.manifest + self.assets.manifest } fn manifest_content_type(&self) -> &str { - &self.assets.manifest_type + self.assets.manifest_type } fn parse_upload(&self, mut file: &mut dyn ReadSeek) -> Result { @@ -229,7 +228,22 @@ mod tests { parsed: api::ParsedPackage, } + const UNLIMITED: api::Descriptor = api::Descriptor { + id: "unlimited", + name: "Unlimited", + version: "0.1.0", + description: "Every id, every version.", + mount: api::DEFAULT_MOUNT, + kinds: &[api::RepositoryKind::Hosted, api::RepositoryKind::Group], + storage: Storage::Packages, + assets: Assets::DEFAULT, + }; + impl FormatPlugin for Unlimited { + fn descriptor(&self) -> &'static api::Descriptor { + &UNLIMITED + } + fn canonical_id(&self, id: &str) -> String { id.to_ascii_lowercase() } @@ -289,14 +303,10 @@ mod tests { /// [`Unlimited`] as the host sees it. fn unlimited(normalized: Option<&str>, parsed: api::ParsedPackage) -> PluginFormat { - let plugin = Plugin::builtin( - "[plugin]\nid = \"unlimited\"\nname = \"Unlimited\"\nversion = \"0.1.0\"\napi = \"0.4.0\"\n", - Unlimited { - normalized: normalized.map(str::to_string), - parsed, - }, - ) - .unwrap(); + let plugin = Plugin::builtin(Unlimited { + normalized: normalized.map(str::to_string), + parsed, + }); PluginFormat::new(&plugin) } diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index ba100537..c8a2d7eb 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -12,66 +12,46 @@ //! to [`forge_core::Format`], so hosted publishing, reads, groups and //! retention work for it unchanged. +pub mod descriptor; mod format; -pub mod manifest; use std::sync::Arc; pub use forge_plugin_api as api; -pub use forge_plugin_api::FormatPlugin; +pub use forge_plugin_api::{Descriptor, FormatPlugin}; pub use format::PluginFormat; -pub use manifest::Manifest; -/// Errors installing a plugin. -#[derive(Debug, thiserror::Error)] -pub enum PluginError { - /// `plugin.toml` is missing a key, malformed, or asks for too much. - #[error("invalid plugin.toml: {0}")] - Manifest(String), -} - -/// An installed format plugin: its `plugin.toml` and its implementation. +/// An installed format plugin: its implementation, which describes itself +/// ([`FormatPlugin::descriptor`]). pub struct Plugin { - manifest: Manifest, - /// The format id, leaked once per plugin so [`forge_core::Format::id`] - /// can hand out a `&'static str`. - id: &'static str, implementation: Arc, } impl std::fmt::Debug for Plugin { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Plugin") - .field("id", &self.id) - .field("version", &self.manifest.version) + .field("id", &self.id()) + .field("version", &self.descriptor().version) .finish_non_exhaustive() } } impl Plugin { - /// A plugin built into the server: `plugin_toml` is its crate's - /// `plugin.toml` ([`forge_plugin_api::plugin_toml!`]), `implementation` - /// the format. - pub fn builtin( - plugin_toml: &str, - implementation: impl FormatPlugin, - ) -> Result, PluginError> { - let manifest = Manifest::parse(plugin_toml)?; - Ok(Arc::new(Plugin { - id: Box::leak(manifest.id.clone().into_boxed_str()), - manifest, + /// A plugin built into the server. + pub fn builtin(implementation: impl FormatPlugin) -> Arc { + Arc::new(Plugin { implementation: Arc::new(implementation), - })) + }) } /// The format id. pub fn id(&self) -> &'static str { - self.id + self.descriptor().id } - /// The parsed `plugin.toml`. - pub fn manifest(&self) -> &Manifest { - &self.manifest + /// What the format says about itself. + pub fn descriptor(&self) -> &'static Descriptor { + self.implementation.descriptor() } /// The format, as the plugin implements it. diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs deleted file mode 100644 index 8df5cba4..00000000 --- a/crates/plugin/src/manifest.rs +++ /dev/null @@ -1,453 +0,0 @@ -//! `plugin.toml`: what a plugin declares about itself, next to its crate's -//! `Cargo.toml` (`crates/formats//plugin.toml`). -//! -//! ```toml -//! [plugin] -//! id = "nuget" # the format id repositories are created with -//! name = "NuGet" -//! version = "1.0.0" # the plugin's own version (informational) -//! api = "0.4.0" # the forge:format contract it is built against -//! mount = "/v3" # optional: served at {mount}/{repository}/… -//! kinds = ["hosted", "group"] # optional: the repository kinds it supports -//! # ("proxy" too, when it implements `upstream`) -//! storage = "packages" # optional: what its repositories hold, -//! # package versions or "files" under paths -//! -//! [assets] # optional: how a version's files are stored -//! package = "package" -//! package_type = "application/octet-stream" -//! manifest = "manifest" -//! manifest_type = "application/octet-stream" -//! ``` - -use forge_plugin_api::RepositoryKind; -use serde::Deserialize; - -use crate::PluginError; - -/// The `forge:format` contract versions this host implements. -pub const SUPPORTED_APIS: &[&str] = &[forge_plugin_api::API]; - -/// Format ids a plugin can never take: the built-in Docker registry's, and -/// names the server routes or reserves. (A built-in plugin's id, such as -/// NuGet's or raw's, is taken by that plugin: see the server's registry.) -const RESERVED_IDS: &[&str] = &[ - "docker", - "v2", - "v3", - "admin", - "browse", - "auth", - "repository", -]; - -/// Where a plugin's repositories are served unless it names a mount. -pub const DEFAULT_MOUNT: &str = "/repository"; - -/// Mounts a plugin can never take: the server's own routes, the web UI's -/// pages (the first segment of every route in `web/src/routes.ts`) and the -/// Docker registry. -const RESERVED_MOUNTS: &[&str] = &[ - "admin", - "api", - "assets", - "auth", - "browse", - "healthz", - "images", - "invite", - "login", - "manage", - "packages", - "readyz", - "realms", - "repos", - "repositories", - "tokens", - "users", - "v2", - "version", -]; - -/// A parsed and validated `plugin.toml`. -#[derive(Debug, Clone)] -pub struct Manifest { - /// The format id (`repository.format`). - pub id: String, - /// Display name. - pub name: String, - /// The plugin's own version. - pub version: String, - /// The `forge:format` contract version it is built against. - pub api: String, - /// Optional one-line description. - pub description: Option, - /// Where repositories of the format are served: `{mount}/{repository}/…`. - pub mount: String, - /// The repository kinds the format supports. `proxy` means the plugin - /// implements [`forge_plugin_api::FormatPlugin::upstream`]. - pub kinds: Vec, - /// What the format's repositories hold. - pub storage: Storage, - /// How a version's files are stored. - pub assets: Assets, -} - -/// What a format's repositories hold (`storage`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Storage { - /// Package versions: an id, a version, a package file and its manifest - /// (NuGet, Helm). - #[default] - Packages, - /// Files under paths, with their digests (raw). - Files, -} - -impl Storage { - /// The name `plugin.toml` and the admin API use. - pub fn as_str(self) -> &'static str { - match self { - Storage::Packages => "packages", - Storage::Files => "files", - } - } -} - -/// The `[assets]` table: the names (within a version) and content types of -/// a version's package and manifest. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(deny_unknown_fields, default)] -pub struct Assets { - pub package: String, - pub package_type: String, - pub manifest: String, - pub manifest_type: String, -} - -impl Default for Assets { - fn default() -> Self { - Assets { - package: "package".into(), - package_type: "application/octet-stream".into(), - manifest: "manifest".into(), - manifest_type: "application/octet-stream".into(), - } - } -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct RawManifest { - plugin: RawPlugin, - #[serde(default)] - assets: Assets, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct RawPlugin { - id: String, - name: String, - version: String, - api: String, - description: Option, - mount: Option, - kinds: Option>, - storage: Option, -} - -impl Manifest { - /// Parse and validate the text of a `plugin.toml`. - pub fn parse(text: &str) -> Result { - let raw: RawManifest = - toml::from_str(text).map_err(|e| PluginError::Manifest(e.to_string()))?; - let p = raw.plugin; - validate_id(&p.id)?; - if !SUPPORTED_APIS.contains(&p.api.as_str()) { - return Err(PluginError::Manifest(format!( - "api {:?} is not supported (this server implements {SUPPORTED_APIS:?})", - p.api - ))); - } - if p.name.trim().is_empty() { - return Err(PluginError::Manifest("name must not be empty".into())); - } - let mount = p.mount.unwrap_or_else(|| DEFAULT_MOUNT.to_string()); - validate_mount(&mount)?; - let kinds = match p.kinds { - None => vec![RepositoryKind::Hosted, RepositoryKind::Group], - Some(kinds) => parse_kinds(&kinds)?, - }; - let storage = match p.storage.as_deref() { - None | Some("packages") => Storage::Packages, - Some("files") => Storage::Files, - Some(other) => { - return Err(PluginError::Manifest(format!( - "unknown storage {other:?} (packages, files)" - ))) - } - }; - validate_assets(&raw.assets)?; - Ok(Manifest { - id: p.id, - name: p.name, - version: p.version, - api: p.api, - description: p.description, - mount, - kinds, - storage, - assets: raw.assets, - }) - } -} - -/// A mount: `/` and one path segment of 2–32 lowercase letters, digits and -/// `-`, starting with a letter, and not one of the reserved names. -pub fn validate_mount(mount: &str) -> Result<(), PluginError> { - let name = mount.strip_prefix('/').unwrap_or(""); - let well_formed = (2..=32).contains(&name.len()) - && name.starts_with(|c: char| c.is_ascii_lowercase()) - && name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); - if !well_formed { - return Err(PluginError::Manifest(format!( - "mount {mount:?} must be '/' and 2-32 lowercase letters, digits or '-', \ - starting with a letter" - ))); - } - if RESERVED_MOUNTS.contains(&name) { - return Err(PluginError::Manifest(format!( - "mount {mount:?} is reserved" - ))); - } - Ok(()) -} - -/// The `kinds` of a manifest: some of `hosted`, `proxy` and `group`, each -/// once. -fn parse_kinds(kinds: &[String]) -> Result, PluginError> { - let mut parsed = Vec::new(); - for kind in kinds { - let kind = match kind.as_str() { - "hosted" => RepositoryKind::Hosted, - "group" => RepositoryKind::Group, - "proxy" => RepositoryKind::Proxy, - other => { - return Err(PluginError::Manifest(format!( - "unknown repository kind {other:?} (hosted, proxy, group)" - ))) - } - }; - if parsed.contains(&kind) { - return Err(PluginError::Manifest(format!( - "kind {kind:?} is listed twice" - ))); - } - parsed.push(kind); - } - if parsed.is_empty() { - return Err(PluginError::Manifest("kinds must not be empty".into())); - } - Ok(parsed) -} - -/// Asset names are plain file names; content types are `type/subtype` -/// (parameters allowed) in printable ASCII. -fn validate_assets(assets: &Assets) -> Result<(), PluginError> { - for name in [&assets.package, &assets.manifest] { - let ok = (1..=64).contains(&name.len()) - && !name.starts_with('.') - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); - if !ok { - return Err(PluginError::Manifest(format!( - "asset name {name:?} must be 1-64 letters, digits, '.', '_' or '-', \ - not starting with '.'" - ))); - } - } - if assets.package == assets.manifest { - return Err(PluginError::Manifest( - "the package and the manifest need different asset names".into(), - )); - } - for content_type in [&assets.package_type, &assets.manifest_type] { - let essence = content_type.split(';').next().unwrap_or("").trim(); - let ok = content_type.len() <= 127 - && content_type - .chars() - .all(|c| c.is_ascii_graphic() || c == ' ') - && essence - .split_once('/') - .is_some_and(|(t, s)| !t.is_empty() && !s.is_empty()); - if !ok { - return Err(PluginError::Manifest(format!( - "content type {content_type:?} must be type/subtype" - ))); - } - } - Ok(()) -} - -/// A format id: 2–32 characters of lowercase ASCII letters, digits and `-`, -/// starting with a letter, and not one of the reserved names. -pub fn validate_id(id: &str) -> Result<(), PluginError> { - let well_formed = (2..=32).contains(&id.len()) - && id.starts_with(|c: char| c.is_ascii_lowercase()) - && id - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); - if !well_formed { - return Err(PluginError::Manifest(format!( - "id {id:?} must be 2-32 lowercase letters, digits or '-', starting with a letter" - ))); - } - if RESERVED_IDS.contains(&id) { - return Err(PluginError::Manifest(format!("id {id:?} is reserved"))); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const GOOD: &str = r#" - [plugin] - id = "helm" - name = "Helm charts" - version = "0.1.0" - api = "0.4.0" - "#; - - #[test] - fn parses_a_manifest() { - let m = Manifest::parse(GOOD).unwrap(); - assert_eq!(m.id, "helm"); - assert_eq!(m.name, "Helm charts"); - assert_eq!(m.description, None); - } - - #[test] - fn refuses_bad_ids_and_reserved_names() { - for id in [ - "", - "h", - "Helm", - "1helm", - "helm_charts", - "docker", - "v2", - "v3", - &"a".repeat(33), - ] { - let text = GOOD.replace("id = \"helm\"", &format!("id = {id:?}")); - assert!(Manifest::parse(&text).is_err(), "{id:?} accepted"); - } - assert!(validate_id("npm").is_ok()); - assert!(validate_id("go-modules").is_ok()); - } - - #[test] - fn mounts_kinds_and_assets_default_and_validate() { - let m = Manifest::parse(GOOD).unwrap(); - assert_eq!(m.mount, DEFAULT_MOUNT); - assert_eq!(m.kinds, [RepositoryKind::Hosted, RepositoryKind::Group]); - assert_eq!(m.assets, Assets::default()); - - let with = |plugin_keys: &str, tables: &str| { - let text = GOOD.replace( - "api = \"0.4.0\"", - &format!("api = \"0.4.0\"\n{plugin_keys}"), - ); - Manifest::parse(&format!("{text}\n{tables}")) - }; - let m = with( - "mount = \"/charts\"\nkinds = [\"hosted\"]", - "[assets]\npackage = \"chart.tgz\"\nmanifest_type = \"application/yaml\"\n", - ) - .unwrap(); - assert_eq!(m.mount, "/charts"); - assert_eq!(m.kinds, [RepositoryKind::Hosted]); - assert_eq!(m.assets.package, "chart.tgz"); - assert_eq!(m.assets.manifest, "manifest"); - assert_eq!(m.assets.manifest_type, "application/yaml"); - - for mount in [ - "charts", "/", "/c", "/Charts", "/a/b", "/admin", "/v2", "/manage", "/images", - ] { - assert!( - with(&format!("mount = {mount:?}"), "").is_err(), - "{mount:?} accepted" - ); - } - // A plugin with an upstream side serves proxies. - let m = with("kinds = [\"hosted\", \"proxy\"]", "").unwrap(); - assert_eq!(m.kinds, [RepositoryKind::Hosted, RepositoryKind::Proxy]); - assert_eq!(m.storage, Storage::Packages); - let m = with("storage = \"files\"", "").unwrap(); - assert_eq!(m.storage, Storage::Files); - assert!(with("storage = \"images\"", "").is_err()); - // `[proxy]` (0.2's host-side upstream protocols) is gone. - assert!(with("", "[proxy]\nupstream = \"nuget-v3\"\n").is_err()); - for kinds in ["[]", "[\"mirror\"]", "[\"hosted\", \"hosted\"]"] { - assert!( - with(&format!("kinds = {kinds}"), "").is_err(), - "{kinds} accepted" - ); - } - for assets in [ - "package = \"\"", - "package = \"../x\"", - "package = \".hidden\"", - "package = \"manifest\"", - "package_type = \"binary\"", - "manifest_type = \"text/\\u0001\"", - ] { - assert!( - with("", &format!("[assets]\n{assets}\n")).is_err(), - "{assets} accepted" - ); - } - } - - /// Every top-level page of the web UI is reserved, so a plugin cannot - /// take a page's deep links from the SPA fallback. - #[test] - fn reserves_the_web_uis_pages() { - let tree = include_str!("../../../web/src/routeTree.gen.ts"); - let mut pages: Vec<&str> = tree - .lines() - .filter_map(|line| line.trim().strip_prefix("fullPath: '/")) - .filter_map(|path| path.split(['/', '\'']).next()) - .filter(|page| !page.is_empty()) - .collect(); - pages.sort_unstable(); - pages.dedup(); - assert!(pages.contains(&"images"), "no pages read: {pages:?}"); - for page in pages { - assert!( - RESERVED_MOUNTS.contains(&page), - "the web UI's page /{page} is not in RESERVED_MOUNTS" - ); - } - } - - #[test] - fn refuses_unknown_apis_and_keys() { - assert!(Manifest::parse(&GOOD.replace("api = \"0.4.0\"", "api = \"9.0.0\"")).is_err()); - // Contracts before 0.4.0 had no files. - for old in ["0.2.0", "0.3.0"] { - let text = GOOD.replace("api = \"0.4.0\"", &format!("api = {old:?}")); - assert!(Manifest::parse(&text).is_err(), "{old} accepted"); - } - assert!(Manifest::parse(&format!("{GOOD}\nunknown = 1\n")).is_err()); - // What a WebAssembly component named is gone with it. - assert!(Manifest::parse(&format!("{GOOD}component = \"helm.wasm\"\n")).is_err()); - assert!(Manifest::parse(&format!("{GOOD}\n[limits]\nmemory_mib = 32\n")).is_err()); - } -} diff --git a/crates/server/src/admin/formats.rs b/crates/server/src/admin/formats.rs index 49dc1d30..043ace17 100644 --- a/crates/server/src/admin/formats.rs +++ b/crates/server/src/admin/formats.rs @@ -43,15 +43,15 @@ pub async fn list(State(state): State) -> Response { let kinds = |id: &str| state.formats.kinds(id).unwrap_or_default(); let ceiling = state.config.max_repository_upload_bytes(); let view = |plugin: &forge_plugin::Plugin| { - let manifest = plugin.manifest(); + let descriptor = plugin.descriptor(); FormatView { id: plugin.id().to_string(), - name: manifest.name.clone(), + name: descriptor.name.to_string(), kinds: kinds(plugin.id()), - storage: manifest.storage.as_str(), - mount: manifest.mount.clone(), - version: Some(manifest.version.clone()), - description: manifest.description.clone(), + storage: descriptor.storage.as_str(), + mount: descriptor.mount.to_string(), + version: Some(descriptor.version.to_string()), + description: Some(descriptor.description.to_string()), default_max_upload_bytes: state.config.default_upload_limit(plugin.id()), max_upload_bytes_limit: ceiling, } diff --git a/crates/server/src/browse.rs b/crates/server/src/browse.rs index 98142186..452e65c6 100644 --- a/crates/server/src/browse.rs +++ b/crates/server/src/browse.rs @@ -147,7 +147,7 @@ pub async fn list_repos( online: repo.online, format: repo.format.clone(), url: state.formats.plugin(&repo.format).map(|plugin| { - crate::plugins::repository_url(&state, &plugin.manifest().mount, &repo.name) + crate::plugins::repository_url(&state, plugin.descriptor().mount, &repo.name) }), storage: if repo.is_docker() { "images" diff --git a/crates/server/src/files/mod.rs b/crates/server/src/files/mod.rs index 406fa7ff..6aac72d5 100644 --- a/crates/server/src/files/mod.rs +++ b/crates/server/src/files/mod.rs @@ -1,5 +1,5 @@ -//! The file store: the repositories of a format whose `plugin.toml` says -//! `storage = "files"` (raw) hold files under paths, like Nexus' raw-hosted, +//! The file store: the repositories of a format whose descriptor says +//! `Storage::Files` (raw) hold files under paths, like Nexus' raw-hosted, //! raw-proxy and raw-group repositories. The format's plugin owns the //! protocol (`crates/formats/raw`, served under its mount by //! [`crate::plugins`]); this module answers the file queries and actions it diff --git a/crates/server/src/plugins.rs b/crates/server/src/plugins.rs index fca9d6e5..cf53d06f 100644 --- a/crates/server/src/plugins.rs +++ b/crates/server/src/plugins.rs @@ -1,6 +1,6 @@ //! Format plugins: the built-in ones, and serving the repositories of their //! formats under each format's mount, `{mount}/{repo}/...` (`/repository` -//! unless its `plugin.toml` names another; see `docs/format-plugins.md`). +//! unless its descriptor names another; see `docs/format-plugins.md`). //! //! The dispatcher does what [`crate::dispatch`] does for the NuGet feed — //! resolve the repository, authorize the request's action (GET/HEAD read, @@ -66,9 +66,9 @@ use crate::files::{FileError, Files}; use crate::middleware::principal_of; use crate::state::AppState; -/// Where a plugin format's repositories are served unless its -/// `plugin.toml` names another mount. -pub const MOUNT: &str = forge_plugin::manifest::DEFAULT_MOUNT; +/// Where a plugin format's repositories are served unless its descriptor +/// names another mount. +pub const MOUNT: &str = forge_plugin::api::DEFAULT_MOUNT; /// The most rounds of queries one request may load. pub const MAX_ROUNDS: usize = 4; @@ -110,13 +110,8 @@ pub const PASSED_RESPONSE_HEADERS: &[&str] = &[ /// reads archives under `nuget_limits` (`[nuget]`). pub fn builtin(nuget_limits: forge_format_nuget::NupkgLimits) -> Vec> { vec![ - Plugin::builtin( - forge_format_nuget::PLUGIN_TOML, - forge_format_nuget::Nuget::with_limits(nuget_limits), - ) - .expect("crates/formats/nuget/plugin.toml is valid"), - Plugin::builtin(forge_format_raw::PLUGIN_TOML, forge_format_raw::Raw) - .expect("crates/formats/raw/plugin.toml is valid"), + Plugin::builtin(forge_format_nuget::Nuget::with_limits(nuget_limits)), + Plugin::builtin(forge_format_raw::Raw), ] } @@ -191,7 +186,7 @@ async fn dispatch_request(state: AppState, req: Request) -> Response { }; }; // Each format is served under its own mount only. - if plugin.manifest().mount != mount { + if plugin.descriptor().mount != mount { return (StatusCode::NOT_FOUND, "repository is not served here").into_response(); } let format = match state.formats.for_repository(&repo) { @@ -1301,6 +1296,48 @@ fn write_error(e: WriteError) -> Response { mod tests { use super::*; + /// The built-in formats' descriptors are checked here, not at startup: a + /// well-formed id that is not reserved and no other format has, a free + /// mount, kinds, asset names. + #[test] + fn the_builtin_formats_describe_themselves_well() { + let plugins = builtin(forge_format_nuget::NupkgLimits::default()); + let descriptors: Vec<_> = plugins.iter().map(|p| p.descriptor()).collect(); + assert_eq!(forge_plugin::descriptor::check(&descriptors), Ok(())); + let ids: Vec<_> = plugins.iter().map(|p| p.id()).collect(); + assert_eq!(ids, ["nuget", "raw"]); + for plugin in &plugins { + assert_eq!(plugin.descriptor().version, "1.0.0", "{}", plugin.id()); + } + + // The check refuses a malformed or reserved id, a second format + // with a built-in's id, and invalid asset names. + let nuget = forge_format_nuget::DESCRIPTOR; + for id in ["NuGet", "n", "docker", "v2"] { + let renamed = forge_plugin::Descriptor { id, ..nuget }; + assert!( + forge_plugin::descriptor::check(&[&renamed]).is_err(), + "{id:?}" + ); + } + let twin = forge_plugin::Descriptor { + id: "nuget", + mount: "/twin", + ..forge_format_raw::DESCRIPTOR + }; + let mut with_twin = descriptors.clone(); + with_twin.push(&twin); + assert!(forge_plugin::descriptor::check(&with_twin).is_err()); + let bad_assets = forge_plugin::Descriptor { + assets: forge_plugin::api::Assets { + package: "../package.nupkg", + ..nuget.assets + }, + ..nuget + }; + assert!(forge_plugin::descriptor::check(&[&bad_assets]).is_err()); + } + #[test] fn splits_repository_paths() { let mounts = ["/repository", "/charts"]; diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index aae89cd7..8d2419e7 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -224,7 +224,7 @@ impl FormatRegistry { } /// Whether format `id`'s repositories hold files under paths (raw): it - /// is installed and its `plugin.toml` says `storage = "files"`. + /// is installed and its descriptor says `Storage::Files`. pub fn stores_files(&self, id: &str) -> bool { self.formats.get(id).is_some_and(|f| f.stores_files()) } @@ -235,7 +235,7 @@ impl FormatRegistry { let mut mounts: Vec<&str> = self .plugins .values() - .map(|p| p.manifest().mount.as_str()) + .map(|p| p.descriptor().mount) .collect(); mounts.sort_unstable(); mounts.dedup(); @@ -252,7 +252,7 @@ impl FormatRegistry { /// The repository kinds format `id` supports, `None` when it is not /// installed. Docker has every kind; a format plugin serves the kinds its - /// `plugin.toml` names (NuGet's and raw's all three). + /// descriptor names (NuGet's and raw's all three). pub fn kinds(&self, id: &str) -> Option> { if Self::is_native(id) { return Some(vec!["hosted", "proxy", "group"]); @@ -260,14 +260,10 @@ impl FormatRegistry { let plugin = self.plugins.get(id)?; Some( plugin - .manifest() + .descriptor() .kinds .iter() - .map(|kind| match kind { - forge_plugin::api::RepositoryKind::Hosted => "hosted", - forge_plugin::api::RepositoryKind::Proxy => "proxy", - forge_plugin::api::RepositoryKind::Group => "group", - }) + .map(|kind| kind.as_str()) .collect(), ) } diff --git a/crates/server/tests/plugin_contract.rs b/crates/server/tests/plugin_contract.rs index 9fff98fa..0fe8ced1 100644 --- a/crates/server/tests/plugin_contract.rs +++ b/crates/server/tests/plugin_contract.rs @@ -12,29 +12,29 @@ use std::sync::Arc; use common::{start_with, unique, ServerOpts, TestServer}; use forge_plugin::api::{ - Action, Answer, ChangeRequest, FormatError, FormatPlugin, Loaded, PackageInfo, PackageRef, - PackageSource, PackageVersions, ParsedPackage, PublishRequest, Query, Repository, Request, - Response, SearchQuery, Upload, + Action, Answer, Assets, ChangeRequest, Descriptor, FormatError, FormatPlugin, Loaded, + PackageInfo, PackageRef, PackageSource, PackageVersions, ParsedPackage, PublishRequest, Query, + Repository, RepositoryKind, Request, Response, SearchQuery, Storage, Upload, }; use forge_plugin::Plugin; use reqwest::StatusCode; use serde_json::{json, Value}; -const PROBE_TOML: &str = r#" -[plugin] -id = "probe" -name = "Probe" -version = "0.0.1" -api = "0.4.0" -mount = "/probe" -kinds = ["hosted"] - -[assets] -package = "probe.bin" -package_type = "application/x-probe" -manifest = "probe.txt" -manifest_type = "text/plain" -"#; +const PROBE: Descriptor = Descriptor { + id: "probe", + name: "Probe", + version: "0.0.1", + description: "Reports what the host answers.", + mount: "/probe", + kinds: &[RepositoryKind::Hosted], + storage: Storage::Packages, + assets: Assets { + package: "probe.bin", + package_type: "application/x-probe", + manifest: "probe.txt", + manifest_type: "text/plain", + }, +}; /// A package is text whose first line is ` `. struct Probe; @@ -58,6 +58,10 @@ fn package_info_json(p: &PackageInfo) -> Value { } impl FormatPlugin for Probe { + fn descriptor(&self) -> &'static Descriptor { + &PROBE + } + fn canonical_id(&self, id: &str) -> String { id.to_ascii_lowercase() } @@ -197,7 +201,7 @@ async fn start_probe(extra_toml: Option) -> TestServer { common::pool().await, ServerOpts { bootstrap_admin: Some((format!("admin{}@example.com", unique()), "adminpw".into())), - extra_plugins: vec![Plugin::builtin(PROBE_TOML, Probe).unwrap()], + extra_plugins: vec![Plugin::builtin(Probe)], extra_toml, ..Default::default() }, @@ -682,7 +686,11 @@ async fn mutations_outside_the_selector_are_unknown() { /// The probe is a valid built-in plugin; nothing in it relies on the server. #[test] fn the_probe_is_a_plugin() { - let plugin = Plugin::builtin(PROBE_TOML, Probe).unwrap(); - assert_eq!(plugin.manifest().mount, "/probe"); + let plugin = Plugin::builtin(Probe); + assert_eq!(plugin.descriptor().mount, "/probe"); + assert_eq!( + forge_plugin::descriptor::check(&[plugin.descriptor()]), + Ok(()) + ); let _: Arc = plugin.implementation().clone(); } diff --git a/docs/format-plugins.md b/docs/format-plugins.md index 4b00ef53..06e4de85 100644 --- a/docs/format-plugins.md +++ b/docs/format-plugins.md @@ -17,11 +17,11 @@ configuration. Forge ships two: **NuGet** [Files formats](#files-formats)). Helm charts are served by the registry ([docker.md](docker.md#helm-charts)). -> **Status: preview.** The plugin contract is `forge:format@0.4.0`. It may -> change before 1.x of the contract; a plugin declares the contract version it -> was built for, and the server refuses one it does not implement. 0.4.0 added -> files formats ([Files formats](#files-formats)), and `render` returns an -> action, like `handle`. +> **Status: preview.** The plugin contract may change in any release. The +> plugins build with the server, so the compiler checks each of them against +> the contract it is linked with. Contract 0.4.0 added files formats +> ([Files formats](#files-formats)), and `render` returns an action, like +> `handle`. ## Contents @@ -36,21 +36,21 @@ configuration. Forge ships two: **NuGet** ## Layout -A plugin is a workspace crate with a `plugin.toml`: +A plugin is a workspace crate that describes itself in Rust: ``` crates/formats/nuget/ -├── Cargo.toml -├── plugin.toml # id, name, version, contract version; mount "/v3", kinds hosted/proxy/group -├── src/lib.rs # impl FormatPlugin for Nuget +├── Cargo.toml # the plugin's version +├── src/lib.rs # DESCRIPTOR (id, name, mount "/v3", kinds hosted/proxy/group) +│ # and impl FormatPlugin for Nuget ├── src/protocol.rs # the V3 protocol: handle and render ├── src/upstream.rs # proxies: reading another V3 feed (upstream) └── tests/ # the protocol against canned answers (forge-plugin-api `testing`) crates/formats/raw/ ├── Cargo.toml -├── plugin.toml # mount "/raw", storage "files", kinds hosted/proxy/group -├── src/lib.rs # impl FormatPlugin for Raw: paths, methods, upstream URLs +├── src/lib.rs # DESCRIPTOR (mount "/raw", storage files, kinds hosted/proxy/group) +│ # and impl FormatPlugin for Raw: paths, methods, upstream URLs ├── src/listing.rs # directory listings as HTML or JSON ├── src/sidecar.rs # .sha256/.sha512/.sha1/.md5 from the stored digests ├── src/form.rs # multipart uploads, Nexus' field names @@ -63,7 +63,7 @@ The pieces: | --- | --- | | [`forge-plugin-api`](../crates/plugin-api) | The contract: the `FormatPlugin` trait and its types, and, behind its `testing` feature, helpers that run a plugin's protocol against canned answers. Depends on nothing but `std`. | | [`crates/formats/`](../crates/formats) | One crate per plugin, implementing `FormatPlugin`. | -| [`forge-plugin`](../crates/plugin) | The host side: `Plugin` (an installed plugin: its `plugin.toml` and its implementation) and `PluginFormat`, which adapts it to Forge's `Format` seam. | +| [`forge-plugin`](../crates/plugin) | The host side: `Plugin` (an installed plugin, which describes itself), `PluginFormat`, which adapts it to Forge's `Format` seam, and the checks of a descriptor (`descriptor::check`). | | [`forge-server`](../crates/server/src/plugins.rs) | The plugins it links in (`plugins::builtin`), the `{mount}/{repo}/...` dispatcher, and the file store files formats keep their files in ([`crates/server/src/files`](../crates/server/src/files)). | ## How it fits together @@ -81,7 +81,7 @@ The pieces: | Files (a files format) | Keeps files under paths, content-addressed with their digests: serving, ranges and validators, uploads under the deployment policy, listings, proxies and groups, selectors on paths, retention by path ([Files formats](#files-formats)) | Which path a request names, which file or directory it needs, what the response looks like | A request to a plugin repository is served under its format's mount, -`{mount}/{repo}/...`: `/repository` unless the plugin's `plugin.toml` names +`{mount}/{repo}/...`: `/repository` unless the plugin's descriptor names another (see [Writing a plugin](#writing-a-plugin)); NuGet's names `/v3`. A repository is served under its own format's mount only. The dispatcher @@ -143,7 +143,7 @@ should stay small. ## Files formats -A format whose `plugin.toml` says `storage = "files"` holds files under +A format whose descriptor says `storage: Storage::Files` holds files under paths, not package versions: [raw](raw.md) is one. It keeps the defaults of the identity rules and `parse_package` (which refuse every id, version and package) and implements the protocol over the host's file store, which keeps @@ -266,7 +266,7 @@ side (NuGet and raw do). Docker has proxies of its own. A repository of a plugin's format is created like any other, with the plugin's format id, and served under the plugin's mount (`/repository` -unless its `plugin.toml` names another): +unless its descriptor names another): ```bash # Create a hosted repository of the plugin `example` (or use the web UI). @@ -347,20 +347,40 @@ policy (an outbound proxy, a Kubernetes `NetworkPolicy`). ## Writing a plugin -A plugin is a crate in `crates/formats//` with a `plugin.toml`, a member -of the workspace, depending on `forge-plugin-api` and implementing its trait -([`crates/formats/nuget`](../crates/formats/nuget) is a complete one): +A plugin is a crate in `crates/formats//`, a member of the workspace, +depending on `forge-plugin-api` and implementing its trait +([`crates/formats/nuget`](../crates/formats/nuget) is a complete one). It +describes itself with a `'static` [`Descriptor`](../crates/plugin-api/src/types.rs) +that `descriptor()` returns: ```rust -use forge_plugin_api::{Action, FormatPlugin, Loaded, Repository, Request /* … */}; - -/// This crate's plugin.toml (id, name, version). -pub const PLUGIN_TOML: &str = forge_plugin_api::plugin_toml!(); +use forge_plugin_api::{ + Action, Assets, Descriptor, FormatPlugin, Loaded, Repository, RepositoryKind, Request, + Storage, /* … */ +}; + +pub const DESCRIPTOR: Descriptor = Descriptor { + id: "example", // the format id repositories are created with + name: "Example packages", + version: env!("CARGO_PKG_VERSION"), + description: "Example packages: push and pull with the example client.", + mount: forge_plugin_api::DEFAULT_MOUNT, // served at /repository/{repo}/… + // The repository kinds it serves (`Proxy` too when it implements + // `upstream`). + kinds: &[RepositoryKind::Hosted, RepositoryKind::Group], + storage: Storage::Packages, // or `Storage::Files` (Files formats) + // How a version's files are stored: `package` and `manifest`, both + // application/octet-stream, unless it names its own. + assets: Assets::DEFAULT, +}; #[derive(Default)] pub struct MyFormat; impl FormatPlugin for MyFormat { + fn descriptor(&self) -> &'static Descriptor { + &DESCRIPTOR + } // identity: canonical_id, validate_id, normalize_version, compare_versions // packages: parse_package, manifest_summary // (a files format keeps the defaults of these, which refuse everything) @@ -369,33 +389,17 @@ impl FormatPlugin for MyFormat { } ``` -```toml -# plugin.toml -[plugin] -id = "example" # the format id repositories are created with -name = "Example packages" -version = "0.1.0" -api = "0.4.0" # the forge:format contract -mount = "/repository" # optional: served at {mount}/{repo}/… -kinds = ["hosted", "group"] # optional: the repository kinds it serves - # ("proxy" too when it implements `upstream`) -storage = "packages" # optional: or "files" (Files formats) - -[assets] # optional: how a version's files are stored -package = "package" -package_type = "application/octet-stream" -manifest = "manifest" -manifest_type = "application/octet-stream" -``` - -A mount is `/` and one lowercase word; the server's own paths (`/admin`, -`/auth`, `/browse`, `/v2`, the web UI's pages, …) are reserved, and plugins -may share a mount. List `proxy` among the kinds only when the plugin -implements `upstream`. +An id is 2–32 lowercase letters, digits and `-`, starting with a letter, +and no other format's. A mount is `/` and one lowercase word; the server's +own paths (`/admin`, `/auth`, `/browse`, `/v2`, the web UI's pages, …) are +reserved, and plugins may share a mount. List `Proxy` among the kinds only +when the plugin implements `upstream`. Asset names are plain file names, and +their content types `type/subtype`. Then build it in: add the crate to `forge-server`'s dependencies and to `plugins::builtin()` in -[`crates/server/src/plugins.rs`](../crates/server/src/plugins.rs). A few +[`crates/server/src/plugins.rs`](../crates/server/src/plugins.rs), whose +tests check every built-in format's descriptor against these rules. A few rules hold: - **Keep methods pure.** The host may call any method from any thread, in