Skip to main content

apcore_toolkit/output/
registry_writer.rs

1// Registry writer for direct module registration.
2//
3// Converts ScannedModule instances into apcore Module implementations
4// and registers them directly into an apcore Registry.
5//
6// Framework adapters provide a `HandlerFactory` to resolve targets to real
7// async handlers. Without a factory, modules are registered with a passthrough
8// handler that echoes inputs (useful for schema-only registration).
9
10use std::pin::Pin;
11use std::sync::Arc;
12
13use tracing::{debug, warn};
14
15use apcore::context::Context;
16use apcore::errors::ModuleError;
17use apcore::Registry;
18
19use crate::output::types::{Verifier, WriteResult};
20use crate::output::verifiers::{run_verifier_chain, RegistryVerifier};
21use crate::types::ScannedModule;
22
23/// Async handler function type for registered modules.
24pub type HandlerFn = Arc<
25    dyn for<'a> Fn(
26            serde_json::Value,
27            &'a Context<serde_json::Value>,
28        ) -> Pin<
29            Box<
30                dyn std::future::Future<Output = Result<serde_json::Value, ModuleError>>
31                    + Send
32                    + 'a,
33            >,
34        > + Send
35        + Sync,
36>;
37
38/// Factory that resolves a `target` string to an async handler.
39///
40/// Framework adapters implement this to map target strings (e.g., `"myapp:get_user"`)
41/// to actual handler functions. For example, an Axum adapter might look up the
42/// handler in a route table; a generic adapter might use a dynamic dispatch map.
43///
44/// ```ignore
45/// let factory: HandlerFactory = Arc::new(|target: &str| {
46///     let handler = lookup_handler(target);
47///     Some(Arc::new(move |inputs, _ctx| {
48///         let h = handler.clone();
49///         Box::pin(async move { h.call(inputs).await })
50///     }))
51/// });
52/// let writer = RegistryWriter::with_handler_factory(factory);
53/// ```
54pub type HandlerFactory = Arc<dyn Fn(&str) -> Option<HandlerFn> + Send + Sync>;
55
56/// Registers ScannedModule instances directly into an apcore Registry.
57///
58/// This is the default writer used when no output_format is specified.
59/// Instead of writing files, it registers modules directly for immediate use.
60///
61/// ## Handler Resolution
62///
63/// By default (`RegistryWriter::new()`), modules are registered with a passthrough
64/// handler that returns inputs unchanged — useful for schema-only registration
65/// where execution is handled elsewhere.
66///
67/// For executable modules, use `RegistryWriter::with_handler_factory(factory)` to
68/// provide a [`HandlerFactory`] that resolves target strings to real handlers.
69pub struct RegistryWriter {
70    handler_factory: Option<HandlerFactory>,
71    /// Optional allow-list of `target` prefixes. When set, any module whose
72    /// `target` does not start with one of these prefixes is rejected with a
73    /// failed `WriteResult` before any handler factory is invoked. Mirrors the
74    /// `allowed_prefixes` parameter on the Python and TypeScript SDKs and
75    /// provides a defence-in-depth boundary on dynamically-supplied targets.
76    allowed_prefixes: Option<Vec<String>>,
77}
78
79impl Default for RegistryWriter {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl RegistryWriter {
86    /// Create a RegistryWriter with passthrough handlers (schema-only registration).
87    ///
88    /// # Handler resolution
89    ///
90    /// Unlike the Python and TypeScript implementations which dynamically import
91    /// the target function at write time (`resolve_target`), the Rust implementation
92    /// registers a passthrough handler that echoes its inputs when no HandlerFactory
93    /// is configured. This means calling a module registered by this writer will
94    /// succeed but will not execute real business logic. To register real handlers,
95    /// use the HandlerFactory integration.
96    ///
97    /// # Panics
98    ///
99    /// This constructor does not panic. However, note that without a `HandlerFactory`,
100    /// all registered modules will use a passthrough handler that echoes inputs unchanged.
101    /// This is suitable for schema-only registration. For real execution, use
102    /// [`RegistryWriter::with_handler_factory`] to supply a factory that resolves targets
103    /// to actual async handlers.
104    pub fn new() -> Self {
105        Self {
106            handler_factory: None,
107            allowed_prefixes: None,
108        }
109    }
110
111    /// Create a RegistryWriter with a custom handler factory for target resolution.
112    pub fn with_handler_factory(factory: HandlerFactory) -> Self {
113        Self {
114            handler_factory: Some(factory),
115            allowed_prefixes: None,
116        }
117    }
118
119    /// Restrict registration to modules whose `target` starts with one of the
120    /// supplied prefixes. Modules with a non-matching target are rejected with
121    /// a failed `WriteResult` and never reach the handler factory.
122    ///
123    /// Matches the `allowed_prefixes` parameter on the Python `RegistryWriter`
124    /// and the TypeScript `allowedPrefixes` option. Use it to bound the set of
125    /// callable Python/Rust paths a binding YAML may resolve to (defence in
126    /// depth against forged or attacker-controlled `target` strings).
127    pub fn with_allowed_prefixes(mut self, prefixes: Vec<String>) -> Self {
128        self.allowed_prefixes = Some(prefixes);
129        self
130    }
131
132    /// Returns `true` when the module target is permitted by the configured
133    /// `allowed_prefixes` (or when no allow-list is configured).
134    ///
135    /// Performs boundary-aware module-path matching: the module path component
136    /// of `target` (everything before the `:` separator) must equal the prefix
137    /// or be a dotted descendant of it. Mirrors Python's
138    /// `_module_path_matches_prefix` — `"myapp"` does NOT permit `"myappx"`.
139    fn target_allowed(&self, target: &str) -> bool {
140        match self.allowed_prefixes.as_ref() {
141            None => true,
142            Some(prefixes) => {
143                let module_path = target.split(':').next().unwrap_or(target);
144                prefixes
145                    .iter()
146                    .any(|p| module_path_matches_prefix(module_path, p))
147            }
148        }
149    }
150}
151
152/// Boundary-aware module-path prefix match.
153///
154/// Returns `true` when `module_path` is exactly `prefix` or a dotted
155/// descendant of it. A trailing dot on `prefix` is tolerated; an empty
156/// prefix never matches. Mirrors the Python `_module_path_matches_prefix`
157/// helper in `apcore-toolkit-python/src/apcore_toolkit/resolve_target.py`.
158fn module_path_matches_prefix(module_path: &str, prefix: &str) -> bool {
159    let normalized = prefix.trim_end_matches('.');
160    if normalized.is_empty() {
161        return false;
162    }
163    if module_path == normalized {
164        return true;
165    }
166    let mut boundary = String::with_capacity(normalized.len() + 1);
167    boundary.push_str(normalized);
168    boundary.push('.');
169    module_path.starts_with(&boundary)
170}
171
172impl RegistryWriter {
173    /// Register scanned modules into the registry.
174    ///
175    /// - `registry`: The apcore Registry to register modules into.
176    /// - `dry_run`: If true, skip registration and return results only.
177    /// - `verify`: If true, verify modules are retrievable after registration.
178    /// - `verifiers`: Optional custom verifiers run after the built-in check.
179    ///
180    /// # Verifier contract for registry-based modules
181    ///
182    /// Registry modules have no output file, so custom verifiers receive
183    /// `path = ""`. Built-in file-based verifiers (`YAMLVerifier`, `JSONVerifier`,
184    /// etc.) skip gracefully when path is empty. Custom verifiers must also
185    /// handle `path = ""` without erroring — use `module_id` for any
186    /// registry-based checks.
187    pub fn write(
188        &self,
189        modules: &[ScannedModule],
190        registry: &mut Registry,
191        dry_run: bool,
192        verify: bool,
193        verifiers: Option<&[&dyn Verifier]>,
194    ) -> Vec<WriteResult> {
195        let mut results: Vec<WriteResult> = Vec::new();
196
197        for module in modules {
198            if dry_run {
199                results.push(WriteResult::new(module.module_id.clone()));
200                continue;
201            }
202
203            if !self.target_allowed(&module.target) {
204                warn!(
205                    module_id = %module.module_id,
206                    target = %module.target,
207                    "RegistryWriter: target rejected by allowed_prefixes"
208                );
209                results.push(WriteResult::failed(
210                    module.module_id.clone(),
211                    None,
212                    format!(
213                        "target '{}' is not in allowed_prefixes — registration refused",
214                        module.target
215                    ),
216                ));
217                continue;
218            }
219
220            let fm = self.to_function_module(module);
221            // Register with a descriptor
222            let descriptor = apcore::registry::registry::ModuleDescriptor {
223                module_id: module.module_id.clone(),
224                name: Some(module.module_id.clone()),
225                description: module.description.clone(),
226                documentation: module.documentation.clone(),
227                input_schema: module.input_schema.clone(),
228                output_schema: module.output_schema.clone(),
229                version: module.version.clone(),
230                tags: module.tags.clone(),
231                annotations: module.annotations.clone(),
232                examples: module.examples.clone(),
233                metadata: module.metadata.clone(),
234                display: module.display.clone(),
235                sunset_date: None,
236                dependencies: vec![],
237                enabled: true,
238            };
239            // Note: unlike Python/TypeScript, Rust collects per-module registration errors
240            // rather than aborting. This is intentional — partial registration is preferred
241            // over a hard stop, giving callers the opportunity to inspect and handle each failure.
242            if let Err(e) = registry.register(&module.module_id, Box::new(fm), descriptor) {
243                warn!(
244                    module_id = %module.module_id,
245                    error = %e,
246                    "RegistryWriter registration failed"
247                );
248                results.push(WriteResult::failed(
249                    module.module_id.clone(),
250                    None,
251                    format!("Registration failed: {e}"),
252                ));
253                continue;
254            }
255            debug!("Registered module: {}", module.module_id);
256
257            let mut result = WriteResult::new(module.module_id.clone());
258            if verify {
259                result = verify_registry(&result, &module.module_id, registry);
260            }
261            if result.verified {
262                if let Some(vs) = verifiers {
263                    let chain_result = run_verifier_chain(vs, "", &module.module_id);
264                    if !chain_result.ok {
265                        result = WriteResult::failed(
266                            result.module_id,
267                            result.path,
268                            chain_result.error.unwrap_or_default(),
269                        );
270                    }
271                }
272            }
273            results.push(result);
274        }
275
276        results
277    }
278}
279
280impl RegistryWriter {
281    /// Convert a ScannedModule to an apcore FunctionModule.
282    ///
283    /// If a handler factory is configured and resolves the target, uses the
284    /// resolved handler. Otherwise falls back to a passthrough handler that
285    /// returns inputs unchanged.
286    fn to_function_module(&self, module: &ScannedModule) -> apcore::decorator::FunctionModule {
287        let annotations = module.annotations.clone().unwrap_or_default();
288        let input_schema = module.input_schema.clone();
289        let output_schema = module.output_schema.clone();
290
291        // Try to resolve the target via the handler factory
292        if let Some(factory) = &self.handler_factory {
293            if let Some(handler) = factory(&module.target) {
294                return apcore::decorator::FunctionModule::new::<_, ()>(
295                    annotations,
296                    input_schema,
297                    output_schema,
298                    move |inputs: serde_json::Value,
299                          ctx: &Context<serde_json::Value>|
300                          -> Pin<
301                        Box<
302                            dyn std::future::Future<Output = Result<serde_json::Value, ModuleError>>
303                                + Send
304                                + '_,
305                        >,
306                    > { handler(inputs, ctx) },
307                );
308            }
309        }
310
311        // Fallback: passthrough handler (schema-only registration)
312        debug!(
313            module_id = %module.module_id,
314            "RegistryWriter using passthrough handler (no HandlerFactory configured)",
315        );
316        fn passthrough<'a>(
317            inputs: serde_json::Value,
318            _ctx: &'a Context<serde_json::Value>,
319        ) -> Pin<
320            Box<
321                dyn std::future::Future<Output = Result<serde_json::Value, ModuleError>>
322                    + Send
323                    + 'a,
324            >,
325        > {
326            Box::pin(async move { Ok(inputs) })
327        }
328
329        apcore::decorator::FunctionModule::new::<_, ()>(
330            annotations,
331            input_schema,
332            output_schema,
333            passthrough,
334        )
335    }
336}
337
338/// Verify that a module was successfully registered and is retrievable.
339fn verify_registry(result: &WriteResult, module_id: &str, registry: &Registry) -> WriteResult {
340    let verifier = RegistryVerifier::new(registry);
341    let vr = verifier.verify("", module_id);
342    if vr.ok {
343        result.clone()
344    } else {
345        WriteResult::failed(module_id.into(), None, vr.error.unwrap_or_default())
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    fn sample_module() -> ScannedModule {
355        ScannedModule::new(
356            "users.get".into(),
357            "Get user".into(),
358            json!({"type": "object"}),
359            json!({"type": "object"}),
360            vec!["users".into()],
361            "app:get_user".into(),
362        )
363    }
364
365    #[test]
366    fn test_write_dry_run() {
367        let writer = RegistryWriter::new();
368        let mut registry = Registry::new();
369        let modules = vec![sample_module()];
370        let results = writer.write(&modules, &mut registry, true, false, None);
371        assert_eq!(results.len(), 1);
372        assert_eq!(results[0].module_id, "users.get");
373        assert!(!registry.has("users.get"));
374    }
375
376    #[test]
377    fn test_write_registers_module() {
378        let writer = RegistryWriter::new();
379        let mut registry = Registry::new();
380        let modules = vec![sample_module()];
381        let results = writer.write(&modules, &mut registry, false, false, None);
382        assert_eq!(results.len(), 1);
383        assert!(registry.has("users.get"));
384    }
385
386    #[test]
387    fn test_write_with_verify() {
388        let writer = RegistryWriter::new();
389        let mut registry = Registry::new();
390        let modules = vec![sample_module()];
391        let results = writer.write(&modules, &mut registry, false, true, None);
392        assert_eq!(results.len(), 1);
393        assert!(results[0].verified);
394    }
395
396    #[test]
397    fn test_write_empty_list() {
398        let writer = RegistryWriter::new();
399        let mut registry = Registry::new();
400        let results = writer.write(&[], &mut registry, false, false, None);
401        assert!(results.is_empty());
402    }
403
404    #[test]
405    fn test_custom_verifier_runs_even_when_verify_false() {
406        // D11-011: verify=false skips the built-in registry check, but custom
407        // verifiers must still run. A failing custom verifier with verify=false
408        // should produce a result with verified=false.
409        use crate::output::types::{Verifier, VerifyResult};
410
411        struct AlwaysFail;
412        impl Verifier for AlwaysFail {
413            fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
414                VerifyResult::fail("custom verifier failed".into())
415            }
416        }
417
418        let writer = RegistryWriter::new();
419        let mut registry = Registry::new();
420        let modules = vec![sample_module()];
421        let failing_verifier = AlwaysFail;
422        let verifiers: &[&dyn Verifier] = &[&failing_verifier];
423        // verify=false: built-in registry check skipped, but custom verifier runs
424        let results = writer.write(&modules, &mut registry, false, false, Some(verifiers));
425        assert_eq!(results.len(), 1);
426        // Module was registered successfully
427        assert!(registry.has("users.get"));
428        // But custom verifier ran and failed — verified must be false
429        assert!(
430            !results[0].verified,
431            "custom verifier must run even when verify=false; result: {:?}",
432            results[0]
433        );
434        assert!(
435            results[0]
436                .verification_error
437                .as_deref()
438                .unwrap_or("")
439                .contains("custom verifier failed"),
440            "verification_error should contain the custom verifier message"
441        );
442    }
443
444    #[test]
445    fn test_write_multiple_modules() {
446        let writer = RegistryWriter::new();
447        let mut registry = Registry::new();
448        let modules = vec![
449            ScannedModule::new(
450                "mod.a".into(),
451                "A".into(),
452                json!({"type": "object"}),
453                json!({"type": "object"}),
454                vec![],
455                "app:a".into(),
456            ),
457            ScannedModule::new(
458                "mod.b".into(),
459                "B".into(),
460                json!({"type": "object"}),
461                json!({"type": "object"}),
462                vec![],
463                "app:b".into(),
464            ),
465        ];
466        let results = writer.write(&modules, &mut registry, false, false, None);
467        assert_eq!(results.len(), 2);
468        assert!(registry.has("mod.a"));
469        assert!(registry.has("mod.b"));
470        assert!(results[0].verified);
471        assert!(results[1].verified);
472    }
473
474    // D11-2 regression: allowed_prefixes is a defence-in-depth allow-list on
475    // the `target` field. A module whose target does not match any prefix
476    // must be rejected with a failed WriteResult and never registered.
477    #[test]
478    fn test_allowed_prefixes_rejects_non_matching_target() {
479        // Use module-path-only prefixes (no trailing colon) — matches the
480        // canonical Python/TypeScript behavior where prefixes are dotted
481        // module paths, not target strings with the `:callable` suffix.
482        let writer =
483            RegistryWriter::new().with_allowed_prefixes(vec!["app".into(), "myapp".into()]);
484        let mut registry = Registry::new();
485        let allowed = sample_module(); // target = "app:get_user"
486        let denied = ScannedModule::new(
487            "evil.module".into(),
488            "Forged target".into(),
489            json!({"type": "object"}),
490            json!({"type": "object"}),
491            vec![],
492            "evil:run_attacker_code".into(),
493        );
494        let results = writer.write(&[allowed, denied], &mut registry, false, false, None);
495        assert_eq!(results.len(), 2);
496        // app:get_user is in allowed_prefixes — registered.
497        assert!(registry.has("users.get"));
498        assert!(results[0].verified);
499        // evil:* is not — rejected, NOT registered.
500        assert!(!registry.has("evil.module"));
501        assert!(!results[1].verified);
502        let err = results[1].verification_error.as_deref().unwrap_or("");
503        assert!(
504            err.contains("allowed_prefixes"),
505            "rejection message should mention allowed_prefixes: got {err:?}"
506        );
507    }
508
509    // D11-002 regression: boundary-aware module-path matching. Prefix `"myapp"`
510    // must reject `"myappx.evil:fn"` (peer SDKs already reject; Rust used to
511    // accept due to bare `starts_with`). Mirrors Python's
512    // `_module_path_matches_prefix`.
513    #[test]
514    fn test_target_allowed_boundary_aware() {
515        let writer = RegistryWriter::new().with_allowed_prefixes(vec!["myapp".into()]);
516        // Exact match
517        assert!(writer.target_allowed("myapp:fn"));
518        // Dotted descendant
519        assert!(writer.target_allowed("myapp.foo:fn"));
520        assert!(writer.target_allowed("myapp.foo.bar:fn"));
521        // Non-match: same character prefix without dotted boundary
522        assert!(!writer.target_allowed("myappx.evil:fn"));
523        assert!(!writer.target_allowed("myappx:fn"));
524        // Unrelated module path
525        assert!(!writer.target_allowed("other:fn"));
526
527        // Nested prefix
528        let writer2 = RegistryWriter::new().with_allowed_prefixes(vec!["myapp.foo".into()]);
529        assert!(writer2.target_allowed("myapp.foo:fn"));
530        assert!(writer2.target_allowed("myapp.foo.bar:fn"));
531        assert!(!writer2.target_allowed("myapp.foobar:fn"));
532        assert!(!writer2.target_allowed("myapp:fn"));
533
534        // Trailing-dot tolerance and empty-prefix rejection
535        let writer3 = RegistryWriter::new().with_allowed_prefixes(vec!["myapp.".into()]);
536        assert!(writer3.target_allowed("myapp:fn"));
537        let writer4 = RegistryWriter::new().with_allowed_prefixes(vec!["".into()]);
538        assert!(!writer4.target_allowed("anything:fn"));
539    }
540
541    #[test]
542    fn test_allowed_prefixes_default_none_admits_everything() {
543        // Without allowed_prefixes set, target_allowed must return true for
544        // every input — preserves existing behaviour for callers that have
545        // not opted in.
546        let writer = RegistryWriter::new();
547        let mut registry = Registry::new();
548        let module = ScannedModule::new(
549            "any.module".into(),
550            "Any target".into(),
551            json!({"type": "object"}),
552            json!({"type": "object"}),
553            vec![],
554            "anything-goes:func".into(),
555        );
556        let results = writer.write(&[module], &mut registry, false, false, None);
557        assert_eq!(results.len(), 1);
558        assert!(registry.has("any.module"));
559    }
560}