Skip to main content

semifold_resolver/plugin/
registry.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs::File;
3use std::io::Read;
4use std::sync::Arc;
5
6use camino::{Utf8Path, Utf8PathBuf};
7use semifold_core::EcosystemId;
8use sha2::{Digest, Sha256};
9
10use super::file::{PluginFileError, ScopedPluginFileClient};
11use super::http::{
12    DenyPluginHttpClient, PluginHttpOrigin, PluginHttpTransport, ReqwestPluginHttpTransport,
13};
14use super::protocol::{PluginMetadataV1, PluginRequestV1, PluginResponseV1};
15use super::runtime::{BoaPluginRuntime, MAX_SOURCE_BYTES, PluginRuntimeError};
16
17/// Immutable configuration required to locate and authenticate one repository-local plugin.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct PluginDefinition {
20    ecosystem: EcosystemId,
21    path: Utf8PathBuf,
22    sha256: Option<String>,
23    allowed_origins: BTreeSet<PluginHttpOrigin>,
24}
25
26impl PluginDefinition {
27    pub fn new(
28        ecosystem: EcosystemId,
29        path: impl Into<Utf8PathBuf>,
30    ) -> Result<Self, PluginRegistryError> {
31        if ecosystem.is_builtin() {
32            return Err(PluginRegistryError::BuiltInEcosystemReserved { ecosystem });
33        }
34        let path = path.into();
35        validate_plugin_path(&path)?;
36        Ok(Self {
37            ecosystem,
38            path,
39            sha256: None,
40            allowed_origins: BTreeSet::new(),
41        })
42    }
43
44    pub fn with_sha256(mut self, sha256: impl Into<String>) -> Result<Self, PluginRegistryError> {
45        let sha256 = sha256.into();
46        if sha256.len() != 64
47            || !sha256
48                .bytes()
49                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
50        {
51            return Err(PluginRegistryError::InvalidDigest { digest: sha256 });
52        }
53        self.sha256 = Some(sha256);
54        Ok(self)
55    }
56
57    #[must_use]
58    pub fn with_allowed_origins(
59        mut self,
60        allowed_origins: impl IntoIterator<Item = PluginHttpOrigin>,
61    ) -> Self {
62        self.allowed_origins = allowed_origins.into_iter().collect();
63        self
64    }
65
66    #[must_use]
67    pub const fn ecosystem(&self) -> &EcosystemId {
68        &self.ecosystem
69    }
70
71    #[must_use]
72    pub fn path(&self) -> &Utf8Path {
73        &self.path
74    }
75
76    #[must_use]
77    pub fn sha256(&self) -> Option<&str> {
78        self.sha256.as_deref()
79    }
80
81    #[must_use]
82    pub const fn allowed_origins(&self) -> &BTreeSet<PluginHttpOrigin> {
83        &self.allowed_origins
84    }
85}
86
87/// A digest-verified plugin bound to its project-scoped host capabilities.
88#[derive(Clone)]
89pub struct LoadedPlugin {
90    project_root: Utf8PathBuf,
91    definition: PluginDefinition,
92    content_sha256: String,
93    metadata: PluginMetadataV1,
94    source: Arc<str>,
95    runtime: BoaPluginRuntime,
96}
97
98impl std::fmt::Debug for LoadedPlugin {
99    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        formatter
101            .debug_struct("LoadedPlugin")
102            .field("definition", &self.definition)
103            .field("content_sha256", &self.content_sha256)
104            .field("metadata", &self.metadata)
105            .finish_non_exhaustive()
106    }
107}
108
109impl LoadedPlugin {
110    #[must_use]
111    pub fn project_root(&self) -> &Utf8Path {
112        &self.project_root
113    }
114
115    #[must_use]
116    pub const fn definition(&self) -> &PluginDefinition {
117        &self.definition
118    }
119
120    #[must_use]
121    pub const fn metadata(&self) -> &PluginMetadataV1 {
122        &self.metadata
123    }
124
125    #[must_use]
126    pub fn content_sha256(&self) -> &str {
127        &self.content_sha256
128    }
129
130    pub fn execute(
131        &self,
132        request: &PluginRequestV1,
133    ) -> Result<PluginResponseV1, PluginRuntimeError> {
134        self.runtime
135            .execute(&self.source, request, &self.metadata.ecosystem)
136    }
137}
138
139/// Stable registry of authenticated plugins keyed by their declared ecosystem identity.
140#[derive(Clone, Debug)]
141pub struct PluginRegistry {
142    project_root: Utf8PathBuf,
143    plugins: BTreeMap<EcosystemId, LoadedPlugin>,
144}
145
146impl PluginRegistry {
147    pub fn load(
148        project_root: impl Into<Utf8PathBuf>,
149        definitions: impl IntoIterator<Item = PluginDefinition>,
150        runtime: BoaPluginRuntime,
151    ) -> Result<Self, PluginRegistryError> {
152        Self::load_inner(project_root.into(), definitions, runtime, None)
153    }
154
155    pub fn load_with_http_transport(
156        project_root: impl Into<Utf8PathBuf>,
157        definitions: impl IntoIterator<Item = PluginDefinition>,
158        runtime: BoaPluginRuntime,
159        transport: impl PluginHttpTransport,
160    ) -> Result<Self, PluginRegistryError> {
161        Self::load_inner(
162            project_root.into(),
163            definitions,
164            runtime,
165            Some(Arc::new(transport)),
166        )
167    }
168
169    pub fn load_with_reqwest(
170        project_root: impl Into<Utf8PathBuf>,
171        definitions: impl IntoIterator<Item = PluginDefinition>,
172        runtime: BoaPluginRuntime,
173    ) -> Result<Self, PluginRegistryError> {
174        let transport = ReqwestPluginHttpTransport::new()
175            .map_err(|source| PluginRegistryError::HttpTransportInitialization { source })?;
176        Self::load_with_http_transport(project_root, definitions, runtime, transport)
177    }
178
179    fn load_inner(
180        project_root: Utf8PathBuf,
181        definitions: impl IntoIterator<Item = PluginDefinition>,
182        runtime: BoaPluginRuntime,
183        http_transport: Option<Arc<dyn PluginHttpTransport>>,
184    ) -> Result<Self, PluginRegistryError> {
185        let project_root = canonical_project_root(project_root)?;
186        let mut definitions = definitions.into_iter().collect::<Vec<_>>();
187        definitions.sort_by(|left, right| {
188            left.ecosystem
189                .cmp(&right.ecosystem)
190                .then_with(|| left.path.cmp(&right.path))
191                .then_with(|| left.sha256.cmp(&right.sha256))
192        });
193        if let Some(pair) = definitions
194            .windows(2)
195            .find(|pair| pair[0].ecosystem == pair[1].ecosystem)
196        {
197            return Err(PluginRegistryError::DuplicateEcosystem {
198                ecosystem: pair[0].ecosystem.clone(),
199            });
200        }
201
202        let mut plugins = BTreeMap::new();
203        for definition in definitions {
204            let (source, content_sha256) = load_authenticated_source(&project_root, &definition)?;
205            let metadata =
206                runtime
207                    .metadata(&source)
208                    .map_err(|source| PluginRegistryError::Runtime {
209                        ecosystem: definition.ecosystem.clone(),
210                        source,
211                    })?;
212            if metadata.ecosystem != definition.ecosystem {
213                return Err(PluginRegistryError::MetadataEcosystemMismatch {
214                    configured: definition.ecosystem,
215                    declared: metadata.ecosystem,
216                });
217            }
218            let file_client = ScopedPluginFileClient::new(
219                project_root.clone(),
220                metadata.read_patterns.iter().cloned(),
221            )
222            .map_err(|source| PluginRegistryError::FileCapability {
223                ecosystem: metadata.ecosystem.clone(),
224                source,
225            })?;
226            let ecosystem = metadata.ecosystem.clone();
227            let runtime = if let Some(transport) = &http_transport {
228                runtime.clone().with_shared_http_transport(
229                    definition.allowed_origins.clone(),
230                    transport.clone(),
231                )
232            } else {
233                runtime
234                    .clone()
235                    .with_shared_http_client(Arc::new(DenyPluginHttpClient))
236            };
237            let plugin = LoadedPlugin {
238                project_root: project_root.clone(),
239                definition,
240                content_sha256,
241                metadata,
242                source: Arc::from(source),
243                runtime: runtime.with_file_client(file_client),
244            };
245            plugins.insert(ecosystem, plugin);
246        }
247
248        Ok(Self {
249            project_root,
250            plugins,
251        })
252    }
253
254    #[must_use]
255    pub fn project_root(&self) -> &Utf8Path {
256        &self.project_root
257    }
258
259    #[must_use]
260    pub fn get(&self, ecosystem: &EcosystemId) -> Option<&LoadedPlugin> {
261        self.plugins.get(ecosystem)
262    }
263
264    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&EcosystemId, &LoadedPlugin)> {
265        self.plugins.iter()
266    }
267
268    #[must_use]
269    pub fn len(&self) -> usize {
270        self.plugins.len()
271    }
272
273    #[must_use]
274    pub fn is_empty(&self) -> bool {
275        self.plugins.is_empty()
276    }
277}
278
279fn canonical_project_root(root: Utf8PathBuf) -> Result<Utf8PathBuf, PluginRegistryError> {
280    let canonical =
281        std::fs::canonicalize(&root).map_err(|source| PluginRegistryError::ResolveProjectRoot {
282            root: root.clone(),
283            source,
284        })?;
285    let canonical =
286        Utf8PathBuf::from_path_buf(canonical).map_err(|path| PluginRegistryError::NonUtf8Path {
287            path: path.display().to_string(),
288        })?;
289    if !canonical.is_dir() {
290        return Err(PluginRegistryError::ProjectRootNotDirectory { root: canonical });
291    }
292    Ok(canonical)
293}
294
295fn load_authenticated_source(
296    project_root: &Utf8Path,
297    definition: &PluginDefinition,
298) -> Result<(String, String), PluginRegistryError> {
299    let configured_path = project_root.join(&definition.path);
300    let canonical = std::fs::canonicalize(&configured_path).map_err(|source| {
301        PluginRegistryError::ResolvePluginPath {
302            ecosystem: definition.ecosystem.clone(),
303            path: definition.path.clone(),
304            source,
305        }
306    })?;
307    let canonical =
308        Utf8PathBuf::from_path_buf(canonical).map_err(|path| PluginRegistryError::NonUtf8Path {
309            path: path.display().to_string(),
310        })?;
311    if !canonical.starts_with(project_root) {
312        return Err(PluginRegistryError::PluginOutsideProjectRoot {
313            ecosystem: definition.ecosystem.clone(),
314            path: definition.path.clone(),
315        });
316    }
317    let metadata =
318        canonical
319            .metadata()
320            .map_err(|source| PluginRegistryError::InspectPluginPath {
321                ecosystem: definition.ecosystem.clone(),
322                path: definition.path.clone(),
323                source,
324            })?;
325    if !metadata.is_file() {
326        return Err(PluginRegistryError::PluginNotFile {
327            ecosystem: definition.ecosystem.clone(),
328            path: definition.path.clone(),
329        });
330    }
331    let metadata_size = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
332    if metadata_size > MAX_SOURCE_BYTES {
333        return Err(PluginRegistryError::SourceTooLarge {
334            ecosystem: definition.ecosystem.clone(),
335            actual: metadata_size,
336            maximum: MAX_SOURCE_BYTES,
337        });
338    }
339
340    let maximum =
341        u64::try_from(MAX_SOURCE_BYTES).map_err(|source| PluginRegistryError::InternalLimit {
342            reason: source.to_string(),
343        })?;
344    let file = File::open(&canonical).map_err(|source| PluginRegistryError::ReadPlugin {
345        ecosystem: definition.ecosystem.clone(),
346        path: definition.path.clone(),
347        source,
348    })?;
349    let mut bytes = Vec::with_capacity(metadata_size);
350    file.take(maximum.saturating_add(1))
351        .read_to_end(&mut bytes)
352        .map_err(|source| PluginRegistryError::ReadPlugin {
353            ecosystem: definition.ecosystem.clone(),
354            path: definition.path.clone(),
355            source,
356        })?;
357    if bytes.len() > MAX_SOURCE_BYTES {
358        return Err(PluginRegistryError::SourceTooLarge {
359            ecosystem: definition.ecosystem.clone(),
360            actual: bytes.len(),
361            maximum: MAX_SOURCE_BYTES,
362        });
363    }
364
365    let actual = sha256_hex(&bytes);
366    if let Some(expected) = &definition.sha256
367        && &actual != expected
368    {
369        return Err(PluginRegistryError::DigestMismatch {
370            ecosystem: definition.ecosystem.clone(),
371            expected: expected.clone(),
372            actual,
373        });
374    }
375    let source = String::from_utf8(bytes).map_err(|source| PluginRegistryError::InvalidUtf8 {
376        ecosystem: definition.ecosystem.clone(),
377        path: definition.path.clone(),
378        source,
379    })?;
380    Ok((source, actual))
381}
382
383fn validate_plugin_path(path: &Utf8Path) -> Result<(), PluginRegistryError> {
384    let value = path.as_str();
385    let invalid = value.is_empty()
386        || path.is_absolute()
387        || value.contains('\\')
388        || value.ends_with('/')
389        || value.split('/').any(|segment| {
390            segment.is_empty()
391                || segment == "."
392                || segment == ".."
393                || is_windows_drive_segment(segment)
394        });
395    if invalid || path.extension() != Some("js") {
396        return Err(PluginRegistryError::InvalidPluginPath {
397            path: path.to_owned(),
398        });
399    }
400    Ok(())
401}
402
403fn is_windows_drive_segment(segment: &str) -> bool {
404    let bytes = segment.as_bytes();
405    bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
406}
407
408fn sha256_hex(bytes: &[u8]) -> String {
409    Sha256::digest(bytes)
410        .iter()
411        .map(|byte| format!("{byte:02x}"))
412        .collect()
413}
414
415#[derive(Debug, thiserror::Error)]
416pub enum PluginRegistryError {
417    #[error("plugin ecosystem id {ecosystem} is reserved for a built-in ecosystem")]
418    BuiltInEcosystemReserved { ecosystem: EcosystemId },
419    #[error("plugin path must be a normalized project-relative `.js` path: `{path}`")]
420    InvalidPluginPath { path: Utf8PathBuf },
421    #[error("plugin SHA-256 digest must contain exactly 64 hexadecimal characters: `{digest}`")]
422    InvalidDigest { digest: String },
423    #[error("plugin ecosystem id is registered more than once: {ecosystem}")]
424    DuplicateEcosystem { ecosystem: EcosystemId },
425    #[error("failed to resolve plugin project root `{root}`: {source}")]
426    ResolveProjectRoot {
427        root: Utf8PathBuf,
428        #[source]
429        source: std::io::Error,
430    },
431    #[error("plugin project root is not a directory: `{root}`")]
432    ProjectRootNotDirectory { root: Utf8PathBuf },
433    #[error("plugin path is not UTF-8: `{path}`")]
434    NonUtf8Path { path: String },
435    #[error("failed to resolve plugin path `{path}` for {ecosystem}: {source}")]
436    ResolvePluginPath {
437        ecosystem: EcosystemId,
438        path: Utf8PathBuf,
439        #[source]
440        source: std::io::Error,
441    },
442    #[error("plugin path `{path}` for {ecosystem} resolves outside the project root")]
443    PluginOutsideProjectRoot {
444        ecosystem: EcosystemId,
445        path: Utf8PathBuf,
446    },
447    #[error("failed to inspect plugin path `{path}` for {ecosystem}: {source}")]
448    InspectPluginPath {
449        ecosystem: EcosystemId,
450        path: Utf8PathBuf,
451        #[source]
452        source: std::io::Error,
453    },
454    #[error("plugin path `{path}` for {ecosystem} is not a regular file")]
455    PluginNotFile {
456        ecosystem: EcosystemId,
457        path: Utf8PathBuf,
458    },
459    #[error("plugin source for {ecosystem} contains {actual} bytes; maximum is {maximum}")]
460    SourceTooLarge {
461        ecosystem: EcosystemId,
462        actual: usize,
463        maximum: usize,
464    },
465    #[error("failed to read plugin `{path}` for {ecosystem}: {source}")]
466    ReadPlugin {
467        ecosystem: EcosystemId,
468        path: Utf8PathBuf,
469        #[source]
470        source: std::io::Error,
471    },
472    #[error("plugin digest mismatch for {ecosystem}: expected {expected}, got {actual}")]
473    DigestMismatch {
474        ecosystem: EcosystemId,
475        expected: String,
476        actual: String,
477    },
478    #[error("plugin `{path}` for {ecosystem} is not valid UTF-8: {source}")]
479    InvalidUtf8 {
480        ecosystem: EcosystemId,
481        path: Utf8PathBuf,
482        #[source]
483        source: std::string::FromUtf8Error,
484    },
485    #[error(
486        "plugin metadata declares ecosystem {declared}, but configuration registers {configured}"
487    )]
488    MetadataEcosystemMismatch {
489        configured: EcosystemId,
490        declared: EcosystemId,
491    },
492    #[error("failed to load plugin runtime for {ecosystem}: {source}")]
493    Runtime {
494        ecosystem: EcosystemId,
495        #[source]
496        source: PluginRuntimeError,
497    },
498    #[error("failed to configure file capabilities for {ecosystem}: {source}")]
499    FileCapability {
500        ecosystem: EcosystemId,
501        #[source]
502        source: PluginFileError,
503    },
504    #[error("failed to initialize plugin HTTP transport: {source}")]
505    HttpTransportInitialization {
506        #[source]
507        source: super::http::PluginHttpError,
508    },
509    #[error("invalid internal plugin source limit: {reason}")]
510    InternalLimit { reason: String },
511}
512
513#[cfg(test)]
514mod tests {
515    use std::fs;
516    use std::sync::Mutex;
517    use std::time::{SystemTime, UNIX_EPOCH};
518
519    use super::*;
520    use crate::plugin::http::{
521        PluginHttpFuture, PluginHttpRequest, PluginHttpResponse, PluginHttpTransport,
522    };
523    use crate::plugin::protocol::{PluginCallV1, PluginDiscoverInputV1};
524
525    fn fixture_root(test: &str) -> Utf8PathBuf {
526        let nonce = SystemTime::now()
527            .duration_since(UNIX_EPOCH)
528            .unwrap()
529            .as_nanos();
530        let root = std::env::temp_dir().join(format!(
531            "semifold-plugin-registry-{}-{test}-{nonce}",
532            std::process::id()
533        ));
534        fs::create_dir_all(&root).unwrap();
535        Utf8PathBuf::from_path_buf(root).unwrap()
536    }
537
538    fn plugin_source(ecosystem: &str, pattern: &str) -> String {
539        format!(
540            r#"
541            export const metadata = {{
542                "schema-version": 1,
543                ecosystem: "{ecosystem}",
544                "plugin-version": "1.0.0",
545                operations: ["discover", "inspect", "plan-edits"],
546                "read-patterns": ["{pattern}"]
547            }};
548            export default async function(request, host) {{
549                const files = await host.listFiles("{pattern}");
550                await Promise.all(files.map(path => host.readText(path)));
551                return {{
552                    "schema-version": request["schema-version"],
553                    diagnostics: [],
554                    status: "success",
555                    output: {{
556                        operation: request.operation,
557                        output: {{ packages: [] }}
558                    }}
559                }};
560            }};
561            "#
562        )
563    }
564
565    fn write_plugin(root: &Utf8Path, path: &str, source: &str) -> PluginDefinition {
566        let path = Utf8PathBuf::from(path);
567        if let Some(parent) = path.parent() {
568            fs::create_dir_all(root.join(parent)).unwrap();
569        }
570        fs::write(root.join(&path), source).unwrap();
571        let ecosystem = source
572            .split("ecosystem: \"")
573            .nth(1)
574            .and_then(|rest| rest.split('"').next())
575            .unwrap();
576        PluginDefinition::new(EcosystemId::new(ecosystem).unwrap(), path)
577            .unwrap()
578            .with_sha256(sha256_hex(source.as_bytes()))
579            .unwrap()
580    }
581
582    fn discover_request() -> PluginRequestV1 {
583        PluginRequestV1::new(PluginCallV1::Discover(PluginDiscoverInputV1 {
584            project_root: ".".to_owned(),
585        }))
586    }
587
588    #[derive(Clone, Debug)]
589    struct RecordingTransport {
590        requests: Arc<Mutex<Vec<PluginHttpRequest>>>,
591    }
592
593    impl PluginHttpTransport for RecordingTransport {
594        fn send_once(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
595            Box::pin(async move {
596                self.requests.lock().unwrap().push(request.clone());
597                Ok(PluginHttpResponse {
598                    url: request.url,
599                    status: 200,
600                    headers: vec![("content-type".to_owned(), b"text/plain".to_vec())],
601                    body: b"ok".to_vec(),
602                })
603            })
604        }
605    }
606
607    #[test]
608    fn loads_in_stable_identity_order_and_binds_project_file_capabilities() {
609        let root = fixture_root("stable-order");
610        fs::create_dir_all(root.join("data")).unwrap();
611        fs::write(root.join("data/package.json"), "{}").unwrap();
612        let alpha_source = plugin_source("com.example.alpha", "data/*.json");
613        let zeta_source = plugin_source("com.example.zeta", "data/*.json");
614        let alpha = write_plugin(&root, "plugins/alpha.js", &alpha_source);
615        let zeta = write_plugin(&root, "plugins/zeta.js", &zeta_source);
616
617        let registry =
618            PluginRegistry::load(root.clone(), [zeta, alpha], BoaPluginRuntime::default()).unwrap();
619        let identities = registry
620            .iter()
621            .map(|(ecosystem, _)| ecosystem.as_str())
622            .collect::<Vec<_>>();
623        assert_eq!(identities, vec!["com.example.alpha", "com.example.zeta"]);
624        let alpha = EcosystemId::new("com.example.alpha").unwrap();
625        registry
626            .get(&alpha)
627            .unwrap()
628            .execute(&discover_request())
629            .unwrap();
630        fs::remove_dir_all(root).unwrap();
631    }
632
633    #[test]
634    fn loads_without_a_configured_digest_and_records_the_actual_content_hash() {
635        let root = fixture_root("optional-digest");
636        let source = plugin_source("com.example.unpinned", "data/*.json");
637        fs::write(root.join("plugin.js"), &source).unwrap();
638        let definition = PluginDefinition::new(
639            EcosystemId::new("com.example.unpinned").unwrap(),
640            "plugin.js",
641        )
642        .unwrap();
643        assert_eq!(definition.sha256(), None);
644
645        let registry =
646            PluginRegistry::load(root.clone(), [definition], BoaPluginRuntime::default()).unwrap();
647        let plugin = registry
648            .get(&EcosystemId::new("com.example.unpinned").unwrap())
649            .unwrap();
650        assert_eq!(plugin.content_sha256(), sha256_hex(source.as_bytes()));
651        fs::remove_dir_all(root).unwrap();
652    }
653
654    #[test]
655    fn binds_definition_origins_to_an_injected_transport_and_defaults_to_deny() {
656        let root = fixture_root("network-origins");
657        let source = r#"
658            export const metadata = {
659                "schema-version": 1,
660                ecosystem: "com.example.network",
661                "plugin-version": "1.0.0",
662                operations: ["discover", "inspect", "plan-edits"]
663            };
664            export default async function(request) {
665                const response = await fetch("https://api.example.test/data");
666                if (await response.text() !== "ok") {
667                    throw new Error("unexpected transport response");
668                }
669                return {
670                    "schema-version": request["schema-version"],
671                    diagnostics: [],
672                    status: "success",
673                    output: {
674                        operation: request.operation,
675                        output: { packages: [] }
676                    }
677                };
678            };
679        "#;
680        let definition = write_plugin(&root, "network.js", source)
681            .with_allowed_origins([PluginHttpOrigin::parse("https://api.example.test").unwrap()]);
682
683        let denied = PluginRegistry::load(
684            root.clone(),
685            [definition.clone()],
686            BoaPluginRuntime::default(),
687        )
688        .unwrap();
689        assert!(matches!(
690            denied
691                .get(definition.ecosystem())
692                .unwrap()
693                .execute(&discover_request()),
694            Err(PluginRuntimeError::EntrypointInvocation(message))
695                if message.contains("network access is not configured")
696        ));
697
698        let requests = Arc::new(Mutex::new(Vec::new()));
699        let registry = PluginRegistry::load_with_http_transport(
700            root.clone(),
701            [definition.clone()],
702            BoaPluginRuntime::default(),
703            RecordingTransport {
704                requests: requests.clone(),
705            },
706        )
707        .unwrap();
708        registry
709            .get(definition.ecosystem())
710            .unwrap()
711            .execute(&discover_request())
712            .unwrap();
713        assert_eq!(requests.lock().unwrap().len(), 1);
714        fs::remove_dir_all(root).unwrap();
715    }
716
717    #[test]
718    fn verifies_digest_before_parsing_or_executing_the_module() {
719        let root = fixture_root("digest-first");
720        fs::write(root.join("invalid.js"), "this is not JavaScript").unwrap();
721        let definition = PluginDefinition::new(
722            EcosystemId::new("com.example.invalid").unwrap(),
723            "invalid.js",
724        )
725        .unwrap()
726        .with_sha256("0".repeat(64))
727        .unwrap();
728
729        assert!(matches!(
730            PluginRegistry::load(root.clone(), [definition], BoaPluginRuntime::default()),
731            Err(PluginRegistryError::DigestMismatch { .. })
732        ));
733        fs::remove_dir_all(root).unwrap();
734    }
735
736    #[test]
737    fn applies_the_source_size_and_utf8_limits_before_metadata_loading() {
738        let root = fixture_root("source-limits");
739        let large = vec![b'x'; MAX_SOURCE_BYTES + 1];
740        fs::write(root.join("large.js"), &large).unwrap();
741        let large_definition =
742            PluginDefinition::new(EcosystemId::new("com.example.large").unwrap(), "large.js")
743                .unwrap()
744                .with_sha256(sha256_hex(&large))
745                .unwrap();
746        assert!(matches!(
747            PluginRegistry::load(
748                root.clone(),
749                [large_definition],
750                BoaPluginRuntime::default()
751            ),
752            Err(PluginRegistryError::SourceTooLarge { .. })
753        ));
754
755        let invalid_utf8 = [0xff, 0xfe];
756        fs::write(root.join("invalid-utf8.js"), invalid_utf8).unwrap();
757        let utf8_definition = PluginDefinition::new(
758            EcosystemId::new("com.example.invalid-utf8").unwrap(),
759            "invalid-utf8.js",
760        )
761        .unwrap()
762        .with_sha256(sha256_hex(&invalid_utf8))
763        .unwrap();
764        assert!(matches!(
765            PluginRegistry::load(root.clone(), [utf8_definition], BoaPluginRuntime::default()),
766            Err(PluginRegistryError::InvalidUtf8 { .. })
767        ));
768        fs::remove_dir_all(root).unwrap();
769    }
770
771    #[test]
772    fn reports_load_failures_in_stable_ecosystem_order() {
773        let root = fixture_root("stable-errors");
774        fs::write(root.join("alpha.js"), "invalid alpha").unwrap();
775        fs::write(root.join("zeta.js"), "invalid zeta").unwrap();
776        let alpha =
777            PluginDefinition::new(EcosystemId::new("com.example.alpha").unwrap(), "alpha.js")
778                .unwrap()
779                .with_sha256("0".repeat(64))
780                .unwrap();
781        let zeta = PluginDefinition::new(EcosystemId::new("com.example.zeta").unwrap(), "zeta.js")
782            .unwrap()
783            .with_sha256("0".repeat(64))
784            .unwrap();
785
786        assert!(matches!(
787            PluginRegistry::load(root.clone(), [zeta, alpha], BoaPluginRuntime::default()),
788            Err(PluginRegistryError::DigestMismatch { ecosystem, .. })
789                if ecosystem.as_str() == "com.example.alpha"
790        ));
791        fs::remove_dir_all(root).unwrap();
792    }
793
794    #[test]
795    fn rejects_duplicate_and_mismatched_ecosystem_identities() {
796        let root = fixture_root("identity");
797        let source = plugin_source("com.example.declared", "data/*.json");
798        let declared = write_plugin(&root, "declared.js", &source);
799        let mismatched = PluginDefinition::new(
800            EcosystemId::new("com.example.configured").unwrap(),
801            declared.path().to_owned(),
802        )
803        .unwrap()
804        .with_sha256(declared.sha256().unwrap())
805        .unwrap();
806        assert!(matches!(
807            PluginRegistry::load(root.clone(), [mismatched], BoaPluginRuntime::default()),
808            Err(PluginRegistryError::MetadataEcosystemMismatch { .. })
809        ));
810
811        let duplicate = declared.clone();
812        assert!(matches!(
813            PluginRegistry::load(
814                root.clone(),
815                [declared, duplicate],
816                BoaPluginRuntime::default()
817            ),
818            Err(PluginRegistryError::DuplicateEcosystem { .. })
819        ));
820        fs::remove_dir_all(root).unwrap();
821    }
822
823    #[test]
824    fn validates_definition_identity_path_and_digest() {
825        assert!(matches!(
826            PluginDefinition::new(EcosystemId::new("rust").unwrap(), "plugin.js"),
827            Err(PluginRegistryError::BuiltInEcosystemReserved { .. })
828        ));
829        assert!(matches!(
830            PluginDefinition::new(
831                EcosystemId::new("com.example.plugin").unwrap(),
832                "../plugin.js"
833            ),
834            Err(PluginRegistryError::InvalidPluginPath { .. })
835        ));
836        assert!(matches!(
837            PluginDefinition::new(EcosystemId::new("com.example.plugin").unwrap(), "plugin.js")
838                .unwrap()
839                .with_sha256("not-a-digest"),
840            Err(PluginRegistryError::InvalidDigest { .. })
841        ));
842        assert!(matches!(
843            PluginDefinition::new(EcosystemId::new("com.example.plugin").unwrap(), "plugin.js")
844                .unwrap()
845                .with_sha256("A".repeat(64)),
846            Err(PluginRegistryError::InvalidDigest { .. })
847        ));
848    }
849
850    #[cfg(unix)]
851    #[test]
852    fn rejects_plugin_symlinks_outside_the_project_root() {
853        use std::os::unix::fs::symlink;
854
855        let root = fixture_root("outside-root");
856        let outside = fixture_root("outside-source");
857        let source = plugin_source("com.example.outside", "data/*.json");
858        fs::write(outside.join("outside.js"), &source).unwrap();
859        symlink(outside.join("outside.js"), root.join("plugin.js")).unwrap();
860        let definition = PluginDefinition::new(
861            EcosystemId::new("com.example.outside").unwrap(),
862            "plugin.js",
863        )
864        .unwrap()
865        .with_sha256(sha256_hex(source.as_bytes()))
866        .unwrap();
867
868        assert!(matches!(
869            PluginRegistry::load(root.clone(), [definition], BoaPluginRuntime::default()),
870            Err(PluginRegistryError::PluginOutsideProjectRoot { .. })
871        ));
872        fs::remove_dir_all(root).unwrap();
873        fs::remove_dir_all(outside).unwrap();
874    }
875}