1use std::collections::BTreeMap;
8use std::fs::File;
9use std::io::{self, Read};
10use std::path::{Path, PathBuf};
11
12use player_plugin::{PluginCatalog, PluginCatalogError, PluginCatalogRecord};
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15
16pub const MAX_PLUGIN_CATALOG_IMPORT_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
19const DIGEST_BUFFER_BYTES: usize = 64 * 1024;
20
21#[derive(Debug, Error)]
23pub enum PluginCatalogImportError {
24 #[error(transparent)]
25 Catalog(PluginCatalogError),
26 #[error(
27 "catalog import rejected duplicate identity `{identity}` from `{first_path}` and `{duplicate_path}`"
28 )]
29 DuplicateIdentity {
30 identity: String,
31 first_path: String,
32 duplicate_path: String,
33 },
34 #[error("failed to read plugin artifact `{path}`: {source}")]
35 ReadArtifact {
36 path: String,
37 #[source]
38 source: io::Error,
39 },
40 #[error("plugin artifact `{path}` is not a regular file")]
41 ArtifactNotFile { path: String },
42 #[error(
43 "plugin artifact `{path}` is {actual_bytes} bytes; maximum allowed for catalog import is {maximum_bytes}"
44 )]
45 ArtifactTooLarge {
46 path: String,
47 actual_bytes: u64,
48 maximum_bytes: u64,
49 },
50 #[error("stale plugin artifact digest for `{path}`: declared {expected}, actual {actual}")]
51 StaleDigest {
52 path: String,
53 expected: String,
54 actual: String,
55 },
56 #[error("catalog import path `{path}` is not valid: {message}")]
57 InvalidPath { path: String, message: String },
58}
59
60impl From<PluginCatalogError> for PluginCatalogImportError {
61 fn from(error: PluginCatalogError) -> Self {
62 match error {
63 PluginCatalogError::DuplicateIdentity {
64 identity,
65 first_path,
66 duplicate_path,
67 } => Self::DuplicateIdentity {
68 identity,
69 first_path,
70 duplicate_path,
71 },
72 other => Self::Catalog(other),
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PluginCatalogIndex {
84 catalog: PluginCatalog,
85 identity_index: BTreeMap<String, usize>,
86 plugin_index: BTreeMap<String, Vec<usize>>,
87}
88
89impl Default for PluginCatalogIndex {
90 fn default() -> Self {
91 Self {
92 catalog: PluginCatalog::empty(),
93 identity_index: BTreeMap::new(),
94 plugin_index: BTreeMap::new(),
95 }
96 }
97}
98
99impl PluginCatalogIndex {
100 pub fn from_catalog(catalog: PluginCatalog) -> Result<Self, PluginCatalogImportError> {
102 let mut identity_index = BTreeMap::new();
103 let mut plugin_index: BTreeMap<String, Vec<usize>> = BTreeMap::new();
104 for (index, record) in catalog.records().iter().enumerate() {
105 let canonical_identity = record.canonical_identity_key();
106 if let Some(first_index) = identity_index.insert(canonical_identity, index) {
107 let first_path = catalog.records()[first_index].artifact_path().to_owned();
108 return Err(PluginCatalogImportError::DuplicateIdentity {
109 identity: record.identity_key(),
110 first_path,
111 duplicate_path: record.artifact_path().to_owned(),
112 });
113 }
114 plugin_index
115 .entry(record.descriptor().plugin_id.clone())
116 .or_default()
117 .push(index);
118 }
119 Ok(Self {
120 catalog,
121 identity_index,
122 plugin_index,
123 })
124 }
125
126 pub fn catalog(&self) -> &PluginCatalog {
127 &self.catalog
128 }
129
130 pub fn records(&self) -> &[PluginCatalogRecord] {
131 self.catalog.records()
132 }
133
134 pub fn len(&self) -> usize {
135 self.catalog.len()
136 }
137
138 pub fn is_empty(&self) -> bool {
139 self.catalog.is_empty()
140 }
141
142 pub fn fingerprint(&self) -> &str {
143 self.catalog.fingerprint()
144 }
145
146 pub fn get(&self, identity: &str) -> Option<&PluginCatalogRecord> {
147 self.identity_index
148 .get(identity)
149 .and_then(|index| self.catalog.records().get(*index))
150 }
151
152 pub fn find(&self, plugin_id: &str) -> impl Iterator<Item = &PluginCatalogRecord> {
153 self.plugin_index
154 .get(plugin_id)
155 .into_iter()
156 .flat_map(|indices| indices.iter())
157 .filter_map(|index| self.catalog.records().get(*index))
158 }
159}
160
161#[derive(Debug, Clone, Default)]
167pub struct PluginCatalogImporter {
168 index: PluginCatalogIndex,
169}
170
171impl PluginCatalogImporter {
172 pub fn new() -> Self {
173 Self::default()
174 }
175
176 pub fn from_catalog(catalog: PluginCatalog) -> Result<Self, PluginCatalogImportError> {
177 Ok(Self {
178 index: PluginCatalogIndex::from_catalog(catalog)?,
179 })
180 }
181
182 pub fn import(
184 records: impl IntoIterator<Item = PluginCatalogRecord>,
185 ) -> Result<PluginCatalogIndex, PluginCatalogImportError> {
186 let catalog =
187 PluginCatalog::from_records(records).map_err(PluginCatalogImportError::from)?;
188 PluginCatalogIndex::from_catalog(catalog)
189 }
190
191 pub fn import_json(bytes: &[u8]) -> Result<PluginCatalogIndex, PluginCatalogImportError> {
193 let catalog = PluginCatalog::from_json(bytes).map_err(PluginCatalogImportError::from)?;
194 PluginCatalogIndex::from_catalog(catalog)
195 }
196
197 pub fn index(&self) -> &PluginCatalogIndex {
198 &self.index
199 }
200
201 pub fn into_index(self) -> PluginCatalogIndex {
202 self.index
203 }
204
205 pub fn import_record(
207 &mut self,
208 record: PluginCatalogRecord,
209 ) -> Result<(), PluginCatalogImportError> {
210 self.commit_records(std::iter::once(record))
211 }
212
213 pub fn import_records(
216 &mut self,
217 records: impl IntoIterator<Item = PluginCatalogRecord>,
218 ) -> Result<(), PluginCatalogImportError> {
219 self.commit_records(records)
220 }
221
222 pub fn import_json_into(&mut self, bytes: &[u8]) -> Result<(), PluginCatalogImportError> {
223 let catalog = PluginCatalog::from_json(bytes).map_err(PluginCatalogImportError::from)?;
224 self.commit_records(catalog.records().iter().cloned())
225 }
226
227 pub fn import_record_at(
230 &mut self,
231 record: PluginCatalogRecord,
232 path: impl AsRef<Path>,
233 ) -> Result<(), PluginCatalogImportError> {
234 let path = path.as_ref();
235 record.validate().map_err(PluginCatalogImportError::from)?;
236 verify_artifact_digest(&record, path)?;
237 self.import_record(record)
238 }
239
240 pub fn import_record_from_root(
243 &mut self,
244 record: PluginCatalogRecord,
245 root: impl AsRef<Path>,
246 ) -> Result<(), PluginCatalogImportError> {
247 let path = resolve_record_path(&record, root.as_ref())?;
248 self.import_record_at(record, path)
249 }
250
251 pub fn import_records_from_root(
252 &mut self,
253 records: impl IntoIterator<Item = PluginCatalogRecord>,
254 root: impl AsRef<Path>,
255 ) -> Result<(), PluginCatalogImportError> {
256 let root = root.as_ref();
257 let records = records.into_iter().collect::<Vec<_>>();
258 for record in &records {
259 record.validate().map_err(PluginCatalogImportError::from)?;
260 let path = resolve_record_path(record, root)?;
261 verify_artifact_digest(record, &path)?;
262 }
263 self.commit_records(records)
264 }
265
266 fn commit_records(
267 &mut self,
268 records: impl IntoIterator<Item = PluginCatalogRecord>,
269 ) -> Result<(), PluginCatalogImportError> {
270 let mut candidate = self.index.records().to_vec();
271 candidate.extend(records);
272 let catalog =
273 PluginCatalog::from_records(candidate).map_err(PluginCatalogImportError::from)?;
274 let index = PluginCatalogIndex::from_catalog(catalog)?;
275 self.index = index;
276 Ok(())
277 }
278}
279
280fn resolve_record_path(
281 record: &PluginCatalogRecord,
282 root: &Path,
283) -> Result<PathBuf, PluginCatalogImportError> {
284 if root.as_os_str().is_empty() {
285 return Err(PluginCatalogImportError::InvalidPath {
286 path: record.artifact_path().to_owned(),
287 message: "root path must not be empty".to_owned(),
288 });
289 }
290 let path = Path::new(record.artifact_path());
291 if path.is_absolute() {
292 Ok(path.to_path_buf())
293 } else {
294 Ok(root.join(path))
295 }
296}
297
298fn verify_artifact_digest(
299 record: &PluginCatalogRecord,
300 path: &Path,
301) -> Result<(), PluginCatalogImportError> {
302 let path_string = path.display().to_string();
303 let metadata =
304 std::fs::metadata(path).map_err(|source| PluginCatalogImportError::ReadArtifact {
305 path: path_string.clone(),
306 source,
307 })?;
308 if !metadata.is_file() {
309 return Err(PluginCatalogImportError::ArtifactNotFile { path: path_string });
310 }
311 if metadata.len() > MAX_PLUGIN_CATALOG_IMPORT_ARTIFACT_BYTES {
312 return Err(PluginCatalogImportError::ArtifactTooLarge {
313 path: path_string,
314 actual_bytes: metadata.len(),
315 maximum_bytes: MAX_PLUGIN_CATALOG_IMPORT_ARTIFACT_BYTES,
316 });
317 }
318
319 let mut file = File::open(path).map_err(|source| PluginCatalogImportError::ReadArtifact {
320 path: path.display().to_string(),
321 source,
322 })?;
323 let mut hasher = Sha256::new();
324 let mut buffer = [0_u8; DIGEST_BUFFER_BYTES];
325 let mut total_read = 0_u64;
326 loop {
327 let read =
328 file.read(&mut buffer)
329 .map_err(|source| PluginCatalogImportError::ReadArtifact {
330 path: path.display().to_string(),
331 source,
332 })?;
333 if read == 0 {
334 break;
335 }
336 total_read = total_read.saturating_add(read as u64);
337 if total_read > MAX_PLUGIN_CATALOG_IMPORT_ARTIFACT_BYTES {
338 return Err(PluginCatalogImportError::ArtifactTooLarge {
339 path: path.display().to_string(),
340 actual_bytes: total_read,
341 maximum_bytes: MAX_PLUGIN_CATALOG_IMPORT_ARTIFACT_BYTES,
342 });
343 }
344 hasher.update(&buffer[..read]);
345 }
346 let actual = hex::encode(hasher.finalize());
347 if actual != record.artifact_sha256() {
348 return Err(PluginCatalogImportError::StaleDigest {
349 path: path.display().to_string(),
350 expected: record.artifact_sha256().to_owned(),
351 actual,
352 });
353 }
354 Ok(())
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use player_plugin::{
361 PLUGIN_CATALOG_MIGRATION_VERSION, PLUGIN_CATALOG_SCHEMA_VERSION, PluginArtifactCapability,
362 PluginArtifactDescriptor, PluginArtifactFormat, PluginArtifactTransport,
363 PluginCatalogSource, PluginResourcePolicy,
364 };
365
366 fn descriptor(plugin_id: &str, instance_id: &str) -> PluginArtifactDescriptor {
367 PluginArtifactDescriptor {
368 schema_version: PLUGIN_CATALOG_SCHEMA_VERSION,
369 plugin_id: plugin_id.to_owned(),
370 version: "1.0.0".to_owned(),
371 publisher: "dev.vesper.publisher".to_owned(),
372 transport: PluginArtifactTransport::Native,
373 target: "aarch64-apple-darwin".to_owned(),
374 format: PluginArtifactFormat::Dylib,
375 architecture: "arm64".to_owned(),
376 abi_major: 1,
377 abi_minor_min: 0,
378 abi_minor_max: 0,
379 capabilities: vec![PluginArtifactCapability {
380 interface_id: "e9479dbc-42d2-575e-b39e-a24bc512fbc7".to_owned(),
381 instance_id: instance_id.to_owned(),
382 }],
383 requires: Vec::new(),
384 provides: Vec::new(),
385 runtime_dependencies: Vec::new(),
386 resource_policy: PluginResourcePolicy::default(),
387 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
388 }
389 }
390
391 fn record(plugin_id: &str, path: &str) -> PluginCatalogRecord {
392 record_with_digest(
393 plugin_id,
394 path,
395 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
396 )
397 }
398
399 fn record_with_digest(plugin_id: &str, path: &str, digest: &str) -> PluginCatalogRecord {
400 PluginCatalogRecord::new(
401 descriptor(plugin_id, &format!("{plugin_id}.primary")),
402 path,
403 digest,
404 PluginCatalogSource::Development,
405 )
406 .expect("fixture record")
407 }
408
409 #[test]
410 fn failed_duplicate_import_keeps_the_previous_index() {
411 let mut importer = PluginCatalogImporter::new();
412 importer
413 .import_record(record("dev.vesper.one", "/tmp/one"))
414 .expect("first record");
415 let before = importer.index().fingerprint().to_owned();
416 let error = importer
417 .import_record(record("dev.vesper.one", "/tmp/duplicate"))
418 .expect_err("duplicate identity");
419 assert!(matches!(
420 error,
421 PluginCatalogImportError::DuplicateIdentity { .. }
422 ));
423 assert_eq!(before, importer.index().fingerprint());
424 assert_eq!(importer.index().len(), 1);
425 }
426
427 #[test]
428 fn json_import_rebuilds_an_immutable_index() {
429 let record = record("dev.vesper.one", "/tmp/one");
430 let catalog = PluginCatalog::from_records([record]).expect("catalog");
431 let bytes = catalog.to_json().expect("json");
432 let index = PluginCatalogImporter::import_json(&bytes).expect("index");
433 assert_eq!(index.fingerprint(), catalog.fingerprint());
434 assert_eq!(index.find("dev.vesper.one").count(), 1);
435 }
436
437 #[test]
438 fn verified_artifact_import_commits_only_after_digest_match() {
439 let directory = tempfile::tempdir().expect("temporary directory");
440 let path = directory.path().join("fixture.bin");
441 let bytes = b"catalog importer fixture";
442 std::fs::write(&path, bytes).expect("artifact bytes");
443 let digest = hex::encode(Sha256::digest(bytes));
444 let mut importer = PluginCatalogImporter::new();
445
446 importer
447 .import_record_at(
448 record_with_digest("dev.vesper.one", &path.to_string_lossy(), &digest),
449 &path,
450 )
451 .expect("matching digest");
452 let before = importer.index().fingerprint().to_owned();
453 let error = importer
454 .import_record_at(
455 PluginCatalogRecord::new(
456 descriptor("dev.vesper.two", "dev.vesper.two.primary"),
457 path.to_string_lossy(),
458 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
459 PluginCatalogSource::Development,
460 )
461 .expect("stale record"),
462 &path,
463 )
464 .expect_err("stale digest");
465 assert!(matches!(
466 error,
467 PluginCatalogImportError::StaleDigest { .. }
468 ));
469 assert_eq!(before, importer.index().fingerprint());
470 assert_eq!(importer.index().len(), 1);
471 }
472}