Skip to main content

semifold_resolver/plugin/
adapter.rs

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/// Failure raised while converting an authenticated plugin response into adapter domain data.
807#[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}