Skip to main content

soma_application/providers/
resource_files.rs

1//! Providers backing structured `providers/resources/` files: one
2//! `ResourceFileProvider` per discovered file, either serving static file
3//! content directly or dispatching to a sandboxed TypeScript `read()`
4//! reader — see `docs/contracts/drop-in-provider-layout.md`.
5
6use std::{
7    collections::BTreeMap,
8    path::{Path, PathBuf},
9};
10
11use async_trait::async_trait;
12use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
13use serde_json::Value;
14use soma_provider_core::{
15    ProviderCatalog, ProviderIdentity, ProviderKind, ProviderManifest, ProviderResource,
16};
17
18use soma_provider_adapters::{error::SidecarError, sidecar::run_bounded_sidecar};
19
20use crate::{
21    provider_errors::ProviderError,
22    provider_registry::{
23        DynamicResourceTemplate, Provider, ProviderCall, ProviderOutput, ResourceReadOutput,
24    },
25    providers::resource_uri,
26};
27
28/// Static resource files larger than this are rejected at discovery time —
29/// "enforce file size limits for static resources" per the layout contract.
30pub const MAX_STATIC_RESOURCE_BYTES: u64 = 10 * 1024 * 1024;
31
32const DYNAMIC_RESOURCE_TIMEOUT_MS: u64 = 10_000;
33const DYNAMIC_RESOURCE_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
34
35#[derive(Debug, Clone)]
36enum ResourceFileKind {
37    Static {
38        path: PathBuf,
39        resource: Box<ProviderResource>,
40        mime_type: String,
41    },
42    Dynamic {
43        path: PathBuf,
44        template: DynamicResourceTemplate,
45    },
46}
47
48/// Provider for a single file discovered under `providers/resources/`,
49/// serving either static file bytes or a sandboxed TypeScript `read()` reader.
50#[derive(Debug, Clone)]
51pub struct ResourceFileProvider {
52    provider_name: String,
53    kind: ResourceFileKind,
54    /// The canonicalized `providers/resources/` directory this file was
55    /// discovered under, re-verified against on every read (not just at
56    /// discovery time) to close the TOCTOU window a symlink swap could open
57    /// between the directory walk and the eventual read/execute syscall.
58    canonical_root: PathBuf,
59}
60
61impl ResourceFileProvider {
62    /// Builds a provider for one file discovered under `providers/resources/`.
63    /// `relative_path` is the file's path relative to that directory
64    /// (already verified not to escape the provider root — see
65    /// `filesystem::collect_resource_files`). `canonical_root` is that
66    /// directory's canonicalized path, re-checked at read time. A `.ts`
67    /// extension makes the file a dynamic reader; every other extension
68    /// makes it a static resource read directly from disk.
69    pub fn from_file(
70        absolute_path: PathBuf,
71        relative_path: &Path,
72        canonical_root: &Path,
73    ) -> Result<Self, ResourceFileError> {
74        let is_dynamic_reader =
75            relative_path.extension().and_then(|ext| ext.to_str()) == Some("ts");
76
77        let mut stem_segments: Vec<String> = relative_path
78            .components()
79            .map(|component| component.as_os_str().to_string_lossy().into_owned())
80            .collect();
81        if let Some(last) = stem_segments.last_mut()
82            && let Some(stem) = Path::new(last.as_str())
83                .file_stem()
84                .and_then(|stem| stem.to_str())
85        {
86            *last = stem.to_owned();
87        }
88        let segment_refs: Vec<&str> = stem_segments.iter().map(String::as_str).collect();
89        let resource_path = resource_uri::parse_resource_path(&segment_refs)
90            .map_err(|error| ResourceFileError(error.0))?;
91        // Bracket syntax ([name], [...name]) is meaningful in the filename
92        // but must not leak into display strings — use the parsed segments'
93        // clean values/param-names, not the raw stem, for anything the
94        // provider-name schema pattern or a human might see.
95        let clean_name = joined_segment_name(&resource_path);
96
97        let provider_name = format!("resource-{clean_name}");
98
99        if is_dynamic_reader {
100            let description = format!(
101                "Dynamic resource reader from {}",
102                resource_uri::display_with_forward_slashes(relative_path)
103            );
104            let mut template = DynamicResourceTemplate::from_path_segments(
105                &segment_refs,
106                clean_name,
107                description,
108                None,
109            )
110            .map_err(ResourceFileError)?;
111            // Dynamic readers execute arbitrary operator-authored code via
112            // the Node sidecar (unlike static resources, which only ever
113            // return file bytes) — default to the stricter write scope so a
114            // read-scoped principal can't invoke them, since the drop-in
115            // file convention has no manifest to declare a scope
116            // explicitly. `write` satisfies `read` per `scopes_satisfy`, so
117            // this only narrows who may call it, never widens it.
118            template.scope = Some("soma:write".to_owned());
119            return Ok(Self {
120                provider_name,
121                kind: ResourceFileKind::Dynamic {
122                    path: absolute_path,
123                    template,
124                },
125                canonical_root: canonical_root.to_owned(),
126            });
127        }
128
129        let metadata = std::fs::metadata(&absolute_path).map_err(|source| {
130            ResourceFileError(format!("failed to read resource file metadata: {source}"))
131        })?;
132        if metadata.len() > MAX_STATIC_RESOURCE_BYTES {
133            return Err(ResourceFileError(format!(
134                "resource file exceeds the {MAX_STATIC_RESOURCE_BYTES}-byte static resource limit"
135            )));
136        }
137        if resource_path.is_dynamic() {
138            return Err(ResourceFileError(
139                "static resource files (non-.ts) cannot use bracketed [param] path segments"
140                    .to_owned(),
141            ));
142        }
143
144        let mime_type = mime_type_for_extension(relative_path);
145        // The full-path clean_name, not just the leaf stem: two files that
146        // share a leaf name under different directories (resources/api/runbook.md,
147        // resources/ops/runbook.md) have distinct, valid, non-colliding URIs
148        // but would collide on a leaf-only name, tripping the global
149        // resource-name uniqueness check in build_snapshot() and failing
150        // the whole refresh over two perfectly valid resources.
151        let name = clean_name;
152        let description = static_resource_description(&absolute_path, &mime_type, &name)?;
153        let resource = ProviderResource {
154            uri_template: resource_path.uri_string(),
155            name,
156            description,
157            mime_type: Some(mime_type.clone()),
158            scope: None,
159            mcp: None,
160            annotations: serde_json::json!({}),
161        };
162        Ok(Self {
163            provider_name,
164            kind: ResourceFileKind::Static {
165                path: absolute_path,
166                resource: Box::new(resource),
167                mime_type,
168            },
169            canonical_root: canonical_root.to_owned(),
170        })
171    }
172
173    /// Same as [`from_file`](Self::from_file) but returns the provider boxed
174    /// as a shared `Arc<dyn Provider>` for registration.
175    pub fn arc(
176        absolute_path: PathBuf,
177        relative_path: &Path,
178        canonical_root: &Path,
179    ) -> Result<std::sync::Arc<dyn Provider>, ResourceFileError> {
180        Ok(std::sync::Arc::new(Self::from_file(
181            absolute_path,
182            relative_path,
183            canonical_root,
184        )?))
185    }
186}
187
188/// Error raised while discovering or building a `ResourceFileProvider`.
189#[derive(Debug)]
190pub struct ResourceFileError(
191    /// Human-readable failure message.
192    pub String,
193);
194
195impl std::fmt::Display for ResourceFileError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        write!(f, "{}", self.0)
198    }
199}
200
201impl std::error::Error for ResourceFileError {}
202
203/// Joins a parsed path's segment values for use as a display name: literal
204/// segments contribute their slugified value, param/catch-all segments
205/// contribute their (bracket-free) parameter name. Segments are joined with
206/// `_`, never `-`: `slugify()` (used for every literal segment) only ever
207/// produces `-` internally, so joining with the same character would make
208/// nested paths and hyphenated flat names collide (`resources/my/file.md`
209/// and `resources/my-file.md` would both flatten to `my-file`) even though
210/// they map to different, non-colliding URIs — `_` keeps the two
211/// unambiguous since it's schema-valid but never emitted by slugify.
212fn joined_segment_name(path: &resource_uri::ResourcePath) -> String {
213    path.segments
214        .iter()
215        .map(|segment| match segment {
216            resource_uri::PathSegment::Literal(value) => value.clone(),
217            resource_uri::PathSegment::Param(name) | resource_uri::PathSegment::CatchAll(name) => {
218                name.clone()
219            }
220        })
221        .collect::<Vec<_>>()
222        .join("_")
223}
224
225fn static_resource_description(
226    path: &Path,
227    mime_type: &str,
228    name: &str,
229) -> Result<String, ResourceFileError> {
230    if mime_type == "text/markdown" {
231        let text = std::fs::read_to_string(path).map_err(|source| {
232            ResourceFileError(format!("failed to read resource file: {source}"))
233        })?;
234        if let Some(heading) = first_markdown_heading(&text) {
235            return Ok(heading);
236        }
237    }
238    Ok(format!("Resource `{name}`"))
239}
240
241fn first_markdown_heading(text: &str) -> Option<String> {
242    text.lines().find_map(|line| {
243        let trimmed = line.trim();
244        let heading = trimmed.strip_prefix("# ")?.trim();
245        (!heading.is_empty()).then(|| heading.to_owned())
246    })
247}
248
249/// Small static extension table covering the layout contract's own
250/// examples; unknown extensions fall back to `application/octet-stream`.
251fn mime_type_for_extension(path: &Path) -> String {
252    let extension = path
253        .extension()
254        .and_then(|extension| extension.to_str())
255        .unwrap_or_default()
256        .to_ascii_lowercase();
257    match extension.as_str() {
258        "md" | "markdown" => "text/markdown",
259        "txt" => "text/plain",
260        "json" => "application/json",
261        "yaml" | "yml" => "application/yaml",
262        "toml" => "application/toml",
263        "html" | "htm" => "text/html",
264        "css" => "text/css",
265        "js" | "mjs" => "text/javascript",
266        "csv" => "text/csv",
267        "xml" => "application/xml",
268        "svg" => "image/svg+xml",
269        "png" => "image/png",
270        "jpg" | "jpeg" => "image/jpeg",
271        "gif" => "image/gif",
272        "webp" => "image/webp",
273        "pdf" => "application/pdf",
274        _ => "application/octet-stream",
275    }
276    .to_owned()
277}
278
279fn is_text_mime(mime_type: &str) -> bool {
280    mime_type.starts_with("text/")
281        || matches!(
282            mime_type,
283            "application/json" | "application/yaml" | "application/toml" | "application/xml"
284        )
285}
286
287#[async_trait]
288impl Provider for ResourceFileProvider {
289    fn catalog(&self) -> ProviderCatalog {
290        let (title, description, resources) = match &self.kind {
291            ResourceFileKind::Static { resource, path, .. } => (
292                resource.description.clone(),
293                format!("Static resource file loaded from {}", path.display()),
294                vec![resource.as_ref().clone()],
295            ),
296            ResourceFileKind::Dynamic { template, path } => (
297                template.description.clone(),
298                format!("Dynamic resource reader loaded from {}", path.display()),
299                Vec::new(),
300            ),
301        };
302        ProviderManifest {
303            schema_version: 1,
304            provider: ProviderIdentity {
305                name: self.provider_name.clone(),
306                kind: ProviderKind::StaticRust,
307                title: Some(title),
308                description: Some(description),
309                homepage: None,
310                source: Some(self.source_path().display().to_string()),
311                version: None,
312                enabled: None,
313            },
314            tools: Vec::new(),
315            prompts: Vec::new(),
316            resources,
317            tasks: Vec::new(),
318            elicitation: Vec::new(),
319            env: Vec::new(),
320            capabilities: Default::default(),
321            docs: None,
322            plugin: None,
323            ui: None,
324            meta: serde_json::json!({}),
325        }
326    }
327
328    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
329        Err(ProviderError::validation(
330            &self.provider_name,
331            &call.action,
332            "resource_provider_has_no_actions",
333            "resource file providers do not expose any callable actions",
334        ))
335    }
336
337    fn dynamic_resource_templates(&self) -> Vec<DynamicResourceTemplate> {
338        match &self.kind {
339            ResourceFileKind::Dynamic { template, .. } => vec![template.clone()],
340            ResourceFileKind::Static { .. } => Vec::new(),
341        }
342    }
343
344    fn supports_resource_reads(&self) -> bool {
345        true
346    }
347
348    async fn read_resource(
349        &self,
350        uri: &str,
351        params: &BTreeMap<String, String>,
352    ) -> Result<ResourceReadOutput, ProviderError> {
353        match &self.kind {
354            ResourceFileKind::Static {
355                path, mime_type, ..
356            } => read_static_resource(
357                &self.provider_name,
358                uri,
359                path,
360                &self.canonical_root,
361                mime_type,
362            ),
363            ResourceFileKind::Dynamic { path, .. } => {
364                read_dynamic_resource(&self.provider_name, uri, path, &self.canonical_root, params)
365                    .await
366            }
367        }
368    }
369}
370
371impl ResourceFileProvider {
372    fn source_path(&self) -> &Path {
373        match &self.kind {
374            ResourceFileKind::Static { path, .. } | ResourceFileKind::Dynamic { path, .. } => path,
375        }
376    }
377}
378
379/// Re-canonicalizes `path` and verifies it still resolves inside
380/// `canonical_root`, closing the TOCTOU window between the directory walk
381/// that validated a symlink's target at discovery time and this read: the
382/// walk only checked the target once, but a symlink an attacker with local
383/// write access controls could be swapped to point elsewhere afterward.
384fn verify_within_root(
385    provider_name: &str,
386    uri: &str,
387    path: &Path,
388    canonical_root: &Path,
389) -> Result<PathBuf, ProviderError> {
390    let canonical = path.canonicalize().map_err(|source| {
391        ProviderError::execution(
392            provider_name,
393            uri,
394            format!("resource file unreadable: {source}"),
395        )
396    })?;
397    if !canonical.starts_with(canonical_root) {
398        return Err(ProviderError::validation(
399            provider_name,
400            uri,
401            "resource_escapes_root",
402            "resource path no longer resolves within the provider root",
403        ));
404    }
405    Ok(canonical)
406}
407
408fn read_static_resource(
409    provider_name: &str,
410    uri: &str,
411    path: &Path,
412    canonical_root: &Path,
413    mime_type: &str,
414) -> Result<ResourceReadOutput, ProviderError> {
415    let path = verify_within_root(provider_name, uri, path, canonical_root)?;
416    let path = path.as_path();
417    let metadata = std::fs::metadata(path).map_err(|source| {
418        ProviderError::execution(
419            provider_name,
420            uri,
421            format!("resource file unreadable: {source}"),
422        )
423    })?;
424    if metadata.len() > MAX_STATIC_RESOURCE_BYTES {
425        return Err(ProviderError::validation(
426            provider_name,
427            uri,
428            "resource_too_large",
429            format!("resource file exceeds the {MAX_STATIC_RESOURCE_BYTES}-byte limit"),
430        ));
431    }
432    let bytes = std::fs::read(path).map_err(|source| {
433        ProviderError::execution(
434            provider_name,
435            uri,
436            format!("failed to read resource file: {source}"),
437        )
438    })?;
439    if is_text_mime(mime_type) {
440        let text = String::from_utf8(bytes).map_err(|error| {
441            ProviderError::execution(
442                provider_name,
443                uri,
444                format!("resource file is not valid UTF-8 text: {error}"),
445            )
446        })?;
447        return Ok(ResourceReadOutput::Text {
448            text,
449            mime_type: Some(mime_type.to_owned()),
450        });
451    }
452    Ok(ResourceReadOutput::Blob {
453        blob_base64: BASE64.encode(bytes),
454        mime_type: Some(mime_type.to_owned()),
455    })
456}
457
458async fn read_dynamic_resource(
459    provider_name: &str,
460    uri: &str,
461    path: &Path,
462    canonical_root: &Path,
463    params: &BTreeMap<String, String>,
464) -> Result<ResourceReadOutput, ProviderError> {
465    let canonical_path = verify_within_root(provider_name, uri, path, canonical_root)?;
466    let query = resource_uri::query_params(uri);
467    let input = serde_json::json!({
468        "uri": uri,
469        "params": params,
470        "query": query,
471    });
472    let input_bytes = serde_json::to_vec(&input).map_err(|error| {
473        ProviderError::execution(
474            provider_name,
475            uri,
476            format!("failed to serialize reader input: {error}"),
477        )
478    })?;
479
480    let wrapper = dynamic_reader_wrapper(&canonical_path);
481
482    let sidecar = match run_bounded_sidecar(
483        "node",
484        &["--input-type=module", "--eval", &wrapper],
485        Vec::new(),
486        &input_bytes,
487        DYNAMIC_RESOURCE_TIMEOUT_MS,
488        DYNAMIC_RESOURCE_MAX_OUTPUT_BYTES,
489    )
490    .await
491    {
492        Ok(sidecar) => sidecar,
493        Err(SidecarError::Timeout) => {
494            return Err(ProviderError::new(
495                "resource_reader_timeout",
496                provider_name,
497                Some(uri.to_owned()),
498                format!("dynamic resource reader exceeded {DYNAMIC_RESOURCE_TIMEOUT_MS}ms timeout"),
499                "Fix the reader's performance or reduce the work it does per call.",
500            ));
501        }
502        Err(error) => {
503            return Err(ProviderError::execution(provider_name, uri, error));
504        }
505    };
506
507    if sidecar.stdout_exceeded || sidecar.stderr_exceeded {
508        return Err(ProviderError::validation(
509            provider_name,
510            uri,
511            "resource_reader_output_too_large",
512            format!(
513                "dynamic resource reader output exceeds {DYNAMIC_RESOURCE_MAX_OUTPUT_BYTES} bytes"
514            ),
515        ));
516    }
517    let output = sidecar.output;
518    if !output.status.success() {
519        let stderr = String::from_utf8_lossy(&output.stderr);
520        return Err(ProviderError::new(
521            "resource_reader_failed",
522            provider_name,
523            Some(uri.to_owned()),
524            format!("dynamic resource reader failed: {stderr}"),
525            "Fix the TypeScript resource reader and retry.",
526        ));
527    }
528
529    let value: Value = serde_json::from_slice(&output.stdout).map_err(|error| {
530        ProviderError::validation(
531            provider_name,
532            uri,
533            "resource_reader_invalid_json_output",
534            error.to_string(),
535        )
536    })?;
537    parse_reader_output(provider_name, uri, &value)
538}
539
540fn parse_reader_output(
541    provider_name: &str,
542    uri: &str,
543    value: &Value,
544) -> Result<ResourceReadOutput, ProviderError> {
545    let invalid = |message: &str| {
546        ProviderError::validation(
547            provider_name,
548            uri,
549            "resource_reader_invalid_shape",
550            message.to_owned(),
551        )
552    };
553    let object = value
554        .as_object()
555        .ok_or_else(|| invalid("reader must return an object"))?;
556
557    if let Some(text) = object.get("text") {
558        let text = text
559            .as_str()
560            .ok_or_else(|| invalid("`text` must be a string"))?
561            .to_owned();
562        let mime_type = object
563            .get("mimeType")
564            .and_then(Value::as_str)
565            .map(ToOwned::to_owned);
566        return Ok(ResourceReadOutput::Text { text, mime_type });
567    }
568    if let Some(json_value) = object.get("json") {
569        let text = serde_json::to_string(json_value)
570            .map_err(|error| invalid(&format!("`json` result could not be serialized: {error}")))?;
571        return Ok(ResourceReadOutput::Text {
572            text,
573            mime_type: Some("application/json".to_owned()),
574        });
575    }
576    if let Some(blob) = object.get("blob") {
577        let blob_base64 = blob
578            .as_str()
579            .ok_or_else(|| invalid("`blob` must be a base64 string"))?
580            .to_owned();
581        let mime_type = object
582            .get("mimeType")
583            .and_then(Value::as_str)
584            .ok_or_else(|| invalid("`blob` results require a `mimeType`"))?
585            .to_owned();
586        return Ok(ResourceReadOutput::Blob {
587            blob_base64,
588            mime_type: Some(mime_type),
589        });
590    }
591    Err(invalid(
592        "reader must return one of `{ text }`, `{ json }`, or `{ blob, mimeType }`",
593    ))
594}
595
596/// Builds the sidecar's JS source for a reader whose module path has
597/// already been canonicalized and verified within the resources root by the
598/// caller (`read_dynamic_resource`) — this function only formats it.
599/// `module_path` is embedded via `serde_json::to_string` rather than Rust's
600/// `{:?}` Debug formatting: both happen to agree on how to escape a plain
601/// path today, but only the former is a documented guarantee of producing a
602/// valid JS/JSON string literal.
603fn dynamic_reader_wrapper(canonical_path: &Path) -> String {
604    let module_path = serde_json::to_string(&canonical_path.display().to_string())
605        .expect("String serialization to JSON cannot fail");
606    format!(
607        r#"
608import {{ readFileSync }} from "node:fs";
609const chunks = [];
610for await (const chunk of process.stdin) chunks.push(chunk);
611const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{{}}");
612for (const key of Object.keys(process.env)) {{
613  delete process.env[key];
614}}
615const readerSource = readFileSync({module_path}, "utf8");
616const module = await import("data:text/javascript;base64," + Buffer.from(readerSource).toString("base64"));
617const handler = module.read;
618if (typeof handler !== "function") {{
619  throw new Error("Dynamic resource reader must export async function read(input)");
620}}
621const result = await handler(input);
622process.stdout.write(JSON.stringify(result ?? null));
623"#
624    )
625}
626
627#[cfg(test)]
628#[path = "resource_files_tests.rs"]
629mod tests;