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#[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#[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#[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}