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