diff --git a/Cargo.lock b/Cargo.lock index 5a85ce7..44ebe39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2778,9 +2778,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" dependencies = [ "serde 1.0.201", ] @@ -3229,21 +3229,21 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.12" +version = "0.8.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +checksum = "a4e43f8cc456c9704c851ae29c67e17ef65d2c30017c17a9765b89c382dc8bba" dependencies = [ "serde 1.0.201", "serde_spanned", "toml_datetime", - "toml_edit 0.22.12", + "toml_edit 0.22.13", ] [[package]] name = "toml_datetime" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" dependencies = [ "serde 1.0.201", ] @@ -3261,9 +3261,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.12" +version = "0.22.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef" +checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c" dependencies = [ "indexmap 2.2.6", "serde 1.0.201", @@ -3763,6 +3763,19 @@ dependencies = [ "wasmparser 0.209.1", ] +[[package]] +name = "wasm-pkg-common" +version = "0.3.0" +dependencies = [ + "dirs", + "http", + "serde 1.0.201", + "serde_json", + "thiserror", + "toml 0.8.13", + "tracing", +] + [[package]] name = "wasm-pkg-loader" version = "0.3.0" @@ -3785,7 +3798,7 @@ dependencies = [ "thiserror", "tokio", "tokio-util", - "toml 0.8.12", + "toml 0.8.13", "tracing", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index 96739af..9bd4b2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,5 @@ tracing-subscriber = { version = "0.3.18", default-features = false, features = "fmt", "env-filter", ] } +wasm-pkg-common = { version = "0.3.0", path = "crates/wasm-pkg-common" } wasm-pkg-loader = { version = "0.3.0", path = "crates/wasm-pkg-loader" } diff --git a/crates/wasm-pkg-common/Cargo.toml b/crates/wasm-pkg-common/Cargo.toml new file mode 100644 index 0000000..f11a36a --- /dev/null +++ b/crates/wasm-pkg-common/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "wasm-pkg-common" +description = "Wasm Package common types and configuration" +repository = "https://github.com/bytecodealliance/wasm-pkg-tools/tree/main/crates/wasm-pkg-common" +edition.workspace = true +version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +dirs = "5.0.1" +http = "1.1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8.13" +thiserror = "1.0" +tracing = "0.1" \ No newline at end of file diff --git a/crates/wasm-pkg-common/src/config.rs b/crates/wasm-pkg-common/src/config.rs new file mode 100644 index 0000000..b9b01f6 --- /dev/null +++ b/crates/wasm-pkg-common/src/config.rs @@ -0,0 +1,281 @@ +use std::{ + collections::{hash_map::Entry, HashMap}, + io::ErrorKind, + path::Path, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{label::Label, package::PackageRef, Error, Registry}; + +mod toml; + +pub const DEFAULT_REGISTRY: &str = "bytecodealliance.org"; + +/// Wasm Package registry configuration. +/// +/// Most consumers are expected to start with [`Config::global_defaults`] to +/// provide a consistent baseline user experience. Where needed, these defaults +/// can be overridden with application-specific config via [`Config::merge`] or +/// other mutation methods. +#[derive(Debug)] +pub struct Config { + default_registry: Option, + namespace_registries: HashMap, + package_registry_overrides: HashMap, + registry_configs: HashMap, +} + +impl Default for Config { + fn default() -> Self { + Self { + default_registry: Some(DEFAULT_REGISTRY.parse().unwrap()), + namespace_registries: Default::default(), + package_registry_overrides: Default::default(), + registry_configs: Default::default(), + } + } +} + +impl Config { + /// Returns an empty config. + /// + /// Note that this may differ from the `Default` implementation, which + /// includes hard-coded global defaults. + pub fn new() -> Self { + Self { + default_registry: Default::default(), + namespace_registries: Default::default(), + package_registry_overrides: Default::default(), + registry_configs: Default::default(), + } + } + + /// Loads config from several default sources. + /// + /// The following sources are loaded in this order, with later sources + /// merged into (overriding) earlier sources. + /// - Hard-coded defaults + /// - User-global config file (e.g. `~/.config/wasm-pkg/config.toml`) + /// + /// Note: This list is expected to expand in the future to include + /// "workspace" config files like `./.wasm-pkg/config.toml`. + pub fn global_defaults() -> Result { + let mut config = Self::default(); + if let Some(global_config) = Self::read_global_config()? { + config.merge(global_config); + } + Ok(config) + } + + /// Reads config from + pub fn read_global_config() -> Result, Error> { + let Some(config_dir) = dirs::config_dir() else { + return Ok(None); + }; + let path = config_dir.join("wasm-pkg").join("config.toml"); + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(Error::ConfigFileIoError(err)), + }; + Ok(Some(Self::from_toml(&contents)?)) + } + + /// Reads config from a TOML file at the given path. + pub fn from_file(path: impl AsRef) -> Result { + let contents = std::fs::read_to_string(path).map_err(Error::ConfigFileIoError)?; + Self::from_toml(&contents) + } + + /// Parses config from the given TOML contents. + pub fn from_toml(contents: &str) -> Result { + let toml_cfg: toml::TomlConfig = + ::toml::from_str(contents).map_err(Error::invalid_config)?; + Ok(toml_cfg.into()) + } + + /// Merges the given other config into this one. + pub fn merge(&mut self, other: Self) { + let Self { + default_registry, + namespace_registries, + package_registry_overrides: package_registries, + registry_configs, + } = other; + if default_registry.is_some() { + self.default_registry = default_registry; + } + self.namespace_registries.extend(namespace_registries); + self.package_registry_overrides.extend(package_registries); + for (registry, config) in registry_configs { + match self.registry_configs.entry(registry) { + Entry::Occupied(mut occupied) => occupied.get_mut().merge(config), + Entry::Vacant(vacant) => { + vacant.insert(config); + } + } + } + } + + /// Resolves a [`Registry`] for the given [`PackageRef`]. + /// + /// Resolution returns the first of these that matches: + /// - A package registry exactly matching the package + /// - A namespace registry matching the package's namespace + /// - The default registry + pub fn resolve_registry(&self, package: &PackageRef) -> Option<&Registry> { + if let Some(reg) = self.package_registry_overrides.get(package) { + Some(reg) + } else if let Some(reg) = self.namespace_registries.get(package.namespace()) { + Some(reg) + } else if let Some(reg) = self.default_registry.as_ref() { + Some(reg) + } else { + None + } + } + + /// Returns the default registry. + pub fn default_registry(&self) -> Option<&Registry> { + self.default_registry.as_ref() + } + + /// Sets the default registry. + pub fn set_default_registry(&mut self, registry: Option) { + self.default_registry = registry; + } + + /// Returns a registry for the given namespace. + /// + /// Does not fall back to the default registry; see [`Self::resolve`]. + pub fn namespace_registry(&self, namespace: &Label) -> Option<&Registry> { + self.namespace_registries.get(namespace) + } + + /// Sets a registry for the given namespace. + pub fn set_namespace_registry(&mut self, namespace: Label, registry: Registry) { + self.namespace_registries.insert(namespace, registry); + } + + /// Returns a registry override configured for the given package. + /// + /// Does not fall back to namespace or default registries; see [`Self::resolve`]. + pub fn package_registry_override(&self, package: &PackageRef) -> Option<&Registry> { + self.package_registry_overrides.get(package) + } + + /// Sets a registry override for the given package. + pub fn set_package_registry_override(&mut self, package: PackageRef, registry: Registry) { + self.package_registry_overrides.insert(package, registry); + } + + /// Returns [`RegistryConfig`] for the given registry. + pub fn registry_config(&self, registry: &Registry) -> Option<&RegistryConfig> { + self.registry_configs.get(registry) + } + + /// Returns a mutable [`RegistryConfig`] for the given registry, inserting + /// an empty one if needed. + pub fn get_or_insert_registry_config_mut( + &mut self, + registry: &Registry, + ) -> &mut RegistryConfig { + if !self.registry_configs.contains_key(registry) { + self.registry_configs + .insert(registry.clone(), Default::default()); + } + self.registry_configs.get_mut(registry).unwrap() + } +} + +#[derive(Default)] +pub struct RegistryConfig { + backend_type: Option, + backend_configs: HashMap, +} + +impl RegistryConfig { + /// Merges the given other config into this one. + pub fn merge(&mut self, other: Self) { + let Self { + backend_type, + backend_configs, + } = other; + if backend_type.is_some() { + self.backend_type = backend_type; + } + for (ty, config) in backend_configs { + match self.backend_configs.entry(ty) { + Entry::Occupied(mut occupied) => occupied.get_mut().extend(config), + Entry::Vacant(vacant) => { + vacant.insert(config); + } + } + } + } + + /// Returns the backend type override. + pub fn backend_type(&self) -> Option<&str> { + self.backend_type.as_deref() + } + + /// Sets the backend type override. + pub fn set_backend_type(&mut self, backend_type: Option) { + self.backend_type = backend_type; + } + + /// Returns an iterator of configured backend types. + pub fn configured_backend_types(&self) -> impl Iterator { + self.backend_configs.keys().map(|ty| ty.as_str()) + } + + /// Attempts to deserialize backend config with the given type. + /// + /// Returns `Ok(None)` if no configuration was provided. + /// Returns `Err` if configuration was provided but deserialization failed. + pub fn backend_config<'a, T: Deserialize<'a>>( + &'a self, + backend_type: &str, + ) -> Result, Error> { + let Some(table) = self.backend_configs.get(backend_type) else { + return Ok(None); + }; + let config = table.clone().try_into().map_err(Error::invalid_config)?; + Ok(Some(config)) + } + + /// Set the backend config of the given type by serializing the given config. + pub fn set_backend_config( + &mut self, + backend_type: String, + backend_config: T, + ) -> Result<(), Error> { + let table = ::toml::Table::try_from(backend_config).map_err(Error::invalid_config)?; + self.backend_configs.insert(backend_type, table); + Ok(()) + } +} + +impl std::fmt::Debug for RegistryConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegistryConfig") + .field("backend_type", &self.backend_type) + .field( + "backend_configs", + &DebugBackendConfigs(&self.backend_configs), + ) + .finish() + } +} + +// Redact backend configs, which may contain sensitive values. +struct DebugBackendConfigs<'a>(&'a HashMap); + +impl<'a> std::fmt::Debug for DebugBackendConfigs<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map() + .entries(self.0.keys().map(|ty| (ty, &""))) + .finish() + } +} diff --git a/crates/wasm-pkg-common/src/config/toml.rs b/crates/wasm-pkg-common/src/config/toml.rs new file mode 100644 index 0000000..b984cda --- /dev/null +++ b/crates/wasm-pkg-common/src/config/toml.rs @@ -0,0 +1,116 @@ +// TODO: caused by inner bytes::Bytes; probably fixed in Rust 1.79 +#![allow(clippy::mutable_key_type)] + +use std::collections::HashMap; + +use serde::Deserialize; + +use crate::{label::Label, package::PackageRef, Registry}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TomlConfig { + default_registry: Option, + #[serde(default)] + namespace_registries: HashMap, + #[serde(default)] + package_registry_overrides: HashMap, + #[serde(default)] + registry: HashMap, +} + +impl From for super::Config { + fn from(value: TomlConfig) -> Self { + let TomlConfig { + default_registry, + namespace_registries, + package_registry_overrides, + registry, + } = value; + + let registry_configs = registry + .into_iter() + .map(|(reg, config)| (reg, config.into())) + .collect(); + + Self { + default_registry, + namespace_registries, + package_registry_overrides, + registry_configs, + } + } +} + +#[derive(Deserialize)] +struct TomlRegistryConfig { + #[serde(rename = "type")] + type_: Option, + #[serde(flatten)] + backend_configs: HashMap, +} + +impl From for super::RegistryConfig { + fn from(value: TomlRegistryConfig) -> Self { + let TomlRegistryConfig { + type_, + backend_configs, + } = value; + Self { + backend_type: type_, + backend_configs, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn smoke_test() { + let toml_config = toml::toml! { + default_registry = "example.com" + + [namespace_registries] + wasi = "wasi.dev" + + [package_registry_overrides] + "example:foo" = "example.com" + + [registry."wasi.dev".oci] + auth = { username = "open", password = "sesame" } + + [registry."example.com"] + type = "test" + test = { token = "top_secret" } + }; + let wasi_dev: Registry = "wasi.dev".parse().unwrap(); + let example_com: Registry = "example.com".parse().unwrap(); + + let toml_cfg: TomlConfig = toml_config.try_into().unwrap(); + let cfg = crate::config::Config::from(toml_cfg); + + assert_eq!(cfg.default_registry(), Some(&example_com)); + assert_eq!( + cfg.resolve_registry(&"wasi:http".parse().unwrap()), + Some(&wasi_dev) + ); + assert_eq!( + cfg.resolve_registry(&"example:foo".parse().unwrap()), + Some(&example_com) + ); + + #[derive(Deserialize)] + struct TestConfig { + token: String, + } + let test_cfg: TestConfig = cfg + .registry_config(&example_com) + .unwrap() + .backend_config("test") + .unwrap() + .unwrap(); + assert_eq!(test_cfg.token, "top_secret"); + } +} diff --git a/crates/wasm-pkg-common/src/label.rs b/crates/wasm-pkg-common/src/label.rs new file mode 100644 index 0000000..637cbef --- /dev/null +++ b/crates/wasm-pkg-common/src/label.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +/// A Component Model kebab-case label. +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +pub struct Label(String); + +impl AsRef for Label { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::fmt::Debug for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl From