From 7d6d56a547b942ed23d4256502d8ccf328ee7d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E6=AD=A3=E8=B6=85?= Date: Fri, 12 Jun 2026 15:07:17 +0800 Subject: [PATCH] feat(extension): add schema contracts (#3384) --- Cargo.lock | 9 + Cargo.toml | 2 + crates/extension-schema/Cargo.toml | 38 ++ crates/extension-schema/src/lib.rs | 351 +++++++++++++++++++ scripts/check_extension_schema_boundaries.sh | 30 ++ 5 files changed, 430 insertions(+) create mode 100644 crates/extension-schema/Cargo.toml create mode 100644 crates/extension-schema/src/lib.rs create mode 100755 scripts/check_extension_schema_boundaries.sh diff --git a/Cargo.lock b/Cargo.lock index 8e9ef5811..1178f62df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9360,6 +9360,15 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "rustfs-extension-schema" +version = "1.0.0-beta.8" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "rustfs-filemeta" version = "1.0.0-beta.8" diff --git a/Cargo.toml b/Cargo.toml index 08e694b59..67d2cbdc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ members = [ "crates/s3select-query", # S3 Select query engine "crates/scanner", # Scanner for data integrity checks and health monitoring "crates/security-governance", # Security governance contracts + "crates/extension-schema", # Extension schema contracts "crates/signer", # client signer "crates/storage-api", # Storage API contracts "crates/targets", # Target-specific configurations and utilities @@ -113,6 +114,7 @@ rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.8" } rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.8" } rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.8" } rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.8" } +rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.8" } rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.8" } rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.8" } rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.8" } diff --git a/crates/extension-schema/Cargo.toml b/crates/extension-schema/Cargo.toml new file mode 100644 index 000000000..c84bbaf5e --- /dev/null +++ b/crates/extension-schema/Cargo.toml @@ -0,0 +1,38 @@ +# Copyright 2024 RustFS Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "rustfs-extension-schema" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +homepage.workspace = true +description = "Extension schema contracts for RustFS." +keywords = ["extension", "schema", "contract", "rustfs"] +categories = ["web-programming", "development-tools"] + +[lib] +doctest = false + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/extension-schema/src/lib.rs b/crates/extension-schema/src/lib.rs new file mode 100644 index 000000000..929fde040 --- /dev/null +++ b/crates/extension-schema/src/lib.rs @@ -0,0 +1,351 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const EXTENSION_SCHEMA_VERSION: &str = "rustfs.extension-schema.v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtensionKind { + TargetPlugin, + S3Hook, + OpsDiagnostics, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtensionRuntimeBoundary { + Builtin, + Sidecar, + Wasm, +} + +impl ExtensionRuntimeBoundary { + pub const fn requires_disabled_by_default(self) -> bool { + matches!(self, Self::Sidecar | Self::Wasm) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtensionRuntimeContract { + pub api_version: String, + pub boundary: ExtensionRuntimeBoundary, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ExtensionCapabilityRef(String); + +impl ExtensionCapabilityRef { + pub fn new(capability: impl Into) -> Self { + Self(capability.into()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtensionSchema { + pub schema_version: String, + pub extension_id: String, + pub display_name: String, + pub provider: String, + pub version: String, + pub kind: ExtensionKind, + pub runtime: ExtensionRuntimeContract, + pub capabilities: Vec, + pub disabled_by_default: bool, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ExtensionSchemaError { + #[error("extension schema at index {index} has an empty extension id")] + EmptyExtensionId { index: usize }, + + #[error("extension schema for {extension_id} has an unsupported schema version {schema_version}")] + UnsupportedSchemaVersion { extension_id: String, schema_version: String }, + + #[error("extension schema for {extension_id} has an empty display name")] + EmptyDisplayName { extension_id: String }, + + #[error("extension schema for {extension_id} has an empty provider")] + EmptyProvider { extension_id: String }, + + #[error("extension schema for {extension_id} has an empty version")] + EmptyVersion { extension_id: String }, + + #[error("extension schema for {extension_id} has an empty runtime API version")] + EmptyRuntimeApiVersion { extension_id: String }, + + #[error("extension schema for {extension_id} must declare at least one capability")] + EmptyCapabilities { extension_id: String }, + + #[error("extension schema for {extension_id} has an empty capability")] + EmptyCapability { extension_id: String }, + + #[error("extension schema for {extension_id} duplicates capability {capability}")] + DuplicateCapability { extension_id: String, capability: String }, + + #[error("external extension schema for {extension_id} must be disabled by default")] + ExternalMustBeDisabledByDefault { extension_id: String }, + + #[error("duplicate extension schema for {extension_id}")] + DuplicateExtension { extension_id: String }, +} + +pub fn validate_extension_schemas(schemas: &[ExtensionSchema]) -> Result<(), ExtensionSchemaError> { + let mut extension_ids = BTreeSet::new(); + + for (index, schema) in schemas.iter().enumerate() { + let extension_id = schema.extension_id.trim(); + if extension_id.is_empty() { + return Err(ExtensionSchemaError::EmptyExtensionId { index }); + } + + if schema.schema_version != EXTENSION_SCHEMA_VERSION { + return Err(ExtensionSchemaError::UnsupportedSchemaVersion { + extension_id: schema.extension_id.clone(), + schema_version: schema.schema_version.clone(), + }); + } + + if schema.display_name.trim().is_empty() { + return Err(ExtensionSchemaError::EmptyDisplayName { + extension_id: schema.extension_id.clone(), + }); + } + + if schema.provider.trim().is_empty() { + return Err(ExtensionSchemaError::EmptyProvider { + extension_id: schema.extension_id.clone(), + }); + } + + if schema.version.trim().is_empty() { + return Err(ExtensionSchemaError::EmptyVersion { + extension_id: schema.extension_id.clone(), + }); + } + + if schema.runtime.api_version.trim().is_empty() { + return Err(ExtensionSchemaError::EmptyRuntimeApiVersion { + extension_id: schema.extension_id.clone(), + }); + } + + if schema.capabilities.is_empty() { + return Err(ExtensionSchemaError::EmptyCapabilities { + extension_id: schema.extension_id.clone(), + }); + } + + let mut capabilities = BTreeSet::new(); + for capability in &schema.capabilities { + if capability.as_str().trim().is_empty() { + return Err(ExtensionSchemaError::EmptyCapability { + extension_id: schema.extension_id.clone(), + }); + } + + if !capabilities.insert(capability.as_str()) { + return Err(ExtensionSchemaError::DuplicateCapability { + extension_id: schema.extension_id.clone(), + capability: capability.as_str().to_string(), + }); + } + } + + if schema.runtime.boundary.requires_disabled_by_default() && !schema.disabled_by_default { + return Err(ExtensionSchemaError::ExternalMustBeDisabledByDefault { + extension_id: schema.extension_id.clone(), + }); + } + + if !extension_ids.insert(schema.extension_id.as_str()) { + return Err(ExtensionSchemaError::DuplicateExtension { + extension_id: schema.extension_id.clone(), + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + EXTENSION_SCHEMA_VERSION, ExtensionCapabilityRef, ExtensionKind, ExtensionRuntimeBoundary, ExtensionRuntimeContract, + ExtensionSchema, ExtensionSchemaError, validate_extension_schemas, + }; + use serde_json::json; + + fn target_schema( + extension_id: &str, + capability: &str, + boundary: ExtensionRuntimeBoundary, + disabled_by_default: bool, + ) -> ExtensionSchema { + ExtensionSchema { + schema_version: EXTENSION_SCHEMA_VERSION.to_string(), + extension_id: extension_id.to_string(), + display_name: "Webhook Target".to_string(), + provider: "rustfs".to_string(), + version: "1.0.0".to_string(), + kind: ExtensionKind::TargetPlugin, + runtime: ExtensionRuntimeContract { + api_version: "rustfs.extension.v1".to_string(), + boundary, + }, + capabilities: vec![ExtensionCapabilityRef::new(capability)], + disabled_by_default, + } + } + + #[test] + fn extension_schema_serializes_stable_json_shape() { + let schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false); + + let value = serde_json::to_value(schema).expect("extension schema should serialize"); + + assert_eq!( + value, + json!({ + "schema_version": "rustfs.extension-schema.v1", + "extension_id": "rustfs.builtin.webhook", + "display_name": "Webhook Target", + "provider": "rustfs", + "version": "1.0.0", + "kind": "target_plugin", + "runtime": { + "api_version": "rustfs.extension.v1", + "boundary": "builtin" + }, + "capabilities": ["target.notify.v1"], + "disabled_by_default": false + }) + ); + } + + #[test] + fn validates_extension_schema_contracts() { + let schemas = [ExtensionSchema { + schema_version: EXTENSION_SCHEMA_VERSION.to_string(), + extension_id: "rustfs.ops.diagnostics".to_string(), + display_name: "Ops Diagnostics".to_string(), + provider: "rustfs".to_string(), + version: "1.0.0".to_string(), + kind: ExtensionKind::OpsDiagnostics, + runtime: ExtensionRuntimeContract { + api_version: "rustfs.extension.v1".to_string(), + boundary: ExtensionRuntimeBoundary::Builtin, + }, + capabilities: vec![ExtensionCapabilityRef::new("ops.diagnostics.v1")], + disabled_by_default: false, + }]; + + assert!(validate_extension_schemas(&schemas).is_ok()); + } + + #[test] + fn rejects_external_extensions_enabled_by_default() { + let schemas = [target_schema( + "external.sidecar.audit", + "target.audit.v1", + ExtensionRuntimeBoundary::Sidecar, + false, + )]; + + let err = validate_extension_schemas(&schemas).expect_err("external sidecars must start disabled"); + + assert_eq!( + err, + ExtensionSchemaError::ExternalMustBeDisabledByDefault { + extension_id: "external.sidecar.audit".to_string() + } + ); + } + + #[test] + fn rejects_duplicate_extension_capabilities() { + let mut schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false); + schema.capabilities.push(ExtensionCapabilityRef::new("target.notify.v1")); + let schemas = [schema]; + + let err = validate_extension_schemas(&schemas).expect_err("duplicate capabilities should fail validation"); + + assert_eq!( + err, + ExtensionSchemaError::DuplicateCapability { + extension_id: "rustfs.builtin.webhook".to_string(), + capability: "target.notify.v1".to_string() + } + ); + } + + #[test] + fn rejects_duplicate_extension_ids() { + let schemas = [ + target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false), + target_schema("rustfs.builtin.webhook", "target.notify.v2", ExtensionRuntimeBoundary::Builtin, false), + ]; + + let err = validate_extension_schemas(&schemas).expect_err("duplicate extension ids should fail validation"); + + assert_eq!( + err, + ExtensionSchemaError::DuplicateExtension { + extension_id: "rustfs.builtin.webhook".to_string() + } + ); + } + + #[test] + fn rejects_unknown_schema_version() { + let mut schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false); + schema.schema_version = "rustfs.extension-schema.v0".to_string(); + let schemas = [schema]; + + let err = validate_extension_schemas(&schemas).expect_err("unknown schema version should fail validation"); + + assert_eq!( + err, + ExtensionSchemaError::UnsupportedSchemaVersion { + extension_id: "rustfs.builtin.webhook".to_string(), + schema_version: "rustfs.extension-schema.v0".to_string() + } + ); + } + + #[test] + fn rejects_empty_capabilities() { + let mut schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false); + schema.capabilities.clear(); + let schemas = [schema]; + + let err = validate_extension_schemas(&schemas).expect_err("extension capabilities must be explicit"); + + assert_eq!( + err, + ExtensionSchemaError::EmptyCapabilities { + extension_id: "rustfs.builtin.webhook".to_string() + } + ); + } +} diff --git a/scripts/check_extension_schema_boundaries.sh b/scripts/check_extension_schema_boundaries.sh new file mode 100755 index 000000000..a19d4830d --- /dev/null +++ b/scripts/check_extension_schema_boundaries.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CRATE_DIR="${ROOT_DIR}/crates/extension-schema" +CARGO_TOML="${CRATE_DIR}/Cargo.toml" + +fail() { + printf 'Extension schema boundary check failed: %s\n' "$1" >&2 + exit 1 +} + +[[ -f "$CARGO_TOML" ]] || fail "crates/extension-schema/Cargo.toml is missing" + +grep -qxF ' "crates/extension-schema", # Extension schema contracts' "${ROOT_DIR}/Cargo.toml" || + fail "workspace members must include crates/extension-schema" + +grep -Eq '^rustfs-extension-schema = \{ path = "crates/extension-schema", version = "[^"]+" \}$' "${ROOT_DIR}/Cargo.toml" || + fail "workspace dependencies must include rustfs-extension-schema" + +if grep -E '^(rustfs-|tokio|axum|hyper|reqwest|tower|s3s)[A-Za-z0-9_-]*[[:space:]]*=' "$CARGO_TOML" >/dev/null; then + fail "extension-schema must stay a lightweight contract crate" +fi + +if (cd "$ROOT_DIR" && rg -n 'rustfs_targets|rustfs_ecstore|rustfs::|tokio::|axum::|hyper::|reqwest::' crates/extension-schema/src >/dev/null); then + fail "extension-schema source must not depend on runtime or implementation crates" +fi + +echo "Extension schema boundaries passed."