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