1use std::collections::{BTreeMap, BTreeSet};
2use std::fs::{self, File};
3use std::io::Read;
4
5use camino::{Utf8Path, Utf8PathBuf};
6use semifold_core::{
7 DependencyKind, DependencySource, EcosystemId, EditSource, FileEdit, FileEditExpectation,
8 FileHash, PackageId, PackageSnapshot, SharedVersionEdit, VersionSource, VersionSourceId,
9};
10use semver::Version;
11use sha2::{Digest, Sha256};
12
13use crate::adapter::{
14 AdapterError, EcosystemAdapter, EcosystemPlanInput, ManifestDependency, PackageInspection,
15 PackageLocation,
16};
17
18use super::protocol::{
19 PluginCallV1, PluginDependencyKindV1, PluginDependencySourceV1, PluginDiagnosticV1,
20 PluginDiscoverInputV1, PluginEditSourceV1, PluginFileEditExpectationV1, PluginFileEditV1,
21 PluginInspectInputV1, PluginManifestDependencyV1, PluginOperation, PluginOutcomeV1,
22 PluginOutputV1, PluginPackageInspectionV1, PluginPackageLocationV1, PluginPackageSnapshotV1,
23 PluginPlanEditsInputV1, PluginRequestV1, PluginSharedVersionEditV1, PluginVersionSourceV1,
24};
25use super::registry::LoadedPlugin;
26use super::runtime::PluginRuntimeError;
27
28const PLUGIN_PROJECT_ROOT: &str = ".";
29
30impl EcosystemAdapter for LoadedPlugin {
31 fn ecosystem(&self) -> EcosystemId {
32 self.metadata().ecosystem.clone()
33 }
34
35 fn encode_version(&self, version: &Version) -> Result<String, AdapterError> {
36 Ok(version.to_string())
37 }
38
39 fn discover(&self, root: &Utf8Path) -> Result<Vec<PackageInspection>, AdapterError> {
40 self.validate_project_root(root)?;
41 let output = self.call(PluginCallV1::Discover(PluginDiscoverInputV1 {
42 project_root: PLUGIN_PROJECT_ROOT.to_owned(),
43 }))?;
44 let PluginOutputV1::Discover { packages } = output else {
45 return Err(self
46 .invalid_output(
47 PluginOperation::Discover,
48 "runtime returned an output for a different operation",
49 )
50 .into());
51 };
52
53 let mut seen = BTreeSet::new();
54 let mut inspections = packages
55 .into_iter()
56 .map(|package| {
57 if !seen.insert(package.id.clone()) {
58 return Err(self.invalid_output(
59 PluginOperation::Discover,
60 format!("package id {} was returned more than once", package.id),
61 ));
62 }
63 self.package_inspection(PluginOperation::Discover, package)
64 })
65 .collect::<Result<Vec<_>, PluginAdapterError>>()?;
66 inspections.sort_by(|left, right| {
67 left.id
68 .cmp(&right.id)
69 .then_with(|| left.path.cmp(&right.path))
70 });
71 Ok(inspections)
72 }
73
74 fn inspect(&self, package: &PackageLocation) -> Result<PackageInspection, AdapterError> {
75 self.validate_project_root(&package.project_root)?;
76 let path = validate_package_path(&package.path).map_err(|reason| {
77 self.invalid_output(
78 PluginOperation::Inspect,
79 format!("invalid input path: {reason}"),
80 )
81 })?;
82 validate_package_directory(self.project_root(), &path).map_err(|reason| {
83 self.invalid_output(
84 PluginOperation::Inspect,
85 format!("invalid input path: {reason}"),
86 )
87 })?;
88 let output = self.call(PluginCallV1::Inspect(PluginInspectInputV1 {
89 project_root: PLUGIN_PROJECT_ROOT.to_owned(),
90 package: PluginPackageLocationV1 {
91 id: package.id.clone(),
92 path: path.to_string(),
93 },
94 }))?;
95 let PluginOutputV1::Inspect { package: output } = output else {
96 return Err(self
97 .invalid_output(
98 PluginOperation::Inspect,
99 "runtime returned an output for a different operation",
100 )
101 .into());
102 };
103 if output.id != package.id {
104 return Err(self
105 .invalid_output(
106 PluginOperation::Inspect,
107 format!(
108 "inspection returned package id {}, expected {}",
109 output.id, package.id
110 ),
111 )
112 .into());
113 }
114 if output.path != path.as_str() {
115 return Err(self
116 .invalid_output(
117 PluginOperation::Inspect,
118 format!(
119 "inspection returned package path {}, expected {}",
120 output.path, path
121 ),
122 )
123 .into());
124 }
125 Ok(self.package_inspection(PluginOperation::Inspect, output)?)
126 }
127
128 fn plan_edits(&self, input: EcosystemPlanInput<'_>) -> Result<Vec<FileEdit>, AdapterError> {
129 self.validate_project_root(input.project_root)?;
130 let ecosystem = self.ecosystem();
131 let mut workspace_ids = BTreeSet::new();
132 let mut workspace_packages = input
133 .workspace_packages
134 .iter()
135 .map(|package| {
136 if package.ecosystem != ecosystem {
137 return Err(self.invalid_output(
138 PluginOperation::PlanEdits,
139 format!(
140 "workspace package {} belongs to {}, expected {}",
141 package.id, package.ecosystem, ecosystem
142 ),
143 ));
144 }
145 if !workspace_ids.insert(package.id.clone()) {
146 return Err(self.invalid_output(
147 PluginOperation::PlanEdits,
148 format!("workspace package id {} occurs more than once", package.id),
149 ));
150 }
151 self.package_snapshot(package)
152 })
153 .collect::<Result<Vec<_>, PluginAdapterError>>()?;
154 workspace_packages.sort_by(|left, right| {
155 left.id
156 .cmp(&right.id)
157 .then_with(|| left.path.cmp(&right.path))
158 });
159
160 let mut released_packages = input.released_packages.to_vec();
161 released_packages.sort();
162 released_packages.dedup();
163 for package in &released_packages {
164 if !workspace_ids.contains(package) {
165 return Err(self
166 .invalid_output(
167 PluginOperation::PlanEdits,
168 format!("released package {package} is not in the plugin workspace"),
169 )
170 .into());
171 }
172 if !input.versions.contains_key(package) {
173 return Err(self
174 .invalid_output(
175 PluginOperation::PlanEdits,
176 format!("released package {package} is missing from VersionMap"),
177 )
178 .into());
179 }
180 }
181 let released_ids = released_packages.iter().cloned().collect::<BTreeSet<_>>();
182
183 let output = self.call(PluginCallV1::PlanEdits(PluginPlanEditsInputV1 {
184 project_root: PLUGIN_PROJECT_ROOT.to_owned(),
185 workspace_packages,
186 released_packages,
187 versions: input.versions.clone(),
188 }))?;
189 let PluginOutputV1::PlanEdits { edits } = output else {
190 return Err(self
191 .invalid_output(
192 PluginOperation::PlanEdits,
193 "runtime returned an output for a different operation",
194 )
195 .into());
196 };
197 self.file_edits(edits, &workspace_ids, &released_ids, input.versions)
198 .map_err(AdapterError::from)
199 }
200}
201
202impl LoadedPlugin {
203 fn call(&self, call: PluginCallV1) -> Result<PluginOutputV1, PluginAdapterError> {
204 let request = PluginRequestV1::new(call);
205 let operation = request.operation();
206 let response = self
207 .execute(&request)
208 .map_err(|source| PluginAdapterError::Runtime {
209 plugin: self.ecosystem(),
210 operation,
211 source,
212 })?;
213 validate_diagnostics(&response.diagnostics).map_err(|reason| {
214 self.invalid_output(operation, format!("invalid diagnostic: {reason}"))
215 })?;
216 match response.outcome {
217 PluginOutcomeV1::Success { output } => Ok(*output),
218 PluginOutcomeV1::Failure => Err(PluginAdapterError::OperationFailed {
219 plugin: self.ecosystem(),
220 operation,
221 diagnostics: response.diagnostics,
222 }),
223 }
224 }
225
226 fn validate_project_root(&self, root: &Utf8Path) -> Result<(), PluginAdapterError> {
227 let canonical =
228 fs::canonicalize(root).map_err(|source| PluginAdapterError::ResolveProjectRoot {
229 root: root.to_owned(),
230 source,
231 })?;
232 let canonical = Utf8PathBuf::from_path_buf(canonical).map_err(|path| {
233 PluginAdapterError::NonUtf8ProjectRoot {
234 path: path.display().to_string(),
235 }
236 })?;
237 if canonical != self.project_root() {
238 return Err(PluginAdapterError::ProjectRootMismatch {
239 plugin: self.ecosystem(),
240 expected: self.project_root().to_owned(),
241 actual: canonical,
242 });
243 }
244 Ok(())
245 }
246
247 fn package_inspection(
248 &self,
249 operation: PluginOperation,
250 package: PluginPackageInspectionV1,
251 ) -> Result<PackageInspection, PluginAdapterError> {
252 if package.ecosystem != self.metadata().ecosystem {
253 return Err(self.invalid_output(
254 operation,
255 format!(
256 "package {} belongs to {}, expected {}",
257 package.id,
258 package.ecosystem,
259 self.metadata().ecosystem
260 ),
261 ));
262 }
263 if package.id.as_str().is_empty() {
264 return Err(self.invalid_output(operation, "package id must not be empty"));
265 }
266 if package.manifest_name.is_empty() {
267 return Err(self.invalid_output(
268 operation,
269 format!("package {} has an empty manifest name", package.id),
270 ));
271 }
272 let path = validate_package_path(Utf8Path::new(&package.path))
273 .and_then(|path| {
274 validate_package_directory(self.project_root(), &path)?;
275 Ok(path)
276 })
277 .map_err(|reason| self.invalid_output(operation, reason))?;
278 let version_source = self.version_source(operation, package.version_source)?;
279 let mut dependencies = package
280 .dependencies
281 .into_iter()
282 .map(|dependency| self.manifest_dependency(operation, dependency))
283 .collect::<Result<Vec<_>, _>>()?;
284 dependencies.sort_by(|left, right| {
285 left.manifest_name
286 .cmp(&right.manifest_name)
287 .then_with(|| {
288 dependency_kind_rank(left.kind).cmp(&dependency_kind_rank(right.kind))
289 })
290 .then_with(|| left.requirement.cmp(&right.requirement))
291 });
292 Ok(PackageInspection {
293 id: package.id,
294 manifest_name: package.manifest_name,
295 version: package.version,
296 version_source,
297 ecosystem: package.ecosystem,
298 path,
299 publishable: package.publishable,
300 dependencies,
301 })
302 }
303
304 fn version_source(
305 &self,
306 operation: PluginOperation,
307 source: PluginVersionSourceV1,
308 ) -> Result<VersionSource, PluginAdapterError> {
309 match source {
310 PluginVersionSourceV1::PackageManifest => Ok(VersionSource::PackageManifest),
311 PluginVersionSourceV1::Shared { manifest, field } => {
312 let manifest = validate_file_path(&manifest)
313 .and_then(|path| {
314 validate_existing_file(self.project_root(), &path).map(|_| path)
315 })
316 .map_err(|reason| self.invalid_output(operation, reason))?;
317 if field.is_empty() {
318 return Err(self.invalid_output(
319 operation,
320 "shared version source field must not be empty",
321 ));
322 }
323 Ok(VersionSource::Shared {
324 source: VersionSourceId { manifest, field },
325 })
326 }
327 }
328 }
329
330 fn manifest_dependency(
331 &self,
332 operation: PluginOperation,
333 dependency: PluginManifestDependencyV1,
334 ) -> Result<ManifestDependency, PluginAdapterError> {
335 if dependency.manifest_name.is_empty() {
336 return Err(
337 self.invalid_output(operation, "dependency manifest name must not be empty")
338 );
339 }
340 Ok(ManifestDependency {
341 manifest_name: dependency.manifest_name,
342 kind: dependency_kind(dependency.kind),
343 requirement: dependency.requirement,
344 })
345 }
346
347 fn package_snapshot(
348 &self,
349 package: &PackageSnapshot,
350 ) -> Result<PluginPackageSnapshotV1, PluginAdapterError> {
351 let path = validate_package_path(&package.path).map_err(|reason| {
352 self.invalid_output(
353 PluginOperation::PlanEdits,
354 format!("invalid workspace package path: {reason}"),
355 )
356 })?;
357 validate_package_directory(self.project_root(), &path)
358 .map_err(|reason| self.invalid_output(PluginOperation::PlanEdits, reason))?;
359 let version_source = match &package.version_source {
360 VersionSource::PackageManifest => PluginVersionSourceV1::PackageManifest,
361 VersionSource::Shared { source } => {
362 let manifest = validate_file_path(source.manifest.as_str())
363 .map_err(|reason| self.invalid_output(PluginOperation::PlanEdits, reason))?;
364 validate_existing_file(self.project_root(), &manifest)
365 .map_err(|reason| self.invalid_output(PluginOperation::PlanEdits, reason))?;
366 if source.field.is_empty() {
367 return Err(self.invalid_output(
368 PluginOperation::PlanEdits,
369 "shared version source field must not be empty",
370 ));
371 }
372 PluginVersionSourceV1::Shared {
373 manifest: manifest.to_string(),
374 field: source.field.clone(),
375 }
376 }
377 };
378 let mut dependencies = package
379 .dependencies
380 .iter()
381 .map(|dependency| super::protocol::PluginDependencyV1 {
382 package: dependency.package.clone(),
383 kind: plugin_dependency_kind(dependency.kind),
384 requirement: dependency.requirement.clone(),
385 source: plugin_dependency_source(dependency.source),
386 })
387 .collect::<Vec<_>>();
388 dependencies.sort_by(|left, right| {
389 left.package
390 .cmp(&right.package)
391 .then_with(|| {
392 plugin_dependency_kind_rank(left.kind)
393 .cmp(&plugin_dependency_kind_rank(right.kind))
394 })
395 .then_with(|| {
396 plugin_dependency_source_rank(left.source)
397 .cmp(&plugin_dependency_source_rank(right.source))
398 })
399 .then_with(|| left.requirement.cmp(&right.requirement))
400 });
401 Ok(PluginPackageSnapshotV1 {
402 id: package.id.clone(),
403 manifest_name: package.manifest_name.clone(),
404 version: package.version.clone(),
405 version_source,
406 ecosystem: package.ecosystem.clone(),
407 path: path.to_string(),
408 publishable: package.publishable,
409 dependencies,
410 })
411 }
412
413 fn file_edits(
414 &self,
415 edits: Vec<PluginFileEditV1>,
416 workspace_ids: &BTreeSet<PackageId>,
417 released_ids: &BTreeSet<PackageId>,
418 versions: &BTreeMap<PackageId, Version>,
419 ) -> Result<Vec<FileEdit>, PluginAdapterError> {
420 let mut paths = BTreeSet::new();
421 let mut converted = edits
422 .into_iter()
423 .map(|edit| {
424 let path = validate_file_path(&edit.path)
425 .map_err(|reason| self.invalid_output(PluginOperation::PlanEdits, reason))?;
426 if !paths.insert(path.clone()) {
427 return Err(self.invalid_output(
428 PluginOperation::PlanEdits,
429 format!("file edit target {path} was returned more than once"),
430 ));
431 }
432 let expected = match edit.expected {
433 PluginFileEditExpectationV1::Existing { sha256 } => {
434 let expected = FileHash::from_sha256(&sha256).map_err(|source| {
435 self.invalid_output(PluginOperation::PlanEdits, source.to_string())
436 })?;
437 let actual =
438 hash_existing_file(self.project_root(), &path).map_err(|reason| {
439 self.invalid_output(PluginOperation::PlanEdits, reason)
440 })?;
441 if actual != expected {
442 return Err(self.invalid_output(
443 PluginOperation::PlanEdits,
444 format!(
445 "file edit hash mismatch for {path}: expected {sha256}, got {}",
446 actual.as_str()
447 ),
448 ));
449 }
450 FileEditExpectation::Existing { hash: actual }
451 }
452 PluginFileEditExpectationV1::Missing => {
453 validate_missing_file(self.project_root(), &path).map_err(|reason| {
454 self.invalid_output(PluginOperation::PlanEdits, reason)
455 })?;
456 FileEditExpectation::Missing
457 }
458 };
459 let source =
460 self.edit_source(edit.source, workspace_ids, released_ids, versions)?;
461 Ok(FileEdit {
462 path,
463 expected,
464 new_content: edit.new_content,
465 source,
466 })
467 })
468 .collect::<Result<Vec<_>, PluginAdapterError>>()?;
469 converted.sort_by(|left, right| {
470 left.path
471 .cmp(&right.path)
472 .then_with(|| left.source.cmp(&right.source))
473 });
474 Ok(converted)
475 }
476
477 fn edit_source(
478 &self,
479 source: PluginEditSourceV1,
480 workspace_ids: &BTreeSet<PackageId>,
481 released_ids: &BTreeSet<PackageId>,
482 versions: &BTreeMap<PackageId, Version>,
483 ) -> Result<EditSource, PluginAdapterError> {
484 match source {
485 PluginEditSourceV1::PackageVersion { package } => {
486 validate_released_package(self, &package, released_ids)?;
487 Ok(EditSource::PackageVersion { package })
488 }
489 PluginEditSourceV1::DependencyVersion {
490 package,
491 dependency,
492 } => {
493 validate_released_package(self, &package, released_ids)?;
494 validate_version_package(self, &dependency, versions)?;
495 Ok(EditSource::DependencyVersion {
496 package,
497 dependency,
498 })
499 }
500 PluginEditSourceV1::WorkspaceDependencies { mut dependencies } => {
501 normalize_version_packages(self, &mut dependencies, versions)?;
502 Ok(EditSource::WorkspaceDependencies { dependencies })
503 }
504 PluginEditSourceV1::WorkspaceManifest {
505 shared_versions,
506 mut dependencies,
507 } => {
508 normalize_version_packages(self, &mut dependencies, versions)?;
509 let mut shared_versions = shared_versions
510 .into_iter()
511 .map(|shared| self.shared_version_edit(shared, workspace_ids))
512 .collect::<Result<Vec<_>, _>>()?;
513 shared_versions.sort();
514 shared_versions.dedup();
515 Ok(EditSource::WorkspaceManifest {
516 shared_versions,
517 dependencies,
518 })
519 }
520 }
521 }
522
523 fn shared_version_edit(
524 &self,
525 shared: PluginSharedVersionEditV1,
526 workspace_ids: &BTreeSet<PackageId>,
527 ) -> Result<SharedVersionEdit, PluginAdapterError> {
528 let manifest = validate_file_path(&shared.manifest)
529 .and_then(|path| validate_existing_file(self.project_root(), &path).map(|_| path))
530 .map_err(|reason| self.invalid_output(PluginOperation::PlanEdits, reason))?;
531 if shared.field.is_empty() {
532 return Err(self.invalid_output(
533 PluginOperation::PlanEdits,
534 "shared version edit field must not be empty",
535 ));
536 }
537 let mut packages = shared.packages;
538 packages.sort();
539 packages.dedup();
540 for package in &packages {
541 if !workspace_ids.contains(package) {
542 return Err(self.invalid_output(
543 PluginOperation::PlanEdits,
544 format!("shared version edit references unknown package {package}"),
545 ));
546 }
547 }
548 Ok(SharedVersionEdit {
549 source: VersionSourceId {
550 manifest,
551 field: shared.field,
552 },
553 packages,
554 })
555 }
556
557 fn invalid_output(
558 &self,
559 operation: PluginOperation,
560 reason: impl Into<String>,
561 ) -> PluginAdapterError {
562 PluginAdapterError::InvalidOutput {
563 plugin: self.ecosystem(),
564 operation,
565 reason: reason.into(),
566 }
567 }
568}
569
570fn validate_diagnostics(diagnostics: &[PluginDiagnosticV1]) -> Result<(), String> {
571 for diagnostic in diagnostics {
572 if diagnostic.code.is_empty() {
573 return Err("diagnostic code must not be empty".to_owned());
574 }
575 if diagnostic.message.is_empty() {
576 return Err(format!(
577 "diagnostic {} has an empty message",
578 diagnostic.code
579 ));
580 }
581 if let Some(path) = &diagnostic.path {
582 validate_file_path(path)?;
583 }
584 }
585 Ok(())
586}
587
588fn validate_package_path(path: &Utf8Path) -> Result<Utf8PathBuf, String> {
589 if path == Utf8Path::new(".") {
590 return Ok(path.to_owned());
591 }
592 validate_file_path(path.as_str())
593}
594
595fn validate_file_path(path: &str) -> Result<Utf8PathBuf, String> {
596 let invalid = path.is_empty()
597 || path.contains('\\')
598 || path.starts_with('/')
599 || path.ends_with('/')
600 || path.split('/').any(|segment| {
601 segment.is_empty()
602 || segment == "."
603 || segment == ".."
604 || is_windows_drive_segment(segment)
605 });
606 if invalid {
607 Err(format!(
608 "path must be a normalized project-relative UTF-8 path: {path}"
609 ))
610 } else {
611 Ok(Utf8PathBuf::from(path))
612 }
613}
614
615fn is_windows_drive_segment(segment: &str) -> bool {
616 let bytes = segment.as_bytes();
617 bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
618}
619
620fn validate_package_directory(root: &Utf8Path, path: &Utf8Path) -> Result<(), String> {
621 let target = root.join(path);
622 let canonical = canonical_utf8(&target)?;
623 if !canonical.starts_with(root) {
624 return Err(format!(
625 "package path resolves outside the project root: {path}"
626 ));
627 }
628 if !canonical.is_dir() {
629 return Err(format!("package path is not a directory: {path}"));
630 }
631 Ok(())
632}
633
634fn validate_existing_file(root: &Utf8Path, path: &Utf8Path) -> Result<Utf8PathBuf, String> {
635 let target = root.join(path);
636 let canonical = canonical_utf8(&target)?;
637 if !canonical.starts_with(root) {
638 return Err(format!(
639 "file path resolves outside the project root: {path}"
640 ));
641 }
642 if !canonical.is_file() {
643 return Err(format!("file path is not a regular file: {path}"));
644 }
645 Ok(canonical)
646}
647
648fn hash_existing_file(root: &Utf8Path, path: &Utf8Path) -> Result<FileHash, String> {
649 let canonical = validate_existing_file(root, path)?;
650 let mut file =
651 File::open(&canonical).map_err(|source| format!("failed to read {path}: {source}"))?;
652 let mut hasher = Sha256::new();
653 let mut buffer = [0_u8; 64 * 1024];
654 loop {
655 let count = file
656 .read(&mut buffer)
657 .map_err(|source| format!("failed to read {path}: {source}"))?;
658 if count == 0 {
659 break;
660 }
661 hasher.update(&buffer[..count]);
662 }
663 let value = hasher
664 .finalize()
665 .iter()
666 .map(|byte| format!("{byte:02x}"))
667 .collect::<String>();
668 FileHash::from_sha256(value)
669 .map_err(|source| format!("failed to construct the validated hash for {path}: {source}"))
670}
671
672fn validate_missing_file(root: &Utf8Path, path: &Utf8Path) -> Result<(), String> {
673 let target = root.join(path);
674 match fs::symlink_metadata(&target) {
675 Ok(_) => {
676 return Err(format!(
677 "file edit expects a missing target, but {path} exists"
678 ));
679 }
680 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
681 Err(source) => return Err(format!("failed to inspect {path}: {source}")),
682 }
683 let parent = target
684 .parent()
685 .ok_or_else(|| format!("file edit target has no parent directory: {path}"))?;
686 let canonical_parent = canonical_utf8(parent)?;
687 if !canonical_parent.starts_with(root) {
688 return Err(format!(
689 "file edit parent resolves outside the project root: {path}"
690 ));
691 }
692 if !canonical_parent.is_dir() {
693 return Err(format!("file edit parent is not a directory: {path}"));
694 }
695 Ok(())
696}
697
698fn canonical_utf8(path: &Utf8Path) -> Result<Utf8PathBuf, String> {
699 let canonical = fs::canonicalize(path)
700 .map_err(|source| format!("failed to resolve path {path}: {source}"))?;
701 Utf8PathBuf::from_path_buf(canonical)
702 .map_err(|path| format!("resolved path is not UTF-8: {}", path.display()))
703}
704
705fn validate_released_package(
706 plugin: &LoadedPlugin,
707 package: &PackageId,
708 released_ids: &BTreeSet<PackageId>,
709) -> Result<(), PluginAdapterError> {
710 if released_ids.contains(package) {
711 Ok(())
712 } else {
713 Err(plugin.invalid_output(
714 PluginOperation::PlanEdits,
715 format!("file edit source references unreleased package {package}"),
716 ))
717 }
718}
719
720fn validate_version_package(
721 plugin: &LoadedPlugin,
722 package: &PackageId,
723 versions: &BTreeMap<PackageId, Version>,
724) -> Result<(), PluginAdapterError> {
725 if versions.contains_key(package) {
726 Ok(())
727 } else {
728 Err(plugin.invalid_output(
729 PluginOperation::PlanEdits,
730 format!("file edit source references package {package} missing from VersionMap"),
731 ))
732 }
733}
734
735fn normalize_version_packages(
736 plugin: &LoadedPlugin,
737 packages: &mut Vec<PackageId>,
738 versions: &BTreeMap<PackageId, Version>,
739) -> Result<(), PluginAdapterError> {
740 packages.sort();
741 packages.dedup();
742 for package in packages {
743 validate_version_package(plugin, package, versions)?;
744 }
745 Ok(())
746}
747
748const fn dependency_kind(kind: PluginDependencyKindV1) -> DependencyKind {
749 match kind {
750 PluginDependencyKindV1::Unspecified => DependencyKind::Unspecified,
751 PluginDependencyKindV1::Runtime => DependencyKind::Runtime,
752 PluginDependencyKindV1::Development => DependencyKind::Development,
753 PluginDependencyKindV1::Build => DependencyKind::Build,
754 PluginDependencyKindV1::Optional => DependencyKind::Optional,
755 PluginDependencyKindV1::Peer => DependencyKind::Peer,
756 }
757}
758
759const fn plugin_dependency_kind(kind: DependencyKind) -> PluginDependencyKindV1 {
760 match kind {
761 DependencyKind::Unspecified => PluginDependencyKindV1::Unspecified,
762 DependencyKind::Runtime => PluginDependencyKindV1::Runtime,
763 DependencyKind::Development => PluginDependencyKindV1::Development,
764 DependencyKind::Build => PluginDependencyKindV1::Build,
765 DependencyKind::Optional => PluginDependencyKindV1::Optional,
766 DependencyKind::Peer => PluginDependencyKindV1::Peer,
767 }
768}
769
770const fn plugin_dependency_source(source: DependencySource) -> PluginDependencySourceV1 {
771 match source {
772 DependencySource::Manifest => PluginDependencySourceV1::Manifest,
773 DependencySource::Config => PluginDependencySourceV1::Config,
774 }
775}
776
777const fn dependency_kind_rank(kind: DependencyKind) -> u8 {
778 match kind {
779 DependencyKind::Unspecified => 0,
780 DependencyKind::Runtime => 1,
781 DependencyKind::Development => 2,
782 DependencyKind::Build => 3,
783 DependencyKind::Optional => 4,
784 DependencyKind::Peer => 5,
785 }
786}
787
788const fn plugin_dependency_kind_rank(kind: PluginDependencyKindV1) -> u8 {
789 match kind {
790 PluginDependencyKindV1::Unspecified => 0,
791 PluginDependencyKindV1::Runtime => 1,
792 PluginDependencyKindV1::Development => 2,
793 PluginDependencyKindV1::Build => 3,
794 PluginDependencyKindV1::Optional => 4,
795 PluginDependencyKindV1::Peer => 5,
796 }
797}
798
799const fn plugin_dependency_source_rank(source: PluginDependencySourceV1) -> u8 {
800 match source {
801 PluginDependencySourceV1::Manifest => 0,
802 PluginDependencySourceV1::Config => 1,
803 }
804}
805
806#[derive(Debug, thiserror::Error)]
808pub enum PluginAdapterError {
809 #[error("failed to resolve plugin adapter project root `{root}`: {source}")]
810 ResolveProjectRoot {
811 root: Utf8PathBuf,
812 #[source]
813 source: std::io::Error,
814 },
815 #[error("plugin adapter project root is not UTF-8: `{path}`")]
816 NonUtf8ProjectRoot { path: String },
817 #[error("plugin {plugin} is bound to project root `{expected}`, but received `{actual}`")]
818 ProjectRootMismatch {
819 plugin: EcosystemId,
820 expected: Utf8PathBuf,
821 actual: Utf8PathBuf,
822 },
823 #[error("plugin {plugin} failed while executing {operation:?}: {source}")]
824 Runtime {
825 plugin: EcosystemId,
826 operation: PluginOperation,
827 #[source]
828 source: PluginRuntimeError,
829 },
830 #[error("plugin {plugin} reported failure while executing {operation:?}")]
831 OperationFailed {
832 plugin: EcosystemId,
833 operation: PluginOperation,
834 diagnostics: Vec<PluginDiagnosticV1>,
835 },
836 #[error("plugin {plugin} returned invalid {operation:?} output: {reason}")]
837 InvalidOutput {
838 plugin: EcosystemId,
839 operation: PluginOperation,
840 reason: String,
841 },
842}
843
844#[cfg(test)]
845mod tests {
846 use std::collections::BTreeMap;
847 use std::time::{SystemTime, UNIX_EPOCH};
848
849 use semifold_core::Dependency;
850 use sha2::{Digest, Sha256};
851
852 use super::*;
853 use crate::plugin::protocol::PluginDiagnosticSeverityV1;
854 use crate::plugin::registry::{PluginDefinition, PluginRegistry};
855 use crate::plugin::runtime::BoaPluginRuntime;
856
857 fn fixture_root(test: &str) -> Utf8PathBuf {
858 let nonce = SystemTime::now()
859 .duration_since(UNIX_EPOCH)
860 .unwrap()
861 .as_nanos();
862 let root = std::env::temp_dir().join(format!(
863 "semifold-plugin-adapter-{}-{test}-{nonce}",
864 std::process::id()
865 ));
866 fs::create_dir_all(&root).unwrap();
867 Utf8PathBuf::from_path_buf(root).unwrap()
868 }
869
870 fn digest(bytes: &[u8]) -> String {
871 Sha256::digest(bytes)
872 .iter()
873 .map(|byte| format!("{byte:02x}"))
874 .collect()
875 }
876
877 fn load_plugin(root: &Utf8Path, source: &str) -> LoadedPlugin {
878 let plugin_path = Utf8Path::new("plugins/example.js");
879 fs::create_dir_all(root.join("plugins")).unwrap();
880 fs::write(root.join(plugin_path), source).unwrap();
881 let definition =
882 PluginDefinition::new(EcosystemId::new("com.example.game").unwrap(), plugin_path)
883 .unwrap()
884 .with_sha256(digest(source.as_bytes()))
885 .unwrap();
886 let registry =
887 PluginRegistry::load(root, [definition], BoaPluginRuntime::default()).unwrap();
888 registry
889 .get(&EcosystemId::new("com.example.game").unwrap())
890 .unwrap()
891 .clone()
892 }
893
894 fn successful_source(manifest_hash: &str, dependency_hash: &str) -> String {
895 format!(
896 r#"
897 export const metadata = {{
898 "schema-version": 1,
899 ecosystem: "com.example.game",
900 "plugin-version": "1.0.0",
901 operations: ["discover", "inspect", "plan-edits"],
902 "read-patterns": ["game/*.json"]
903 }};
904
905 const packageInspection = (id, path) => ({{
906 id,
907 "manifest-name": "game",
908 version: "1.0.0",
909 "version-source": {{ kind: "package-manifest" }},
910 ecosystem: "com.example.game",
911 path,
912 publishable: true,
913 dependencies: [{{
914 "manifest-name": "engine",
915 kind: "runtime",
916 requirement: "^2.0.0"
917 }}]
918 }});
919
920 export default function(request) {{
921 let output;
922 if (request.operation === "discover") {{
923 output = {{ packages: [packageInspection("game", "game")] }};
924 }} else if (request.operation === "inspect") {{
925 const location = request.input.package;
926 output = {{ package: packageInspection(location.id, location.path) }};
927 }} else {{
928 output = {{ edits: [
929 {{
930 path: "game/dependency.json",
931 expected: {{ kind: "existing", sha256: "{dependency_hash}" }},
932 "new-content": "{{\"version\":\"2.1.0\"}}\n",
933 source: {{
934 kind: "dependency-version",
935 package: "game",
936 dependency: "engine"
937 }}
938 }},
939 {{
940 path: "game/manifest.json",
941 expected: {{ kind: "existing", sha256: "{manifest_hash}" }},
942 "new-content": "{{\"version\":\"1.1.0\"}}\n",
943 source: {{ kind: "package-version", package: "game" }}
944 }}
945 ] }};
946 }}
947 return {{
948 "schema-version": 1,
949 diagnostics: [],
950 status: "success",
951 output: {{ operation: request.operation, output }}
952 }};
953 }};
954 "#
955 )
956 }
957
958 fn package_snapshot(inspection: &PackageInspection) -> PackageSnapshot {
959 PackageSnapshot {
960 id: inspection.id.clone(),
961 manifest_name: inspection.manifest_name.clone(),
962 version: inspection.version.clone(),
963 version_source: inspection.version_source.clone(),
964 ecosystem: inspection.ecosystem.clone(),
965 path: inspection.path.clone(),
966 publishable: inspection.publishable,
967 dependencies: vec![Dependency {
968 package: PackageId::new("engine"),
969 kind: DependencyKind::Runtime,
970 requirement: Some("^2.0.0".to_owned()),
971 source: DependencySource::Manifest,
972 }],
973 }
974 }
975
976 #[test]
977 fn loaded_plugin_implements_the_complete_adapter_contract_without_writing_files() {
978 let root = fixture_root("contract");
979 fs::create_dir_all(root.join("game")).unwrap();
980 let manifest = b"{\"version\":\"1.0.0\"}\n";
981 let dependency = b"{\"version\":\"2.0.0\"}\n";
982 fs::write(root.join("game/manifest.json"), manifest).unwrap();
983 fs::write(root.join("game/dependency.json"), dependency).unwrap();
984 let plugin = load_plugin(
985 &root,
986 &successful_source(&digest(manifest), &digest(dependency)),
987 );
988 let adapter: Box<dyn EcosystemAdapter> = Box::new(plugin);
989
990 assert_eq!(
991 adapter.encode_version(&Version::new(1, 2, 3)).unwrap(),
992 "1.2.3"
993 );
994 let discovered = adapter.discover(&root).unwrap();
995 assert_eq!(discovered.len(), 1);
996 assert_eq!(discovered[0].id, PackageId::new("game"));
997 assert_eq!(discovered[0].dependencies[0].manifest_name, "engine");
998 let inspected = adapter
999 .inspect(&PackageLocation {
1000 id: PackageId::new("configured-game"),
1001 project_root: root.clone(),
1002 path: "game".into(),
1003 })
1004 .unwrap();
1005 assert_eq!(inspected.id, PackageId::new("configured-game"));
1006
1007 let snapshot = package_snapshot(&discovered[0]);
1008 let versions = BTreeMap::from([
1009 (PackageId::new("game"), Version::new(1, 1, 0)),
1010 (PackageId::new("engine"), Version::new(2, 1, 0)),
1011 ]);
1012 let edits = adapter
1013 .plan_edits(EcosystemPlanInput {
1014 project_root: &root,
1015 workspace_packages: std::slice::from_ref(&snapshot),
1016 released_packages: std::slice::from_ref(&snapshot.id),
1017 versions: &versions,
1018 })
1019 .unwrap();
1020 let repeated = adapter
1021 .plan_edits(EcosystemPlanInput {
1022 project_root: &root,
1023 workspace_packages: std::slice::from_ref(&snapshot),
1024 released_packages: std::slice::from_ref(&snapshot.id),
1025 versions: &versions,
1026 })
1027 .unwrap();
1028
1029 assert_eq!(repeated, edits);
1030 assert_eq!(
1031 edits
1032 .iter()
1033 .map(|edit| edit.path.as_str())
1034 .collect::<Vec<_>>(),
1035 vec!["game/dependency.json", "game/manifest.json"]
1036 );
1037 assert!(matches!(
1038 &edits[0].source,
1039 EditSource::DependencyVersion { package, dependency }
1040 if package == &PackageId::new("game")
1041 && dependency == &PackageId::new("engine")
1042 ));
1043 assert_eq!(fs::read(root.join("game/manifest.json")).unwrap(), manifest);
1044 assert_eq!(
1045 fs::read(root.join("game/dependency.json")).unwrap(),
1046 dependency
1047 );
1048 fs::remove_dir_all(root).unwrap();
1049 }
1050
1051 #[test]
1052 fn rejects_calls_for_a_different_project_root() {
1053 let root = fixture_root("bound-root");
1054 let other = fixture_root("other-root");
1055 fs::create_dir_all(root.join("game")).unwrap();
1056 let manifest = b"{\"version\":\"1.0.0\"}\n";
1057 let dependency = b"{\"version\":\"2.0.0\"}\n";
1058 fs::write(root.join("game/manifest.json"), manifest).unwrap();
1059 fs::write(root.join("game/dependency.json"), dependency).unwrap();
1060 let plugin = load_plugin(
1061 &root,
1062 &successful_source(&digest(manifest), &digest(dependency)),
1063 );
1064
1065 assert!(matches!(
1066 plugin.discover(&other),
1067 Err(AdapterError::Plugin(
1068 PluginAdapterError::ProjectRootMismatch { .. }
1069 ))
1070 ));
1071 fs::remove_dir_all(root).unwrap();
1072 fs::remove_dir_all(other).unwrap();
1073 }
1074
1075 #[cfg(unix)]
1076 #[test]
1077 fn rejects_existing_and_missing_edit_targets_through_outside_symlinks() {
1078 use std::os::unix::fs::symlink;
1079
1080 let root = fixture_root("edit-symlink-root");
1081 let outside = fixture_root("edit-symlink-outside");
1082 fs::write(outside.join("manifest.json"), "outside").unwrap();
1083 symlink(outside.join("manifest.json"), root.join("manifest.json")).unwrap();
1084 symlink(&outside, root.join("outside-dir")).unwrap();
1085
1086 assert!(validate_existing_file(&root, Utf8Path::new("manifest.json")).is_err());
1087 assert!(validate_missing_file(&root, Utf8Path::new("outside-dir/new.json")).is_err());
1088 fs::remove_dir_all(root).unwrap();
1089 fs::remove_dir_all(outside).unwrap();
1090 }
1091
1092 #[test]
1093 fn rejects_untrusted_package_paths_and_duplicate_discovery_identities() {
1094 assert!(validate_package_path(Utf8Path::new("../outside")).is_err());
1095 let root = fixture_root("invalid-discovery");
1096 fs::create_dir_all(root.join("game")).unwrap();
1097 let source = r#"
1098 export const metadata = {
1099 "schema-version": 1,
1100 ecosystem: "com.example.game",
1101 "plugin-version": "1.0.0",
1102 operations: ["discover", "inspect", "plan-edits"]
1103 };
1104 export default function(request) {
1105 const discoveredPackage = {
1106 id: "game",
1107 "manifest-name": "game",
1108 version: "1.0.0",
1109 "version-source": { kind: "package-manifest" },
1110 ecosystem: "com.example.game",
1111 path: "game",
1112 publishable: true,
1113 dependencies: []
1114 };
1115 return {
1116 "schema-version": 1,
1117 diagnostics: [],
1118 status: "success",
1119 output: {
1120 operation: request.operation,
1121 output: { packages: [discoveredPackage, discoveredPackage] }
1122 }
1123 };
1124 }
1125 "#;
1126 let plugin = load_plugin(&root, source);
1127
1128 assert!(matches!(
1129 plugin.discover(&root),
1130 Err(AdapterError::Plugin(PluginAdapterError::InvalidOutput {
1131 operation: PluginOperation::Discover,
1132 ..
1133 }))
1134 ));
1135 fs::remove_dir_all(root).unwrap();
1136 }
1137
1138 #[test]
1139 fn rejects_stale_edit_hashes_and_keeps_the_workspace_unchanged() {
1140 let root = fixture_root("stale-hash");
1141 fs::create_dir_all(root.join("game")).unwrap();
1142 let manifest = b"{\"version\":\"1.0.0\"}\n";
1143 let dependency = b"{\"version\":\"2.0.0\"}\n";
1144 fs::write(root.join("game/manifest.json"), manifest).unwrap();
1145 fs::write(root.join("game/dependency.json"), dependency).unwrap();
1146 let plugin = load_plugin(
1147 &root,
1148 &successful_source(&"0".repeat(64), &digest(dependency)),
1149 );
1150 let inspection = plugin.discover(&root).unwrap().remove(0);
1151 let snapshot = package_snapshot(&inspection);
1152 let versions = BTreeMap::from([
1153 (PackageId::new("game"), Version::new(1, 1, 0)),
1154 (PackageId::new("engine"), Version::new(2, 1, 0)),
1155 ]);
1156
1157 assert!(matches!(
1158 plugin.plan_edits(EcosystemPlanInput {
1159 project_root: &root,
1160 workspace_packages: std::slice::from_ref(&snapshot),
1161 released_packages: std::slice::from_ref(&snapshot.id),
1162 versions: &versions,
1163 }),
1164 Err(AdapterError::Plugin(PluginAdapterError::InvalidOutput {
1165 operation: PluginOperation::PlanEdits,
1166 ..
1167 }))
1168 ));
1169 assert_eq!(fs::read(root.join("game/manifest.json")).unwrap(), manifest);
1170 fs::remove_dir_all(root).unwrap();
1171 }
1172
1173 #[test]
1174 fn preserves_structured_failure_diagnostics_at_the_adapter_boundary() {
1175 let root = fixture_root("diagnostics");
1176 let source = r#"
1177 export const metadata = {
1178 "schema-version": 1,
1179 ecosystem: "com.example.game",
1180 "plugin-version": "1.0.0",
1181 operations: ["discover", "inspect", "plan-edits"]
1182 };
1183 export default function(request) {
1184 return {
1185 "schema-version": 1,
1186 diagnostics: [{
1187 plugin: "com.example.game",
1188 operation: request.operation,
1189 severity: "error",
1190 code: "manifest-invalid",
1191 message: "The manifest is invalid.",
1192 path: "game/manifest.json"
1193 }],
1194 status: "failure"
1195 };
1196 }
1197 "#;
1198 let plugin = load_plugin(&root, source);
1199
1200 let error = plugin.discover(&root).unwrap_err();
1201 let AdapterError::Plugin(PluginAdapterError::OperationFailed { diagnostics, .. }) = error
1202 else {
1203 panic!("expected a structured plugin operation failure");
1204 };
1205 assert_eq!(diagnostics.len(), 1);
1206 assert_eq!(diagnostics[0].code, "manifest-invalid");
1207 assert_eq!(diagnostics[0].severity, PluginDiagnosticSeverityV1::Error);
1208 fs::remove_dir_all(root).unwrap();
1209 }
1210}