Skip to main content

plugin_packager/
lib.rs

1// Copyright 2024 Vincents AI
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use anyhow::{bail, Context, Result};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use std::fs::{self, File};
8use std::io::{Read, Write};
9#[cfg(unix)]
10use std::os::unix::fs::PermissionsExt;
11use std::path::{Path, PathBuf};
12
13// Registry integration module
14pub mod registry;
15pub use registry::{
16    DependencyResolution, DependencyResolver, LocalRegistry, PluginDependency, PluginRegistryEntry,
17    RegistryPersistence, VersionRequirement,
18};
19
20// Configuration module
21pub mod config;
22pub use config::Config;
23
24// Metadata extraction module
25pub mod metadata;
26pub use metadata::{DependencyMetadata, PluginMetadata, PluginRequirements, PluginStats};
27
28// Remote registry integration module
29pub mod remote;
30pub use remote::{CacheStats, HybridRegistry, RemoteRegistry, RemoteRegistryConfig};
31
32// Plugin upgrade and rollback module
33pub mod upgrade;
34pub use upgrade::{BackupManager, BackupRecord, SemanticVersion, UpgradeInfo, UpgradeResult};
35
36// ABI v2.0 compatibility module
37pub mod abi_compat;
38pub use abi_compat::{
39    ABICompatibleInfo, ABIValidationResult, ABIValidator, ABIVersion, CapabilityInfo,
40    DependencyInfo, MaturityLevel, PluginCategory, ResourceRequirements,
41};
42
43// Plugin signature verification and cryptographic signing module
44pub mod signature;
45pub use signature::{
46    KeyInfo, PluginSignature, SignatureAlgorithm, SignatureAuditLog, SignatureManager, TrustLevel,
47    VerificationResult,
48};
49
50// Vulnerability scanning and security auditing module
51pub mod security;
52pub use security::{
53    LicenseCompliance, LicenseType, RiskLevel, SecurityAuditReport, SecurityScanResult,
54    Vulnerability, VulnerabilityScanner, VulnerabilitySeverity,
55};
56
57// Plugin manifest validation framework module
58pub mod validation;
59pub use validation::{
60    ManifestValidator, ValidationIssue, ValidationReport, ValidationRule, ValidationSeverity,
61};
62
63// Plugin health check and verification framework module
64pub mod health_check;
65pub use health_check::{
66    Architecture, BinaryCompatibility, HealthCheckResult, HealthReport, HealthScore,
67    HealthSeverity, HealthStatus, PerformanceBaseline, PerformanceThresholds, Platform,
68    PluginHealthChecker, SymbolRequirement,
69};
70
71// Plugin compatibility matrix and analysis module
72pub mod compat_matrix;
73pub use compat_matrix::{
74    AbiCompatibilityEntry, AbiVersion, BreakingChange, CompatibilityAnalysis, CompatibilityLevel,
75    CompatibilityReport, DependencyCompatibility, PlatformArch, PlatformSupportEntry,
76    PluginCompatibilityMatrix,
77};
78
79// Plugin sandbox verification and security analysis module
80pub mod sandbox;
81pub use sandbox::{
82    Permission, PluginCapability, PluginSandboxVerifier, ResourceLimits, SandboxCheckResult,
83    SandboxRiskLevel, SandboxSeverity, SandboxVerificationReport, SystemCallInfo,
84};
85
86// Dependency tree visualization and graph analysis module
87pub mod dep_tree;
88pub use dep_tree::{
89    CircularDependency, DependencyEdge, DependencyGraph, DependencyMetrics, DependencyNode,
90};
91
92// Plugin composition and meta-package support module
93pub mod composition;
94pub use composition::{
95    BundleMetadata, BundleType, CompositePlugin, CompositeSize, CompositionManager,
96    ConflictResolution, DependencyResolutionResult, PluginBundle, PluginComponent,
97    ValidationResult, VersionConflict,
98};
99
100// Optional dependencies and feature gates support module
101pub mod optional_deps;
102pub use optional_deps::{
103    ConditionType, DependencyCondition, FeatureGate, OptionalDependency, OptionalDependencyManager,
104    PlatformSpecific,
105};
106
107// RFC-0003: Plugin artifact extraction with security checks
108pub mod extractor;
109pub use extractor::{extract_artifact, ExtractionResult, ExtractorConfig, PluginExtractor};
110
111// RFC-0003: Cross-platform plugin artifact support
112pub mod platform;
113// Note: Access Platform enum via plugin_packager::platform::Platform
114// to avoid conflict with health_check::Platform
115pub use platform::{
116    get_valid_artifact_filenames, is_valid_artifact_extension, is_valid_artifact_filename,
117    validate_platform_artifact, ArtifactMetadata, SUPPORTED_ARTIFACT_EXTENSIONS,
118    SUPPORTED_ARTIFACT_FILENAMES,
119};
120
121// RFC-0003: Registry publishing support
122pub mod publish;
123pub use publish::{ArtifactPublishResult, ArtifactPublisher, LocalArtifact, PublishConfig};
124
125#[derive(Deserialize, Debug)]
126pub struct ManifestPackage {
127    pub name: String,
128    pub version: String,
129    pub abi_version: String,
130    pub entrypoint: Option<String>,
131}
132
133#[derive(Deserialize, Debug)]
134pub struct Manifest {
135    pub package: Option<ManifestPackage>,
136    // Support legacy flat format
137    pub name: Option<String>,
138    pub version: Option<String>,
139    pub abi_version: Option<String>,
140    pub entrypoint: Option<String>,
141}
142
143pub fn read_manifest(path: &Path) -> Result<Manifest> {
144    let s =
145        fs::read_to_string(path).with_context(|| format!("reading manifest {}", path.display()))?;
146    let m: Manifest = toml::from_str(&s).context("parsing plugin.toml")?;
147
148    // Get the effective values - prefer [package] section, fallback to flat fields
149    let name = if let Some(pkg) = &m.package {
150        pkg.name.clone()
151    } else {
152        m.name.clone().unwrap_or_default()
153    };
154
155    let version = if let Some(pkg) = &m.package {
156        pkg.version.clone()
157    } else {
158        m.version.clone().unwrap_or_default()
159    };
160
161    let abi_version = if let Some(pkg) = &m.package {
162        pkg.abi_version.clone()
163    } else {
164        m.abi_version.clone().unwrap_or_default()
165    };
166
167    // basic validation
168    if name.trim().is_empty() || version.trim().is_empty() || abi_version.trim().is_empty() {
169        bail!("manifest must have name, version, and abi_version (either in [package] section or at top level)");
170    }
171    Ok(m)
172}
173
174/// Create a .tar.gz artifact from a plugin directory. The archive will contain a single
175/// root directory named "<name>-<version>/" and all files from `src_dir` will be placed
176/// under that root preserving relative layout.
177///
178/// # RFC-0003 Compliance
179/// - Validates plugin name is lowercase with hyphens/underscores only
180/// - Validates version follows semantic versioning (major.minor.patch)
181/// - Includes optional CHANGELOG.md and doc/ directory if present
182pub fn pack_dir(src_dir: &Path, out_path: &Path) -> Result<PathBuf> {
183    // ensure manifest exists
184    let manifest_path = src_dir.join("plugin.toml");
185    let manifest = read_manifest(&manifest_path)?;
186
187    // Extract effective name and version
188    let (name, version) = if let Some(pkg) = &manifest.package {
189        (pkg.name.clone(), pkg.version.clone())
190    } else {
191        (
192            manifest.name.clone().unwrap_or_default(),
193            manifest.version.clone().unwrap_or_default(),
194        )
195    };
196
197    // RFC-0003: Validate name format
198    if !name
199        .chars()
200        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
201    {
202        bail!(
203            "Plugin name must be lowercase with hyphens/underscores only (RFC-0003)\n\
204             Got: '{}'\n\
205             Example: 'my-plugin' or 'my_plugin'",
206            name
207        );
208    }
209
210    // RFC-0003: Validate version format
211    let version_parts: Vec<&str> = version.split('.').collect();
212    if version_parts.len() < 3 {
213        bail!(
214            "Version must follow semantic versioning (RFC-0003)\n\
215             Got: '{}'\n\
216             Expected format: major.minor.patch (e.g., '1.0.0')",
217            version
218        );
219    }
220    for part in &version_parts[..3] {
221        if part.parse::<u32>().is_err() {
222            bail!(
223                "Version parts must be numeric (RFC-0003)\n\
224                 Got: '{}'\n\
225                 Expected format: major.minor.patch (e.g., '1.0.0')",
226                version
227            );
228        }
229    }
230
231    let file = File::create(out_path)
232        .with_context(|| format!("creating output {}", out_path.display()))?;
233    let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
234    let mut builder = tar::Builder::new(enc);
235
236    let root = format!("{}-{}", name, version);
237
238    // Create deterministic root directory entry
239    {
240        let mut header = tar::Header::new_gnu();
241        header.set_entry_type(tar::EntryType::Directory);
242        header.set_mode(0o755);
243        header.set_mtime(0);
244        header.set_uid(0);
245        header.set_gid(0);
246        header.set_size(0);
247        header.set_cksum();
248        builder.append_data(&mut header, Path::new(&root), std::io::empty())?;
249    }
250
251    // Track optional files included
252    let mut optional_files: Vec<&str> = Vec::new();
253
254    // append files under root with deterministic metadata
255    for entry in walkdir::WalkDir::new(src_dir)
256        .into_iter()
257        .filter_map(|e| e.ok())
258    {
259        let path = entry.path();
260        if path == src_dir {
261            continue;
262        }
263        let rel = path.strip_prefix(src_dir).unwrap();
264        let target_path = Path::new(&root).join(rel);
265
266        // Track optional files
267        if let Some(fname) = rel.file_name().and_then(|s| s.to_str()) {
268            if fname == "CHANGELOG.md" {
269                optional_files.push("CHANGELOG.md");
270            }
271        }
272        if rel.starts_with("doc") && !optional_files.contains(&"doc/") {
273            optional_files.push("doc/");
274        }
275
276        if path.is_dir() {
277            let mut header = tar::Header::new_gnu();
278            header.set_entry_type(tar::EntryType::Directory);
279            header.set_mode(0o755);
280            header.set_mtime(0);
281            header.set_uid(0);
282            header.set_gid(0);
283            header.set_size(0);
284            header.set_cksum();
285            builder.append_data(&mut header, &target_path, std::io::empty())?;
286        } else if path.is_file() {
287            let mut f = File::open(path)?;
288            let meta = f.metadata()?;
289            let mut header = tar::Header::new_gnu();
290            header.set_size(meta.len());
291            // set executable bit for plugin binary names, otherwise 0644
292            let mut mode = 0o644;
293            if let Some(fname) = path.file_name().and_then(|s| s.to_str()) {
294                if fname == "plugin.so" || fname == "plugin.dll" || fname == "plugin.dylib" {
295                    mode = 0o755;
296                }
297            }
298            // preserve executable bit on unix if present
299            #[cfg(unix)]
300            {
301                let p = meta.permissions();
302                if (p.mode() & 0o111) != 0 {
303                    mode = 0o755;
304                }
305            }
306            header.set_mode(mode);
307            header.set_mtime(0);
308            header.set_uid(0);
309            header.set_gid(0);
310            header.set_cksum();
311            builder.append_data(&mut header, &target_path, &mut f)?;
312        }
313    }
314
315    // finish to flush encoder
316    let enc = builder.into_inner()?;
317    enc.finish()?;
318
319    // compute sha256
320    let sha = compute_sha256(out_path)?;
321    // produce sidecar file named '<artifact>.sha256' (e.g. artifact.tar.gz.sha256)
322    let checksum_name = format!("{}.sha256", out_path.file_name().unwrap().to_string_lossy());
323    let checksum_path = out_path
324        .parent()
325        .unwrap_or_else(|| Path::new("."))
326        .join(checksum_name);
327    let mut f = File::create(&checksum_path)?;
328    writeln!(
329        f,
330        "{}  {}",
331        hex::encode(sha),
332        out_path.file_name().unwrap().to_string_lossy()
333    )?;
334
335    // RFC-0003: Log optional files included
336    if !optional_files.is_empty() {
337        tracing::error!(
338            "RFC-0003: Optional files included: {}",
339            optional_files.join(", ")
340        );
341    }
342
343    Ok(checksum_path)
344}
345
346/// Create a .tar.gz artifact with RFC-0003 compliant naming.
347///
348/// The artifact will be named: `<plugin-name>-v<version>-<target-triple>.tar.gz`
349///
350/// # Arguments
351/// * `src_dir` - Source directory containing plugin files
352/// * `output_dir` - Directory to write the artifact to
353/// * `target_triple` - Target triple (e.g., "x86_64-unknown-linux-gnu")
354///
355/// # Returns
356/// * Path to the checksum file on success
357///
358/// # Example
359/// ```no_run
360/// use plugin_packager::pack_dir_with_target;
361/// use std::path::Path;
362///
363/// let checksum = pack_dir_with_target(
364///     Path::new("./my-plugin"),
365///     Path::new("./dist"),
366///     "x86_64-unknown-linux-gnu"
367/// ).unwrap();
368/// // Creates: ./dist/my-plugin-v1.0.0-x86_64-unknown-linux-gnu.tar.gz
369/// ```
370pub fn pack_dir_with_target(
371    src_dir: &Path,
372    output_dir: &Path,
373    target_triple: &str,
374) -> Result<PathBuf> {
375    // Ensure manifest exists and get name/version
376    let manifest_path = src_dir.join("plugin.toml");
377    let manifest = read_manifest(&manifest_path)?;
378
379    let (name, version) = if let Some(pkg) = &manifest.package {
380        (pkg.name.clone(), pkg.version.clone())
381    } else {
382        (
383            manifest.name.clone().unwrap_or_default(),
384            manifest.version.clone().unwrap_or_default(),
385        )
386    };
387
388    // Validate target triple
389    let _platform =
390        crate::platform::Platform::from_target_triple(target_triple).ok_or_else(|| {
391            anyhow::anyhow!(
392                "Unknown platform in target triple: {}\n\
393             Supported: linux, windows, apple/darwin",
394                target_triple
395            )
396        })?;
397
398    // Construct RFC-0003 compliant filename
399    let artifact_name = format!("{}-v{}-{}.tar.gz", name, version, target_triple);
400    let out_path = output_dir.join(&artifact_name);
401
402    // Create output directory if needed
403    if !output_dir.exists() {
404        fs::create_dir_all(output_dir)?;
405    }
406
407    // Pack using the standard pack_dir
408    pack_dir(src_dir, &out_path)?;
409
410    // Verify the artifact name is parseable
411    let _meta = crate::platform::ArtifactMetadata::parse(&artifact_name).with_context(|| {
412        format!(
413            "Generated artifact name is not RFC-0003 compliant: {}",
414            artifact_name
415        )
416    })?;
417
418    // Return checksum path
419    let checksum_name = format!("{}.sha256", artifact_name);
420    Ok(output_dir.join(checksum_name))
421}
422
423fn compute_sha256(path: &Path) -> Result<Vec<u8>> {
424    let mut f = File::open(path)?;
425    let mut hasher = Sha256::new();
426    let mut buf = [0u8; 8192];
427    loop {
428        let n = f.read(&mut buf)?;
429        if n == 0 {
430            break;
431        }
432        hasher.update(&buf[..n]);
433    }
434    Ok(hasher.finalize().to_vec())
435}
436
437/// Verify an artifact: check checksum, archive layout, and manifest fields. `checksum_path`
438/// may be None in which case we look for a sibling `.tar.gz.sha256` file.
439pub fn verify_artifact(artifact: &Path, checksum_path: Option<&Path>) -> Result<()> {
440    let checksum_path = match checksum_path {
441        Some(p) => p.to_path_buf(),
442        None => {
443            let name = format!("{}.sha256", artifact.file_name().unwrap().to_string_lossy());
444            artifact
445                .parent()
446                .unwrap_or_else(|| Path::new("."))
447                .join(name)
448        }
449    };
450
451    if !checksum_path.exists() {
452        bail!("checksum file not found: {}", checksum_path.display());
453    }
454
455    // read checksum file: expect '<hex>  <filename>'
456    let s = fs::read_to_string(&checksum_path)?;
457    let token = s.split_whitespace().next().context("checksum file empty")?;
458    let expected = hex::decode(token.trim()).context("decoding checksum hex")?;
459
460    let computed = compute_sha256(artifact)?;
461    if expected != computed {
462        bail!(
463            "checksum mismatch: expected {} got {}",
464            hex::encode(expected),
465            hex::encode(computed)
466        );
467    }
468
469    // open tar.gz and inspect top-level layout
470    let f = File::open(artifact)?;
471    let dec = flate2::read::GzDecoder::new(f);
472    let mut ar = tar::Archive::new(dec);
473
474    let mut roots = std::collections::HashSet::new();
475    let mut seen_plugin_toml = false;
476    let mut seen_plugin_so = false;
477    let mut seen_license = false;
478    let mut seen_readme = false;
479
480    for entry in ar.entries()? {
481        let entry = entry?;
482        let path = match entry.path() {
483            Ok(p) => p.into_owned(),
484            Err(_) => continue,
485        };
486        let comps: Vec<_> = path.components().collect();
487        if comps.is_empty() {
488            continue;
489        }
490        // first component is the root dir
491        if let Some(root_comp) = comps.first() {
492            roots.insert(root_comp.as_os_str().to_owned());
493        }
494        // check for required files at root
495        if comps.len() == 2 {
496            if let Some(name) = path.file_name() {
497                match name.to_string_lossy().to_lowercase().as_str() {
498                    "plugin.toml" => seen_plugin_toml = true,
499                    "plugin.so" | "plugin.dll" | "plugin.dylib" => seen_plugin_so = true,
500                    "license" => seen_license = true,
501                    "readme.md" => seen_readme = true,
502                    _ => {}
503                }
504            }
505        }
506    }
507
508    if roots.len() != 1 {
509        bail!("archive must contain a single root directory");
510    }
511
512    if !(seen_plugin_toml && seen_plugin_so && seen_license && seen_readme) {
513        bail!("archive missing required files: plugin.toml, plugin.so, LICENSE, README.md");
514    }
515
516    // Extract and validate manifest from tar (reopen)
517    let f = File::open(artifact)?;
518    let dec = flate2::read::GzDecoder::new(f);
519    let mut ar = tar::Archive::new(dec);
520    let root = roots.into_iter().next().unwrap();
521    let manifest_path = Path::new(&root).join("plugin.toml");
522    for entry in ar.entries()? {
523        let mut entry = entry?;
524        if entry.path()? == manifest_path {
525            let mut s = String::new();
526            entry.read_to_string(&mut s)?;
527            let m: Manifest = toml::from_str(&s).context("parsing manifest in archive")?;
528
529            // Extract effective name and version (required fields)
530            let name = if let Some(pkg) = &m.package {
531                pkg.name.clone()
532            } else {
533                m.name.clone().unwrap_or_default()
534            };
535
536            let version = if let Some(pkg) = &m.package {
537                pkg.version.clone()
538            } else {
539                m.version.clone().unwrap_or_default()
540            };
541
542            let abi_version = if let Some(pkg) = &m.package {
543                pkg.abi_version.clone()
544            } else {
545                m.abi_version.clone().unwrap_or_default()
546            };
547
548            // entrypoint is optional (v2 ABI doesn't use it)
549            if name.trim().is_empty() || version.trim().is_empty() || abi_version.trim().is_empty()
550            {
551                bail!("manifest missing required fields: name, version, abi_version");
552            }
553            return Ok(());
554        }
555    }
556
557    bail!("manifest not found inside archive");
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    // no extra imports
564    use tempfile::tempdir;
565
566    #[test]
567    fn pack_and_verify_roundtrip() -> Result<()> {
568        let dir = tempdir()?;
569        let base = dir.path();
570        // create minimal plugin files
571        fs::write(
572            base.join("plugin.toml"),
573            r#"name = "testplugin"
574version = "0.1.0"
575abi_version = "1"
576entrypoint = "init""#,
577        )?;
578        fs::write(base.join("plugin.so"), b"binary")?;
579        fs::write(base.join("LICENSE"), "MIT")?;
580        fs::write(base.join("README.md"), "readme")?;
581
582        let out = dir.path().join("artifact.tar.gz");
583        let checksum = pack_dir(base, &out)?;
584        // verify
585        verify_artifact(&out, Some(&checksum))?;
586        Ok(())
587    }
588}