1use std::collections::BTreeMap;
16
17use rustfs_extension_schema::{
18 ExtensionContractError, ExtensionKind, ExtensionSchema, S3_POST_AUTH_HOOK_CAPABILITY, S3HookContract, S3HookPoint,
19 validate_s3_hook_contract,
20};
21use thiserror::Error;
22
23#[derive(Debug, Error, PartialEq, Eq)]
24pub enum S3HookRegistryError {
25 #[error(transparent)]
26 InvalidContract(#[from] ExtensionContractError),
27
28 #[error("extension {extension_id} is {kind:?}, not an S3 hook")]
29 UnsupportedExtensionKind { extension_id: String, kind: ExtensionKind },
30
31 #[error("s3 hook extension {extension_id} is missing capability {capability}")]
32 MissingCapability { extension_id: String, capability: &'static str },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct S3HookRegistration {
37 pub extension_id: String,
38 pub hook_point: S3HookPoint,
39}
40
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
42pub struct S3HookRegistry {
43 registrations: BTreeMap<S3HookPoint, Vec<S3HookRegistration>>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct S3HookContext<'a> {
48 pub authenticated_principal: &'a str,
49 pub bucket: &'a str,
50 pub object: Option<&'a str>,
51}
52
53impl<'a> S3HookContext<'a> {
54 pub fn post_auth(authenticated_principal: &'a str, bucket: &'a str, object: Option<&'a str>) -> Option<Self> {
55 if authenticated_principal.trim().is_empty() {
56 return None;
57 }
58
59 Some(Self {
60 authenticated_principal,
61 bucket,
62 object,
63 })
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum S3HookDecision {
69 Continue,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct S3HookDispatchOutcome {
76 pub decision: S3HookDecision,
77 pub dispatched: Vec<String>,
78}
79
80impl S3HookRegistry {
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 pub fn register_schema(&mut self, schema: &ExtensionSchema, contract: &S3HookContract) -> Result<(), S3HookRegistryError> {
86 if schema.kind != ExtensionKind::S3Hook {
87 return Err(S3HookRegistryError::UnsupportedExtensionKind {
88 extension_id: schema.extension_id.clone(),
89 kind: schema.kind,
90 });
91 }
92
93 if !schema
94 .capabilities
95 .iter()
96 .any(|capability| capability.as_str() == S3_POST_AUTH_HOOK_CAPABILITY)
97 {
98 return Err(S3HookRegistryError::MissingCapability {
99 extension_id: schema.extension_id.clone(),
100 capability: S3_POST_AUTH_HOOK_CAPABILITY,
101 });
102 }
103
104 validate_s3_hook_contract(contract)?;
105
106 for hook_point in &contract.hook_points {
107 self.registrations.entry(*hook_point).or_default().push(S3HookRegistration {
108 extension_id: schema.extension_id.clone(),
109 hook_point: *hook_point,
110 });
111 }
112
113 Ok(())
114 }
115
116 pub fn is_empty(&self) -> bool {
117 self.registrations.values().all(Vec::is_empty)
118 }
119
120 pub fn registered_hook_count(&self) -> usize {
121 self.registrations.values().map(Vec::len).sum()
122 }
123
124 pub fn hooks_for(&self, hook_point: S3HookPoint) -> impl Iterator<Item = &S3HookRegistration> {
125 self.registrations.get(&hook_point).into_iter().flatten()
126 }
127
128 pub fn dispatch_post_auth(&self, hook_point: S3HookPoint, context: &S3HookContext<'_>) -> S3HookDispatchOutcome {
129 let mut dispatched = Vec::new();
135 for registration in self.hooks_for(hook_point) {
136 let _ = context;
137 dispatched.push(registration.extension_id.clone());
138 }
139
140 S3HookDispatchOutcome {
141 decision: S3HookDecision::Continue,
142 dispatched,
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::{S3HookContext, S3HookDecision, S3HookRegistry, S3HookRegistryError};
150 use crate::{builtin_s3_hook_contract, builtin_s3_hook_extension_schema};
151 use rustfs_extension_schema::{ExtensionContractError, ExtensionKind, S3HookPoint};
152
153 #[test]
154 fn default_registry_has_identical_continue_behavior() {
155 let registry = S3HookRegistry::new();
156 let context = S3HookContext::post_auth("access-key", "photos", Some("2026/image.jpg"))
157 .expect("post-auth context should require an authenticated principal");
158
159 assert!(registry.is_empty());
160 assert_eq!(registry.registered_hook_count(), 0);
161 let outcome = registry.dispatch_post_auth(S3HookPoint::PostAuthGetObject, &context);
162 assert_eq!(outcome.decision, S3HookDecision::Continue);
163 assert!(outcome.dispatched.is_empty(), "empty registry dispatches to no hooks");
164 }
165
166 #[test]
167 fn post_auth_context_rejects_missing_principal() {
168 assert!(S3HookContext::post_auth("", "photos", Some("2026/image.jpg")).is_none());
169 }
170
171 #[test]
172 fn registers_valid_post_auth_hook_contract_without_dispatch_side_effects() {
173 let mut registry = S3HookRegistry::new();
174 let schema = builtin_s3_hook_extension_schema();
175 let contract = builtin_s3_hook_contract();
176 let context = S3HookContext::post_auth("access-key", "photos", None).expect("post-auth context should be valid");
177
178 registry
179 .register_schema(&schema, &contract)
180 .expect("builtin s3 hook contract should register");
181
182 assert_eq!(registry.registered_hook_count(), contract.hook_points.len());
183 assert_eq!(
184 registry.hooks_for(S3HookPoint::PostAuthListObjects).count(),
185 1,
186 "registered hooks stay catalogued by allowlisted point"
187 );
188
189 let expected: Vec<String> = registry
191 .hooks_for(S3HookPoint::PostAuthListObjects)
192 .map(|registration| registration.extension_id.clone())
193 .collect();
194 assert!(!expected.is_empty());
195
196 let outcome = registry.dispatch_post_auth(S3HookPoint::PostAuthListObjects, &context);
197 assert_eq!(outcome.decision, S3HookDecision::Continue);
198 assert_eq!(outcome.dispatched, expected, "registered hooks must be dispatched");
199
200 let other_point = S3HookPoint::PostAuthGetObject;
202 let other_outcome = registry.dispatch_post_auth(other_point, &context);
203 assert_eq!(
204 other_outcome.dispatched,
205 registry
206 .hooks_for(other_point)
207 .map(|registration| registration.extension_id.clone())
208 .collect::<Vec<_>>()
209 );
210 }
211
212 #[test]
213 fn rejects_non_s3_hook_schema_and_unsafe_contracts() {
214 let mut registry = S3HookRegistry::new();
215 let mut schema = builtin_s3_hook_extension_schema();
216 schema.kind = ExtensionKind::TargetPlugin;
217
218 assert_eq!(
219 registry
220 .register_schema(&schema, &builtin_s3_hook_contract())
221 .expect_err("target plugin schema should not register as s3 hook"),
222 S3HookRegistryError::UnsupportedExtensionKind {
223 extension_id: "builtin:s3-post-auth-hooks".to_string(),
224 kind: ExtensionKind::TargetPlugin
225 }
226 );
227
228 schema.kind = ExtensionKind::S3Hook;
229 let mut contract = builtin_s3_hook_contract();
230 contract.bypasses_iam = true;
231
232 assert_eq!(
233 registry
234 .register_schema(&schema, &contract)
235 .expect_err("IAM bypass should be rejected"),
236 S3HookRegistryError::InvalidContract(ExtensionContractError::S3HookBypassesIam)
237 );
238 }
239}