Skip to main content

player_plugin_package/
plugin_package.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::ffi::OsStr;
3use std::fs::{self, File, OpenOptions, TryLockError};
4use std::io::{self, Read, Seek, SeekFrom, Write};
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
11use player_plugin::{
12    PLUGIN_CATALOG_MIGRATION_VERSION, PLUGIN_CATALOG_SCHEMA_VERSION, PluginArtifactDescriptor,
13    PluginCatalog, PluginCatalogError, PluginCatalogRecord, PluginCatalogSource, PluginProvision,
14    PluginReference, PluginRequirement, PluginTransport,
15};
16use semver::Version;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20use zip::write::SimpleFileOptions;
21use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
22
23use crate::{
24    PluginArtifactFormat, PluginArtifactTransport, PluginCapabilityDescriptor,
25    PluginCompatibilityDescriptor, PluginDescriptor, PluginDescriptorError,
26    PluginIdentityDescriptor, PluginProjectManifest, PluginProjectManifestError,
27    PluginRedistributionDescriptor, PluginRuntimeDependencySource,
28};
29
30pub const PLUGIN_PACKAGE_MANIFEST_PATH: &str = "manifest.json";
31pub const PLUGIN_PACKAGE_CHECKSUMS_PATH: &str = "SHA256SUMS";
32pub const PLUGIN_PACKAGE_SIGNATURE_PATH: &str = "signature.json";
33pub const MAX_PLUGIN_PACKAGE_BYTES: u64 = 4 * 1024 * 1024 * 1024;
34pub const MAX_PLUGIN_PACKAGE_ENTRY_BYTES: u64 = 2 * 1024 * 1024 * 1024;
35pub const MAX_PLUGIN_PACKAGE_ENTRIES: usize = 256;
36pub const MAX_PLUGIN_TRUST_STORE_BYTES: u64 = 1024 * 1024;
37const MAX_SMALL_METADATA_BYTES: u64 = 1024 * 1024;
38const MAX_COMPRESSION_RATIO: u64 = 100;
39const SIGNING_KEY_SCHEMA_VERSION: u32 = 1;
40const TRUST_STORE_SCHEMA_VERSION: u32 = 1;
41const PACKAGE_MANIFEST_SCHEMA_VERSION: u32 = 1;
42const SIGNATURE_SCHEMA_VERSION: u32 = 1;
43const SIGNATURE_ALGORITHM: &str = "ed25519";
44const SIGNATURE_DOMAIN: &[u8] = b"vesper-plugin-signature\0";
45pub(crate) const INSTALL_MARKER_PATH: &str = ".vesper-package-sha256";
46const CATALOG_LOCK_PATH: &str = ".vesper-catalog.lock";
47const UNIX_FILE_TYPE_MASK: u32 = 0o170000;
48const UNIX_REGULAR_FILE: u32 = 0o100000;
49const ARTIFACT_FILE_MODE: u32 = 0o755;
50const PACKAGE_METADATA_FILE_MODE: u32 = 0o644;
51const MAX_INSTALLED_PLUGIN_IDENTITIES: usize = 1024;
52const MAX_INSTALLED_VERSIONS_PER_PLUGIN: usize = 256;
53const RUST_WASM_COMPONENT_TARGET: &str = "wasm32-wasip2";
54const RUST_WASM_COMPONENT_ARCHITECTURE: &str = "wasm32";
55
56pub struct PluginSigningKey {
57    publisher: String,
58    key_id: String,
59    key: SigningKey,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct PluginPublicKey {
64    publisher: String,
65    key_id: String,
66    public_key: [u8; 32],
67    status: TrustedKeyStatus,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72struct SigningKeyWire {
73    schema_version: u32,
74    algorithm: String,
75    publisher: String,
76    key_id: String,
77    public_key: String,
78    secret_key: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83struct TrustStoreWire {
84    schema_version: u32,
85    publishers: BTreeMap<String, Vec<TrustedKeyWire>>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90struct TrustedKeyWire {
91    algorithm: String,
92    key_id: String,
93    public_key: String,
94    status: TrustedKeyStatus,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum TrustedKeyStatus {
100    Active,
101    Revoked,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct PluginTrustStore {
106    publishers: BTreeMap<String, Vec<PluginPublicKey>>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct PluginPackageManifest {
112    pub schema_version: u32,
113    pub plugin: PluginIdentityDescriptor,
114    pub compatibility: PluginCompatibilityDescriptor,
115    pub capabilities: Vec<PluginCapabilityDescriptor>,
116    #[serde(default)]
117    pub requires: Vec<PluginRequirement>,
118    #[serde(default)]
119    pub provides: Vec<PluginProvision>,
120    pub artifacts: Vec<PluginPackageArtifact>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub redistribution: Vec<PluginRedistributionDescriptor>,
123    pub generated_by: PluginPackageGenerator,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct PluginPackageArtifact {
129    pub transport: PluginArtifactTransport,
130    pub target: String,
131    pub format: PluginArtifactFormat,
132    pub path: String,
133    pub architecture: String,
134    pub capabilities: Vec<crate::PluginArtifactCapability>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub minimum_os: Option<String>,
137    pub sha256: String,
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub runtime_dependencies: Vec<PluginRuntimeDependencySource>,
140}
141
142impl PluginPackageManifest {
143    /// Builds a pure catalog projection from the canonical package manifest.
144    ///
145    /// This method only validates and copies metadata.  It never opens an
146    /// artifact, creates a loader owner, or initializes a WASM runtime.
147    pub fn catalog(&self) -> Result<PluginCatalog, PluginPackageError> {
148        let mut canonical = self.clone();
149        canonical_manifest_bytes(&mut canonical)?;
150        let records = canonical
151            .artifacts
152            .iter()
153            .map(|artifact| canonical.catalog_record(artifact))
154            .collect::<Result<Vec<_>, _>>()?;
155        Ok(PluginCatalog::from_records(records)?)
156    }
157
158    /// Returns the metadata descriptor for one package artifact without
159    /// reading the artifact bytes.
160    pub fn artifact_descriptor(
161        &self,
162        artifact: &PluginPackageArtifact,
163    ) -> Result<PluginArtifactDescriptor, PluginPackageError> {
164        if !self.artifacts.iter().any(|candidate| candidate == artifact) {
165            return Err(PluginPackageError::InvalidPackage(
166                "artifact does not belong to the package manifest".to_owned(),
167            ));
168        }
169        let mut canonical = self.clone();
170        canonical_manifest_bytes(&mut canonical)?;
171        let artifact = canonical
172            .artifacts
173            .iter()
174            .find(|candidate| {
175                candidate.path == artifact.path && candidate.sha256 == artifact.sha256
176            })
177            .ok_or_else(|| {
178                PluginPackageError::InvalidPackage(
179                    "artifact does not belong to the canonical package manifest".to_owned(),
180                )
181            })?;
182        let descriptor = PluginArtifactDescriptor {
183            schema_version: PLUGIN_CATALOG_SCHEMA_VERSION,
184            plugin_id: self.plugin.id.clone(),
185            version: self.plugin.version.clone(),
186            publisher: self.plugin.publisher.clone(),
187            transport: artifact.transport,
188            target: artifact.target.clone(),
189            format: artifact.format,
190            architecture: artifact.architecture.clone(),
191            abi_major: self.compatibility.abi_major,
192            abi_minor_min: self.compatibility.abi_minor_min,
193            abi_minor_max: self.compatibility.abi_minor_max,
194            capabilities: artifact
195                .capabilities
196                .iter()
197                .map(|capability| player_plugin::PluginArtifactCapability {
198                    interface_id: capability.interface_id.clone(),
199                    instance_id: capability.instance_id.clone(),
200                })
201                .collect(),
202            requires: self.requires.clone(),
203            provides: self.provides.clone(),
204            runtime_dependencies: artifact.runtime_dependencies.clone(),
205            resource_policy: Default::default(),
206            migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
207        };
208        descriptor.validate().map_err(PluginPackageError::Catalog)?;
209        Ok(descriptor)
210    }
211
212    fn catalog_record(
213        &self,
214        artifact: &PluginPackageArtifact,
215    ) -> Result<PluginCatalogRecord, PluginPackageError> {
216        let descriptor = self.artifact_descriptor(artifact)?;
217        PluginCatalogRecord::new(
218            descriptor,
219            artifact.path.clone(),
220            artifact.sha256.clone(),
221            PluginCatalogSource::Package,
222        )
223        .map_err(PluginPackageError::Catalog)
224    }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct PluginPackageGenerator {
230    pub vesper: String,
231    pub sdk: String,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(deny_unknown_fields)]
236struct PluginPackageSignature {
237    schema_version: u32,
238    algorithm: String,
239    publisher: String,
240    key_id: String,
241    signature: String,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
245pub struct PluginPackageBuildReport {
246    pub package_path: PathBuf,
247    pub plugin_id: String,
248    pub publisher: String,
249    pub key_id: String,
250    pub artifact_count: usize,
251    pub package_sha256: String,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
255pub struct PluginPackageVerification {
256    pub package_path: PathBuf,
257    pub plugin_id: String,
258    pub version: String,
259    pub publisher: String,
260    pub key_id: String,
261    pub artifact_count: usize,
262    pub package_sha256: String,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
266pub struct PluginInstallationReport {
267    pub plugin_id: String,
268    pub version: String,
269    pub install_path: PathBuf,
270    pub package_sha256: String,
271    pub already_installed: bool,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
275pub struct InstalledPluginRecord {
276    pub plugin_id: String,
277    pub version: String,
278    pub install_path: PathBuf,
279    pub package_sha256: String,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct InstalledPluginActivation {
284    plugin_id: String,
285    version: String,
286}
287
288impl InstalledPluginActivation {
289    pub fn new(
290        plugin_id: impl Into<String>,
291        version: impl Into<String>,
292    ) -> Result<Self, PluginPackageError> {
293        let plugin_id = plugin_id.into();
294        let version = version.into();
295        validate_reverse_dns_identifier(&plugin_id).map_err(PluginPackageError::InvalidPackage)?;
296        Version::parse(&version).map_err(|error| {
297            PluginPackageError::InvalidPackage(format!(
298                "invalid activation version '{version}': {error}"
299            ))
300        })?;
301        Ok(Self { plugin_id, version })
302    }
303
304    pub fn plugin_id(&self) -> &str {
305        &self.plugin_id
306    }
307
308    pub fn version(&self) -> &str {
309        &self.version
310    }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct PluginHostTarget {
315    host_sdk: Version,
316    target: String,
317    architecture: String,
318}
319
320impl PluginHostTarget {
321    pub fn new(
322        host_sdk: Version,
323        target: impl Into<String>,
324        architecture: impl Into<String>,
325    ) -> Result<Self, PluginPackageError> {
326        let target = target.into();
327        let architecture = architecture.into();
328        validate_package_text(
329            "host target",
330            &target,
331            crate::plugin_project::MAX_TARGET_BYTES,
332        )?;
333        validate_package_text(
334            "host architecture",
335            &architecture,
336            crate::plugin_project::MAX_ARCHITECTURE_BYTES,
337        )?;
338        Ok(Self {
339            host_sdk,
340            target,
341            architecture,
342        })
343    }
344
345    pub fn host_sdk(&self) -> &Version {
346        &self.host_sdk
347    }
348
349    pub fn target(&self) -> &str {
350        &self.target
351    }
352
353    pub fn architecture(&self) -> &str {
354        &self.architecture
355    }
356}
357
358#[derive(Debug, Clone)]
359pub struct VerifiedInstalledArtifact {
360    plugin_id: String,
361    version: String,
362    publisher: String,
363    abi_major: u16,
364    abi_minor_min: u16,
365    abi_minor_max: u16,
366    requires: Vec<PluginRequirement>,
367    provides: Vec<PluginProvision>,
368    transport: PluginArtifactTransport,
369    target: String,
370    format: PluginArtifactFormat,
371    architecture: String,
372    capabilities: Vec<PluginCapabilityDescriptor>,
373    minimum_os: Option<String>,
374    runtime_dependencies: Vec<PluginRuntimeDependencySource>,
375    installed_path: PathBuf,
376    sha256: String,
377    snapshot: Arc<tempfile::NamedTempFile>,
378}
379
380impl VerifiedInstalledArtifact {
381    pub fn plugin_id(&self) -> &str {
382        &self.plugin_id
383    }
384
385    pub fn version(&self) -> &str {
386        &self.version
387    }
388
389    pub fn publisher(&self) -> &str {
390        &self.publisher
391    }
392
393    pub const fn abi_major(&self) -> u16 {
394        self.abi_major
395    }
396
397    pub const fn abi_minor_range(&self) -> (u16, u16) {
398        (self.abi_minor_min, self.abi_minor_max)
399    }
400
401    pub const fn transport(&self) -> PluginArtifactTransport {
402        self.transport
403    }
404
405    pub fn target(&self) -> &str {
406        &self.target
407    }
408
409    pub const fn format(&self) -> PluginArtifactFormat {
410        self.format
411    }
412
413    pub fn architecture(&self) -> &str {
414        &self.architecture
415    }
416
417    pub fn capabilities(&self) -> &[PluginCapabilityDescriptor] {
418        &self.capabilities
419    }
420
421    pub fn requires(&self) -> &[PluginRequirement] {
422        &self.requires
423    }
424
425    pub fn provides(&self) -> &[PluginProvision] {
426        &self.provides
427    }
428
429    pub fn minimum_os(&self) -> Option<&str> {
430        self.minimum_os.as_deref()
431    }
432
433    pub fn runtime_dependencies(&self) -> &[PluginRuntimeDependencySource] {
434        &self.runtime_dependencies
435    }
436
437    pub fn installed_path(&self) -> &Path {
438        &self.installed_path
439    }
440
441    pub fn sha256(&self) -> &str {
442        &self.sha256
443    }
444
445    /// Projects the verified installed metadata into the pure catalog model.
446    /// The process-private snapshot remains outside the returned descriptor.
447    pub fn artifact_descriptor(&self) -> Result<PluginArtifactDescriptor, PluginPackageError> {
448        let descriptor = PluginArtifactDescriptor {
449            schema_version: PLUGIN_CATALOG_SCHEMA_VERSION,
450            plugin_id: self.plugin_id.clone(),
451            version: self.version.clone(),
452            publisher: self.publisher.clone(),
453            transport: self.transport,
454            target: self.target.clone(),
455            format: self.format,
456            architecture: self.architecture.clone(),
457            abi_major: self.abi_major,
458            abi_minor_min: self.abi_minor_min,
459            abi_minor_max: self.abi_minor_max,
460            capabilities: self
461                .capabilities
462                .iter()
463                .map(|capability| player_plugin::PluginArtifactCapability {
464                    interface_id: capability.interface_id.clone(),
465                    instance_id: capability.instance_id.clone(),
466                })
467                .collect(),
468            requires: self.requires.clone(),
469            provides: self.provides.clone(),
470            runtime_dependencies: self.runtime_dependencies.clone(),
471            resource_policy: Default::default(),
472            migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
473        };
474        descriptor.validate().map_err(PluginPackageError::Catalog)?;
475        Ok(descriptor)
476    }
477
478    pub fn catalog_record(&self) -> Result<PluginCatalogRecord, PluginPackageError> {
479        let path = self.installed_path.to_str().ok_or_else(|| {
480            PluginPackageError::Catalog(PluginCatalogError::InvalidField {
481                field: "artifact_path".to_owned(),
482                message: "installed artifact path is not valid UTF-8".to_owned(),
483            })
484        })?;
485        PluginCatalogRecord::new(
486            self.artifact_descriptor()?,
487            path.to_owned(),
488            self.sha256.clone(),
489            PluginCatalogSource::Installed,
490        )
491        .map_err(PluginPackageError::Catalog)
492    }
493
494    /// Process-private immutable snapshot used by checked loaders.
495    pub fn snapshot_path(&self) -> &Path {
496        self.snapshot.path()
497    }
498
499    pub fn read_snapshot(&self, maximum_bytes: usize) -> Result<Vec<u8>, PluginPackageError> {
500        let metadata =
501            self.snapshot
502                .as_file()
503                .metadata()
504                .map_err(|source| PluginPackageError::Io {
505                    operation: "inspect verified artifact snapshot",
506                    path: self.snapshot.path().display().to_string(),
507                    source,
508                })?;
509        if metadata.len() > maximum_bytes as u64 {
510            return Err(PluginPackageError::InvalidPackage(format!(
511                "verified artifact snapshot '{}' exceeds {maximum_bytes} bytes",
512                self.snapshot.path().display()
513            )));
514        }
515        let mut file =
516            self.snapshot
517                .as_file()
518                .try_clone()
519                .map_err(|source| PluginPackageError::Io {
520                    operation: "clone verified artifact snapshot",
521                    path: self.snapshot.path().display().to_string(),
522                    source,
523                })?;
524        file.seek(SeekFrom::Start(0))
525            .map_err(|source| PluginPackageError::Io {
526                operation: "rewind verified artifact snapshot",
527                path: self.snapshot.path().display().to_string(),
528                source,
529            })?;
530        let mut bytes = Vec::with_capacity(metadata.len() as usize);
531        file.read_to_end(&mut bytes)
532            .map_err(|source| PluginPackageError::Io {
533                operation: "read verified artifact snapshot",
534                path: self.snapshot.path().display().to_string(),
535                source,
536            })?;
537        Ok(bytes)
538    }
539}
540
541#[derive(Debug)]
542pub struct VerifiedInstalledPluginCatalog {
543    _catalog_lock: Option<PluginCatalogLock>,
544    artifacts: Vec<VerifiedInstalledArtifact>,
545}
546
547impl VerifiedInstalledPluginCatalog {
548    pub fn artifacts(&self) -> &[VerifiedInstalledArtifact] {
549        &self.artifacts
550    }
551
552    /// Rebuilds a pure catalog projection from verified installed metadata.
553    /// The process-private snapshots remain owned by this verification object.
554    pub fn catalog(&self) -> Result<PluginCatalog, PluginPackageError> {
555        let records = self
556            .artifacts
557            .iter()
558            .map(VerifiedInstalledArtifact::catalog_record)
559            .collect::<Result<Vec<_>, _>>()?;
560        Ok(PluginCatalog::from_records(records)?)
561    }
562}
563
564#[derive(Debug)]
565pub struct VerifiedPluginPackage {
566    package_file: Arc<File>,
567    manifest: PluginPackageManifest,
568    verification: PluginPackageVerification,
569    entries: Vec<VerifiedPackageEntry>,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
573struct VerifiedPackageEntry {
574    path: String,
575    size: u64,
576    mode: u32,
577    sha256: String,
578}
579
580#[derive(Debug)]
581struct PositionedFile {
582    file: Arc<File>,
583    position: u64,
584}
585
586#[derive(Debug)]
587struct PluginCatalogLock {
588    _file: File,
589}
590
591#[derive(Debug)]
592struct EmptyIdentityDirectoryRollback {
593    path: PathBuf,
594    armed: bool,
595}
596
597impl EmptyIdentityDirectoryRollback {
598    fn new(path: PathBuf) -> Self {
599        Self { path, armed: true }
600    }
601
602    fn disarm(&mut self) {
603        self.armed = false;
604    }
605}
606
607impl Drop for EmptyIdentityDirectoryRollback {
608    fn drop(&mut self) {
609        if self.armed {
610            let _ = fs::remove_dir(&self.path);
611        }
612    }
613}
614
615impl PluginCatalogLock {
616    fn acquire(install_root: &Path) -> Result<Self, PluginPackageError> {
617        Self::acquire_with_mode(install_root, false)
618    }
619
620    fn acquire_shared(install_root: &Path) -> Result<Self, PluginPackageError> {
621        Self::acquire_with_mode(install_root, true)
622    }
623
624    fn acquire_with_mode(install_root: &Path, shared: bool) -> Result<Self, PluginPackageError> {
625        let lock_path = install_root.join(CATALOG_LOCK_PATH);
626        let file = match OpenOptions::new()
627            .read(true)
628            .write(true)
629            .create_new(true)
630            .open(&lock_path)
631        {
632            Ok(file) => {
633                file.sync_all().map_err(|source| PluginPackageError::Io {
634                    operation: "sync plugin install catalog lock",
635                    path: lock_path.display().to_string(),
636                    source,
637                })?;
638                sync_directory(install_root)?;
639                file
640            }
641            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
642                let metadata =
643                    fs::symlink_metadata(&lock_path).map_err(|source| PluginPackageError::Io {
644                        operation: "inspect plugin install catalog lock",
645                        path: lock_path.display().to_string(),
646                        source,
647                    })?;
648                if !metadata.file_type().is_file() {
649                    return Err(PluginPackageError::InvalidPackage(format!(
650                        "plugin install catalog lock '{}' is not a regular file",
651                        lock_path.display()
652                    )));
653                }
654                OpenOptions::new()
655                    .read(true)
656                    .write(true)
657                    .open(&lock_path)
658                    .map_err(|source| PluginPackageError::Io {
659                        operation: "open plugin install catalog lock",
660                        path: lock_path.display().to_string(),
661                        source,
662                    })?
663            }
664            Err(source) => {
665                return Err(PluginPackageError::Io {
666                    operation: "create plugin install catalog lock",
667                    path: lock_path.display().to_string(),
668                    source,
669                });
670            }
671        };
672        let lock_result = if shared {
673            file.try_lock_shared()
674        } else {
675            file.try_lock()
676        };
677        match lock_result {
678            Ok(()) => Ok(Self { _file: file }),
679            Err(TryLockError::WouldBlock) => Err(PluginPackageError::CatalogBusy {
680                path: install_root.display().to_string(),
681            }),
682            Err(TryLockError::Error(source)) => Err(PluginPackageError::Io {
683                operation: "lock plugin install catalog",
684                path: lock_path.display().to_string(),
685                source,
686            }),
687        }
688    }
689}
690
691impl PositionedFile {
692    fn new(file: Arc<File>) -> Self {
693        Self { file, position: 0 }
694    }
695}
696
697impl Read for PositionedFile {
698    fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
699        let read = positioned_read(&self.file, buffer, self.position)?;
700        self.position = self
701            .position
702            .checked_add(read as u64)
703            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file offset overflow"))?;
704        Ok(read)
705    }
706}
707
708impl Seek for PositionedFile {
709    fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
710        let next = match position {
711            SeekFrom::Start(position) => position,
712            SeekFrom::End(delta) => checked_seek_position(self.file.metadata()?.len(), delta)?,
713            SeekFrom::Current(delta) => checked_seek_position(self.position, delta)?,
714        };
715        self.position = next;
716        Ok(next)
717    }
718}
719
720fn checked_seek_position(base: u64, delta: i64) -> io::Result<u64> {
721    let position = i128::from(base) + i128::from(delta);
722    u64::try_from(position)
723        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid file seek"))
724}
725
726#[cfg(unix)]
727fn positioned_read(file: &File, buffer: &mut [u8], position: u64) -> io::Result<usize> {
728    use std::os::unix::fs::FileExt;
729
730    file.read_at(buffer, position)
731}
732
733#[cfg(windows)]
734fn positioned_read(file: &File, buffer: &mut [u8], position: u64) -> io::Result<usize> {
735    use std::os::windows::fs::FileExt;
736
737    file.seek_read(buffer, position)
738}
739
740#[derive(Debug, Error)]
741pub enum PluginPackageError {
742    #[error(transparent)]
743    Project(#[from] PluginProjectManifestError),
744    #[error(transparent)]
745    Descriptor(#[from] PluginDescriptorError),
746    #[error(transparent)]
747    Catalog(#[from] PluginCatalogError),
748    #[error("failed to {operation} '{path}': {source}")]
749    Io {
750        operation: &'static str,
751        path: String,
752        #[source]
753        source: io::Error,
754    },
755    #[error("plugin install catalog '{path}' is busy")]
756    CatalogBusy { path: String },
757    #[error("invalid signing key: {0}")]
758    InvalidSigningKey(String),
759    #[error("invalid trust store: {0}")]
760    InvalidTrustStore(String),
761    #[error("invalid plugin package: {0}")]
762    InvalidPackage(String),
763    #[error("plugin package signature is not trusted or valid")]
764    InvalidSignature,
765    #[error("installed plugin '{plugin_id}' has no version '{version}'")]
766    InstalledVersionNotFound { plugin_id: String, version: String },
767    #[error("installed plugin '{plugin_id}' has ambiguous versions {versions:?}")]
768    AmbiguousInstalledVersions {
769        plugin_id: String,
770        versions: Vec<String>,
771    },
772    #[error(
773        "installed plugin '{plugin_id}' has no {transport} artifact for target '{target}' architecture '{architecture}'"
774    )]
775    InstalledArtifactNotFound {
776        plugin_id: String,
777        transport: &'static str,
778        target: String,
779        architecture: String,
780    },
781    #[error("installed plugin compatibility check failed: {0}")]
782    Compatibility(String),
783    #[error("failed to encode plugin package JSON: {0}")]
784    Json(#[from] serde_json::Error),
785    #[error("failed to process plugin package ZIP: {0}")]
786    Zip(#[from] zip::result::ZipError),
787}
788
789impl PluginSigningKey {
790    pub fn generate(publisher: impl Into<String>) -> Result<Self, PluginPackageError> {
791        let publisher = publisher.into();
792        validate_reverse_dns_identifier(&publisher)
793            .map_err(PluginPackageError::InvalidSigningKey)?;
794        let mut secret_key = [0_u8; 32];
795        getrandom::fill(&mut secret_key).map_err(|error| {
796            PluginPackageError::InvalidSigningKey(format!(
797                "failed to obtain system randomness for signing key: {error}"
798            ))
799        })?;
800        let key = SigningKey::from_bytes(&secret_key);
801        let key_id = key_id(&key.verifying_key().to_bytes());
802        Ok(Self {
803            publisher,
804            key_id,
805            key,
806        })
807    }
808
809    pub fn from_json(bytes: &[u8]) -> Result<Self, PluginPackageError> {
810        let wire: SigningKeyWire = serde_json::from_slice(bytes)
811            .map_err(|error| PluginPackageError::InvalidSigningKey(error.to_string()))?;
812        if wire.schema_version != SIGNING_KEY_SCHEMA_VERSION {
813            return Err(PluginPackageError::InvalidSigningKey(format!(
814                "expected schema version {SIGNING_KEY_SCHEMA_VERSION}"
815            )));
816        }
817        if wire.algorithm != SIGNATURE_ALGORITHM {
818            return Err(PluginPackageError::InvalidSigningKey(
819                "algorithm must be ed25519".to_owned(),
820            ));
821        }
822        validate_reverse_dns_identifier(&wire.publisher)
823            .map_err(PluginPackageError::InvalidSigningKey)?;
824        let secret_key =
825            decode_hex::<32>(&wire.secret_key).map_err(PluginPackageError::InvalidSigningKey)?;
826        let public_key =
827            decode_hex::<32>(&wire.public_key).map_err(PluginPackageError::InvalidSigningKey)?;
828        let key = SigningKey::from_bytes(&secret_key);
829        if key.verifying_key().to_bytes() != public_key {
830            return Err(PluginPackageError::InvalidSigningKey(
831                "public key does not match the secret key".to_owned(),
832            ));
833        }
834        let expected_key_id = key_id(&public_key);
835        if wire.key_id != expected_key_id {
836            return Err(PluginPackageError::InvalidSigningKey(
837                "key_id does not match the public key".to_owned(),
838            ));
839        }
840        Ok(Self {
841            publisher: wire.publisher,
842            key_id: expected_key_id,
843            key,
844        })
845    }
846
847    pub fn to_json(&self) -> Result<Vec<u8>, PluginPackageError> {
848        let wire = SigningKeyWire {
849            schema_version: SIGNING_KEY_SCHEMA_VERSION,
850            algorithm: SIGNATURE_ALGORITHM.to_owned(),
851            publisher: self.publisher.clone(),
852            key_id: self.key_id.clone(),
853            public_key: encode_hex(&self.key.verifying_key().to_bytes()),
854            secret_key: encode_hex(&self.key.to_bytes()),
855        };
856        Ok(serde_json::to_vec(&wire)?)
857    }
858
859    pub fn publisher(&self) -> &str {
860        &self.publisher
861    }
862
863    pub fn key_id(&self) -> &str {
864        &self.key_id
865    }
866
867    pub fn public_key(&self) -> PluginPublicKey {
868        PluginPublicKey {
869            publisher: self.publisher.clone(),
870            key_id: self.key_id.clone(),
871            public_key: self.key.verifying_key().to_bytes(),
872            status: TrustedKeyStatus::Active,
873        }
874    }
875}
876
877impl PluginPublicKey {
878    pub fn publisher(&self) -> &str {
879        &self.publisher
880    }
881
882    pub fn key_id(&self) -> &str {
883        &self.key_id
884    }
885
886    pub const fn status(&self) -> TrustedKeyStatus {
887        self.status
888    }
889}
890
891impl PluginTrustStore {
892    pub fn empty() -> Self {
893        Self {
894            publishers: BTreeMap::new(),
895        }
896    }
897
898    pub fn from_json(bytes: &[u8]) -> Result<Self, PluginPackageError> {
899        let wire: TrustStoreWire = serde_json::from_slice(bytes)
900            .map_err(|error| PluginPackageError::InvalidTrustStore(error.to_string()))?;
901        if wire.schema_version != TRUST_STORE_SCHEMA_VERSION {
902            return Err(PluginPackageError::InvalidTrustStore(format!(
903                "expected schema version {TRUST_STORE_SCHEMA_VERSION}"
904            )));
905        }
906        let mut store = Self::empty();
907        for (publisher, keys) in wire.publishers {
908            validate_reverse_dns_identifier(&publisher)
909                .map_err(PluginPackageError::InvalidTrustStore)?;
910            if keys.is_empty() || keys.len() > 16 {
911                return Err(PluginPackageError::InvalidTrustStore(format!(
912                    "publisher '{publisher}' must contain 1 to 16 keys"
913                )));
914            }
915            for key in keys {
916                if key.algorithm != SIGNATURE_ALGORITHM {
917                    return Err(PluginPackageError::InvalidTrustStore(
918                        "algorithm must be ed25519".to_owned(),
919                    ));
920                }
921                let public_key = decode_hex::<32>(&key.public_key)
922                    .map_err(PluginPackageError::InvalidTrustStore)?;
923                let expected_key_id = key_id(&public_key);
924                if key.key_id != expected_key_id {
925                    return Err(PluginPackageError::InvalidTrustStore(format!(
926                        "key_id does not match a public key for publisher '{publisher}'"
927                    )));
928                }
929                store.insert(PluginPublicKey {
930                    publisher: publisher.clone(),
931                    key_id: expected_key_id,
932                    public_key,
933                    status: key.status,
934                })?;
935            }
936        }
937        Ok(store)
938    }
939
940    pub fn from_file(path: &Path) -> Result<Self, PluginPackageError> {
941        let bytes = read_bounded_file(path, MAX_PLUGIN_TRUST_STORE_BYTES, "plugin trust store")?;
942        Self::from_json(&bytes)
943    }
944
945    pub fn to_json(&self) -> Result<Vec<u8>, PluginPackageError> {
946        let publishers = self
947            .publishers
948            .iter()
949            .map(|(publisher, keys)| {
950                (
951                    publisher.clone(),
952                    keys.iter()
953                        .map(|key| TrustedKeyWire {
954                            algorithm: SIGNATURE_ALGORITHM.to_owned(),
955                            key_id: key.key_id.clone(),
956                            public_key: encode_hex(&key.public_key),
957                            status: key.status,
958                        })
959                        .collect(),
960                )
961            })
962            .collect();
963        Ok(serde_json::to_vec(&TrustStoreWire {
964            schema_version: TRUST_STORE_SCHEMA_VERSION,
965            publishers,
966        })?)
967    }
968
969    pub fn insert(&mut self, key: PluginPublicKey) -> Result<(), PluginPackageError> {
970        validate_reverse_dns_identifier(&key.publisher)
971            .map_err(PluginPackageError::InvalidTrustStore)?;
972        if key.key_id != key_id(&key.public_key) {
973            return Err(PluginPackageError::InvalidTrustStore(
974                "key_id does not match the public key".to_owned(),
975            ));
976        }
977        let keys = self.publishers.entry(key.publisher.clone()).or_default();
978        if let Some(existing) = keys.iter().find(|existing| existing.key_id == key.key_id) {
979            if existing.public_key == key.public_key {
980                return Ok(());
981            }
982            return Err(PluginPackageError::InvalidTrustStore(format!(
983                "duplicate key_id '{}' has different key bytes",
984                key.key_id
985            )));
986        }
987        if keys.len() >= 16 {
988            return Err(PluginPackageError::InvalidTrustStore(format!(
989                "publisher '{}' already has 16 keys",
990                key.publisher
991            )));
992        }
993        keys.push(key);
994        keys.sort_by(|left, right| left.key_id.cmp(&right.key_id));
995        Ok(())
996    }
997
998    pub fn revoke(
999        &mut self,
1000        publisher: &str,
1001        requested_key_id: &str,
1002    ) -> Result<(), PluginPackageError> {
1003        let key = self
1004            .publishers
1005            .get_mut(publisher)
1006            .and_then(|keys| keys.iter_mut().find(|key| key.key_id == requested_key_id))
1007            .ok_or_else(|| {
1008                PluginPackageError::InvalidTrustStore(format!(
1009                    "publisher '{publisher}' has no key '{requested_key_id}'"
1010                ))
1011            })?;
1012        key.status = TrustedKeyStatus::Revoked;
1013        Ok(())
1014    }
1015
1016    fn verifying_key(
1017        &self,
1018        publisher: &str,
1019        requested_key_id: &str,
1020    ) -> Result<VerifyingKey, PluginPackageError> {
1021        let key = self
1022            .publishers
1023            .get(publisher)
1024            .and_then(|keys| {
1025                keys.iter().find(|key| {
1026                    key.key_id == requested_key_id && key.status == TrustedKeyStatus::Active
1027                })
1028            })
1029            .ok_or(PluginPackageError::InvalidSignature)?;
1030        VerifyingKey::from_bytes(&key.public_key).map_err(|_| PluginPackageError::InvalidSignature)
1031    }
1032}
1033
1034enum PreparedEntryData {
1035    Bytes(Vec<u8>),
1036    Snapshot { file: File, source_path: PathBuf },
1037}
1038
1039struct PreparedEntry {
1040    path: String,
1041    data: PreparedEntryData,
1042    sha256: String,
1043    size: u64,
1044    mode: u32,
1045}
1046
1047pub fn build_signed_plugin_package(
1048    project: &PluginProjectManifest,
1049    base_directory: &Path,
1050    signing_key: &PluginSigningKey,
1051    output: &Path,
1052) -> Result<PluginPackageBuildReport, PluginPackageError> {
1053    project.validate_package_inputs()?;
1054    let descriptor = project.descriptor().canonicalize()?.descriptor().clone();
1055    if signing_key.publisher() != descriptor.plugin.publisher {
1056        return Err(PluginPackageError::InvalidSigningKey(format!(
1057            "key publisher '{}' does not match manifest publisher '{}'",
1058            signing_key.publisher(),
1059            descriptor.plugin.publisher
1060        )));
1061    }
1062
1063    let mut entries =
1064        Vec::with_capacity(project.artifacts().len() + project.package_files().len() + 3);
1065    let mut artifacts = Vec::with_capacity(project.artifacts().len());
1066    let reserved_paths = [
1067        PLUGIN_PACKAGE_MANIFEST_PATH,
1068        PLUGIN_PACKAGE_CHECKSUMS_PATH,
1069        PLUGIN_PACKAGE_SIGNATURE_PATH,
1070        INSTALL_MARKER_PATH,
1071    ]
1072    .into_iter()
1073    .map(crate::plugin_project::normalized_package_path)
1074    .collect::<HashSet<_>>();
1075
1076    for artifact in project.artifacts() {
1077        if reserved_paths.contains(&crate::plugin_project::normalized_package_path(
1078            &artifact.path,
1079        )) {
1080            return Err(PluginPackageError::InvalidPackage(format!(
1081                "artifact path '{}' is reserved",
1082                artifact.path
1083            )));
1084        }
1085        let prepared = prepare_file_entry(
1086            base_directory,
1087            &artifact.source,
1088            &artifact.path,
1089            ARTIFACT_FILE_MODE,
1090        )?;
1091        let mut runtime_dependencies = artifact.runtime_dependencies.clone();
1092        runtime_dependencies.sort_by(|left, right| left.id.cmp(&right.id));
1093        artifacts.push(PluginPackageArtifact {
1094            transport: artifact.transport,
1095            target: artifact.target.clone(),
1096            format: artifact.format,
1097            path: artifact.path.clone(),
1098            architecture: artifact.architecture.clone(),
1099            capabilities: artifact.capabilities.clone(),
1100            minimum_os: artifact.minimum_os.clone(),
1101            sha256: prepared.sha256.clone(),
1102            runtime_dependencies,
1103        });
1104        entries.push(prepared);
1105    }
1106    for file in project.package_files() {
1107        if reserved_paths.contains(&crate::plugin_project::normalized_package_path(&file.path)) {
1108            return Err(PluginPackageError::InvalidPackage(format!(
1109                "package file path '{}' is reserved",
1110                file.path
1111            )));
1112        }
1113        entries.push(prepare_file_entry(
1114            base_directory,
1115            &file.source,
1116            &file.path,
1117            PACKAGE_METADATA_FILE_MODE,
1118        )?);
1119    }
1120
1121    artifacts.sort_by(|left, right| {
1122        (
1123            left.transport.as_str(),
1124            &left.target,
1125            &left.architecture,
1126            &left.path,
1127        )
1128            .cmp(&(
1129                right.transport.as_str(),
1130                &right.target,
1131                &right.architecture,
1132                &right.path,
1133            ))
1134    });
1135    let mut manifest = PluginPackageManifest {
1136        schema_version: PACKAGE_MANIFEST_SCHEMA_VERSION,
1137        plugin: descriptor.plugin,
1138        compatibility: descriptor.compatibility,
1139        capabilities: descriptor.capabilities,
1140        requires: descriptor.requires,
1141        provides: descriptor.provides,
1142        artifacts,
1143        redistribution: descriptor.redistribution,
1144        generated_by: PluginPackageGenerator {
1145            vesper: env!("CARGO_PKG_VERSION").to_owned(),
1146            sdk: env!("CARGO_PKG_VERSION").to_owned(),
1147        },
1148    };
1149    let manifest_bytes = canonical_manifest_bytes(&mut manifest)?;
1150    entries.push(prepared_bytes_entry(
1151        PLUGIN_PACKAGE_MANIFEST_PATH,
1152        manifest_bytes,
1153        PACKAGE_METADATA_FILE_MODE,
1154    )?);
1155    entries.sort_by(|left, right| left.path.cmp(&right.path));
1156
1157    let checksums = entries
1158        .iter()
1159        .map(|entry| (entry.path.clone(), entry.sha256.clone()))
1160        .collect::<BTreeMap<_, _>>();
1161    let checksums_bytes = canonical_checksums(&checksums);
1162    let signature = signing_key.key.sign(&signature_message(&checksums_bytes));
1163    let signature_bytes = serde_json::to_vec(&PluginPackageSignature {
1164        schema_version: SIGNATURE_SCHEMA_VERSION,
1165        algorithm: SIGNATURE_ALGORITHM.to_owned(),
1166        publisher: signing_key.publisher.clone(),
1167        key_id: signing_key.key_id.clone(),
1168        signature: encode_hex(&signature.to_bytes()),
1169    })?;
1170    entries.push(prepared_bytes_entry(
1171        PLUGIN_PACKAGE_CHECKSUMS_PATH,
1172        checksums_bytes,
1173        PACKAGE_METADATA_FILE_MODE,
1174    )?);
1175    entries.push(prepared_bytes_entry(
1176        PLUGIN_PACKAGE_SIGNATURE_PATH,
1177        signature_bytes,
1178        PACKAGE_METADATA_FILE_MODE,
1179    )?);
1180    entries.sort_by(|left, right| left.path.cmp(&right.path));
1181
1182    write_package_atomically(output, &entries)?;
1183    let package_sha256 = sha256_file(output)?;
1184    Ok(PluginPackageBuildReport {
1185        package_path: output.to_path_buf(),
1186        plugin_id: manifest.plugin.id,
1187        publisher: manifest.plugin.publisher,
1188        key_id: signing_key.key_id.clone(),
1189        artifact_count: manifest.artifacts.len(),
1190        package_sha256,
1191    })
1192}
1193
1194fn canonical_manifest_bytes(
1195    manifest: &mut PluginPackageManifest,
1196) -> Result<Vec<u8>, PluginPackageError> {
1197    if manifest.schema_version != PACKAGE_MANIFEST_SCHEMA_VERSION {
1198        return Err(PluginPackageError::InvalidPackage(format!(
1199            "manifest schema_version must be {PACKAGE_MANIFEST_SCHEMA_VERSION}"
1200        )));
1201    }
1202    let descriptor = PluginDescriptor {
1203        schema_version: manifest.schema_version,
1204        plugin: manifest.plugin.clone(),
1205        compatibility: manifest.compatibility.clone(),
1206        capabilities: manifest.capabilities.clone(),
1207        requires: manifest.requires.clone(),
1208        provides: manifest.provides.clone(),
1209        redistribution: manifest.redistribution.clone(),
1210    }
1211    .canonicalize()?
1212    .descriptor()
1213    .clone();
1214    manifest.plugin = descriptor.plugin;
1215    manifest.compatibility = descriptor.compatibility;
1216    manifest.capabilities = descriptor.capabilities;
1217    manifest.requires = descriptor.requires;
1218    manifest.provides = descriptor.provides;
1219    manifest.redistribution = descriptor.redistribution;
1220
1221    if manifest.artifacts.is_empty()
1222        || manifest.artifacts.len() > crate::plugin_project::MAX_ARTIFACTS
1223    {
1224        return Err(PluginPackageError::InvalidPackage(format!(
1225            "manifest artifacts must contain 1 to {} entries",
1226            crate::plugin_project::MAX_ARTIFACTS
1227        )));
1228    }
1229    let mut paths = HashSet::with_capacity(manifest.artifacts.len());
1230    let mut selectors = HashSet::with_capacity(manifest.artifacts.len());
1231    let descriptor_capabilities = manifest
1232        .capabilities
1233        .iter()
1234        .map(|capability| {
1235            (
1236                capability.interface_id.as_str(),
1237                capability.instance_id.as_str(),
1238            )
1239        })
1240        .collect::<HashSet<_>>();
1241    let mut covered_capabilities = HashSet::with_capacity(descriptor_capabilities.len());
1242    for artifact in &mut manifest.artifacts {
1243        crate::plugin_project::validate_archive_path("artifacts.path", &artifact.path)?;
1244        crate::plugin_project::insert_archive_file_path(&mut paths, &artifact.path)?;
1245        validate_package_text(
1246            "artifacts.target",
1247            &artifact.target,
1248            crate::plugin_project::MAX_TARGET_BYTES,
1249        )?;
1250        validate_package_text(
1251            "artifacts.architecture",
1252            &artifact.architecture,
1253            crate::plugin_project::MAX_ARCHITECTURE_BYTES,
1254        )?;
1255        if let Some(minimum_os) = artifact.minimum_os.as_deref() {
1256            validate_package_text(
1257                "artifacts.minimum_os",
1258                minimum_os,
1259                crate::plugin_project::MAX_MINIMUM_OS_BYTES,
1260            )?;
1261        }
1262        match (artifact.transport, artifact.format) {
1263            (PluginArtifactTransport::Wasm, PluginArtifactFormat::WasmComponent)
1264            | (PluginArtifactTransport::Native, PluginArtifactFormat::Dylib)
1265            | (PluginArtifactTransport::Native, PluginArtifactFormat::Aar)
1266            | (PluginArtifactTransport::Native, PluginArtifactFormat::Xcframework) => {}
1267            _ => {
1268                return Err(PluginPackageError::InvalidPackage(format!(
1269                    "artifact format '{}' is incompatible with transport '{}'",
1270                    artifact.format.as_str(),
1271                    artifact.transport.as_str()
1272                )));
1273            }
1274        }
1275        let selector = (
1276            artifact.transport,
1277            artifact.target.clone(),
1278            artifact.architecture.clone(),
1279        );
1280        if !selectors.insert(selector) {
1281            return Err(PluginPackageError::InvalidPackage(format!(
1282                "ambiguous artifact target '{}:{}:{}'",
1283                artifact.transport.as_str(),
1284                artifact.target,
1285                artifact.architecture
1286            )));
1287        }
1288        if artifact.capabilities.is_empty()
1289            || artifact.capabilities.len() > descriptor_capabilities.len()
1290        {
1291            return Err(PluginPackageError::InvalidPackage(format!(
1292                "artifact capabilities must contain 1 to {} descriptor capability references",
1293                descriptor_capabilities.len()
1294            )));
1295        }
1296        artifact.capabilities.sort_by(|left, right| {
1297            (&left.interface_id, &left.instance_id).cmp(&(&right.interface_id, &right.instance_id))
1298        });
1299        let mut artifact_capabilities = HashSet::with_capacity(artifact.capabilities.len());
1300        for capability in &artifact.capabilities {
1301            let key = (
1302                capability.interface_id.as_str(),
1303                capability.instance_id.as_str(),
1304            );
1305            if !descriptor_capabilities.contains(&key) || !artifact_capabilities.insert(key) {
1306                return Err(PluginPackageError::InvalidPackage(format!(
1307                    "artifact capability '{}:{}' is absent from the descriptor or duplicated",
1308                    capability.interface_id, capability.instance_id
1309                )));
1310            }
1311            covered_capabilities.insert(key);
1312        }
1313        validate_sha256(&artifact.sha256)?;
1314        if artifact.runtime_dependencies.len() > crate::plugin_project::MAX_RUNTIME_DEPENDENCIES {
1315            return Err(PluginPackageError::InvalidPackage(format!(
1316                "artifacts.runtime_dependencies must contain at most {} entries",
1317                crate::plugin_project::MAX_RUNTIME_DEPENDENCIES
1318            )));
1319        }
1320        artifact
1321            .runtime_dependencies
1322            .sort_by(|left, right| left.id.cmp(&right.id));
1323        let mut dependency_ids = HashSet::with_capacity(artifact.runtime_dependencies.len());
1324        for dependency in &artifact.runtime_dependencies {
1325            validate_reverse_dns_identifier(&dependency.id)
1326                .map_err(PluginPackageError::InvalidPackage)?;
1327            validate_package_text(
1328                "artifacts.runtime_dependencies.version",
1329                &dependency.version,
1330                crate::plugin_project::MAX_RUNTIME_VALUE_BYTES,
1331            )?;
1332            validate_package_text(
1333                "artifacts.runtime_dependencies.compatibility_key",
1334                &dependency.compatibility_key,
1335                crate::plugin_project::MAX_RUNTIME_VALUE_BYTES,
1336            )?;
1337            if !dependency_ids.insert(dependency.id.as_str()) {
1338                return Err(PluginPackageError::InvalidPackage(format!(
1339                    "invalid or duplicate runtime dependency '{}'",
1340                    dependency.id
1341                )));
1342            }
1343        }
1344    }
1345    if covered_capabilities != descriptor_capabilities {
1346        return Err(PluginPackageError::InvalidPackage(
1347            "every descriptor capability must be provided by at least one artifact".to_owned(),
1348        ));
1349    }
1350    manifest.artifacts.sort_by(|left, right| {
1351        (
1352            left.transport.as_str(),
1353            &left.target,
1354            &left.architecture,
1355            &left.path,
1356        )
1357            .cmp(&(
1358                right.transport.as_str(),
1359                &right.target,
1360                &right.architecture,
1361                &right.path,
1362            ))
1363    });
1364    if manifest.generated_by.vesper.is_empty() || manifest.generated_by.sdk.is_empty() {
1365        return Err(PluginPackageError::InvalidPackage(
1366            "manifest generated_by versions must not be empty".to_owned(),
1367        ));
1368    }
1369    Ok(serde_json::to_vec(manifest)?)
1370}
1371
1372fn prepare_file_entry(
1373    base_directory: &Path,
1374    source: &Path,
1375    package_path: &str,
1376    mode: u32,
1377) -> Result<PreparedEntry, PluginPackageError> {
1378    let resolved = if source.is_absolute() {
1379        source.to_path_buf()
1380    } else {
1381        base_directory.join(source)
1382    };
1383    let metadata = fs::symlink_metadata(&resolved).map_err(|source| PluginPackageError::Io {
1384        operation: "inspect package input",
1385        path: resolved.display().to_string(),
1386        source,
1387    })?;
1388    if !metadata.file_type().is_file() {
1389        return Err(PluginPackageError::InvalidPackage(format!(
1390            "package input '{}' is not a regular non-symlink file",
1391            resolved.display()
1392        )));
1393    }
1394    if metadata.len() > MAX_PLUGIN_PACKAGE_ENTRY_BYTES {
1395        return Err(PluginPackageError::InvalidPackage(format!(
1396            "package input '{}' exceeds {MAX_PLUGIN_PACKAGE_ENTRY_BYTES} bytes",
1397            resolved.display()
1398        )));
1399    }
1400    let mut input = File::open(&resolved).map_err(|source| PluginPackageError::Io {
1401        operation: "open package input",
1402        path: resolved.display().to_string(),
1403        source,
1404    })?;
1405    let opened_metadata = input.metadata().map_err(|source| PluginPackageError::Io {
1406        operation: "inspect opened package input",
1407        path: resolved.display().to_string(),
1408        source,
1409    })?;
1410    if !opened_metadata.file_type().is_file() {
1411        return Err(PluginPackageError::InvalidPackage(format!(
1412            "package input '{}' did not open as a regular file",
1413            resolved.display()
1414        )));
1415    }
1416    let mut snapshot = tempfile::tempfile().map_err(|source| PluginPackageError::Io {
1417        operation: "create package input snapshot",
1418        path: resolved.display().to_string(),
1419        source,
1420    })?;
1421    let mut hasher = Sha256::new();
1422    let mut size = 0_u64;
1423    let mut buffer = [0_u8; 64 * 1024];
1424    loop {
1425        let read = input
1426            .read(&mut buffer)
1427            .map_err(|source| PluginPackageError::Io {
1428                operation: "read package input snapshot",
1429                path: resolved.display().to_string(),
1430                source,
1431            })?;
1432        if read == 0 {
1433            break;
1434        }
1435        size = size
1436            .checked_add(u64::try_from(read).map_err(|_| {
1437                PluginPackageError::InvalidPackage("package input size overflow".to_owned())
1438            })?)
1439            .ok_or_else(|| {
1440                PluginPackageError::InvalidPackage("package input size overflow".to_owned())
1441            })?;
1442        if size > MAX_PLUGIN_PACKAGE_ENTRY_BYTES {
1443            return Err(PluginPackageError::InvalidPackage(format!(
1444                "package input '{}' exceeds {MAX_PLUGIN_PACKAGE_ENTRY_BYTES} bytes",
1445                resolved.display()
1446            )));
1447        }
1448        snapshot
1449            .write_all(&buffer[..read])
1450            .map_err(|source| PluginPackageError::Io {
1451                operation: "write package input snapshot",
1452                path: resolved.display().to_string(),
1453                source,
1454            })?;
1455        hasher.update(&buffer[..read]);
1456    }
1457    snapshot
1458        .seek(SeekFrom::Start(0))
1459        .map_err(|source| PluginPackageError::Io {
1460            operation: "rewind package input snapshot",
1461            path: resolved.display().to_string(),
1462            source,
1463        })?;
1464    Ok(PreparedEntry {
1465        path: package_path.to_owned(),
1466        sha256: hex::encode(hasher.finalize()),
1467        size,
1468        data: PreparedEntryData::Snapshot {
1469            file: snapshot,
1470            source_path: resolved,
1471        },
1472        mode,
1473    })
1474}
1475
1476fn prepared_bytes_entry(
1477    path: &str,
1478    bytes: Vec<u8>,
1479    mode: u32,
1480) -> Result<PreparedEntry, PluginPackageError> {
1481    let size = u64::try_from(bytes.len()).map_err(|_| {
1482        PluginPackageError::InvalidPackage(format!("metadata entry '{path}' is too large"))
1483    })?;
1484    if size > MAX_SMALL_METADATA_BYTES {
1485        return Err(PluginPackageError::InvalidPackage(format!(
1486            "metadata entry '{path}' exceeds {MAX_SMALL_METADATA_BYTES} bytes"
1487        )));
1488    }
1489    Ok(PreparedEntry {
1490        path: path.to_owned(),
1491        sha256: hex::encode(Sha256::digest(&bytes)),
1492        size,
1493        data: PreparedEntryData::Bytes(bytes),
1494        mode,
1495    })
1496}
1497
1498fn canonical_checksums(checksums: &BTreeMap<String, String>) -> Vec<u8> {
1499    let mut bytes = Vec::new();
1500    for (path, checksum) in checksums {
1501        bytes.extend_from_slice(checksum.as_bytes());
1502        bytes.extend_from_slice(b"  ");
1503        bytes.extend_from_slice(path.as_bytes());
1504        bytes.push(b'\n');
1505    }
1506    bytes
1507}
1508
1509fn signature_message(checksums: &[u8]) -> Vec<u8> {
1510    let mut message = SIGNATURE_DOMAIN.to_vec();
1511    message.extend_from_slice(checksums);
1512    message
1513}
1514
1515fn write_package_atomically(
1516    output: &Path,
1517    entries: &[PreparedEntry],
1518) -> Result<(), PluginPackageError> {
1519    validate_prepared_package_size(entries)?;
1520    let parent = output
1521        .parent()
1522        .filter(|parent| !parent.as_os_str().is_empty())
1523        .unwrap_or_else(|| Path::new("."));
1524    if !parent.is_dir() {
1525        return Err(PluginPackageError::InvalidPackage(format!(
1526            "output directory '{}' is not a directory",
1527            parent.display()
1528        )));
1529    }
1530    let mut temporary =
1531        tempfile::NamedTempFile::new_in(parent).map_err(|source| PluginPackageError::Io {
1532            operation: "create package staging file",
1533            path: parent.display().to_string(),
1534            source,
1535        })?;
1536    {
1537        let mut archive = ZipWriter::new(temporary.as_file_mut());
1538        for entry in entries {
1539            let options = SimpleFileOptions::default()
1540                .compression_method(CompressionMethod::Stored)
1541                .last_modified_time(DateTime::default())
1542                .unix_permissions(entry.mode)
1543                .large_file(entry.size >= u32::MAX as u64);
1544            archive.start_file(&entry.path, options)?;
1545            match &entry.data {
1546                PreparedEntryData::Bytes(bytes) => {
1547                    archive
1548                        .write_all(bytes)
1549                        .map_err(|source| PluginPackageError::Io {
1550                            operation: "write package metadata",
1551                            path: entry.path.clone(),
1552                            source,
1553                        })?
1554                }
1555                PreparedEntryData::Snapshot { file, source_path } => {
1556                    let mut input = file.try_clone().map_err(|source| PluginPackageError::Io {
1557                        operation: "clone package input snapshot",
1558                        path: source_path.display().to_string(),
1559                        source,
1560                    })?;
1561                    input
1562                        .seek(SeekFrom::Start(0))
1563                        .map_err(|source| PluginPackageError::Io {
1564                            operation: "rewind package input snapshot",
1565                            path: source_path.display().to_string(),
1566                            source,
1567                        })?;
1568                    let copied = io::copy(&mut input, &mut archive).map_err(|source| {
1569                        PluginPackageError::Io {
1570                            operation: "write package input",
1571                            path: source_path.display().to_string(),
1572                            source,
1573                        }
1574                    })?;
1575                    if copied != entry.size {
1576                        return Err(PluginPackageError::InvalidPackage(format!(
1577                            "package input snapshot '{}' changed while writing",
1578                            source_path.display()
1579                        )));
1580                    }
1581                }
1582            }
1583        }
1584        archive.finish()?;
1585    }
1586    let staged_size = temporary
1587        .as_file()
1588        .metadata()
1589        .map_err(|source| PluginPackageError::Io {
1590            operation: "inspect package staging file",
1591            path: output.display().to_string(),
1592            source,
1593        })?
1594        .len();
1595    if staged_size > MAX_PLUGIN_PACKAGE_BYTES {
1596        return Err(PluginPackageError::InvalidPackage(format!(
1597            "generated package exceeds {MAX_PLUGIN_PACKAGE_BYTES} bytes"
1598        )));
1599    }
1600    temporary
1601        .as_file()
1602        .sync_all()
1603        .map_err(|source| PluginPackageError::Io {
1604            operation: "sync package staging file",
1605            path: output.display().to_string(),
1606            source,
1607        })?;
1608    temporary
1609        .persist(output)
1610        .map_err(|error| PluginPackageError::Io {
1611            operation: "atomically replace package",
1612            path: output.display().to_string(),
1613            source: error.error,
1614        })?;
1615    Ok(())
1616}
1617
1618fn validate_prepared_package_size(entries: &[PreparedEntry]) -> Result<(), PluginPackageError> {
1619    if entries.len() > MAX_PLUGIN_PACKAGE_ENTRIES {
1620        return Err(PluginPackageError::InvalidPackage(format!(
1621            "package exceeds the {MAX_PLUGIN_PACKAGE_ENTRIES}-entry limit"
1622        )));
1623    }
1624    let total = entries.iter().try_fold(0_u64, |total, entry| {
1625        total.checked_add(entry.size).ok_or_else(|| {
1626            PluginPackageError::InvalidPackage("aggregate package input size overflow".to_owned())
1627        })
1628    })?;
1629    if total > MAX_PLUGIN_PACKAGE_BYTES {
1630        return Err(PluginPackageError::InvalidPackage(format!(
1631            "aggregate package input exceeds {MAX_PLUGIN_PACKAGE_BYTES} bytes"
1632        )));
1633    }
1634    Ok(())
1635}
1636
1637fn sha256_file(path: &Path) -> Result<String, PluginPackageError> {
1638    let file = File::open(path).map_err(|source| PluginPackageError::Io {
1639        operation: "open file for hashing",
1640        path: path.display().to_string(),
1641        source,
1642    })?;
1643    sha256_open_file(&file, path)
1644}
1645
1646fn sha256_open_file(file: &File, path: &Path) -> Result<String, PluginPackageError> {
1647    let mut file = file.try_clone().map_err(|source| PluginPackageError::Io {
1648        operation: "clone file handle for hashing",
1649        path: path.display().to_string(),
1650        source,
1651    })?;
1652    file.seek(SeekFrom::Start(0))
1653        .map_err(|source| PluginPackageError::Io {
1654            operation: "rewind file for hashing",
1655            path: path.display().to_string(),
1656            source,
1657        })?;
1658    let mut hasher = Sha256::new();
1659    let mut buffer = [0_u8; 64 * 1024];
1660    loop {
1661        let read = file
1662            .read(&mut buffer)
1663            .map_err(|source| PluginPackageError::Io {
1664                operation: "read file for hashing",
1665                path: path.display().to_string(),
1666                source,
1667            })?;
1668        if read == 0 {
1669            break;
1670        }
1671        hasher.update(&buffer[..read]);
1672    }
1673    Ok(hex::encode(hasher.finalize()))
1674}
1675
1676fn validate_sha256(value: &str) -> Result<(), PluginPackageError> {
1677    if value.len() != 64
1678        || !value
1679            .bytes()
1680            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1681    {
1682        return Err(PluginPackageError::InvalidPackage(
1683            "SHA-256 values must contain 64 lowercase hexadecimal characters".to_owned(),
1684        ));
1685    }
1686    Ok(())
1687}
1688
1689fn validate_package_text(
1690    field: &str,
1691    value: &str,
1692    maximum_bytes: usize,
1693) -> Result<(), PluginPackageError> {
1694    if value.is_empty() || value.len() > maximum_bytes {
1695        return Err(PluginPackageError::InvalidPackage(format!(
1696            "{field} must contain 1 to {maximum_bytes} UTF-8 bytes"
1697        )));
1698    }
1699    Ok(())
1700}
1701
1702impl VerifiedPluginPackage {
1703    pub fn manifest(&self) -> &PluginPackageManifest {
1704        &self.manifest
1705    }
1706
1707    pub fn verification(&self) -> &PluginPackageVerification {
1708        &self.verification
1709    }
1710}
1711
1712pub fn verify_signed_plugin_package(
1713    package_path: &Path,
1714    trust_store: &PluginTrustStore,
1715) -> Result<VerifiedPluginPackage, PluginPackageError> {
1716    let package_metadata =
1717        fs::symlink_metadata(package_path).map_err(|source| PluginPackageError::Io {
1718            operation: "inspect plugin package",
1719            path: package_path.display().to_string(),
1720            source,
1721        })?;
1722    if !package_metadata.file_type().is_file() {
1723        return Err(PluginPackageError::InvalidPackage(format!(
1724            "'{}' is not a regular non-symlink package file",
1725            package_path.display()
1726        )));
1727    }
1728    if package_metadata.len() > MAX_PLUGIN_PACKAGE_BYTES {
1729        return Err(PluginPackageError::InvalidPackage(format!(
1730            "package exceeds {MAX_PLUGIN_PACKAGE_BYTES} bytes"
1731        )));
1732    }
1733    let package_file = File::open(package_path).map_err(|source| PluginPackageError::Io {
1734        operation: "open plugin package",
1735        path: package_path.display().to_string(),
1736        source,
1737    })?;
1738    let archive_file = package_file
1739        .try_clone()
1740        .map_err(|source| PluginPackageError::Io {
1741            operation: "clone verified plugin package handle",
1742            path: package_path.display().to_string(),
1743            source,
1744        })?;
1745    let mut archive = ZipArchive::new(archive_file)?;
1746    if archive.len() < 4 || archive.len() > MAX_PLUGIN_PACKAGE_ENTRIES {
1747        return Err(PluginPackageError::InvalidPackage(format!(
1748            "archive must contain 4 to {MAX_PLUGIN_PACKAGE_ENTRIES} entries"
1749        )));
1750    }
1751
1752    let mut normalized_paths = HashSet::with_capacity(archive.len());
1753    let mut archive_paths = HashSet::with_capacity(archive.len());
1754    let mut verified_entries = Vec::with_capacity(archive.len());
1755    let mut total_size = 0_u64;
1756    for index in 0..archive.len() {
1757        let entry = archive.by_index(index)?;
1758        let path = entry.name().to_owned();
1759        crate::plugin_project::validate_archive_path("archive entry", &path)?;
1760        if !entry.is_file() || entry.encrypted() {
1761            return Err(PluginPackageError::InvalidPackage(format!(
1762                "archive entry '{path}' must be an unencrypted regular file"
1763            )));
1764        }
1765        let unix_mode = entry.unix_mode().ok_or_else(|| {
1766            PluginPackageError::InvalidPackage(format!(
1767                "archive entry '{path}' is missing canonical Unix file metadata"
1768            ))
1769        })?;
1770        if unix_mode & UNIX_FILE_TYPE_MASK != UNIX_REGULAR_FILE {
1771            return Err(PluginPackageError::InvalidPackage(format!(
1772                "archive entry '{path}' has an unsupported Unix file type"
1773            )));
1774        }
1775        crate::plugin_project::insert_archive_file_path(&mut normalized_paths, &path)
1776            .map_err(|error| PluginPackageError::InvalidPackage(error.to_string()))?;
1777        archive_paths.insert(path.clone());
1778        if entry.size() > MAX_PLUGIN_PACKAGE_ENTRY_BYTES {
1779            return Err(PluginPackageError::InvalidPackage(format!(
1780                "archive entry '{path}' exceeds {MAX_PLUGIN_PACKAGE_ENTRY_BYTES} bytes"
1781            )));
1782        }
1783        total_size = total_size.checked_add(entry.size()).ok_or_else(|| {
1784            PluginPackageError::InvalidPackage("archive size sum overflowed".to_owned())
1785        })?;
1786        if total_size > MAX_PLUGIN_PACKAGE_BYTES {
1787            return Err(PluginPackageError::InvalidPackage(format!(
1788                "archive expands beyond {MAX_PLUGIN_PACKAGE_BYTES} bytes"
1789            )));
1790        }
1791        if entry.size() > MAX_SMALL_METADATA_BYTES
1792            && (entry.compressed_size() == 0
1793                || entry
1794                    .compressed_size()
1795                    .saturating_mul(MAX_COMPRESSION_RATIO)
1796                    < entry.size())
1797        {
1798            return Err(PluginPackageError::InvalidPackage(format!(
1799                "archive entry '{path}' exceeds the {MAX_COMPRESSION_RATIO}:1 compression ratio limit"
1800            )));
1801        }
1802        if matches!(
1803            path.as_str(),
1804            PLUGIN_PACKAGE_MANIFEST_PATH
1805                | PLUGIN_PACKAGE_CHECKSUMS_PATH
1806                | PLUGIN_PACKAGE_SIGNATURE_PATH
1807        ) && entry.size() > MAX_SMALL_METADATA_BYTES
1808        {
1809            return Err(PluginPackageError::InvalidPackage(format!(
1810                "metadata entry '{path}' exceeds {MAX_SMALL_METADATA_BYTES} bytes"
1811            )));
1812        }
1813        verified_entries.push(VerifiedPackageEntry {
1814            path,
1815            size: entry.size(),
1816            mode: unix_mode & 0o777,
1817            sha256: String::new(),
1818        });
1819    }
1820
1821    for required in [
1822        PLUGIN_PACKAGE_MANIFEST_PATH,
1823        PLUGIN_PACKAGE_CHECKSUMS_PATH,
1824        PLUGIN_PACKAGE_SIGNATURE_PATH,
1825    ] {
1826        if !archive_paths.contains(required) {
1827            return Err(PluginPackageError::InvalidPackage(format!(
1828                "archive is missing required entry '{required}'"
1829            )));
1830        }
1831    }
1832    if archive_paths.contains(INSTALL_MARKER_PATH) {
1833        return Err(PluginPackageError::InvalidPackage(format!(
1834            "archive entry '{INSTALL_MARKER_PATH}' is reserved for installation metadata"
1835        )));
1836    }
1837
1838    let checksums_bytes = read_bounded_zip_entry(
1839        &mut archive,
1840        PLUGIN_PACKAGE_CHECKSUMS_PATH,
1841        MAX_SMALL_METADATA_BYTES,
1842    )?;
1843    let checksums = parse_canonical_checksums(&checksums_bytes)?;
1844    let expected_checksum_paths = archive_paths
1845        .iter()
1846        .filter(|path| {
1847            path.as_str() != PLUGIN_PACKAGE_CHECKSUMS_PATH
1848                && path.as_str() != PLUGIN_PACKAGE_SIGNATURE_PATH
1849        })
1850        .cloned()
1851        .collect::<HashSet<_>>();
1852    let actual_checksum_paths = checksums.keys().cloned().collect::<HashSet<_>>();
1853    if expected_checksum_paths != actual_checksum_paths {
1854        return Err(PluginPackageError::InvalidPackage(
1855            "SHA256SUMS must cover every manifest and payload entry exactly once".to_owned(),
1856        ));
1857    }
1858
1859    let manifest_bytes = read_bounded_zip_entry(
1860        &mut archive,
1861        PLUGIN_PACKAGE_MANIFEST_PATH,
1862        MAX_SMALL_METADATA_BYTES,
1863    )?;
1864    let mut manifest: PluginPackageManifest = serde_json::from_slice(&manifest_bytes)
1865        .map_err(|error| PluginPackageError::InvalidPackage(error.to_string()))?;
1866    let canonical_manifest = canonical_manifest_bytes(&mut manifest)?;
1867    if canonical_manifest != manifest_bytes {
1868        return Err(PluginPackageError::InvalidPackage(
1869            "manifest.json is not canonical JSON".to_owned(),
1870        ));
1871    }
1872    let artifact_paths = manifest
1873        .artifacts
1874        .iter()
1875        .map(|artifact| artifact.path.as_str())
1876        .collect::<HashSet<_>>();
1877    for entry in &verified_entries {
1878        let expected_mode = if artifact_paths.contains(entry.path.as_str()) {
1879            ARTIFACT_FILE_MODE
1880        } else {
1881            PACKAGE_METADATA_FILE_MODE
1882        };
1883        if entry.mode != expected_mode {
1884            return Err(PluginPackageError::InvalidPackage(format!(
1885                "archive entry '{}' has non-canonical permissions {:o}; expected {:o}",
1886                entry.path, entry.mode, expected_mode
1887            )));
1888        }
1889    }
1890    for artifact in &manifest.artifacts {
1891        let checksum = checksums.get(&artifact.path).ok_or_else(|| {
1892            PluginPackageError::InvalidPackage(format!(
1893                "artifact '{}' is absent from SHA256SUMS",
1894                artifact.path
1895            ))
1896        })?;
1897        if checksum != &artifact.sha256 {
1898            return Err(PluginPackageError::InvalidPackage(format!(
1899                "artifact '{}' hash disagrees with SHA256SUMS",
1900                artifact.path
1901            )));
1902        }
1903    }
1904    if !archive_paths
1905        .iter()
1906        .any(|path| path.starts_with("licenses/"))
1907        || !archive_paths
1908            .iter()
1909            .any(|path| path.starts_with("notices/"))
1910    {
1911        return Err(PluginPackageError::InvalidPackage(
1912            "archive must contain license and notice entries".to_owned(),
1913        ));
1914    }
1915
1916    let signature_bytes = read_bounded_zip_entry(
1917        &mut archive,
1918        PLUGIN_PACKAGE_SIGNATURE_PATH,
1919        MAX_SMALL_METADATA_BYTES,
1920    )?;
1921    let signature: PluginPackageSignature = serde_json::from_slice(&signature_bytes)
1922        .map_err(|error| PluginPackageError::InvalidPackage(error.to_string()))?;
1923    if serde_json::to_vec(&signature)? != signature_bytes {
1924        return Err(PluginPackageError::InvalidPackage(
1925            "signature.json is not canonical JSON".to_owned(),
1926        ));
1927    }
1928    if signature.schema_version != SIGNATURE_SCHEMA_VERSION
1929        || signature.algorithm != SIGNATURE_ALGORITHM
1930    {
1931        return Err(PluginPackageError::InvalidSignature);
1932    }
1933    if signature.publisher != manifest.plugin.publisher {
1934        return Err(PluginPackageError::InvalidSignature);
1935    }
1936    validate_sha256(&signature.key_id)?;
1937    let signature_value =
1938        decode_hex::<64>(&signature.signature).map_err(|_| PluginPackageError::InvalidSignature)?;
1939    let verifying_key = trust_store.verifying_key(&signature.publisher, &signature.key_id)?;
1940    verifying_key
1941        .verify(
1942            &signature_message(&checksums_bytes),
1943            &Signature::from_bytes(&signature_value),
1944        )
1945        .map_err(|_| PluginPackageError::InvalidSignature)?;
1946
1947    for entry in &mut verified_entries {
1948        let actual = sha256_zip_entry(&mut archive, &entry.path)?;
1949        if let Some(expected) = checksums.get(&entry.path)
1950            && &actual != expected
1951        {
1952            return Err(PluginPackageError::InvalidPackage(format!(
1953                "checksum mismatch for archive entry '{}'",
1954                entry.path
1955            )));
1956        }
1957        entry.sha256 = actual;
1958    }
1959
1960    verified_entries.sort_by(|left, right| left.path.cmp(&right.path));
1961    let package_sha256 = sha256_open_file(&package_file, package_path)?;
1962    let verification = PluginPackageVerification {
1963        package_path: package_path.to_path_buf(),
1964        plugin_id: manifest.plugin.id.clone(),
1965        version: manifest.plugin.version.clone(),
1966        publisher: manifest.plugin.publisher.clone(),
1967        key_id: signature.key_id,
1968        artifact_count: manifest.artifacts.len(),
1969        package_sha256,
1970    };
1971    Ok(VerifiedPluginPackage {
1972        package_file: Arc::new(package_file),
1973        manifest,
1974        verification,
1975        entries: verified_entries,
1976    })
1977}
1978
1979pub fn install_verified_plugin_package(
1980    verified: &VerifiedPluginPackage,
1981    install_root: &Path,
1982) -> Result<PluginInstallationReport, PluginPackageError> {
1983    ensure_directory(install_root, "plugin install root")?;
1984    let _catalog_lock = PluginCatalogLock::acquire(install_root)?;
1985    let plugin_root = install_root.join(&verified.manifest.plugin.id);
1986    let mut identity_rollback = None;
1987    if plugin_root.exists() {
1988        require_existing_directory(&plugin_root, "plugin install identity directory")?;
1989    } else {
1990        ensure_plugin_identity_capacity(install_root)?;
1991        fs::create_dir(&plugin_root).map_err(|source| PluginPackageError::Io {
1992            operation: "create plugin install identity directory",
1993            path: plugin_root.display().to_string(),
1994            source,
1995        })?;
1996        identity_rollback = Some(EmptyIdentityDirectoryRollback::new(plugin_root.clone()));
1997        sync_directory(install_root)?;
1998    }
1999    let target = plugin_root.join(&verified.manifest.plugin.version);
2000    if target.exists() {
2001        require_existing_directory(&target, "existing plugin installation")?;
2002        let marker = read_bounded_file(
2003            &target.join(INSTALL_MARKER_PATH),
2004            65,
2005            "installed package marker",
2006        )?;
2007        let marker = std::str::from_utf8(&marker)
2008            .map_err(|_| {
2009                PluginPackageError::InvalidPackage(
2010                    "installed package marker is not UTF-8".to_owned(),
2011                )
2012            })?
2013            .trim_end_matches('\n');
2014        if marker == verified.verification.package_sha256 {
2015            return Ok(PluginInstallationReport {
2016                plugin_id: verified.manifest.plugin.id.clone(),
2017                version: verified.manifest.plugin.version.clone(),
2018                install_path: target.clone(),
2019                package_sha256: verified.verification.package_sha256.clone(),
2020                already_installed: true,
2021            });
2022        }
2023        return Err(PluginPackageError::InvalidPackage(format!(
2024            "plugin '{}' version '{}' is already installed from a different package",
2025            verified.manifest.plugin.id, verified.manifest.plugin.version
2026        )));
2027    }
2028    ensure_plugin_version_capacity(&plugin_root, &verified.manifest.plugin.id)?;
2029
2030    let staging = tempfile::Builder::new()
2031        .prefix(".vesper-staging-")
2032        .tempdir_in(&plugin_root)
2033        .map_err(|source| PluginPackageError::Io {
2034            operation: "create plugin install staging directory",
2035            path: plugin_root.display().to_string(),
2036            source,
2037        })?;
2038    extract_verified_entries(verified, staging.path())?;
2039    let marker_path = staging.path().join(INSTALL_MARKER_PATH);
2040    let mut marker = fs::OpenOptions::new()
2041        .write(true)
2042        .create_new(true)
2043        .open(&marker_path)
2044        .map_err(|source| PluginPackageError::Io {
2045            operation: "create installed package marker",
2046            path: marker_path.display().to_string(),
2047            source,
2048        })?;
2049    marker
2050        .write_all(verified.verification.package_sha256.as_bytes())
2051        .and_then(|()| marker.write_all(b"\n"))
2052        .and_then(|()| marker.sync_all())
2053        .map_err(|source| PluginPackageError::Io {
2054            operation: "write installed package marker",
2055            path: marker_path.display().to_string(),
2056            source,
2057        })?;
2058    sync_directory(staging.path())?;
2059    fs::rename(staging.path(), &target).map_err(|source| PluginPackageError::Io {
2060        operation: "atomically promote plugin installation",
2061        path: target.display().to_string(),
2062        source,
2063    })?;
2064    if let Some(rollback) = &mut identity_rollback {
2065        rollback.disarm();
2066    }
2067    sync_directory(&plugin_root)?;
2068    sync_directory(install_root)?;
2069    Ok(PluginInstallationReport {
2070        plugin_id: verified.manifest.plugin.id.clone(),
2071        version: verified.manifest.plugin.version.clone(),
2072        install_path: target,
2073        package_sha256: verified.verification.package_sha256.clone(),
2074        already_installed: false,
2075    })
2076}
2077
2078pub fn list_installed_plugins(
2079    install_root: &Path,
2080) -> Result<Vec<InstalledPluginRecord>, PluginPackageError> {
2081    if !install_root.exists() {
2082        return Ok(Vec::new());
2083    }
2084    require_existing_directory(install_root, "plugin install root")?;
2085    let _catalog_lock = PluginCatalogLock::acquire_shared(install_root)?;
2086    let mut records = Vec::new();
2087    let mut identity_entry_count = 0_usize;
2088    for plugin_entry in read_directory(install_root)? {
2089        let plugin_entry = read_directory_entry(plugin_entry, install_root)?;
2090        if plugin_entry.file_name() == OsStr::new(CATALOG_LOCK_PATH) {
2091            continue;
2092        }
2093        identity_entry_count += 1;
2094        if identity_entry_count > MAX_INSTALLED_PLUGIN_IDENTITIES {
2095            return Err(PluginPackageError::InvalidPackage(format!(
2096                "plugin install root exceeds {MAX_INSTALLED_PLUGIN_IDENTITIES} entries"
2097            )));
2098        }
2099        let plugin_path = plugin_entry.path();
2100        if !plugin_entry
2101            .file_type()
2102            .map_err(|source| PluginPackageError::Io {
2103                operation: "inspect installed plugin identity",
2104                path: plugin_path.display().to_string(),
2105                source,
2106            })?
2107            .is_dir()
2108        {
2109            continue;
2110        }
2111        let plugin_id = plugin_entry.file_name().to_string_lossy().into_owned();
2112        if validate_reverse_dns_identifier(&plugin_id).is_err() {
2113            continue;
2114        }
2115        let mut version_entry_count = 0_usize;
2116        for version_entry in read_directory(&plugin_path)? {
2117            version_entry_count += 1;
2118            if version_entry_count > MAX_INSTALLED_VERSIONS_PER_PLUGIN {
2119                return Err(PluginPackageError::InvalidPackage(format!(
2120                    "installed plugin '{plugin_id}' exceeds {MAX_INSTALLED_VERSIONS_PER_PLUGIN} version entries"
2121                )));
2122            }
2123            let version_entry = read_directory_entry(version_entry, &plugin_path)?;
2124            let version_path = version_entry.path();
2125            if !version_entry
2126                .file_type()
2127                .map_err(|source| PluginPackageError::Io {
2128                    operation: "inspect installed plugin version",
2129                    path: version_path.display().to_string(),
2130                    source,
2131                })?
2132                .is_dir()
2133            {
2134                continue;
2135            }
2136            let version = version_entry.file_name().to_string_lossy().into_owned();
2137            if Version::parse(&version).is_err() {
2138                continue;
2139            }
2140            let marker = read_bounded_file(
2141                &version_path.join(INSTALL_MARKER_PATH),
2142                65,
2143                "package marker",
2144            )?;
2145            let package_sha256 = std::str::from_utf8(&marker)
2146                .map_err(|_| {
2147                    PluginPackageError::InvalidPackage(format!(
2148                        "installed marker for '{plugin_id}' version '{version}' is not UTF-8"
2149                    ))
2150                })?
2151                .trim_end_matches('\n')
2152                .to_owned();
2153            validate_sha256(&package_sha256)?;
2154            records.push(InstalledPluginRecord {
2155                plugin_id: plugin_id.clone(),
2156                version,
2157                install_path: version_path,
2158                package_sha256,
2159            });
2160        }
2161    }
2162    records.sort_by(|left, right| {
2163        (&left.plugin_id, &left.version).cmp(&(&right.plugin_id, &right.version))
2164    });
2165    Ok(records)
2166}
2167
2168struct VerifiedInstalledVersion {
2169    manifest: PluginPackageManifest,
2170    snapshots: BTreeMap<String, Arc<tempfile::NamedTempFile>>,
2171}
2172
2173struct InstalledFileLayout {
2174    files: BTreeMap<String, PathBuf>,
2175    directories: BTreeSet<String>,
2176}
2177
2178pub fn verify_installed_plugin_catalog(
2179    install_root: &Path,
2180    trust_store: &PluginTrustStore,
2181    host: &PluginHostTarget,
2182    references: &[PluginReference],
2183    activations: &[InstalledPluginActivation],
2184) -> Result<VerifiedInstalledPluginCatalog, PluginPackageError> {
2185    let mut requested = BTreeMap::<String, Vec<&PluginReference>>::new();
2186    for reference in references {
2187        requested
2188            .entry(reference.plugin_id().to_owned())
2189            .or_default()
2190            .push(reference);
2191    }
2192    if requested.is_empty() {
2193        if activations.is_empty() {
2194            return Ok(VerifiedInstalledPluginCatalog {
2195                _catalog_lock: None,
2196                artifacts: Vec::new(),
2197            });
2198        }
2199        return Err(PluginPackageError::InvalidPackage(
2200            "installed plugin activations require at least one explicit PluginReference".to_owned(),
2201        ));
2202    }
2203
2204    require_existing_directory(install_root, "plugin install root")?;
2205    let catalog_lock = PluginCatalogLock::acquire_shared(install_root)?;
2206    let mut activation_versions = BTreeMap::new();
2207    for activation in activations {
2208        if !requested.contains_key(activation.plugin_id()) {
2209            return Err(PluginPackageError::InvalidPackage(format!(
2210                "activation for unrequested plugin '{}' is not allowed",
2211                activation.plugin_id()
2212            )));
2213        }
2214        if activation_versions
2215            .insert(
2216                activation.plugin_id().to_owned(),
2217                activation.version().to_owned(),
2218            )
2219            .is_some()
2220        {
2221            return Err(PluginPackageError::InvalidPackage(format!(
2222                "duplicate activation for plugin '{}'",
2223                activation.plugin_id()
2224            )));
2225        }
2226    }
2227
2228    let mut artifacts = Vec::new();
2229    for (plugin_id, plugin_references) in requested {
2230        let plugin_root = install_root.join(&plugin_id);
2231        require_existing_directory(&plugin_root, "installed plugin identity")?;
2232        let version = select_installed_version(
2233            &plugin_root,
2234            &plugin_id,
2235            activation_versions.get(&plugin_id).map(String::as_str),
2236        )?;
2237        let version_root = plugin_root.join(&version);
2238        let verified = verify_installed_version(&version_root, &plugin_id, &version, trust_store)?;
2239        let descriptor = PluginDescriptor {
2240            schema_version: verified.manifest.schema_version,
2241            plugin: verified.manifest.plugin.clone(),
2242            compatibility: verified.manifest.compatibility.clone(),
2243            capabilities: verified.manifest.capabilities.clone(),
2244            requires: verified.manifest.requires.clone(),
2245            provides: verified.manifest.provides.clone(),
2246            redistribution: verified.manifest.redistribution.clone(),
2247        };
2248        descriptor
2249            .evaluate_current_host_compatibility(host.host_sdk())
2250            .map_err(|error| PluginPackageError::Compatibility(error.to_string()))?;
2251
2252        let mut requested_transports = HashSet::new();
2253        for reference in &plugin_references {
2254            requested_transports.insert(reference.transport());
2255        }
2256        for requested_transport in requested_transports {
2257            let transport = artifact_transport(requested_transport);
2258            let expected_format = match transport {
2259                PluginArtifactTransport::Native => PluginArtifactFormat::Dylib,
2260                PluginArtifactTransport::Wasm => PluginArtifactFormat::WasmComponent,
2261            };
2262            let (artifact_target, artifact_architecture) = match transport {
2263                PluginArtifactTransport::Native => (host.target(), host.architecture()),
2264                PluginArtifactTransport::Wasm => {
2265                    (RUST_WASM_COMPONENT_TARGET, RUST_WASM_COMPONENT_ARCHITECTURE)
2266                }
2267            };
2268            let matching = verified
2269                .manifest
2270                .artifacts
2271                .iter()
2272                .filter(|artifact| {
2273                    artifact.transport == transport
2274                        && artifact.format == expected_format
2275                        && artifact.target == artifact_target
2276                        && artifact.architecture == artifact_architecture
2277                })
2278                .collect::<Vec<_>>();
2279            let artifact = match matching.as_slice() {
2280                [artifact] => *artifact,
2281                [] => {
2282                    return Err(PluginPackageError::InstalledArtifactNotFound {
2283                        plugin_id: plugin_id.clone(),
2284                        transport: transport.as_str(),
2285                        target: artifact_target.to_owned(),
2286                        architecture: artifact_architecture.to_owned(),
2287                    });
2288                }
2289                _ => {
2290                    return Err(PluginPackageError::InvalidPackage(format!(
2291                        "installed plugin '{plugin_id}' has ambiguous {} artifacts for target '{}:{}'",
2292                        transport.as_str(),
2293                        artifact_target,
2294                        artifact_architecture
2295                    )));
2296                }
2297            };
2298            for reference in plugin_references
2299                .iter()
2300                .filter(|reference| reference.transport() == requested_transport)
2301            {
2302                if let Some(instance_id) = reference.capability_instance_id()
2303                    && !artifact
2304                        .capabilities
2305                        .iter()
2306                        .any(|capability| capability.instance_id == instance_id)
2307                {
2308                    return Err(PluginPackageError::InvalidPackage(format!(
2309                        "installed artifact '{}' does not declare requested capability instance '{instance_id}'",
2310                        artifact.path
2311                    )));
2312                }
2313            }
2314            let snapshot = verified.snapshots.get(&artifact.path).ok_or_else(|| {
2315                PluginPackageError::InvalidPackage(format!(
2316                    "installed artifact '{}' has no verified snapshot",
2317                    artifact.path
2318                ))
2319            })?;
2320            let capabilities = artifact
2321                .capabilities
2322                .iter()
2323                .map(|reference| {
2324                    verified
2325                        .manifest
2326                        .capabilities
2327                        .iter()
2328                        .find(|capability| {
2329                            capability.interface_id == reference.interface_id
2330                                && capability.instance_id == reference.instance_id
2331                        })
2332                        .cloned()
2333                        .ok_or_else(|| {
2334                            PluginPackageError::InvalidPackage(format!(
2335                                "installed artifact capability '{}:{}' is absent from the descriptor",
2336                                reference.interface_id, reference.instance_id
2337                            ))
2338                        })
2339                })
2340                .collect::<Result<Vec<_>, _>>()?;
2341            artifacts.push(VerifiedInstalledArtifact {
2342                plugin_id: plugin_id.clone(),
2343                version: version.clone(),
2344                publisher: verified.manifest.plugin.publisher.clone(),
2345                abi_major: verified.manifest.compatibility.abi_major,
2346                abi_minor_min: verified.manifest.compatibility.abi_minor_min,
2347                abi_minor_max: verified.manifest.compatibility.abi_minor_max,
2348                requires: verified.manifest.requires.clone(),
2349                provides: verified.manifest.provides.clone(),
2350                transport: artifact.transport,
2351                target: artifact.target.clone(),
2352                format: artifact.format,
2353                architecture: artifact.architecture.clone(),
2354                capabilities,
2355                minimum_os: artifact.minimum_os.clone(),
2356                runtime_dependencies: artifact.runtime_dependencies.clone(),
2357                installed_path: version_root.join(&artifact.path),
2358                sha256: artifact.sha256.clone(),
2359                snapshot: Arc::clone(snapshot),
2360            });
2361        }
2362    }
2363    artifacts.sort_by(|left, right| {
2364        (
2365            &left.plugin_id,
2366            left.transport.as_str(),
2367            &left.target,
2368            &left.architecture,
2369        )
2370            .cmp(&(
2371                &right.plugin_id,
2372                right.transport.as_str(),
2373                &right.target,
2374                &right.architecture,
2375            ))
2376    });
2377    Ok(VerifiedInstalledPluginCatalog {
2378        _catalog_lock: Some(catalog_lock),
2379        artifacts,
2380    })
2381}
2382
2383fn select_installed_version(
2384    plugin_root: &Path,
2385    plugin_id: &str,
2386    activation: Option<&str>,
2387) -> Result<String, PluginPackageError> {
2388    let mut versions = Vec::new();
2389    for entry in read_directory(plugin_root)? {
2390        if versions.len() >= MAX_INSTALLED_VERSIONS_PER_PLUGIN {
2391            return Err(PluginPackageError::InvalidPackage(format!(
2392                "installed plugin '{plugin_id}' exceeds {MAX_INSTALLED_VERSIONS_PER_PLUGIN} version entries"
2393            )));
2394        }
2395        let entry = read_directory_entry(entry, plugin_root)?;
2396        let path = entry.path();
2397        if !entry
2398            .file_type()
2399            .map_err(|source| PluginPackageError::Io {
2400                operation: "inspect installed plugin version",
2401                path: path.display().to_string(),
2402                source,
2403            })?
2404            .is_dir()
2405        {
2406            return Err(PluginPackageError::InvalidPackage(format!(
2407                "installed plugin '{plugin_id}' contains a non-directory version entry '{}'",
2408                path.display()
2409            )));
2410        }
2411        let version = entry.file_name().into_string().map_err(|_| {
2412            PluginPackageError::InvalidPackage(format!(
2413                "installed plugin '{plugin_id}' contains a non-UTF-8 version"
2414            ))
2415        })?;
2416        Version::parse(&version).map_err(|error| {
2417            PluginPackageError::InvalidPackage(format!(
2418                "installed plugin '{plugin_id}' contains invalid version '{version}': {error}"
2419            ))
2420        })?;
2421        versions.push(version);
2422    }
2423    versions.sort();
2424    if let Some(activation) = activation {
2425        if versions.iter().any(|version| version == activation) {
2426            return Ok(activation.to_owned());
2427        }
2428        return Err(PluginPackageError::InstalledVersionNotFound {
2429            plugin_id: plugin_id.to_owned(),
2430            version: activation.to_owned(),
2431        });
2432    }
2433    match versions.as_slice() {
2434        [version] => Ok(version.clone()),
2435        [] => Err(PluginPackageError::InstalledVersionNotFound {
2436            plugin_id: plugin_id.to_owned(),
2437            version: "<any>".to_owned(),
2438        }),
2439        _ => Err(PluginPackageError::AmbiguousInstalledVersions {
2440            plugin_id: plugin_id.to_owned(),
2441            versions,
2442        }),
2443    }
2444}
2445
2446fn verify_installed_version(
2447    version_root: &Path,
2448    expected_plugin_id: &str,
2449    expected_version: &str,
2450    trust_store: &PluginTrustStore,
2451) -> Result<VerifiedInstalledVersion, PluginPackageError> {
2452    require_existing_directory(version_root, "installed plugin version")?;
2453    let layout = collect_installed_file_layout(version_root)?;
2454    let manifest_bytes = read_bounded_file(
2455        &version_root.join(PLUGIN_PACKAGE_MANIFEST_PATH),
2456        MAX_SMALL_METADATA_BYTES,
2457        "installed plugin manifest",
2458    )?;
2459    let mut manifest: PluginPackageManifest = serde_json::from_slice(&manifest_bytes)
2460        .map_err(|error| PluginPackageError::InvalidPackage(error.to_string()))?;
2461    if canonical_manifest_bytes(&mut manifest)? != manifest_bytes {
2462        return Err(PluginPackageError::InvalidPackage(
2463            "installed manifest.json is not canonical JSON".to_owned(),
2464        ));
2465    }
2466    if manifest.plugin.id != expected_plugin_id || manifest.plugin.version != expected_version {
2467        return Err(PluginPackageError::InvalidPackage(format!(
2468            "installed manifest identity '{}:{}' does not match directory '{}:{}'",
2469            manifest.plugin.id, manifest.plugin.version, expected_plugin_id, expected_version
2470        )));
2471    }
2472
2473    let checksums_bytes = read_bounded_file(
2474        &version_root.join(PLUGIN_PACKAGE_CHECKSUMS_PATH),
2475        MAX_SMALL_METADATA_BYTES,
2476        "installed plugin checksums",
2477    )?;
2478    let checksums = parse_canonical_checksums(&checksums_bytes)?;
2479    let signature_bytes = read_bounded_file(
2480        &version_root.join(PLUGIN_PACKAGE_SIGNATURE_PATH),
2481        MAX_SMALL_METADATA_BYTES,
2482        "installed plugin signature",
2483    )?;
2484    let signature: PluginPackageSignature = serde_json::from_slice(&signature_bytes)
2485        .map_err(|error| PluginPackageError::InvalidPackage(error.to_string()))?;
2486    if serde_json::to_vec(&signature)? != signature_bytes
2487        || signature.schema_version != SIGNATURE_SCHEMA_VERSION
2488        || signature.algorithm != SIGNATURE_ALGORITHM
2489        || signature.publisher != manifest.plugin.publisher
2490    {
2491        return Err(PluginPackageError::InvalidSignature);
2492    }
2493    validate_sha256(&signature.key_id)?;
2494    let signature_value =
2495        decode_hex::<64>(&signature.signature).map_err(|_| PluginPackageError::InvalidSignature)?;
2496    trust_store
2497        .verifying_key(&signature.publisher, &signature.key_id)?
2498        .verify(
2499            &signature_message(&checksums_bytes),
2500            &Signature::from_bytes(&signature_value),
2501        )
2502        .map_err(|_| PluginPackageError::InvalidSignature)?;
2503
2504    let marker = read_bounded_file(
2505        &version_root.join(INSTALL_MARKER_PATH),
2506        65,
2507        "installed package marker",
2508    )?;
2509    if marker.len() != 65 || marker.last() != Some(&b'\n') {
2510        return Err(PluginPackageError::InvalidPackage(
2511            "installed package marker must be one SHA-256 followed by a newline".to_owned(),
2512        ));
2513    }
2514    let marker_hash = std::str::from_utf8(&marker[..64]).map_err(|_| {
2515        PluginPackageError::InvalidPackage("installed package marker is not UTF-8".to_owned())
2516    })?;
2517    validate_sha256(marker_hash)?;
2518
2519    let mut expected_files = checksums.keys().cloned().collect::<BTreeSet<_>>();
2520    expected_files.insert(PLUGIN_PACKAGE_CHECKSUMS_PATH.to_owned());
2521    expected_files.insert(PLUGIN_PACKAGE_SIGNATURE_PATH.to_owned());
2522    expected_files.insert(INSTALL_MARKER_PATH.to_owned());
2523    let actual_files = layout.files.keys().cloned().collect::<BTreeSet<_>>();
2524    if actual_files != expected_files {
2525        return Err(PluginPackageError::InvalidPackage(
2526            "installed plugin files do not exactly match SHA256SUMS and installation metadata"
2527                .to_owned(),
2528        ));
2529    }
2530    let expected_directories = installed_parent_directories(&expected_files);
2531    if layout.directories != expected_directories {
2532        return Err(PluginPackageError::InvalidPackage(
2533            "installed plugin contains unexpected or missing package directories".to_owned(),
2534        ));
2535    }
2536    if !checksums.contains_key(PLUGIN_PACKAGE_MANIFEST_PATH)
2537        || !checksums.keys().any(|path| path.starts_with("licenses/"))
2538        || !checksums.keys().any(|path| path.starts_with("notices/"))
2539    {
2540        return Err(PluginPackageError::InvalidPackage(
2541            "installed plugin must contain checksummed manifest, license, and notice files"
2542                .to_owned(),
2543        ));
2544    }
2545
2546    let artifact_paths = manifest
2547        .artifacts
2548        .iter()
2549        .map(|artifact| artifact.path.as_str())
2550        .collect::<HashSet<_>>();
2551    for artifact in &manifest.artifacts {
2552        if checksums.get(&artifact.path) != Some(&artifact.sha256) {
2553            return Err(PluginPackageError::InvalidPackage(format!(
2554                "installed artifact '{}' hash disagrees with SHA256SUMS",
2555                artifact.path
2556            )));
2557        }
2558    }
2559    verify_installed_permissions(&layout.files, &artifact_paths)?;
2560
2561    let mut snapshots = BTreeMap::new();
2562    for (relative_path, expected_hash) in &checksums {
2563        let installed_path = layout.files.get(relative_path).ok_or_else(|| {
2564            PluginPackageError::InvalidPackage(format!(
2565                "installed plugin is missing checksummed file '{relative_path}'"
2566            ))
2567        })?;
2568        let snapshot_required = artifact_paths.contains(relative_path.as_str());
2569        let (actual_hash, snapshot) =
2570            hash_and_snapshot_installed_file(installed_path, snapshot_required)?;
2571        if &actual_hash != expected_hash {
2572            return Err(PluginPackageError::InvalidPackage(format!(
2573                "checksum mismatch for installed file '{relative_path}'"
2574            )));
2575        }
2576        if let Some(snapshot) = snapshot {
2577            snapshots.insert(relative_path.clone(), Arc::new(snapshot));
2578        }
2579    }
2580    Ok(VerifiedInstalledVersion {
2581        manifest,
2582        snapshots,
2583    })
2584}
2585
2586fn collect_installed_file_layout(
2587    version_root: &Path,
2588) -> Result<InstalledFileLayout, PluginPackageError> {
2589    let maximum_directories = MAX_PLUGIN_PACKAGE_ENTRIES
2590        .saturating_mul(crate::plugin_project::MAX_ARCHIVE_PATH_BYTES / 2);
2591    let mut files = BTreeMap::new();
2592    let mut directories = BTreeSet::new();
2593    let mut normalized_paths = HashSet::new();
2594    let mut pending = vec![(version_root.to_path_buf(), String::new())];
2595    let mut total_size = 0_u64;
2596    while let Some((directory, relative_directory)) = pending.pop() {
2597        for entry in read_directory(&directory)? {
2598            let entry = read_directory_entry(entry, &directory)?;
2599            let name = entry.file_name().into_string().map_err(|_| {
2600                PluginPackageError::InvalidPackage(format!(
2601                    "installed plugin path below '{}' is not UTF-8",
2602                    version_root.display()
2603                ))
2604            })?;
2605            let relative_path = if relative_directory.is_empty() {
2606                name
2607            } else {
2608                format!("{relative_directory}/{name}")
2609            };
2610            crate::plugin_project::validate_archive_path("installed plugin path", &relative_path)?;
2611            let path = entry.path();
2612            let file_type = entry.file_type().map_err(|source| PluginPackageError::Io {
2613                operation: "inspect installed plugin entry",
2614                path: path.display().to_string(),
2615                source,
2616            })?;
2617            if file_type.is_dir() {
2618                if !directories.insert(relative_path.clone())
2619                    || directories.len() > maximum_directories
2620                {
2621                    return Err(PluginPackageError::InvalidPackage(
2622                        "installed plugin directory layout exceeds package limits".to_owned(),
2623                    ));
2624                }
2625                pending.push((path, relative_path));
2626                continue;
2627            }
2628            if !file_type.is_file() {
2629                return Err(PluginPackageError::InvalidPackage(format!(
2630                    "installed plugin entry '{}' is not a regular non-symlink file",
2631                    path.display()
2632                )));
2633            }
2634            crate::plugin_project::insert_archive_file_path(&mut normalized_paths, &relative_path)?;
2635            if files.len() > MAX_PLUGIN_PACKAGE_ENTRIES {
2636                return Err(PluginPackageError::InvalidPackage(format!(
2637                    "installed plugin exceeds {} files",
2638                    MAX_PLUGIN_PACKAGE_ENTRIES + 1
2639                )));
2640            }
2641            let metadata = entry.metadata().map_err(|source| PluginPackageError::Io {
2642                operation: "inspect installed plugin file",
2643                path: path.display().to_string(),
2644                source,
2645            })?;
2646            if metadata.len() > MAX_PLUGIN_PACKAGE_ENTRY_BYTES {
2647                return Err(PluginPackageError::InvalidPackage(format!(
2648                    "installed plugin file '{}' exceeds {MAX_PLUGIN_PACKAGE_ENTRY_BYTES} bytes",
2649                    path.display()
2650                )));
2651            }
2652            total_size = total_size.checked_add(metadata.len()).ok_or_else(|| {
2653                PluginPackageError::InvalidPackage(
2654                    "installed plugin aggregate file size overflowed".to_owned(),
2655                )
2656            })?;
2657            if total_size > MAX_PLUGIN_PACKAGE_BYTES {
2658                return Err(PluginPackageError::InvalidPackage(format!(
2659                    "installed plugin exceeds {MAX_PLUGIN_PACKAGE_BYTES} aggregate bytes"
2660                )));
2661            }
2662            files.insert(relative_path, path);
2663        }
2664    }
2665    Ok(InstalledFileLayout { files, directories })
2666}
2667
2668fn installed_parent_directories(files: &BTreeSet<String>) -> BTreeSet<String> {
2669    let mut directories = BTreeSet::new();
2670    for file in files {
2671        let mut parent = Path::new(file).parent();
2672        while let Some(path) = parent {
2673            if path.as_os_str().is_empty() {
2674                break;
2675            }
2676            directories.insert(path.to_string_lossy().into_owned());
2677            parent = path.parent();
2678        }
2679    }
2680    directories
2681}
2682
2683#[cfg(unix)]
2684fn verify_installed_permissions(
2685    files: &BTreeMap<String, PathBuf>,
2686    artifact_paths: &HashSet<&str>,
2687) -> Result<(), PluginPackageError> {
2688    use std::os::unix::fs::PermissionsExt;
2689
2690    for (relative_path, path) in files {
2691        if relative_path == INSTALL_MARKER_PATH {
2692            continue;
2693        }
2694        let actual = fs::metadata(path)
2695            .map_err(|source| PluginPackageError::Io {
2696                operation: "inspect installed plugin permissions",
2697                path: path.display().to_string(),
2698                source,
2699            })?
2700            .permissions()
2701            .mode()
2702            & 0o777;
2703        let expected = if artifact_paths.contains(relative_path.as_str()) {
2704            ARTIFACT_FILE_MODE
2705        } else {
2706            PACKAGE_METADATA_FILE_MODE
2707        };
2708        if actual != expected {
2709            return Err(PluginPackageError::InvalidPackage(format!(
2710                "installed file '{relative_path}' has permissions {actual:o}; expected {expected:o}"
2711            )));
2712        }
2713    }
2714    Ok(())
2715}
2716
2717#[cfg(not(unix))]
2718fn verify_installed_permissions(
2719    _files: &BTreeMap<String, PathBuf>,
2720    _artifact_paths: &HashSet<&str>,
2721) -> Result<(), PluginPackageError> {
2722    Ok(())
2723}
2724
2725fn hash_and_snapshot_installed_file(
2726    path: &Path,
2727    snapshot_required: bool,
2728) -> Result<(String, Option<tempfile::NamedTempFile>), PluginPackageError> {
2729    let mut input = File::open(path).map_err(|source| PluginPackageError::Io {
2730        operation: "open installed plugin file",
2731        path: path.display().to_string(),
2732        source,
2733    })?;
2734    let metadata = input.metadata().map_err(|source| PluginPackageError::Io {
2735        operation: "inspect opened installed plugin file",
2736        path: path.display().to_string(),
2737        source,
2738    })?;
2739    if !metadata.file_type().is_file() || metadata.len() > MAX_PLUGIN_PACKAGE_ENTRY_BYTES {
2740        return Err(PluginPackageError::InvalidPackage(format!(
2741            "installed plugin file '{}' did not open as a bounded regular file",
2742            path.display()
2743        )));
2744    }
2745    let suffix = path
2746        .extension()
2747        .and_then(OsStr::to_str)
2748        .map(|extension| format!(".{extension}"))
2749        .unwrap_or_default();
2750    let mut snapshot = if snapshot_required {
2751        Some(
2752            tempfile::Builder::new()
2753                .prefix("vesper-verified-plugin-")
2754                .suffix(&suffix)
2755                .tempfile()
2756                .map_err(|source| PluginPackageError::Io {
2757                    operation: "create verified plugin snapshot",
2758                    path: path.display().to_string(),
2759                    source,
2760                })?,
2761        )
2762    } else {
2763        None
2764    };
2765    let mut hasher = Sha256::new();
2766    let mut copied = 0_u64;
2767    let mut buffer = [0_u8; 64 * 1024];
2768    loop {
2769        let read = input
2770            .read(&mut buffer)
2771            .map_err(|source| PluginPackageError::Io {
2772                operation: "read installed plugin file",
2773                path: path.display().to_string(),
2774                source,
2775            })?;
2776        if read == 0 {
2777            break;
2778        }
2779        copied = copied.checked_add(read as u64).ok_or_else(|| {
2780            PluginPackageError::InvalidPackage(
2781                "installed plugin file size overflowed while hashing".to_owned(),
2782            )
2783        })?;
2784        if copied > metadata.len() || copied > MAX_PLUGIN_PACKAGE_ENTRY_BYTES {
2785            return Err(PluginPackageError::InvalidPackage(format!(
2786                "installed plugin file '{}' changed size while hashing",
2787                path.display()
2788            )));
2789        }
2790        hasher.update(&buffer[..read]);
2791        if let Some(snapshot) = snapshot.as_mut() {
2792            snapshot
2793                .as_file_mut()
2794                .write_all(&buffer[..read])
2795                .map_err(|source| PluginPackageError::Io {
2796                    operation: "write verified plugin snapshot",
2797                    path: path.display().to_string(),
2798                    source,
2799                })?;
2800        }
2801    }
2802    if copied != metadata.len() {
2803        return Err(PluginPackageError::InvalidPackage(format!(
2804            "installed plugin file '{}' changed size while hashing",
2805            path.display()
2806        )));
2807    }
2808    if let Some(snapshot) = snapshot.as_mut() {
2809        #[cfg(unix)]
2810        snapshot
2811            .as_file()
2812            .set_permissions(fs::Permissions::from_mode(ARTIFACT_FILE_MODE))
2813            .map_err(|source| PluginPackageError::Io {
2814                operation: "set verified plugin snapshot permissions",
2815                path: snapshot.path().display().to_string(),
2816                source,
2817            })?;
2818        snapshot
2819            .as_file_mut()
2820            .seek(SeekFrom::Start(0))
2821            .map_err(|source| PluginPackageError::Io {
2822                operation: "rewind verified plugin snapshot",
2823                path: snapshot.path().display().to_string(),
2824                source,
2825            })?;
2826        snapshot
2827            .as_file()
2828            .sync_all()
2829            .map_err(|source| PluginPackageError::Io {
2830                operation: "sync verified plugin snapshot",
2831                path: snapshot.path().display().to_string(),
2832                source,
2833            })?;
2834    }
2835    Ok((hex::encode(hasher.finalize()), snapshot))
2836}
2837
2838const fn artifact_transport(transport: PluginTransport) -> PluginArtifactTransport {
2839    match transport {
2840        PluginTransport::Native => PluginArtifactTransport::Native,
2841        PluginTransport::Wasm => PluginArtifactTransport::Wasm,
2842    }
2843}
2844
2845pub fn uninstall_plugin(
2846    install_root: &Path,
2847    plugin_id: &str,
2848    version: &str,
2849) -> Result<bool, PluginPackageError> {
2850    validate_reverse_dns_identifier(plugin_id).map_err(PluginPackageError::InvalidPackage)?;
2851    Version::parse(version).map_err(|error| {
2852        PluginPackageError::InvalidPackage(format!("invalid plugin version: {error}"))
2853    })?;
2854    if !install_root.exists() {
2855        return Ok(false);
2856    }
2857    require_existing_directory(install_root, "plugin install root")?;
2858    let _catalog_lock = PluginCatalogLock::acquire(install_root)?;
2859    let plugin_root = install_root.join(plugin_id);
2860    if !plugin_root.exists() {
2861        return Ok(false);
2862    }
2863    require_existing_directory(&plugin_root, "plugin install identity directory")?;
2864    let target = plugin_root.join(version);
2865    if !target.exists() {
2866        return Ok(false);
2867    }
2868    require_existing_directory(&target, "plugin uninstall target")?;
2869    let marker_path = target.join(INSTALL_MARKER_PATH);
2870    let marker = read_bounded_file(&marker_path, 65, "installed package marker")?;
2871    let marker = std::str::from_utf8(&marker).map_err(|_| {
2872        PluginPackageError::InvalidPackage("installed package marker is not UTF-8".to_owned())
2873    })?;
2874    validate_sha256(marker.trim_end_matches('\n'))?;
2875    fs::remove_dir_all(&target).map_err(|source| PluginPackageError::Io {
2876        operation: "remove installed plugin version",
2877        path: target.display().to_string(),
2878        source,
2879    })?;
2880    sync_directory(&plugin_root)?;
2881    if let Some(plugin_root) = target.parent()
2882        && fs::read_dir(plugin_root)
2883            .map_err(|source| PluginPackageError::Io {
2884                operation: "inspect plugin identity directory",
2885                path: plugin_root.display().to_string(),
2886                source,
2887            })?
2888            .next()
2889            .is_none()
2890    {
2891        fs::remove_dir(plugin_root).map_err(|source| PluginPackageError::Io {
2892            operation: "remove empty plugin identity directory",
2893            path: plugin_root.display().to_string(),
2894            source,
2895        })?;
2896    }
2897    sync_directory(install_root)?;
2898    Ok(true)
2899}
2900
2901fn extract_verified_entries(
2902    verified: &VerifiedPluginPackage,
2903    destination: &Path,
2904) -> Result<(), PluginPackageError> {
2905    let reader = PositionedFile::new(Arc::clone(&verified.package_file));
2906    let mut archive = ZipArchive::new(reader)?;
2907    for metadata in &verified.entries {
2908        let output = destination.join(Path::new(&metadata.path));
2909        let parent = output.parent().ok_or_else(|| {
2910            PluginPackageError::InvalidPackage(format!(
2911                "archive entry '{}' has no parent directory",
2912                metadata.path
2913            ))
2914        })?;
2915        fs::create_dir_all(parent).map_err(|source| PluginPackageError::Io {
2916            operation: "create plugin install directory",
2917            path: parent.display().to_string(),
2918            source,
2919        })?;
2920        let mut input = archive.by_name(&metadata.path)?;
2921        let mut output_file = fs::OpenOptions::new()
2922            .write(true)
2923            .create_new(true)
2924            .open(&output)
2925            .map_err(|source| PluginPackageError::Io {
2926                operation: "create installed package entry",
2927                path: output.display().to_string(),
2928                source,
2929            })?;
2930        let mut copied = 0_u64;
2931        let mut hasher = Sha256::new();
2932        let mut buffer = [0_u8; 64 * 1024];
2933        loop {
2934            let read = input
2935                .read(&mut buffer)
2936                .map_err(|source| PluginPackageError::Io {
2937                    operation: "read verified package entry",
2938                    path: metadata.path.clone(),
2939                    source,
2940                })?;
2941            if read == 0 {
2942                break;
2943            }
2944            copied = copied.checked_add(read as u64).ok_or_else(|| {
2945                PluginPackageError::InvalidPackage(format!(
2946                    "archive entry '{}' size overflowed during extraction",
2947                    metadata.path
2948                ))
2949            })?;
2950            if copied > metadata.size {
2951                return Err(PluginPackageError::InvalidPackage(format!(
2952                    "archive entry '{}' grew after verification",
2953                    metadata.path
2954                )));
2955            }
2956            output_file
2957                .write_all(&buffer[..read])
2958                .map_err(|source| PluginPackageError::Io {
2959                    operation: "extract verified package entry",
2960                    path: metadata.path.clone(),
2961                    source,
2962                })?;
2963            hasher.update(&buffer[..read]);
2964        }
2965        if copied != metadata.size {
2966            return Err(PluginPackageError::InvalidPackage(format!(
2967                "archive entry '{}' changed size during extraction",
2968                metadata.path
2969            )));
2970        }
2971        let extracted_sha256 = hex::encode(hasher.finalize());
2972        if extracted_sha256 != metadata.sha256 {
2973            return Err(PluginPackageError::InvalidPackage(format!(
2974                "archive entry '{}' changed after verification",
2975                metadata.path
2976            )));
2977        }
2978        #[cfg(unix)]
2979        {
2980            use std::os::unix::fs::PermissionsExt;
2981            output_file
2982                .set_permissions(fs::Permissions::from_mode(metadata.mode))
2983                .map_err(|source| PluginPackageError::Io {
2984                    operation: "set installed package entry permissions",
2985                    path: output.display().to_string(),
2986                    source,
2987                })?;
2988        }
2989        output_file
2990            .sync_all()
2991            .map_err(|source| PluginPackageError::Io {
2992                operation: "sync installed package entry",
2993                path: output.display().to_string(),
2994                source,
2995            })?;
2996    }
2997    sync_extracted_directories(&verified.entries, destination)?;
2998    Ok(())
2999}
3000
3001fn sync_extracted_directories(
3002    entries: &[VerifiedPackageEntry],
3003    destination: &Path,
3004) -> Result<(), PluginPackageError> {
3005    let mut directories = BTreeSet::new();
3006    for entry in entries {
3007        let mut parent = Path::new(&entry.path).parent();
3008        while let Some(relative) = parent {
3009            if relative.as_os_str().is_empty() {
3010                break;
3011            }
3012            directories.insert(destination.join(relative));
3013            parent = relative.parent();
3014        }
3015    }
3016    let mut directories = directories.into_iter().collect::<Vec<_>>();
3017    directories.sort_by(|left, right| {
3018        right
3019            .components()
3020            .count()
3021            .cmp(&left.components().count())
3022            .then_with(|| left.cmp(right))
3023    });
3024    for directory in directories {
3025        sync_directory(&directory)?;
3026    }
3027    Ok(())
3028}
3029
3030fn ensure_directory(path: &Path, label: &str) -> Result<(), PluginPackageError> {
3031    if !path.exists() {
3032        fs::create_dir_all(path).map_err(|source| PluginPackageError::Io {
3033            operation: "create directory",
3034            path: path.display().to_string(),
3035            source,
3036        })?;
3037    }
3038    require_existing_directory(path, label)
3039}
3040
3041fn require_existing_directory(path: &Path, label: &str) -> Result<(), PluginPackageError> {
3042    let metadata = fs::symlink_metadata(path).map_err(|source| PluginPackageError::Io {
3043        operation: "inspect directory",
3044        path: path.display().to_string(),
3045        source,
3046    })?;
3047    if !metadata.file_type().is_dir() {
3048        return Err(PluginPackageError::InvalidPackage(format!(
3049            "{label} '{}' is not a regular directory",
3050            path.display()
3051        )));
3052    }
3053    Ok(())
3054}
3055
3056fn read_directory(path: &Path) -> Result<fs::ReadDir, PluginPackageError> {
3057    fs::read_dir(path).map_err(|source| PluginPackageError::Io {
3058        operation: "read install directory",
3059        path: path.display().to_string(),
3060        source,
3061    })
3062}
3063
3064fn read_directory_entry(
3065    entry: Result<fs::DirEntry, io::Error>,
3066    parent: &Path,
3067) -> Result<fs::DirEntry, PluginPackageError> {
3068    entry.map_err(|source| PluginPackageError::Io {
3069        operation: "read install directory entry",
3070        path: parent.display().to_string(),
3071        source,
3072    })
3073}
3074
3075fn ensure_plugin_identity_capacity(path: &Path) -> Result<(), PluginPackageError> {
3076    ensure_directory_entry_capacity(
3077        path,
3078        MAX_INSTALLED_PLUGIN_IDENTITIES,
3079        "plugin install root",
3080        Some(OsStr::new(CATALOG_LOCK_PATH)),
3081    )
3082}
3083
3084fn ensure_plugin_version_capacity(path: &Path, plugin_id: &str) -> Result<(), PluginPackageError> {
3085    ensure_directory_entry_capacity(
3086        path,
3087        MAX_INSTALLED_VERSIONS_PER_PLUGIN,
3088        &format!("installed plugin '{plugin_id}'"),
3089        None,
3090    )
3091}
3092
3093fn ensure_directory_entry_capacity(
3094    path: &Path,
3095    maximum_entries: usize,
3096    label: &str,
3097    ignored_entry_name: Option<&OsStr>,
3098) -> Result<(), PluginPackageError> {
3099    let mut entry_count = 0_usize;
3100    for entry in read_directory(path)? {
3101        let entry = read_directory_entry(entry, path)?;
3102        if ignored_entry_name.is_some_and(|ignored| entry.file_name() == ignored) {
3103            continue;
3104        }
3105        entry_count += 1;
3106        if entry_count >= maximum_entries {
3107            return Err(PluginPackageError::InvalidPackage(format!(
3108                "{label} has reached its {maximum_entries}-entry installation limit"
3109            )));
3110        }
3111    }
3112    Ok(())
3113}
3114
3115fn read_bounded_file(
3116    path: &Path,
3117    maximum_bytes: u64,
3118    label: &str,
3119) -> Result<Vec<u8>, PluginPackageError> {
3120    let metadata = fs::symlink_metadata(path).map_err(|source| PluginPackageError::Io {
3121        operation: "inspect installed metadata",
3122        path: path.display().to_string(),
3123        source,
3124    })?;
3125    if !metadata.file_type().is_file() || metadata.len() > maximum_bytes {
3126        return Err(PluginPackageError::InvalidPackage(format!(
3127            "{label} '{}' is not a bounded regular file",
3128            path.display()
3129        )));
3130    }
3131    let file = File::open(path).map_err(|source| PluginPackageError::Io {
3132        operation: "open installed metadata",
3133        path: path.display().to_string(),
3134        source,
3135    })?;
3136    let mut bytes = Vec::with_capacity(metadata.len() as usize);
3137    file.take(maximum_bytes + 1)
3138        .read_to_end(&mut bytes)
3139        .map_err(|source| PluginPackageError::Io {
3140            operation: "read installed metadata",
3141            path: path.display().to_string(),
3142            source,
3143        })?;
3144    if bytes.len() as u64 > maximum_bytes {
3145        return Err(PluginPackageError::InvalidPackage(format!(
3146            "{label} '{}' exceeds {maximum_bytes} bytes",
3147            path.display()
3148        )));
3149    }
3150    Ok(bytes)
3151}
3152
3153#[cfg(unix)]
3154fn sync_directory(path: &Path) -> Result<(), PluginPackageError> {
3155    File::open(path)
3156        .and_then(|directory| directory.sync_all())
3157        .map_err(|source| PluginPackageError::Io {
3158            operation: "sync directory",
3159            path: path.display().to_string(),
3160            source,
3161        })
3162}
3163
3164#[cfg(not(unix))]
3165fn sync_directory(_path: &Path) -> Result<(), PluginPackageError> {
3166    Ok(())
3167}
3168
3169fn read_bounded_zip_entry<R: Read + Seek>(
3170    archive: &mut ZipArchive<R>,
3171    path: &str,
3172    maximum_bytes: u64,
3173) -> Result<Vec<u8>, PluginPackageError> {
3174    let entry = archive.by_name(path)?;
3175    let mut bytes =
3176        Vec::with_capacity(usize::try_from(entry.size().min(maximum_bytes)).unwrap_or_default());
3177    entry
3178        .take(maximum_bytes + 1)
3179        .read_to_end(&mut bytes)
3180        .map_err(|source| PluginPackageError::Io {
3181            operation: "read package metadata",
3182            path: path.to_owned(),
3183            source,
3184        })?;
3185    if bytes.len() as u64 > maximum_bytes {
3186        return Err(PluginPackageError::InvalidPackage(format!(
3187            "archive entry '{path}' exceeds {maximum_bytes} bytes"
3188        )));
3189    }
3190    Ok(bytes)
3191}
3192
3193fn parse_canonical_checksums(bytes: &[u8]) -> Result<BTreeMap<String, String>, PluginPackageError> {
3194    let source = std::str::from_utf8(bytes)
3195        .map_err(|_| PluginPackageError::InvalidPackage("SHA256SUMS is not UTF-8".to_owned()))?;
3196    if source.is_empty() || !source.ends_with('\n') {
3197        return Err(PluginPackageError::InvalidPackage(
3198            "SHA256SUMS must be non-empty and newline terminated".to_owned(),
3199        ));
3200    }
3201    let mut checksums = BTreeMap::new();
3202    for line in source.lines() {
3203        let Some((checksum, path)) = line.split_once("  ") else {
3204            return Err(PluginPackageError::InvalidPackage(
3205                "SHA256SUMS contains a malformed line".to_owned(),
3206            ));
3207        };
3208        validate_sha256(checksum)?;
3209        crate::plugin_project::validate_archive_path("SHA256SUMS path", path)?;
3210        if matches!(
3211            path,
3212            PLUGIN_PACKAGE_CHECKSUMS_PATH | PLUGIN_PACKAGE_SIGNATURE_PATH
3213        ) {
3214            return Err(PluginPackageError::InvalidPackage(
3215                "SHA256SUMS must not include itself or signature.json".to_owned(),
3216            ));
3217        }
3218        if checksums
3219            .insert(path.to_owned(), checksum.to_owned())
3220            .is_some()
3221        {
3222            return Err(PluginPackageError::InvalidPackage(format!(
3223                "SHA256SUMS repeats path '{path}'"
3224            )));
3225        }
3226    }
3227    if canonical_checksums(&checksums) != bytes {
3228        return Err(PluginPackageError::InvalidPackage(
3229            "SHA256SUMS is not canonically sorted".to_owned(),
3230        ));
3231    }
3232    Ok(checksums)
3233}
3234
3235fn sha256_zip_entry<R: Read + Seek>(
3236    archive: &mut ZipArchive<R>,
3237    path: &str,
3238) -> Result<String, PluginPackageError> {
3239    let mut entry = archive.by_name(path)?;
3240    let mut hasher = Sha256::new();
3241    let mut buffer = [0_u8; 64 * 1024];
3242    loop {
3243        let read = entry
3244            .read(&mut buffer)
3245            .map_err(|source| PluginPackageError::Io {
3246                operation: "hash package entry",
3247                path: path.to_owned(),
3248                source,
3249            })?;
3250        if read == 0 {
3251            break;
3252        }
3253        hasher.update(&buffer[..read]);
3254    }
3255    Ok(hex::encode(hasher.finalize()))
3256}
3257
3258fn validate_reverse_dns_identifier(value: &str) -> Result<(), String> {
3259    PluginReference::new(value, None, PluginTransport::Native)
3260        .map(|_| ())
3261        .map_err(|error| error.to_string())
3262}
3263
3264fn key_id(public_key: &[u8; 32]) -> String {
3265    hex::encode(Sha256::digest(public_key))
3266}
3267
3268fn encode_hex(bytes: &[u8]) -> String {
3269    const HEX: &[u8; 16] = b"0123456789abcdef";
3270    let mut encoded = String::with_capacity(bytes.len() * 2);
3271    for byte in bytes {
3272        encoded.push(HEX[(byte >> 4) as usize] as char);
3273        encoded.push(HEX[(byte & 0x0f) as usize] as char);
3274    }
3275    encoded
3276}
3277
3278fn decode_hex<const N: usize>(value: &str) -> Result<[u8; N], String> {
3279    if value.len() != N * 2 {
3280        return Err(format!(
3281            "expected {} lowercase hexadecimal characters",
3282            N * 2
3283        ));
3284    }
3285    let mut decoded = [0_u8; N];
3286    let (pairs, _) = value.as_bytes().as_chunks::<2>();
3287    for (index, [high, low]) in pairs.iter().copied().enumerate() {
3288        decoded[index] = (decode_hex_nibble(high)? << 4) | decode_hex_nibble(low)?;
3289    }
3290    Ok(decoded)
3291}
3292
3293fn decode_hex_nibble(value: u8) -> Result<u8, String> {
3294    match value {
3295        b'0'..=b'9' => Ok(value - b'0'),
3296        b'a'..=b'f' => Ok(value - b'a' + 10),
3297        _ => Err("hexadecimal values must use lowercase ASCII".to_owned()),
3298    }
3299}
3300
3301#[cfg(test)]
3302mod tests {
3303    use super::*;
3304
3305    fn project() -> PluginProjectManifest {
3306        project_with_id("dev.vesper.fixture")
3307    }
3308
3309    fn project_with_id(plugin_id: &str) -> PluginProjectManifest {
3310        let source = r#"
3311schema_version = 1
3312
3313[plugin]
3314id = "dev.vesper.fixture"
3315name = "Fixture"
3316version = "1.2.3"
3317description = "Fixture plugin"
3318license = "Apache-2.0"
3319publisher = "dev.vesper.publisher"
3320
3321[compatibility]
3322host_sdk = ">=0.4.0, <0.5.0"
3323abi_major = 1
3324abi_minor_min = 0
3325abi_minor_max = 0
3326
3327[[capabilities]]
3328interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7"
3329instance_id = "dev.vesper.fixture.post-download"
3330interface_major = 1
3331interface_minor = 0
3332stability = "stable"
3333
3334[[requires]]
3335service = "dev.vesper.service.time-stretch"
3336requirement = ">=1.0.0, <2.0.0"
3337
3338[[provides]]
3339service = "dev.vesper.service.time-stretch"
3340version = "1.4.0"
3341
3342[[artifacts]]
3343transport = "native"
3344target = "aarch64-apple-darwin"
3345format = "dylib"
3346source = "fixture plugin.dylib"
3347path = "artifacts/aarch64-apple-darwin/fixture plugin.dylib"
3348architecture = "arm64"
3349minimum_os = "13.0"
3350capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
3351
3352[[package_files]]
3353source = "LICENSE"
3354path = "licenses/LICENSE"
3355kind = "license"
3356
3357[[package_files]]
3358source = "NOTICE"
3359path = "notices/NOTICE"
3360kind = "notice"
3361"#
3362        .replace("dev.vesper.fixture", plugin_id);
3363        PluginProjectManifest::from_toml(&source).expect("valid package project")
3364    }
3365
3366    fn write_inputs(directory: &Path) {
3367        fs::write(directory.join("fixture plugin.dylib"), b"fixture artifact")
3368            .expect("write artifact");
3369        fs::write(directory.join("LICENSE"), b"Apache-2.0\n").expect("write license");
3370        fs::write(directory.join("NOTICE"), b"Fixture notice\n").expect("write notice");
3371    }
3372
3373    #[test]
3374    fn package_manifest_projects_to_a_pure_catalog_without_opening_artifacts() {
3375        let project = project();
3376        let descriptor = project.descriptor();
3377        let manifest = PluginPackageManifest {
3378            schema_version: 1,
3379            plugin: descriptor.plugin.clone(),
3380            compatibility: descriptor.compatibility.clone(),
3381            capabilities: descriptor.capabilities.clone(),
3382            requires: descriptor.requires.clone(),
3383            provides: descriptor.provides.clone(),
3384            artifacts: vec![PluginPackageArtifact {
3385                transport: PluginArtifactTransport::Native,
3386                target: "aarch64-apple-darwin".to_owned(),
3387                format: PluginArtifactFormat::Dylib,
3388                path: "artifacts/fixture.dylib".to_owned(),
3389                architecture: "arm64".to_owned(),
3390                capabilities: vec![crate::PluginArtifactCapability {
3391                    interface_id: "e9479dbc-42d2-575e-b39e-a24bc512fbc7".to_owned(),
3392                    instance_id: "dev.vesper.fixture.post-download".to_owned(),
3393                }],
3394                minimum_os: None,
3395                sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
3396                    .to_owned(),
3397                runtime_dependencies: vec![crate::PluginRuntimeDependencySource {
3398                    id: "dev.vesper.runtime".to_owned(),
3399                    version: "1.0.0".to_owned(),
3400                    linkage: crate::PluginRuntimeLinkage::Dynamic,
3401                    compatibility_key: "darwin-arm64".to_owned(),
3402                }],
3403            }],
3404            redistribution: descriptor.redistribution.clone(),
3405            generated_by: PluginPackageGenerator {
3406                vesper: "0.4.3".to_owned(),
3407                sdk: "0.4.3".to_owned(),
3408            },
3409        };
3410
3411        let catalog = manifest.catalog().expect("pure catalog projection");
3412        assert_eq!(catalog.len(), 1);
3413        assert_eq!(
3414            catalog.records()[0].descriptor().plugin_id,
3415            "dev.vesper.fixture"
3416        );
3417        assert_eq!(
3418            catalog.records()[0].artifact_path(),
3419            "artifacts/fixture.dylib"
3420        );
3421        assert_eq!(
3422            catalog.records()[0].descriptor().runtime_dependencies.len(),
3423            1
3424        );
3425        assert_eq!(
3426            catalog.records()[0].descriptor().requires[0].service,
3427            "dev.vesper.service.time-stretch"
3428        );
3429        assert_eq!(
3430            catalog.records()[0].descriptor().provides[0].version,
3431            "1.4.0"
3432        );
3433        assert_eq!(catalog.records()[0].source, PluginCatalogSource::Package);
3434    }
3435
3436    #[test]
3437    fn prepared_file_entry_keeps_the_bytes_that_were_hashed() {
3438        let directory = tempfile::tempdir().expect("temporary package directory");
3439        let source = directory.path().join("artifact.bin");
3440        fs::write(&source, b"original artifact").expect("write original artifact");
3441        let entry = prepare_file_entry(
3442            directory.path(),
3443            Path::new("artifact.bin"),
3444            "artifacts/artifact.bin",
3445            ARTIFACT_FILE_MODE,
3446        )
3447        .expect("prepare package entry");
3448        fs::write(&source, b"replacement artifact").expect("replace source artifact");
3449
3450        let package = directory.path().join("snapshot.zip");
3451        write_package_atomically(&package, &[entry]).expect("write package from snapshot");
3452        let mut archive =
3453            ZipArchive::new(File::open(&package).expect("open package")).expect("read package");
3454        let mut bytes = Vec::new();
3455        archive
3456            .by_name("artifacts/artifact.bin")
3457            .expect("artifact entry")
3458            .read_to_end(&mut bytes)
3459            .expect("read artifact entry");
3460        assert_eq!(bytes, b"original artifact");
3461    }
3462
3463    #[test]
3464    fn package_writer_rejects_aggregate_entry_size_over_verifier_limit() {
3465        let directory = tempfile::tempdir().expect("temporary package directory");
3466        let package = directory.path().join("oversized.zip");
3467        let entries = [PreparedEntry {
3468            path: "artifacts/oversized.bin".to_owned(),
3469            data: PreparedEntryData::Bytes(Vec::new()),
3470            sha256: hex::encode(Sha256::digest([])),
3471            size: MAX_PLUGIN_PACKAGE_BYTES + 1,
3472            mode: ARTIFACT_FILE_MODE,
3473        }];
3474
3475        assert!(matches!(
3476            write_package_atomically(&package, &entries),
3477            Err(PluginPackageError::InvalidPackage(message))
3478                if message.contains("aggregate package input exceeds")
3479        ));
3480        assert!(!package.exists());
3481    }
3482
3483    fn verified_fixture_package(directory: &Path) -> VerifiedPluginPackage {
3484        verified_fixture_package_with_id(directory, "dev.vesper.fixture")
3485    }
3486
3487    fn verified_fixture_package_with_id(
3488        directory: &Path,
3489        plugin_id: &str,
3490    ) -> VerifiedPluginPackage {
3491        write_inputs(directory);
3492        let project = project_with_id(plugin_id);
3493        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3494        let package_path = directory.join("fixture.vesper-plugin");
3495        build_signed_plugin_package(&project, directory, &key, &package_path)
3496            .expect("signed package");
3497        let mut trust = PluginTrustStore::empty();
3498        trust.insert(key.public_key()).expect("trusted key");
3499        verify_signed_plugin_package(&package_path, &trust).expect("verified package")
3500    }
3501
3502    fn rewrite_with_tampered_artifact(source: &Path, output: &Path) {
3503        let input = File::open(source).expect("open source package");
3504        let mut archive = ZipArchive::new(input).expect("read source package");
3505        let output_file = File::create(output).expect("create tampered package");
3506        let mut writer = ZipWriter::new(output_file);
3507        for index in 0..archive.len() {
3508            let mut entry = archive.by_index(index).expect("source entry");
3509            let name = entry.name().to_owned();
3510            let mut bytes = Vec::new();
3511            entry.read_to_end(&mut bytes).expect("read source entry");
3512            if name.ends_with("fixture plugin.dylib") {
3513                bytes.push(0xff);
3514            }
3515            let options = SimpleFileOptions::default()
3516                .compression_method(CompressionMethod::Stored)
3517                .last_modified_time(DateTime::default())
3518                .unix_permissions(entry.unix_mode().unwrap_or(0o644));
3519            writer
3520                .start_file(name, options)
3521                .expect("start copied entry");
3522            writer.write_all(&bytes).expect("write copied entry");
3523        }
3524        writer.finish().expect("finish tampered package");
3525    }
3526
3527    fn rewrite_with_conflicting_archive_path(source: &Path, output: &Path) {
3528        let input = File::open(source).expect("open source package");
3529        let mut archive = ZipArchive::new(input).expect("read source package");
3530        let output_file = File::create(output).expect("create conflicting package");
3531        let mut writer = ZipWriter::new(output_file);
3532        for index in 0..archive.len() {
3533            let mut entry = archive.by_index(index).expect("source entry");
3534            let options = SimpleFileOptions::default()
3535                .compression_method(CompressionMethod::Stored)
3536                .last_modified_time(DateTime::default())
3537                .unix_permissions(entry.unix_mode().unwrap_or(PACKAGE_METADATA_FILE_MODE));
3538            writer
3539                .start_file(entry.name(), options)
3540                .expect("start copied entry");
3541            io::copy(&mut entry, &mut writer).expect("copy source entry");
3542        }
3543        writer
3544            .start_file(
3545                "licenses/LICENSE/detail",
3546                SimpleFileOptions::default()
3547                    .compression_method(CompressionMethod::Stored)
3548                    .last_modified_time(DateTime::default())
3549                    .unix_permissions(PACKAGE_METADATA_FILE_MODE),
3550            )
3551            .expect("start conflicting entry");
3552        writer.write_all(b"conflict").expect("write conflict");
3553        writer.finish().expect("finish conflicting package");
3554    }
3555
3556    fn rewrite_central_directory_mode(
3557        source: &Path,
3558        output: &Path,
3559        target_path: &str,
3560        unix_mode: u32,
3561    ) {
3562        const CENTRAL_DIRECTORY_HEADER: [u8; 4] = [0x50, 0x4b, 0x01, 0x02];
3563
3564        let mut bytes = fs::read(source).expect("read package for mode rewrite");
3565        let mut cursor = 0_usize;
3566        let mut found = false;
3567        while cursor + 46 <= bytes.len() {
3568            if bytes[cursor..cursor + 4] != CENTRAL_DIRECTORY_HEADER {
3569                cursor += 1;
3570                continue;
3571            }
3572            let name_length = u16::from_le_bytes([bytes[cursor + 28], bytes[cursor + 29]]) as usize;
3573            let extra_length =
3574                u16::from_le_bytes([bytes[cursor + 30], bytes[cursor + 31]]) as usize;
3575            let comment_length =
3576                u16::from_le_bytes([bytes[cursor + 32], bytes[cursor + 33]]) as usize;
3577            let entry_length = 46 + name_length + extra_length + comment_length;
3578            assert!(cursor + entry_length <= bytes.len());
3579            if bytes[cursor + 46..cursor + 46 + name_length] == *target_path.as_bytes() {
3580                bytes[cursor + 5] = 3;
3581                bytes[cursor + 38..cursor + 42].copy_from_slice(&(unix_mode << 16).to_le_bytes());
3582                found = true;
3583                break;
3584            }
3585            cursor += entry_length;
3586        }
3587        assert!(found, "central directory entry must exist");
3588        fs::write(output, bytes).expect("write package with rewritten mode");
3589    }
3590
3591    #[test]
3592    fn signed_package_is_deterministic_and_rejects_payload_tampering() {
3593        let directory = tempfile::tempdir().expect("temporary package directory");
3594        write_inputs(directory.path());
3595        let project = project();
3596        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3597        let first = directory.path().join("first.vesper-plugin");
3598        let second = directory.path().join("second.vesper-plugin");
3599        build_signed_plugin_package(&project, directory.path(), &key, &first)
3600            .expect("first package");
3601        build_signed_plugin_package(&project, directory.path(), &key, &second)
3602            .expect("second package");
3603        assert_eq!(
3604            fs::read(&first).expect("first bytes"),
3605            fs::read(&second).expect("second bytes")
3606        );
3607
3608        let mut trust = PluginTrustStore::empty();
3609        trust
3610            .insert(key.public_key())
3611            .expect("trusted publisher key");
3612        let verified =
3613            verify_signed_plugin_package(&first, &trust).expect("verified signed package");
3614        assert_eq!(verified.manifest().plugin.id, "dev.vesper.fixture");
3615        assert_eq!(verified.manifest().artifacts.len(), 1);
3616        assert_eq!(
3617            verified.manifest().requires[0].service,
3618            "dev.vesper.service.time-stretch"
3619        );
3620        assert_eq!(verified.manifest().provides[0].version, "1.4.0");
3621        let mut manifest_round_trip = verified.manifest().clone();
3622        assert_eq!(
3623            serde_json::to_vec(verified.manifest()).expect("serialize canonical manifest"),
3624            canonical_manifest_bytes(&mut manifest_round_trip).expect("canonical manifest bytes")
3625        );
3626
3627        let tampered = directory.path().join("tampered.vesper-plugin");
3628        rewrite_with_tampered_artifact(&first, &tampered);
3629        assert!(matches!(
3630            verify_signed_plugin_package(&tampered, &trust),
3631            Err(PluginPackageError::InvalidPackage(ref message))
3632                if message.contains("checksum mismatch")
3633        ));
3634    }
3635
3636    #[test]
3637    fn canonical_checksums_round_trip_paths_with_repeated_spaces() {
3638        let checksums =
3639            BTreeMap::from([("artifacts/plugin  debug.dylib".to_owned(), "a".repeat(64))]);
3640        let encoded = canonical_checksums(&checksums);
3641
3642        assert_eq!(
3643            parse_canonical_checksums(&encoded).expect("canonical checksum list"),
3644            checksums
3645        );
3646    }
3647
3648    #[test]
3649    fn package_manifest_validation_matches_published_artifact_limits() {
3650        let directory = tempfile::tempdir().expect("temporary package directory");
3651        let verified = verified_fixture_package(directory.path());
3652        let manifest = verified.manifest().clone();
3653
3654        let mut empty_target = manifest.clone();
3655        empty_target.artifacts[0].target.clear();
3656        assert!(matches!(
3657            canonical_manifest_bytes(&mut empty_target),
3658            Err(PluginPackageError::InvalidPackage(ref message))
3659                if message.contains("artifacts.target")
3660        ));
3661
3662        let mut empty_architecture = manifest.clone();
3663        empty_architecture.artifacts[0].architecture.clear();
3664        assert!(matches!(
3665            canonical_manifest_bytes(&mut empty_architecture),
3666            Err(PluginPackageError::InvalidPackage(ref message))
3667                if message.contains("artifacts.architecture")
3668        ));
3669
3670        let mut empty_minimum_os = manifest.clone();
3671        empty_minimum_os.artifacts[0].minimum_os = Some(String::new());
3672        assert!(matches!(
3673            canonical_manifest_bytes(&mut empty_minimum_os),
3674            Err(PluginPackageError::InvalidPackage(ref message))
3675                if message.contains("artifacts.minimum_os")
3676        ));
3677
3678        let mut excessive_dependencies = manifest;
3679        excessive_dependencies.artifacts[0].runtime_dependencies = (0
3680            ..=crate::plugin_project::MAX_RUNTIME_DEPENDENCIES)
3681            .map(|index| PluginRuntimeDependencySource {
3682                id: format!("dev.vesper.runtime.dep{index}"),
3683                version: "1".to_owned(),
3684                linkage: crate::PluginRuntimeLinkage::Dynamic,
3685                compatibility_key: "baseline".to_owned(),
3686            })
3687            .collect();
3688        assert!(matches!(
3689            canonical_manifest_bytes(&mut excessive_dependencies),
3690            Err(PluginPackageError::InvalidPackage(ref message))
3691                if message.contains("runtime_dependencies")
3692        ));
3693    }
3694
3695    #[test]
3696    fn verification_rejects_special_files_and_non_canonical_permissions() {
3697        let directory = tempfile::tempdir().expect("temporary package directory");
3698        write_inputs(directory.path());
3699        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3700        let package_path = directory.path().join("fixture.vesper-plugin");
3701        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
3702            .expect("signed package");
3703        let mut trust = PluginTrustStore::empty();
3704        trust.insert(key.public_key()).expect("trusted key");
3705
3706        let special_path = directory.path().join("special-file.vesper-plugin");
3707        rewrite_central_directory_mode(
3708            &package_path,
3709            &special_path,
3710            PLUGIN_PACKAGE_MANIFEST_PATH,
3711            0o010644,
3712        );
3713        assert!(matches!(
3714            verify_signed_plugin_package(&special_path, &trust),
3715            Err(PluginPackageError::InvalidPackage(ref message))
3716                if message.contains("unsupported Unix file type")
3717        ));
3718
3719        let permissive_path = directory.path().join("permissive.vesper-plugin");
3720        rewrite_central_directory_mode(
3721            &package_path,
3722            &permissive_path,
3723            "artifacts/aarch64-apple-darwin/fixture plugin.dylib",
3724            UNIX_REGULAR_FILE | 0o777,
3725        );
3726        assert!(matches!(
3727            verify_signed_plugin_package(&permissive_path, &trust),
3728            Err(PluginPackageError::InvalidPackage(ref message))
3729                if message.contains("non-canonical permissions")
3730        ));
3731    }
3732
3733    #[test]
3734    fn verification_rejects_file_and_directory_archive_path_conflicts() {
3735        let directory = tempfile::tempdir().expect("temporary package directory");
3736        write_inputs(directory.path());
3737        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3738        let package_path = directory.path().join("fixture.vesper-plugin");
3739        let conflicting_path = directory.path().join("conflicting.vesper-plugin");
3740        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
3741            .expect("signed package");
3742        rewrite_with_conflicting_archive_path(&package_path, &conflicting_path);
3743        let mut trust = PluginTrustStore::empty();
3744        trust.insert(key.public_key()).expect("trusted key");
3745
3746        assert!(matches!(
3747            verify_signed_plugin_package(&conflicting_path, &trust),
3748            Err(PluginPackageError::InvalidPackage(ref message))
3749                if message.contains("conflicts with")
3750        ));
3751    }
3752
3753    #[test]
3754    fn trust_store_supports_overlap_and_explicit_key_revocation() {
3755        let directory = tempfile::tempdir().expect("temporary package directory");
3756        write_inputs(directory.path());
3757        let project = project();
3758        let old_key = PluginSigningKey::generate("dev.vesper.publisher").expect("old key");
3759        let new_key = PluginSigningKey::generate("dev.vesper.publisher").expect("new key");
3760        let old_package = directory.path().join("old.vesper-plugin");
3761        let new_package = directory.path().join("new.vesper-plugin");
3762        build_signed_plugin_package(&project, directory.path(), &old_key, &old_package)
3763            .expect("old-key package");
3764        build_signed_plugin_package(&project, directory.path(), &new_key, &new_package)
3765            .expect("new-key package");
3766
3767        let mut trust = PluginTrustStore::empty();
3768        trust.insert(old_key.public_key()).expect("insert old key");
3769        trust.insert(new_key.public_key()).expect("insert new key");
3770        let encoded = trust.to_json().expect("trust store JSON");
3771        let mut trust = PluginTrustStore::from_json(&encoded).expect("decode trust store");
3772        verify_signed_plugin_package(&old_package, &trust).expect("old key during overlap");
3773        verify_signed_plugin_package(&new_package, &trust).expect("new key during overlap");
3774
3775        trust
3776            .revoke(old_key.publisher(), old_key.key_id())
3777            .expect("revoke old key");
3778        assert!(matches!(
3779            verify_signed_plugin_package(&old_package, &trust),
3780            Err(PluginPackageError::InvalidSignature)
3781        ));
3782        verify_signed_plugin_package(&new_package, &trust).expect("new key remains active");
3783
3784        let empty = PluginTrustStore::empty();
3785        assert!(matches!(
3786            verify_signed_plugin_package(&new_package, &empty),
3787            Err(PluginPackageError::InvalidSignature)
3788        ));
3789    }
3790
3791    #[test]
3792    fn signing_key_round_trip_rejects_publisher_mismatch() {
3793        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3794        let encoded = key.to_json().expect("signing key JSON");
3795        let decoded = PluginSigningKey::from_json(&encoded).expect("decode signing key");
3796        assert_eq!(decoded.key_id(), key.key_id());
3797
3798        let directory = tempfile::tempdir().expect("temporary package directory");
3799        write_inputs(directory.path());
3800        let wrong_key =
3801            PluginSigningKey::generate("dev.vesper.other-publisher").expect("wrong publisher key");
3802        assert!(matches!(
3803            build_signed_plugin_package(
3804                &project(),
3805                directory.path(),
3806                &wrong_key,
3807                &directory.path().join("rejected.vesper-plugin")
3808            ),
3809            Err(PluginPackageError::InvalidSigningKey(ref message))
3810                if message.contains("does not match")
3811        ));
3812    }
3813
3814    #[test]
3815    fn verified_package_install_is_atomic_idempotent_and_removable() {
3816        let directory = tempfile::tempdir().expect("temporary package directory");
3817        write_inputs(directory.path());
3818        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3819        let package_path = directory.path().join("fixture.vesper-plugin");
3820        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
3821            .expect("signed package");
3822        let mut trust = PluginTrustStore::empty();
3823        trust.insert(key.public_key()).expect("trusted key");
3824        let verified =
3825            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
3826        let install_root = directory.path().join("installed");
3827
3828        let first =
3829            install_verified_plugin_package(&verified, &install_root).expect("first installation");
3830        assert!(!first.already_installed);
3831        let installed_path = Path::new(&first.install_path);
3832        assert_eq!(
3833            fs::read(installed_path.join("artifacts/aarch64-apple-darwin/fixture plugin.dylib"))
3834                .expect("installed artifact"),
3835            b"fixture artifact"
3836        );
3837        assert!(installed_path.join(PLUGIN_PACKAGE_MANIFEST_PATH).is_file());
3838        assert!(installed_path.join(PLUGIN_PACKAGE_CHECKSUMS_PATH).is_file());
3839        assert!(installed_path.join(PLUGIN_PACKAGE_SIGNATURE_PATH).is_file());
3840
3841        let second = install_verified_plugin_package(&verified, &install_root)
3842            .expect("idempotent installation");
3843        assert!(second.already_installed);
3844        assert_eq!(second.install_path, first.install_path);
3845        assert_eq!(second.package_sha256, first.package_sha256);
3846
3847        assert_eq!(
3848            list_installed_plugins(&install_root).expect("installed plugin list"),
3849            vec![InstalledPluginRecord {
3850                plugin_id: "dev.vesper.fixture".to_owned(),
3851                version: "1.2.3".to_owned(),
3852                install_path: first.install_path.clone(),
3853                package_sha256: first.package_sha256.clone(),
3854            }]
3855        );
3856        assert!(
3857            uninstall_plugin(&install_root, "dev.vesper.fixture", "1.2.3")
3858                .expect("remove installed plugin")
3859        );
3860        assert!(
3861            !uninstall_plugin(&install_root, "dev.vesper.fixture", "1.2.3")
3862                .expect("missing installation is idempotent")
3863        );
3864        assert!(
3865            list_installed_plugins(&install_root)
3866                .expect("empty installed plugin list")
3867                .is_empty()
3868        );
3869    }
3870
3871    #[test]
3872    fn installed_catalog_reverifies_and_snapshots_only_explicit_references() {
3873        let directory = tempfile::tempdir().expect("temporary installed catalog");
3874        write_inputs(directory.path());
3875        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3876        let package_path = directory.path().join("fixture.vesper-plugin");
3877        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
3878            .expect("signed package");
3879        let mut trust = PluginTrustStore::empty();
3880        trust.insert(key.public_key()).expect("trusted key");
3881        let verified =
3882            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
3883        let install_root = directory.path().join("installed");
3884        let installation = install_verified_plugin_package(&verified, &install_root)
3885            .expect("verified installation");
3886        let reference = PluginReference::new(
3887            "dev.vesper.fixture",
3888            Some("dev.vesper.fixture.post-download".to_owned()),
3889            PluginTransport::Native,
3890        )
3891        .expect("plugin reference");
3892        let host = PluginHostTarget::new(Version::new(0, 4, 0), "aarch64-apple-darwin", "arm64")
3893            .expect("host target");
3894
3895        let catalog = verify_installed_plugin_catalog(
3896            &install_root,
3897            &trust,
3898            &host,
3899            std::slice::from_ref(&reference),
3900            &[],
3901        )
3902        .expect("verified installed catalog");
3903        let [artifact] = catalog.artifacts() else {
3904            panic!("expected exactly one verified artifact");
3905        };
3906        assert_eq!(artifact.plugin_id(), "dev.vesper.fixture");
3907        assert_eq!(artifact.version(), "1.2.3");
3908        assert_ne!(artifact.snapshot_path(), artifact.installed_path());
3909        let pure_catalog = catalog
3910            .catalog()
3911            .expect("pure installed catalog projection");
3912        assert_eq!(pure_catalog.len(), 1);
3913        assert_eq!(
3914            pure_catalog.records()[0].descriptor().plugin_id,
3915            "dev.vesper.fixture"
3916        );
3917        assert_eq!(
3918            pure_catalog.records()[0].source,
3919            PluginCatalogSource::Installed
3920        );
3921        assert_eq!(
3922            pure_catalog.records()[0].descriptor().requires[0].service,
3923            "dev.vesper.service.time-stretch"
3924        );
3925        assert_eq!(
3926            pure_catalog.records()[0].descriptor().provides[0].version,
3927            "1.4.0"
3928        );
3929        assert_eq!(
3930            artifact.read_snapshot(1024).expect("snapshot bytes"),
3931            b"fixture artifact"
3932        );
3933
3934        fs::write(
3935            artifact.installed_path(),
3936            b"mutated after catalog verification",
3937        )
3938        .expect("mutate host-owned installation for regression test");
3939        assert_eq!(
3940            artifact
3941                .read_snapshot(1024)
3942                .expect("immutable snapshot bytes"),
3943            b"fixture artifact"
3944        );
3945        assert_eq!(installation.plugin_id, "dev.vesper.fixture");
3946    }
3947
3948    #[test]
3949    fn installed_catalog_rejects_payload_tampering() {
3950        let directory = tempfile::tempdir().expect("temporary tampered catalog");
3951        write_inputs(directory.path());
3952        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3953        let package_path = directory.path().join("fixture.vesper-plugin");
3954        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
3955            .expect("signed package");
3956        let mut trust = PluginTrustStore::empty();
3957        trust.insert(key.public_key()).expect("trusted key");
3958        let verified =
3959            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
3960        let install_root = directory.path().join("installed");
3961        let installation = install_verified_plugin_package(&verified, &install_root)
3962            .expect("verified installation");
3963        fs::write(
3964            installation
3965                .install_path
3966                .join("artifacts/aarch64-apple-darwin/fixture plugin.dylib"),
3967            b"tampered artifact",
3968        )
3969        .expect("tamper installed artifact");
3970        let reference = PluginReference::new("dev.vesper.fixture", None, PluginTransport::Native)
3971            .expect("plugin reference");
3972        let host = PluginHostTarget::new(Version::new(0, 4, 0), "aarch64-apple-darwin", "arm64")
3973            .expect("host target");
3974
3975        assert!(matches!(
3976            verify_installed_plugin_catalog(
3977                &install_root,
3978                &trust,
3979                &host,
3980                &[reference],
3981                &[],
3982            ),
3983            Err(PluginPackageError::InvalidPackage(message))
3984                if message.contains("checksum mismatch")
3985        ));
3986    }
3987
3988    #[test]
3989    fn installed_catalog_rejects_extra_files_and_non_regular_entries() {
3990        let directory = tempfile::tempdir().expect("temporary installed catalog layout");
3991        write_inputs(directory.path());
3992        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
3993        let package_path = directory.path().join("fixture.vesper-plugin");
3994        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
3995            .expect("signed package");
3996        let mut trust = PluginTrustStore::empty();
3997        trust.insert(key.public_key()).expect("trusted key");
3998        let verified =
3999            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
4000        let install_root = directory.path().join("installed");
4001        let installation = install_verified_plugin_package(&verified, &install_root)
4002            .expect("verified installation");
4003        let reference = PluginReference::new("dev.vesper.fixture", None, PluginTransport::Native)
4004            .expect("plugin reference");
4005        let host = PluginHostTarget::new(Version::new(0, 4, 0), "aarch64-apple-darwin", "arm64")
4006            .expect("host target");
4007
4008        let extra_file = installation.install_path.join("unexpected.txt");
4009        fs::write(&extra_file, b"not checksummed").expect("write extra installed file");
4010        assert!(matches!(
4011            verify_installed_plugin_catalog(
4012                &install_root,
4013                &trust,
4014                &host,
4015                std::slice::from_ref(&reference),
4016                &[],
4017            ),
4018            Err(PluginPackageError::InvalidPackage(message))
4019                if message.contains("files do not exactly match")
4020        ));
4021        fs::remove_file(&extra_file).expect("remove extra installed file");
4022
4023        #[cfg(unix)]
4024        {
4025            use std::os::unix::fs::symlink;
4026
4027            symlink(
4028                installation.install_path.join("licenses/LICENSE"),
4029                installation.install_path.join("unexpected-link"),
4030            )
4031            .expect("create installed symlink");
4032            assert!(matches!(
4033                verify_installed_plugin_catalog(
4034                    &install_root,
4035                    &trust,
4036                    &host,
4037                    &[reference],
4038                    &[],
4039                ),
4040                Err(PluginPackageError::InvalidPackage(message))
4041                    if message.contains("not a regular non-symlink file")
4042            ));
4043        }
4044    }
4045
4046    #[test]
4047    fn installed_catalog_selects_mixed_transports_without_fallback() {
4048        let directory = tempfile::tempdir().expect("temporary mixed plugin catalog");
4049        fs::write(
4050            directory.path().join("fixture plugin.dylib"),
4051            b"native artifact",
4052        )
4053        .expect("write Native artifact");
4054        fs::write(directory.path().join("fixture.wasm"), b"WASM artifact")
4055            .expect("write WASM artifact");
4056        fs::write(directory.path().join("LICENSE"), b"Apache-2.0\n").expect("write license");
4057        fs::write(directory.path().join("NOTICE"), b"Fixture notice\n").expect("write notice");
4058        let project = PluginProjectManifest::from_toml(
4059            r#"
4060schema_version = 1
4061
4062[plugin]
4063id = "dev.vesper.mixed"
4064name = "Mixed Fixture"
4065version = "1.2.3"
4066description = "Mixed Native and WASM fixture"
4067license = "Apache-2.0"
4068publisher = "dev.vesper.publisher"
4069
4070[compatibility]
4071host_sdk = ">=0.4.0, <0.5.0"
4072abi_major = 1
4073abi_minor_min = 0
4074abi_minor_max = 0
4075
4076[[capabilities]]
4077interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7"
4078instance_id = "dev.vesper.mixed.post-download"
4079interface_major = 1
4080interface_minor = 0
4081stability = "stable"
4082
4083[[capabilities]]
4084interface_id = "c7a69475-79b2-5b5e-a477-08844a5da5d1"
4085instance_id = "dev.vesper.mixed.event-hook"
4086interface_major = 1
4087interface_minor = 0
4088stability = "stable"
4089
4090[[artifacts]]
4091transport = "native"
4092target = "aarch64-apple-darwin"
4093format = "dylib"
4094source = "fixture plugin.dylib"
4095path = "artifacts/aarch64-apple-darwin/fixture plugin.dylib"
4096architecture = "arm64"
4097capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.mixed.post-download" }]
4098
4099[[artifacts]]
4100transport = "wasm"
4101target = "wasm32-wasip2"
4102format = "wasm-component"
4103source = "fixture.wasm"
4104path = "artifacts/wasm32-wasip2/fixture.wasm"
4105architecture = "wasm32"
4106capabilities = [{ interface_id = "c7a69475-79b2-5b5e-a477-08844a5da5d1", instance_id = "dev.vesper.mixed.event-hook" }]
4107
4108[[package_files]]
4109source = "LICENSE"
4110path = "licenses/LICENSE"
4111kind = "license"
4112
4113[[package_files]]
4114source = "NOTICE"
4115path = "notices/NOTICE"
4116kind = "notice"
4117"#,
4118        )
4119        .expect("mixed plugin project");
4120        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
4121        let package_path = directory.path().join("mixed.vesper-plugin");
4122        build_signed_plugin_package(&project, directory.path(), &key, &package_path)
4123            .expect("signed mixed package");
4124        let mut trust = PluginTrustStore::empty();
4125        trust.insert(key.public_key()).expect("trusted key");
4126        let verified =
4127            verify_signed_plugin_package(&package_path, &trust).expect("verified mixed package");
4128        let install_root = directory.path().join("installed");
4129        install_verified_plugin_package(&verified, &install_root).expect("installed mixed package");
4130        let native_reference = PluginReference::new(
4131            "dev.vesper.mixed",
4132            Some("dev.vesper.mixed.post-download".to_owned()),
4133            PluginTransport::Native,
4134        )
4135        .expect("Native reference");
4136        let wasm_reference = PluginReference::new(
4137            "dev.vesper.mixed",
4138            Some("dev.vesper.mixed.event-hook".to_owned()),
4139            PluginTransport::Wasm,
4140        )
4141        .expect("WASM reference");
4142        let host = PluginHostTarget::new(Version::new(0, 4, 0), "aarch64-apple-darwin", "arm64")
4143            .expect("host target");
4144        let catalog = verify_installed_plugin_catalog(
4145            &install_root,
4146            &trust,
4147            &host,
4148            &[native_reference.clone(), wasm_reference],
4149            &[],
4150        )
4151        .expect("verified mixed catalog");
4152        assert_eq!(catalog.artifacts().len(), 2);
4153        assert!(catalog.artifacts().iter().any(|artifact| {
4154            artifact.transport() == PluginArtifactTransport::Native
4155                && artifact.target() == "aarch64-apple-darwin"
4156                && artifact.capabilities().len() == 1
4157                && artifact.capabilities()[0].instance_id == "dev.vesper.mixed.post-download"
4158        }));
4159        assert!(catalog.artifacts().iter().any(|artifact| {
4160            artifact.transport() == PluginArtifactTransport::Wasm
4161                && artifact.target() == RUST_WASM_COMPONENT_TARGET
4162                && artifact.capabilities().len() == 1
4163                && artifact.capabilities()[0].instance_id == "dev.vesper.mixed.event-hook"
4164        }));
4165
4166        let unsupported_host =
4167            PluginHostTarget::new(Version::new(0, 4, 0), "x86_64-unknown-linux-gnu", "x86_64")
4168                .expect("unsupported host target");
4169        assert!(matches!(
4170            verify_installed_plugin_catalog(
4171                &install_root,
4172                &trust,
4173                &unsupported_host,
4174                &[native_reference],
4175                &[],
4176            ),
4177            Err(PluginPackageError::InstalledArtifactNotFound { transport, .. })
4178                if transport == "native"
4179        ));
4180    }
4181
4182    #[test]
4183    fn installed_catalog_requires_version_activation_and_rechecks_revocation() {
4184        let directory = tempfile::tempdir().expect("temporary activated catalog");
4185        write_inputs(directory.path());
4186        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
4187        let key_id = key.key_id().to_owned();
4188        let package_path = directory.path().join("fixture.vesper-plugin");
4189        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
4190            .expect("signed package");
4191        let mut trust = PluginTrustStore::empty();
4192        trust.insert(key.public_key()).expect("trusted key");
4193        let verified =
4194            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
4195        let install_root = directory.path().join("installed");
4196        install_verified_plugin_package(&verified, &install_root).expect("verified installation");
4197        fs::create_dir(install_root.join("dev.vesper.fixture").join("1.2.4"))
4198            .expect("create second version candidate");
4199        let reference = PluginReference::new("dev.vesper.fixture", None, PluginTransport::Native)
4200            .expect("plugin reference");
4201        let host = PluginHostTarget::new(Version::new(0, 4, 0), "aarch64-apple-darwin", "arm64")
4202            .expect("host target");
4203        assert!(matches!(
4204            verify_installed_plugin_catalog(
4205                &install_root,
4206                &trust,
4207                &host,
4208                std::slice::from_ref(&reference),
4209                &[],
4210            ),
4211            Err(PluginPackageError::AmbiguousInstalledVersions { .. })
4212        ));
4213
4214        let activation = InstalledPluginActivation::new("dev.vesper.fixture", "1.2.3")
4215            .expect("version activation");
4216        let catalog = verify_installed_plugin_catalog(
4217            &install_root,
4218            &trust,
4219            &host,
4220            std::slice::from_ref(&reference),
4221            std::slice::from_ref(&activation),
4222        )
4223        .expect("activated catalog");
4224        assert_eq!(catalog.artifacts().len(), 1);
4225        drop(catalog);
4226
4227        trust
4228            .revoke("dev.vesper.publisher", &key_id)
4229            .expect("revoke signing key");
4230        assert!(matches!(
4231            verify_installed_plugin_catalog(
4232                &install_root,
4233                &trust,
4234                &host,
4235                &[reference],
4236                &[activation],
4237            ),
4238            Err(PluginPackageError::InvalidSignature)
4239        ));
4240    }
4241
4242    #[test]
4243    fn verified_package_supports_independent_concurrent_install_reads() {
4244        use std::sync::Arc;
4245
4246        let directory = tempfile::tempdir().expect("temporary package directory");
4247        let verified = Arc::new(verified_fixture_package(directory.path()));
4248        let first_verified = Arc::clone(&verified);
4249        let first_root = directory.path().join("first-install-root");
4250        let first = std::thread::spawn(move || {
4251            install_verified_plugin_package(&first_verified, &first_root)
4252        });
4253        let second_verified = Arc::clone(&verified);
4254        let second_root = directory.path().join("second-install-root");
4255        let second = std::thread::spawn(move || {
4256            install_verified_plugin_package(&second_verified, &second_root)
4257        });
4258
4259        assert!(
4260            !first
4261                .join()
4262                .expect("first install thread")
4263                .expect("first install")
4264                .already_installed
4265        );
4266        assert!(
4267            !second
4268                .join()
4269                .expect("second install thread")
4270                .expect("second install")
4271                .already_installed
4272        );
4273    }
4274
4275    #[test]
4276    fn installed_catalog_enumeration_is_bounded() {
4277        let directory = tempfile::tempdir().expect("temporary install root");
4278        fs::write(directory.path().join(CATALOG_LOCK_PATH), b"").expect("catalog lock");
4279        for index in 0..MAX_INSTALLED_PLUGIN_IDENTITIES {
4280            fs::write(directory.path().join(format!("entry-{index}")), b"").expect("catalog entry");
4281        }
4282
4283        assert!(
4284            list_installed_plugins(directory.path())
4285                .expect("catalog lock does not consume identity capacity")
4286                .is_empty()
4287        );
4288        fs::write(directory.path().join("overflow-entry"), b"").expect("overflow entry");
4289        assert!(matches!(
4290            list_installed_plugins(directory.path()),
4291            Err(PluginPackageError::InvalidPackage(ref message))
4292                if message.contains("plugin install root exceeds")
4293        ));
4294    }
4295
4296    #[cfg(unix)]
4297    #[test]
4298    fn installation_report_paths_preserve_non_utf8_bytes() {
4299        use std::ffi::OsString;
4300        use std::os::unix::ffi::OsStringExt;
4301
4302        let install_path = PathBuf::from(OsString::from_vec(b"install-\xff".to_vec()));
4303        let report = PluginInstallationReport {
4304            plugin_id: "dev.vesper.fixture".to_owned(),
4305            version: "1.2.3".to_owned(),
4306            install_path: install_path.clone(),
4307            package_sha256: "0".repeat(64),
4308            already_installed: false,
4309        };
4310
4311        assert_eq!(report.install_path, install_path);
4312    }
4313
4314    #[test]
4315    fn install_rejects_a_new_identity_when_the_catalog_is_full() {
4316        let directory = tempfile::tempdir().expect("temporary package directory");
4317        let verified = verified_fixture_package(directory.path());
4318        let install_root = directory.path().join("installed-identities");
4319        fs::create_dir(&install_root).expect("install root");
4320        for index in 0..MAX_INSTALLED_PLUGIN_IDENTITIES {
4321            fs::write(install_root.join(format!("entry-{index}")), b"").expect("catalog entry");
4322        }
4323
4324        assert!(matches!(
4325            install_verified_plugin_package(&verified, &install_root),
4326            Err(PluginPackageError::InvalidPackage(ref message))
4327                if message.contains("1024-entry installation limit")
4328        ));
4329        assert!(!install_root.join("dev.vesper.fixture").exists());
4330    }
4331
4332    #[test]
4333    fn catalog_lock_contention_is_nonblocking_and_prevents_mutation() {
4334        let directory = tempfile::tempdir().expect("temporary package directory");
4335        let verified = verified_fixture_package(directory.path());
4336        let install_root = directory.path().join("locked-install-root");
4337        ensure_directory(&install_root, "test install root").expect("install root");
4338        let held_lock = PluginCatalogLock::acquire(&install_root).expect("held catalog lock");
4339
4340        assert!(matches!(
4341            install_verified_plugin_package(&verified, &install_root),
4342            Err(PluginPackageError::CatalogBusy { .. })
4343        ));
4344        assert!(matches!(
4345            list_installed_plugins(&install_root),
4346            Err(PluginPackageError::CatalogBusy { .. })
4347        ));
4348        assert!(!install_root.join("dev.vesper.fixture").exists());
4349
4350        drop(held_lock);
4351        install_verified_plugin_package(&verified, &install_root)
4352            .expect("installation after unlock");
4353        let held_lock = PluginCatalogLock::acquire(&install_root).expect("held catalog lock");
4354        assert!(matches!(
4355            uninstall_plugin(&install_root, "dev.vesper.fixture", "1.2.3"),
4356            Err(PluginPackageError::CatalogBusy { .. })
4357        ));
4358        assert!(install_root.join("dev.vesper.fixture/1.2.3").is_dir());
4359        drop(held_lock);
4360    }
4361
4362    #[test]
4363    fn concurrent_installs_cannot_exceed_identity_capacity() {
4364        use std::sync::{Arc, Barrier};
4365
4366        let directory = tempfile::tempdir().expect("temporary package directory");
4367        let first_source = directory.path().join("first-source");
4368        let second_source = directory.path().join("second-source");
4369        fs::create_dir(&first_source).expect("first package source");
4370        fs::create_dir(&second_source).expect("second package source");
4371        let first_verified = Arc::new(verified_fixture_package_with_id(
4372            &first_source,
4373            "dev.vesper.concurrent-first",
4374        ));
4375        let second_verified = Arc::new(verified_fixture_package_with_id(
4376            &second_source,
4377            "dev.vesper.concurrent-second",
4378        ));
4379        let install_root = directory.path().join("concurrent-install-root");
4380        fs::create_dir(&install_root).expect("install root");
4381        for index in 0..MAX_INSTALLED_PLUGIN_IDENTITIES - 1 {
4382            fs::write(install_root.join(format!("entry-{index}")), b"").expect("catalog entry");
4383        }
4384
4385        let barrier = Arc::new(Barrier::new(3));
4386        let first_barrier = Arc::clone(&barrier);
4387        let first_root = install_root.clone();
4388        let first = std::thread::spawn(move || {
4389            first_barrier.wait();
4390            install_verified_plugin_package(&first_verified, &first_root)
4391        });
4392        let second_barrier = Arc::clone(&barrier);
4393        let second_root = install_root.clone();
4394        let second = std::thread::spawn(move || {
4395            second_barrier.wait();
4396            install_verified_plugin_package(&second_verified, &second_root)
4397        });
4398        barrier.wait();
4399
4400        let results = [
4401            first.join().expect("first install thread"),
4402            second.join().expect("second install thread"),
4403        ];
4404        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
4405        assert!(
4406            results
4407                .iter()
4408                .filter_map(|result| result.as_ref().err())
4409                .all(|error| match error {
4410                    PluginPackageError::CatalogBusy { .. } => true,
4411                    PluginPackageError::InvalidPackage(message) => {
4412                        message.contains("1024-entry installation limit")
4413                    }
4414                    _ => false,
4415                })
4416        );
4417
4418        let catalog_entries = read_directory(&install_root)
4419            .expect("installed catalog")
4420            .map(|entry| read_directory_entry(entry, &install_root).expect("catalog entry"))
4421            .filter(|entry| entry.file_name() != OsStr::new(CATALOG_LOCK_PATH))
4422            .count();
4423        assert_eq!(catalog_entries, MAX_INSTALLED_PLUGIN_IDENTITIES);
4424        let installed_candidates = [
4425            install_root.join("dev.vesper.concurrent-first"),
4426            install_root.join("dev.vesper.concurrent-second"),
4427        ];
4428        assert_eq!(
4429            installed_candidates
4430                .iter()
4431                .filter(|candidate| candidate.is_dir())
4432                .count(),
4433            1
4434        );
4435        for candidate in installed_candidates
4436            .iter()
4437            .filter(|candidate| candidate.is_dir())
4438        {
4439            assert!(candidate.join("1.2.3").is_dir());
4440            assert!(
4441                read_directory(candidate)
4442                    .expect("installed identity")
4443                    .map(|entry| entry.expect("identity entry").file_name())
4444                    .all(|name| !name.to_string_lossy().starts_with(".vesper-staging-"))
4445            );
4446        }
4447    }
4448
4449    #[test]
4450    fn install_rejects_a_new_version_when_the_identity_catalog_is_full() {
4451        let directory = tempfile::tempdir().expect("temporary package directory");
4452        let verified = verified_fixture_package(directory.path());
4453        let install_root = directory.path().join("installed-versions");
4454        let plugin_root = install_root.join("dev.vesper.fixture");
4455        fs::create_dir_all(&plugin_root).expect("plugin identity directory");
4456        for index in 0..MAX_INSTALLED_VERSIONS_PER_PLUGIN {
4457            fs::write(plugin_root.join(format!("entry-{index}")), b"")
4458                .expect("version catalog entry");
4459        }
4460
4461        assert!(matches!(
4462            install_verified_plugin_package(&verified, &install_root),
4463            Err(PluginPackageError::InvalidPackage(ref message))
4464                if message.contains("256-entry installation limit")
4465        ));
4466        assert!(!plugin_root.join("1.2.3").exists());
4467    }
4468
4469    #[test]
4470    fn install_rejects_a_conflicting_package_for_the_same_version() {
4471        let directory = tempfile::tempdir().expect("temporary package directory");
4472        write_inputs(directory.path());
4473        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
4474        let first_path = directory.path().join("first.vesper-plugin");
4475        let second_path = directory.path().join("second.vesper-plugin");
4476        build_signed_plugin_package(&project(), directory.path(), &key, &first_path)
4477            .expect("first signed package");
4478        fs::write(
4479            directory.path().join("fixture plugin.dylib"),
4480            b"different fixture artifact",
4481        )
4482        .expect("replace artifact input");
4483        build_signed_plugin_package(&project(), directory.path(), &key, &second_path)
4484            .expect("second signed package");
4485        let mut trust = PluginTrustStore::empty();
4486        trust.insert(key.public_key()).expect("trusted key");
4487        let first = verify_signed_plugin_package(&first_path, &trust).expect("first verified");
4488        let second = verify_signed_plugin_package(&second_path, &trust).expect("second verified");
4489        let install_root = directory.path().join("installed");
4490        install_verified_plugin_package(&first, &install_root).expect("first installation");
4491
4492        assert!(matches!(
4493            install_verified_plugin_package(&second, &install_root),
4494            Err(PluginPackageError::InvalidPackage(ref message))
4495                if message.contains("already installed from a different package")
4496        ));
4497    }
4498
4499    #[cfg(unix)]
4500    #[test]
4501    fn install_remains_bound_to_the_file_that_was_verified() {
4502        let directory = tempfile::tempdir().expect("temporary package directory");
4503        write_inputs(directory.path());
4504        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
4505        let package_path = directory.path().join("fixture.vesper-plugin");
4506        let replacement_path = directory.path().join("replacement.vesper-plugin");
4507        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
4508            .expect("original signed package");
4509        let mut trust = PluginTrustStore::empty();
4510        trust.insert(key.public_key()).expect("trusted key");
4511        let verified =
4512            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
4513
4514        fs::write(
4515            directory.path().join("fixture plugin.dylib"),
4516            b"different fixture artifact",
4517        )
4518        .expect("replace artifact input");
4519        build_signed_plugin_package(&project(), directory.path(), &key, &replacement_path)
4520            .expect("replacement signed package");
4521        fs::rename(&replacement_path, &package_path).expect("replace verified package path");
4522
4523        let report = install_verified_plugin_package(
4524            &verified,
4525            &directory.path().join("installed-from-pinned-handle"),
4526        )
4527        .expect("install pinned verified package");
4528        assert_eq!(
4529            fs::read(
4530                Path::new(&report.install_path)
4531                    .join("artifacts/aarch64-apple-darwin/fixture plugin.dylib")
4532            )
4533            .expect("installed artifact"),
4534            b"fixture artifact"
4535        );
4536    }
4537
4538    #[cfg(unix)]
4539    #[test]
4540    fn install_rejects_in_place_package_mutation_after_verification() {
4541        let directory = tempfile::tempdir().expect("temporary package directory");
4542        write_inputs(directory.path());
4543        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
4544        let package_path = directory.path().join("fixture.vesper-plugin");
4545        let replacement_path = directory.path().join("replacement.vesper-plugin");
4546        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
4547            .expect("original signed package");
4548        let mut trust = PluginTrustStore::empty();
4549        trust.insert(key.public_key()).expect("trusted key");
4550        let verified =
4551            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
4552
4553        fs::write(
4554            directory.path().join("fixture plugin.dylib"),
4555            b"fixture artifacX",
4556        )
4557        .expect("mutate artifact input");
4558        build_signed_plugin_package(&project(), directory.path(), &key, &replacement_path)
4559            .expect("replacement signed package");
4560        fs::write(
4561            &package_path,
4562            fs::read(&replacement_path).expect("replacement package bytes"),
4563        )
4564        .expect("mutate verified package in place");
4565
4566        let install_root = directory.path().join("rejected-install");
4567        assert!(matches!(
4568            install_verified_plugin_package(&verified, &install_root),
4569            Err(PluginPackageError::InvalidPackage(ref message))
4570                if message.contains("changed after verification")
4571        ));
4572        assert!(!install_root.join("dev.vesper.fixture").exists());
4573        assert_eq!(
4574            read_directory(&install_root)
4575                .expect("failed install root")
4576                .map(|entry| entry.expect("install root entry").file_name())
4577                .collect::<Vec<_>>(),
4578            vec![OsStr::new(CATALOG_LOCK_PATH).to_os_string()]
4579        );
4580    }
4581
4582    #[cfg(unix)]
4583    #[test]
4584    fn install_and_uninstall_reject_symlinked_install_boundaries() {
4585        use std::os::unix::fs::symlink;
4586
4587        let directory = tempfile::tempdir().expect("temporary package directory");
4588        write_inputs(directory.path());
4589        let key = PluginSigningKey::generate("dev.vesper.publisher").expect("signing key");
4590        let package_path = directory.path().join("fixture.vesper-plugin");
4591        build_signed_plugin_package(&project(), directory.path(), &key, &package_path)
4592            .expect("signed package");
4593        let mut trust = PluginTrustStore::empty();
4594        trust.insert(key.public_key()).expect("trusted key");
4595        let verified =
4596            verify_signed_plugin_package(&package_path, &trust).expect("verified package");
4597        let real_root = directory.path().join("real-install-root");
4598        fs::create_dir(&real_root).expect("real install root");
4599        let linked_root = directory.path().join("linked-install-root");
4600        symlink(&real_root, &linked_root).expect("install root symlink");
4601
4602        assert!(matches!(
4603            install_verified_plugin_package(&verified, &linked_root),
4604            Err(PluginPackageError::InvalidPackage(ref message))
4605                if message.contains("not a regular directory")
4606        ));
4607        assert!(matches!(
4608            uninstall_plugin(&linked_root, "dev.vesper.fixture", "1.2.3"),
4609            Err(PluginPackageError::InvalidPackage(ref message))
4610                if message.contains("not a regular directory")
4611        ));
4612
4613        let install_root = directory.path().join("installed");
4614        let plugin_root = install_root.join("dev.vesper.fixture");
4615        fs::create_dir_all(&plugin_root).expect("plugin identity directory");
4616        symlink(&real_root, plugin_root.join("1.2.3")).expect("version target symlink");
4617        assert!(matches!(
4618            install_verified_plugin_package(&verified, &install_root),
4619            Err(PluginPackageError::InvalidPackage(ref message))
4620                if message.contains("not a regular directory")
4621        ));
4622    }
4623}