Skip to main content

rustfs_extension_schema/
lib.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::BTreeSet;
16
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20pub const EXTENSION_SCHEMA_VERSION: &str = "rustfs.extension-schema.v1";
21pub const OPS_DIAGNOSTICS_CAPABILITY: &str = "ops.diagnostics.v1";
22pub const OPS_PROFILER_CAPABILITY: &str = "ops.profiler.v1";
23pub const S3_POST_AUTH_HOOK_CAPABILITY: &str = "s3.hook.post_auth.v1";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ExtensionKind {
28    TargetPlugin,
29    S3Hook,
30    OpsDiagnostics,
31    OpsProfiler,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ExtensionRuntimeBoundary {
37    Builtin,
38    Sidecar,
39    Wasm,
40}
41
42impl ExtensionRuntimeBoundary {
43    pub const fn requires_disabled_by_default(self) -> bool {
44        matches!(self, Self::Sidecar | Self::Wasm)
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct ExtensionRuntimeContract {
50    pub api_version: String,
51    pub boundary: ExtensionRuntimeBoundary,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(transparent)]
56pub struct ExtensionCapabilityRef(String);
57
58impl ExtensionCapabilityRef {
59    pub fn new(capability: impl Into<String>) -> Self {
60        Self(capability.into())
61    }
62
63    pub fn as_str(&self) -> &str {
64        self.0.as_str()
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ExtensionSchema {
70    pub schema_version: String,
71    pub extension_id: String,
72    pub display_name: String,
73    pub provider: String,
74    pub version: String,
75    pub kind: ExtensionKind,
76    pub runtime: ExtensionRuntimeContract,
77    pub capabilities: Vec<ExtensionCapabilityRef>,
78    pub disabled_by_default: bool,
79}
80
81/// Every supported hook point runs strictly after authentication and
82/// authorization; pre-auth hook points are intentionally not representable.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum S3HookPoint {
86    PostAuthGetObject,
87    PostAuthPutObject,
88    PostAuthDeleteObject,
89    PostAuthListObjects,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct S3HookContract {
95    pub hook_points: Vec<S3HookPoint>,
96    pub mutates_object_data: bool,
97    pub bypasses_iam: bool,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum OpsDiagnosticSurface {
103    Metrics,
104    Trace,
105    Profile,
106    Health,
107    Diagnostics,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct OpsDiagnosticsContract {
113    pub surfaces: Vec<OpsDiagnosticSurface>,
114    pub mutates_object_data: bool,
115    pub requires_admin_action: bool,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum OpsProfilerContractMode {
121    CapabilityDescription,
122    ExecutionRequest,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
126#[serde(transparent)]
127pub struct OpsProfilerBackendName(String);
128
129impl OpsProfilerBackendName {
130    pub fn new(backend: impl Into<String>) -> Self {
131        Self(backend.into())
132    }
133
134    pub fn as_str(&self) -> &str {
135        self.0.as_str()
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum OpsProfilerBackendStatus {
142    Enabled,
143    Disabled,
144    Unsupported,
145    Unknown,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum OpsProfilerRedactionField {
151    Secret,
152    Token,
153    LocalPath,
154    Host,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum OpsProfilerTrustLevel {
160    RuntimeTrusted,
161    AdminTrusted,
162    ExtensionProvided,
163    Unknown,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct OpsProfilerProvenance {
169    pub source: String,
170    pub collection_boundary: String,
171    pub trust_level: OpsProfilerTrustLevel,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct OpsProfilerBackendCapability {
177    pub backend: OpsProfilerBackendName,
178    pub status: OpsProfilerBackendStatus,
179    pub supports_profile_export: bool,
180    pub redaction_required: Vec<OpsProfilerRedactionField>,
181    pub provenance: OpsProfilerProvenance,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct OpsProfilerContract {
187    pub mode: OpsProfilerContractMode,
188    pub backends: Vec<OpsProfilerBackendCapability>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct OpsProfilerRuntimeSnapshot {
194    pub boundary: ExtensionRuntimeBoundary,
195    pub disabled_by_default: bool,
196    pub startup_fatal: bool,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct OpsProfilerCapabilitySnapshot {
202    pub capability: ExtensionCapabilityRef,
203    pub runtime: OpsProfilerRuntimeSnapshot,
204    pub contract: OpsProfilerContract,
205}
206
207#[derive(Debug, Error, PartialEq, Eq)]
208pub enum ExtensionSchemaError {
209    #[error("extension schema at index {index} has an empty extension id")]
210    EmptyExtensionId { index: usize },
211
212    #[error("extension schema for {extension_id} has an unsupported schema version {schema_version}")]
213    UnsupportedSchemaVersion { extension_id: String, schema_version: String },
214
215    #[error("extension schema for {extension_id} has an empty display name")]
216    EmptyDisplayName { extension_id: String },
217
218    #[error("extension schema for {extension_id} has an empty provider")]
219    EmptyProvider { extension_id: String },
220
221    #[error("extension schema for {extension_id} has an empty version")]
222    EmptyVersion { extension_id: String },
223
224    #[error("extension schema for {extension_id} has an empty runtime API version")]
225    EmptyRuntimeApiVersion { extension_id: String },
226
227    #[error("extension schema for {extension_id} must declare at least one capability")]
228    EmptyCapabilities { extension_id: String },
229
230    #[error("extension schema for {extension_id} has an empty capability")]
231    EmptyCapability { extension_id: String },
232
233    #[error("extension schema for {extension_id} duplicates capability {capability}")]
234    DuplicateCapability { extension_id: String, capability: String },
235
236    #[error("external extension schema for {extension_id} must be disabled by default")]
237    ExternalMustBeDisabledByDefault { extension_id: String },
238
239    #[error("duplicate extension schema for {extension_id}")]
240    DuplicateExtension { extension_id: String },
241}
242
243#[derive(Debug, Error, PartialEq, Eq)]
244pub enum ExtensionContractError {
245    #[error("s3 hook contract must declare at least one hook point")]
246    EmptyS3HookPoints,
247
248    #[error("s3 hook contract duplicates hook point {hook_point:?}")]
249    DuplicateS3HookPoint { hook_point: S3HookPoint },
250
251    #[error("s3 hook contract cannot mutate object data")]
252    S3HookMutatesObjectData,
253
254    #[error("s3 hook contract cannot bypass IAM")]
255    S3HookBypassesIam,
256
257    #[error("ops diagnostics contract must declare at least one surface")]
258    EmptyOpsDiagnosticSurfaces,
259
260    #[error("ops diagnostics contract duplicates surface {surface:?}")]
261    DuplicateOpsDiagnosticSurface { surface: OpsDiagnosticSurface },
262
263    #[error("ops diagnostics contract cannot mutate object data")]
264    OpsDiagnosticsMutatesObjectData,
265
266    #[error("ops diagnostics contract must require an admin action")]
267    OpsDiagnosticsMissingAdminAction,
268
269    #[error("ops profiler contract must describe capabilities, not execution requests")]
270    OpsProfilerExecutionRequest,
271
272    #[error("ops profiler contract must declare at least one backend")]
273    EmptyOpsProfilerBackends,
274
275    #[error("ops profiler contract has an empty backend name")]
276    EmptyOpsProfilerBackend,
277
278    #[error("ops profiler contract duplicates backend {backend}")]
279    DuplicateOpsProfilerBackend { backend: String },
280
281    #[error("ops profiler backend {backend} duplicates redaction field {field:?}")]
282    DuplicateOpsProfilerRedactionField {
283        backend: String,
284        field: OpsProfilerRedactionField,
285    },
286
287    #[error("ops profiler backend {backend} exports profiles without local path redaction")]
288    OpsProfilerMissingLocalPathRedaction { backend: String },
289
290    #[error("ops profiler backend {backend} has an empty provenance source")]
291    EmptyOpsProfilerProvenanceSource { backend: String },
292
293    #[error("ops profiler backend {backend} has an empty collection boundary")]
294    EmptyOpsProfilerCollectionBoundary { backend: String },
295
296    #[error("ops profiler snapshot has unsupported capability {capability}")]
297    UnsupportedOpsProfilerCapability { capability: String },
298
299    #[error("ops profiler external runtime must be disabled by default")]
300    OpsProfilerExternalRuntimeEnabledByDefault,
301
302    #[error("ops profiler runtime snapshot cannot add a startup fatal boundary")]
303    OpsProfilerStartupFatalBoundary,
304}
305
306pub fn validate_extension_schemas(schemas: &[ExtensionSchema]) -> Result<(), ExtensionSchemaError> {
307    let mut extension_ids = BTreeSet::new();
308
309    for (index, schema) in schemas.iter().enumerate() {
310        let extension_id = schema.extension_id.trim();
311        if extension_id.is_empty() {
312            return Err(ExtensionSchemaError::EmptyExtensionId { index });
313        }
314
315        if schema.schema_version != EXTENSION_SCHEMA_VERSION {
316            return Err(ExtensionSchemaError::UnsupportedSchemaVersion {
317                extension_id: schema.extension_id.clone(),
318                schema_version: schema.schema_version.clone(),
319            });
320        }
321
322        if schema.display_name.trim().is_empty() {
323            return Err(ExtensionSchemaError::EmptyDisplayName {
324                extension_id: schema.extension_id.clone(),
325            });
326        }
327
328        if schema.provider.trim().is_empty() {
329            return Err(ExtensionSchemaError::EmptyProvider {
330                extension_id: schema.extension_id.clone(),
331            });
332        }
333
334        if schema.version.trim().is_empty() {
335            return Err(ExtensionSchemaError::EmptyVersion {
336                extension_id: schema.extension_id.clone(),
337            });
338        }
339
340        if schema.runtime.api_version.trim().is_empty() {
341            return Err(ExtensionSchemaError::EmptyRuntimeApiVersion {
342                extension_id: schema.extension_id.clone(),
343            });
344        }
345
346        if schema.capabilities.is_empty() {
347            return Err(ExtensionSchemaError::EmptyCapabilities {
348                extension_id: schema.extension_id.clone(),
349            });
350        }
351
352        let mut capabilities = BTreeSet::new();
353        for capability in &schema.capabilities {
354            if capability.as_str().trim().is_empty() {
355                return Err(ExtensionSchemaError::EmptyCapability {
356                    extension_id: schema.extension_id.clone(),
357                });
358            }
359
360            if !capabilities.insert(capability.as_str()) {
361                return Err(ExtensionSchemaError::DuplicateCapability {
362                    extension_id: schema.extension_id.clone(),
363                    capability: capability.as_str().to_string(),
364                });
365            }
366        }
367
368        if schema.runtime.boundary.requires_disabled_by_default() && !schema.disabled_by_default {
369            return Err(ExtensionSchemaError::ExternalMustBeDisabledByDefault {
370                extension_id: schema.extension_id.clone(),
371            });
372        }
373
374        if !extension_ids.insert(schema.extension_id.as_str()) {
375            return Err(ExtensionSchemaError::DuplicateExtension {
376                extension_id: schema.extension_id.clone(),
377            });
378        }
379    }
380
381    Ok(())
382}
383
384pub fn validate_s3_hook_contract(contract: &S3HookContract) -> Result<(), ExtensionContractError> {
385    if contract.hook_points.is_empty() {
386        return Err(ExtensionContractError::EmptyS3HookPoints);
387    }
388
389    let mut hook_points = BTreeSet::new();
390    for hook_point in &contract.hook_points {
391        if !hook_points.insert(*hook_point) {
392            return Err(ExtensionContractError::DuplicateS3HookPoint { hook_point: *hook_point });
393        }
394    }
395
396    if contract.mutates_object_data {
397        return Err(ExtensionContractError::S3HookMutatesObjectData);
398    }
399
400    if contract.bypasses_iam {
401        return Err(ExtensionContractError::S3HookBypassesIam);
402    }
403
404    Ok(())
405}
406
407pub fn validate_ops_diagnostics_contract(contract: &OpsDiagnosticsContract) -> Result<(), ExtensionContractError> {
408    if contract.surfaces.is_empty() {
409        return Err(ExtensionContractError::EmptyOpsDiagnosticSurfaces);
410    }
411
412    let mut surfaces = BTreeSet::new();
413    for surface in &contract.surfaces {
414        if !surfaces.insert(*surface) {
415            return Err(ExtensionContractError::DuplicateOpsDiagnosticSurface { surface: *surface });
416        }
417    }
418
419    if contract.mutates_object_data {
420        return Err(ExtensionContractError::OpsDiagnosticsMutatesObjectData);
421    }
422
423    if !contract.requires_admin_action {
424        return Err(ExtensionContractError::OpsDiagnosticsMissingAdminAction);
425    }
426
427    Ok(())
428}
429
430pub fn validate_ops_profiler_contract(contract: &OpsProfilerContract) -> Result<(), ExtensionContractError> {
431    if contract.mode != OpsProfilerContractMode::CapabilityDescription {
432        return Err(ExtensionContractError::OpsProfilerExecutionRequest);
433    }
434
435    if contract.backends.is_empty() {
436        return Err(ExtensionContractError::EmptyOpsProfilerBackends);
437    }
438
439    let mut backends = BTreeSet::new();
440    for backend in &contract.backends {
441        let backend_name = backend.backend.as_str().trim();
442        if backend_name.is_empty() {
443            return Err(ExtensionContractError::EmptyOpsProfilerBackend);
444        }
445
446        if !backends.insert(backend_name) {
447            return Err(ExtensionContractError::DuplicateOpsProfilerBackend {
448                backend: backend_name.to_string(),
449            });
450        }
451
452        let mut redaction_fields = BTreeSet::new();
453        for field in &backend.redaction_required {
454            if !redaction_fields.insert(*field) {
455                return Err(ExtensionContractError::DuplicateOpsProfilerRedactionField {
456                    backend: backend_name.to_string(),
457                    field: *field,
458                });
459            }
460        }
461
462        if backend.supports_profile_export && !redaction_fields.contains(&OpsProfilerRedactionField::LocalPath) {
463            return Err(ExtensionContractError::OpsProfilerMissingLocalPathRedaction {
464                backend: backend_name.to_string(),
465            });
466        }
467
468        if backend.provenance.source.trim().is_empty() {
469            return Err(ExtensionContractError::EmptyOpsProfilerProvenanceSource {
470                backend: backend_name.to_string(),
471            });
472        }
473
474        if backend.provenance.collection_boundary.trim().is_empty() {
475            return Err(ExtensionContractError::EmptyOpsProfilerCollectionBoundary {
476                backend: backend_name.to_string(),
477            });
478        }
479    }
480
481    Ok(())
482}
483
484pub fn validate_ops_profiler_capability_snapshot(snapshot: &OpsProfilerCapabilitySnapshot) -> Result<(), ExtensionContractError> {
485    if snapshot.capability.as_str() != OPS_PROFILER_CAPABILITY {
486        return Err(ExtensionContractError::UnsupportedOpsProfilerCapability {
487            capability: snapshot.capability.as_str().to_string(),
488        });
489    }
490
491    if snapshot.runtime.boundary.requires_disabled_by_default() && !snapshot.runtime.disabled_by_default {
492        return Err(ExtensionContractError::OpsProfilerExternalRuntimeEnabledByDefault);
493    }
494
495    if snapshot.runtime.startup_fatal {
496        return Err(ExtensionContractError::OpsProfilerStartupFatalBoundary);
497    }
498
499    validate_ops_profiler_contract(&snapshot.contract)
500}
501
502#[cfg(test)]
503mod tests {
504    use super::{
505        EXTENSION_SCHEMA_VERSION, ExtensionCapabilityRef, ExtensionContractError, ExtensionKind, ExtensionRuntimeBoundary,
506        ExtensionRuntimeContract, ExtensionSchema, ExtensionSchemaError, OPS_DIAGNOSTICS_CAPABILITY, OPS_PROFILER_CAPABILITY,
507        OpsDiagnosticSurface, OpsDiagnosticsContract, OpsProfilerBackendCapability, OpsProfilerBackendName,
508        OpsProfilerBackendStatus, OpsProfilerCapabilitySnapshot, OpsProfilerContract, OpsProfilerContractMode,
509        OpsProfilerProvenance, OpsProfilerRedactionField, OpsProfilerRuntimeSnapshot, OpsProfilerTrustLevel,
510        S3_POST_AUTH_HOOK_CAPABILITY, S3HookContract, S3HookPoint, validate_extension_schemas, validate_ops_diagnostics_contract,
511        validate_ops_profiler_capability_snapshot, validate_ops_profiler_contract, validate_s3_hook_contract,
512    };
513    use serde_json::json;
514
515    fn target_schema(
516        extension_id: &str,
517        capability: &str,
518        boundary: ExtensionRuntimeBoundary,
519        disabled_by_default: bool,
520    ) -> ExtensionSchema {
521        ExtensionSchema {
522            schema_version: EXTENSION_SCHEMA_VERSION.to_string(),
523            extension_id: extension_id.to_string(),
524            display_name: "Webhook Target".to_string(),
525            provider: "rustfs".to_string(),
526            version: "1.0.0".to_string(),
527            kind: ExtensionKind::TargetPlugin,
528            runtime: ExtensionRuntimeContract {
529                api_version: "rustfs.extension.v1".to_string(),
530                boundary,
531            },
532            capabilities: vec![ExtensionCapabilityRef::new(capability)],
533            disabled_by_default,
534        }
535    }
536
537    #[test]
538    fn extension_schema_serializes_stable_json_shape() {
539        let schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false);
540
541        let value = serde_json::to_value(schema).expect("extension schema should serialize");
542
543        assert_eq!(
544            value,
545            json!({
546                "schema_version": "rustfs.extension-schema.v1",
547                "extension_id": "rustfs.builtin.webhook",
548                "display_name": "Webhook Target",
549                "provider": "rustfs",
550                "version": "1.0.0",
551                "kind": "target_plugin",
552                "runtime": {
553                    "api_version": "rustfs.extension.v1",
554                    "boundary": "builtin"
555                },
556                "capabilities": ["target.notify.v1"],
557                "disabled_by_default": false
558            })
559        );
560    }
561
562    #[test]
563    fn validates_extension_schema_contracts() {
564        let schemas = [
565            ExtensionSchema {
566                schema_version: EXTENSION_SCHEMA_VERSION.to_string(),
567                extension_id: "rustfs.ops.diagnostics".to_string(),
568                display_name: "Ops Diagnostics".to_string(),
569                provider: "rustfs".to_string(),
570                version: "1.0.0".to_string(),
571                kind: ExtensionKind::OpsDiagnostics,
572                runtime: ExtensionRuntimeContract {
573                    api_version: "rustfs.extension.v1".to_string(),
574                    boundary: ExtensionRuntimeBoundary::Builtin,
575                },
576                capabilities: vec![ExtensionCapabilityRef::new(OPS_DIAGNOSTICS_CAPABILITY)],
577                disabled_by_default: false,
578            },
579            ExtensionSchema {
580                schema_version: EXTENSION_SCHEMA_VERSION.to_string(),
581                extension_id: "rustfs.ops.profiler".to_string(),
582                display_name: "Ops Profiler".to_string(),
583                provider: "rustfs".to_string(),
584                version: "1.0.0".to_string(),
585                kind: ExtensionKind::OpsProfiler,
586                runtime: ExtensionRuntimeContract {
587                    api_version: "rustfs.extension.v1".to_string(),
588                    boundary: ExtensionRuntimeBoundary::Builtin,
589                },
590                capabilities: vec![ExtensionCapabilityRef::new(OPS_PROFILER_CAPABILITY)],
591                disabled_by_default: false,
592            },
593        ];
594
595        assert!(validate_extension_schemas(&schemas).is_ok());
596    }
597
598    #[test]
599    fn rejects_external_extensions_enabled_by_default() {
600        let schemas = [target_schema(
601            "external.sidecar.audit",
602            "target.audit.v1",
603            ExtensionRuntimeBoundary::Sidecar,
604            false,
605        )];
606
607        let err = validate_extension_schemas(&schemas).expect_err("external sidecars must start disabled");
608
609        assert_eq!(
610            err,
611            ExtensionSchemaError::ExternalMustBeDisabledByDefault {
612                extension_id: "external.sidecar.audit".to_string()
613            }
614        );
615    }
616
617    #[test]
618    fn rejects_duplicate_extension_capabilities() {
619        let mut schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false);
620        schema.capabilities.push(ExtensionCapabilityRef::new("target.notify.v1"));
621        let schemas = [schema];
622
623        let err = validate_extension_schemas(&schemas).expect_err("duplicate capabilities should fail validation");
624
625        assert_eq!(
626            err,
627            ExtensionSchemaError::DuplicateCapability {
628                extension_id: "rustfs.builtin.webhook".to_string(),
629                capability: "target.notify.v1".to_string()
630            }
631        );
632    }
633
634    #[test]
635    fn rejects_duplicate_extension_ids() {
636        let schemas = [
637            target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false),
638            target_schema("rustfs.builtin.webhook", "target.notify.v2", ExtensionRuntimeBoundary::Builtin, false),
639        ];
640
641        let err = validate_extension_schemas(&schemas).expect_err("duplicate extension ids should fail validation");
642
643        assert_eq!(
644            err,
645            ExtensionSchemaError::DuplicateExtension {
646                extension_id: "rustfs.builtin.webhook".to_string()
647            }
648        );
649    }
650
651    #[test]
652    fn rejects_unknown_schema_version() {
653        let mut schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false);
654        schema.schema_version = "rustfs.extension-schema.v0".to_string();
655        let schemas = [schema];
656
657        let err = validate_extension_schemas(&schemas).expect_err("unknown schema version should fail validation");
658
659        assert_eq!(
660            err,
661            ExtensionSchemaError::UnsupportedSchemaVersion {
662                extension_id: "rustfs.builtin.webhook".to_string(),
663                schema_version: "rustfs.extension-schema.v0".to_string()
664            }
665        );
666    }
667
668    #[test]
669    fn rejects_empty_capabilities() {
670        let mut schema = target_schema("rustfs.builtin.webhook", "target.notify.v1", ExtensionRuntimeBoundary::Builtin, false);
671        schema.capabilities.clear();
672        let schemas = [schema];
673
674        let err = validate_extension_schemas(&schemas).expect_err("extension capabilities must be explicit");
675
676        assert_eq!(
677            err,
678            ExtensionSchemaError::EmptyCapabilities {
679                extension_id: "rustfs.builtin.webhook".to_string()
680            }
681        );
682    }
683
684    #[test]
685    fn validates_s3_post_auth_hook_contract() {
686        let contract = S3HookContract {
687            hook_points: vec![S3HookPoint::PostAuthGetObject, S3HookPoint::PostAuthPutObject],
688            mutates_object_data: false,
689            bypasses_iam: false,
690        };
691
692        assert!(validate_s3_hook_contract(&contract).is_ok());
693        assert_eq!(S3_POST_AUTH_HOOK_CAPABILITY, "s3.hook.post_auth.v1");
694    }
695
696    #[test]
697    fn rejects_s3_hooks_that_mutate_or_bypass_iam() {
698        let mut contract = S3HookContract {
699            hook_points: vec![S3HookPoint::PostAuthGetObject],
700            mutates_object_data: true,
701            bypasses_iam: false,
702        };
703
704        assert_eq!(
705            validate_s3_hook_contract(&contract).expect_err("object mutation should be rejected"),
706            ExtensionContractError::S3HookMutatesObjectData
707        );
708
709        contract.mutates_object_data = false;
710        contract.bypasses_iam = true;
711
712        assert_eq!(
713            validate_s3_hook_contract(&contract).expect_err("IAM bypass should be rejected"),
714            ExtensionContractError::S3HookBypassesIam
715        );
716    }
717
718    #[test]
719    fn rejects_duplicate_s3_hook_points() {
720        let contract = S3HookContract {
721            hook_points: vec![S3HookPoint::PostAuthDeleteObject, S3HookPoint::PostAuthDeleteObject],
722            mutates_object_data: false,
723            bypasses_iam: false,
724        };
725
726        assert_eq!(
727            validate_s3_hook_contract(&contract).expect_err("duplicate hook points should fail validation"),
728            ExtensionContractError::DuplicateS3HookPoint {
729                hook_point: S3HookPoint::PostAuthDeleteObject
730            }
731        );
732    }
733
734    #[test]
735    fn validates_ops_diagnostics_contract() {
736        let contract = OpsDiagnosticsContract {
737            surfaces: vec![
738                OpsDiagnosticSurface::Metrics,
739                OpsDiagnosticSurface::Trace,
740                OpsDiagnosticSurface::Profile,
741                OpsDiagnosticSurface::Health,
742                OpsDiagnosticSurface::Diagnostics,
743            ],
744            mutates_object_data: false,
745            requires_admin_action: true,
746        };
747
748        assert!(validate_ops_diagnostics_contract(&contract).is_ok());
749        assert_eq!(OPS_DIAGNOSTICS_CAPABILITY, "ops.diagnostics.v1");
750    }
751
752    fn profiler_backend(
753        backend: &str,
754        status: OpsProfilerBackendStatus,
755        supports_profile_export: bool,
756    ) -> OpsProfilerBackendCapability {
757        OpsProfilerBackendCapability {
758            backend: OpsProfilerBackendName::new(backend),
759            status,
760            supports_profile_export,
761            redaction_required: vec![
762                OpsProfilerRedactionField::Secret,
763                OpsProfilerRedactionField::Token,
764                OpsProfilerRedactionField::LocalPath,
765                OpsProfilerRedactionField::Host,
766            ],
767            provenance: OpsProfilerProvenance {
768                source: "rustfs.profiling".to_string(),
769                collection_boundary: "rustfs-process".to_string(),
770                trust_level: OpsProfilerTrustLevel::RuntimeTrusted,
771            },
772        }
773    }
774
775    #[test]
776    fn validates_ops_profiler_contract_states_and_redaction() {
777        let contract = OpsProfilerContract {
778            mode: OpsProfilerContractMode::CapabilityDescription,
779            backends: vec![
780                profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true),
781                profiler_backend("memory_pprof", OpsProfilerBackendStatus::Disabled, false),
782                profiler_backend("ebpf", OpsProfilerBackendStatus::Unsupported, false),
783                profiler_backend("future_kernel_profiler", OpsProfilerBackendStatus::Unknown, false),
784            ],
785        };
786
787        assert!(validate_ops_profiler_contract(&contract).is_ok());
788        assert_eq!(OPS_PROFILER_CAPABILITY, "ops.profiler.v1");
789        assert!(
790            contract
791                .backends
792                .iter()
793                .all(|backend| !backend.provenance.source.contains("token"))
794        );
795    }
796
797    #[test]
798    fn ops_profiler_schema_serializes_stable_json_shape() {
799        let contract = OpsProfilerContract {
800            mode: OpsProfilerContractMode::CapabilityDescription,
801            backends: vec![profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true)],
802        };
803
804        let value = serde_json::to_value(contract).expect("ops profiler schema should serialize");
805
806        assert_eq!(
807            value,
808            json!({
809                "mode": "capability_description",
810                "backends": [{
811                    "backend": "pyroscope",
812                    "status": "enabled",
813                    "supports_profile_export": true,
814                    "redaction_required": ["secret", "token", "local_path", "host"],
815                    "provenance": {
816                        "source": "rustfs.profiling",
817                        "collection_boundary": "rustfs-process",
818                        "trust_level": "runtime_trusted"
819                    }
820                }]
821            })
822        );
823    }
824
825    #[test]
826    fn ops_profiler_schema_accepts_unknown_future_backend_names() {
827        let contract: OpsProfilerContract = serde_json::from_value(json!({
828            "mode": "capability_description",
829            "backends": [{
830                "backend": "vendor.future-profiler",
831                "status": "unknown",
832                "supports_profile_export": false,
833                "redaction_required": ["host"],
834                "provenance": {
835                    "source": "extension.schema",
836                    "collection_boundary": "read_only_inventory",
837                    "trust_level": "unknown"
838                }
839            }]
840        }))
841        .expect("unknown future backend names should remain representable");
842
843        assert!(validate_ops_profiler_contract(&contract).is_ok());
844    }
845
846    #[test]
847    fn ops_profiler_capability_snapshot_preserves_runtime_states() {
848        let snapshot = OpsProfilerCapabilitySnapshot {
849            capability: ExtensionCapabilityRef::new(OPS_PROFILER_CAPABILITY),
850            runtime: OpsProfilerRuntimeSnapshot {
851                boundary: ExtensionRuntimeBoundary::Sidecar,
852                disabled_by_default: true,
853                startup_fatal: false,
854            },
855            contract: OpsProfilerContract {
856                mode: OpsProfilerContractMode::CapabilityDescription,
857                backends: vec![
858                    profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true),
859                    profiler_backend("memory_pprof", OpsProfilerBackendStatus::Disabled, false),
860                    profiler_backend("ebpf", OpsProfilerBackendStatus::Unsupported, false),
861                ],
862            },
863        };
864
865        assert!(validate_ops_profiler_capability_snapshot(&snapshot).is_ok());
866
867        let encoded = serde_json::to_string(&snapshot).expect("ops profiler snapshot should serialize");
868        let decoded: OpsProfilerCapabilitySnapshot =
869            serde_json::from_str(&encoded).expect("ops profiler snapshot should deserialize");
870
871        let states: Vec<_> = decoded.contract.backends.iter().map(|backend| backend.status).collect();
872        assert_eq!(
873            states,
874            vec![
875                OpsProfilerBackendStatus::Enabled,
876                OpsProfilerBackendStatus::Disabled,
877                OpsProfilerBackendStatus::Unsupported,
878            ]
879        );
880        assert_eq!(decoded.runtime.boundary, ExtensionRuntimeBoundary::Sidecar);
881        assert!(decoded.runtime.disabled_by_default);
882        assert!(!decoded.runtime.startup_fatal);
883    }
884
885    #[test]
886    fn ops_profiler_capability_snapshot_serializes_stable_json_shape() {
887        let snapshot = OpsProfilerCapabilitySnapshot {
888            capability: ExtensionCapabilityRef::new(OPS_PROFILER_CAPABILITY),
889            runtime: OpsProfilerRuntimeSnapshot {
890                boundary: ExtensionRuntimeBoundary::Builtin,
891                disabled_by_default: false,
892                startup_fatal: false,
893            },
894            contract: OpsProfilerContract {
895                mode: OpsProfilerContractMode::CapabilityDescription,
896                backends: vec![profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true)],
897            },
898        };
899
900        let value = serde_json::to_value(snapshot).expect("ops profiler snapshot should serialize");
901
902        assert_eq!(
903            value,
904            json!({
905                "capability": "ops.profiler.v1",
906                "runtime": {
907                    "boundary": "builtin",
908                    "disabled_by_default": false,
909                    "startup_fatal": false
910                },
911                "contract": {
912                    "mode": "capability_description",
913                    "backends": [{
914                        "backend": "pyroscope",
915                        "status": "enabled",
916                        "supports_profile_export": true,
917                        "redaction_required": ["secret", "token", "local_path", "host"],
918                        "provenance": {
919                            "source": "rustfs.profiling",
920                            "collection_boundary": "rustfs-process",
921                            "trust_level": "runtime_trusted"
922                        }
923                    }]
924                }
925            })
926        );
927    }
928
929    #[test]
930    fn rejects_ops_profiler_snapshot_wrong_capability_or_fatal_runtime() {
931        let mut snapshot = OpsProfilerCapabilitySnapshot {
932            capability: ExtensionCapabilityRef::new("ops.not-profiler.v1"),
933            runtime: OpsProfilerRuntimeSnapshot {
934                boundary: ExtensionRuntimeBoundary::Sidecar,
935                disabled_by_default: true,
936                startup_fatal: false,
937            },
938            contract: OpsProfilerContract {
939                mode: OpsProfilerContractMode::CapabilityDescription,
940                backends: vec![profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true)],
941            },
942        };
943
944        assert_eq!(
945            validate_ops_profiler_capability_snapshot(&snapshot).expect_err("only ops.profiler.v1 snapshots are accepted"),
946            ExtensionContractError::UnsupportedOpsProfilerCapability {
947                capability: "ops.not-profiler.v1".to_string()
948            }
949        );
950
951        snapshot.capability = ExtensionCapabilityRef::new(OPS_PROFILER_CAPABILITY);
952        snapshot.runtime.disabled_by_default = false;
953
954        assert_eq!(
955            validate_ops_profiler_capability_snapshot(&snapshot)
956                .expect_err("external profiler runtimes must stay disabled by default"),
957            ExtensionContractError::OpsProfilerExternalRuntimeEnabledByDefault
958        );
959
960        snapshot.runtime.disabled_by_default = true;
961        snapshot.runtime.startup_fatal = true;
962
963        assert_eq!(
964            validate_ops_profiler_capability_snapshot(&snapshot)
965                .expect_err("optional profiler runtimes must not become startup fatal"),
966            ExtensionContractError::OpsProfilerStartupFatalBoundary
967        );
968    }
969
970    #[test]
971    fn rejects_ops_profiler_execution_requests_and_empty_backends() {
972        let mut contract = OpsProfilerContract {
973            mode: OpsProfilerContractMode::ExecutionRequest,
974            backends: vec![profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true)],
975        };
976
977        assert_eq!(
978            validate_ops_profiler_contract(&contract).expect_err("execution requests are outside schema scope"),
979            ExtensionContractError::OpsProfilerExecutionRequest
980        );
981
982        contract.mode = OpsProfilerContractMode::CapabilityDescription;
983        contract.backends.clear();
984
985        assert_eq!(
986            validate_ops_profiler_contract(&contract).expect_err("profiler capability must declare backends"),
987            ExtensionContractError::EmptyOpsProfilerBackends
988        );
989    }
990
991    #[test]
992    fn rejects_ops_profiler_missing_required_fields() {
993        let err = serde_json::from_value::<OpsProfilerContract>(json!({
994            "mode": "capability_description",
995            "backends": [{
996                "backend": "pyroscope",
997                "status": "enabled",
998                "supports_profile_export": true,
999                "redaction_required": ["local_path"]
1000            }]
1001        }))
1002        .expect_err("provenance is required");
1003
1004        assert!(err.to_string().contains("missing field `provenance`"));
1005    }
1006
1007    #[test]
1008    fn rejects_ops_profiler_unknown_credential_fields() {
1009        let err = serde_json::from_value::<OpsProfilerContract>(json!({
1010            "mode": "capability_description",
1011            "backends": [{
1012                "backend": "pyroscope",
1013                "status": "enabled",
1014                "supports_profile_export": true,
1015                "redaction_required": ["local_path"],
1016                "provenance": {
1017                    "source": "rustfs.profiling",
1018                    "collection_boundary": "rustfs-process",
1019                    "trust_level": "runtime_trusted",
1020                    "credentials": "not-allowed"
1021                }
1022            }]
1023        }))
1024        .expect_err("credential-bearing fields are outside the schema");
1025
1026        assert!(err.to_string().contains("unknown field `credentials`"));
1027    }
1028
1029    #[test]
1030    fn rejects_ops_profiler_invalid_backend_and_redaction_shape() {
1031        let mut contract = OpsProfilerContract {
1032            mode: OpsProfilerContractMode::CapabilityDescription,
1033            backends: vec![profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true)],
1034        };
1035        contract.backends[0].backend = OpsProfilerBackendName::new(" ");
1036
1037        assert_eq!(
1038            validate_ops_profiler_contract(&contract).expect_err("backend name should be required"),
1039            ExtensionContractError::EmptyOpsProfilerBackend
1040        );
1041
1042        contract.backends[0] = profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true);
1043        contract
1044            .backends
1045            .push(profiler_backend("pyroscope", OpsProfilerBackendStatus::Disabled, false));
1046
1047        assert_eq!(
1048            validate_ops_profiler_contract(&contract).expect_err("duplicate backends should fail validation"),
1049            ExtensionContractError::DuplicateOpsProfilerBackend {
1050                backend: "pyroscope".to_string()
1051            }
1052        );
1053
1054        contract.backends.truncate(1);
1055        contract.backends[0].redaction_required.push(OpsProfilerRedactionField::Host);
1056
1057        assert_eq!(
1058            validate_ops_profiler_contract(&contract).expect_err("duplicate redaction fields should fail validation"),
1059            ExtensionContractError::DuplicateOpsProfilerRedactionField {
1060                backend: "pyroscope".to_string(),
1061                field: OpsProfilerRedactionField::Host
1062            }
1063        );
1064
1065        contract.backends[0].redaction_required = vec![OpsProfilerRedactionField::Host];
1066
1067        assert_eq!(
1068            validate_ops_profiler_contract(&contract).expect_err("profile export requires local path redaction"),
1069            ExtensionContractError::OpsProfilerMissingLocalPathRedaction {
1070                backend: "pyroscope".to_string()
1071            }
1072        );
1073    }
1074
1075    #[test]
1076    fn rejects_ops_profiler_missing_provenance_boundary() {
1077        let mut contract = OpsProfilerContract {
1078            mode: OpsProfilerContractMode::CapabilityDescription,
1079            backends: vec![profiler_backend("pyroscope", OpsProfilerBackendStatus::Enabled, true)],
1080        };
1081        contract.backends[0].provenance.source = " ".to_string();
1082
1083        assert_eq!(
1084            validate_ops_profiler_contract(&contract).expect_err("provenance source should be required"),
1085            ExtensionContractError::EmptyOpsProfilerProvenanceSource {
1086                backend: "pyroscope".to_string()
1087            }
1088        );
1089
1090        contract.backends[0].provenance.source = "rustfs.profiling".to_string();
1091        contract.backends[0].provenance.collection_boundary.clear();
1092
1093        assert_eq!(
1094            validate_ops_profiler_contract(&contract).expect_err("collection boundary should be required"),
1095            ExtensionContractError::EmptyOpsProfilerCollectionBoundary {
1096                backend: "pyroscope".to_string()
1097            }
1098        );
1099    }
1100
1101    #[test]
1102    fn rejects_ops_diagnostics_without_admin_action_or_read_only_contract() {
1103        let mut contract = OpsDiagnosticsContract {
1104            surfaces: vec![OpsDiagnosticSurface::Metrics],
1105            mutates_object_data: false,
1106            requires_admin_action: false,
1107        };
1108
1109        assert_eq!(
1110            validate_ops_diagnostics_contract(&contract).expect_err("admin action should be required"),
1111            ExtensionContractError::OpsDiagnosticsMissingAdminAction
1112        );
1113
1114        contract.requires_admin_action = true;
1115        contract.mutates_object_data = true;
1116
1117        assert_eq!(
1118            validate_ops_diagnostics_contract(&contract).expect_err("object mutation should be rejected"),
1119            ExtensionContractError::OpsDiagnosticsMutatesObjectData
1120        );
1121    }
1122}