1use 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#[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}