Skip to main content

plugin_packager/
publish.rs

1// Copyright 2024 Vincents AI
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Plugin artifact publishing to registry
5///
6/// This module provides functionality for publishing plugin artifacts to
7/// registries. It handles:
8/// - Artifact validation before publishing
9/// - Checksum computation and verification
10/// - Publishing metadata extraction from artifacts
11///
12/// RFC-0003: Plugin Package and Artifact Specification
13use anyhow::{anyhow, Context, Result};
14use serde::{Deserialize, Serialize};
15use std::path::Path;
16
17use crate::platform::ArtifactMetadata;
18
19/// Publishing client for plugin artifacts
20#[derive(Clone)]
21pub struct ArtifactPublisher {
22    config: PublishConfig,
23}
24
25/// Configuration for artifact publishing
26#[derive(Debug, Clone)]
27pub struct PublishConfig {
28    /// Registry base URL (e.g., "https://registry.example.com")
29    pub registry_url: String,
30    /// Authentication token (required for publishing)
31    pub auth_token: String,
32    /// Whether to skip verification before publishing
33    pub skip_verify: bool,
34    /// Whether to publish as draft (not publicly visible)
35    pub as_draft: bool,
36    /// Whether to sign the artifact before publishing
37    pub sign: bool,
38    /// Signing key ID (if signing)
39    pub key_id: Option<String>,
40}
41
42/// Result of a publish operation
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ArtifactPublishResult {
45    /// Plugin ID in the registry
46    pub plugin_id: String,
47    /// Published version
48    pub version: String,
49    /// Artifact download URL
50    pub download_url: String,
51    /// Artifact checksum (SHA256)
52    pub checksum: String,
53    /// Publish status
54    pub status: String,
55    /// Human-readable message
56    pub message: String,
57    /// URL to view plugin in registry (if available)
58    pub registry_url: Option<String>,
59}
60
61/// Local artifact information for publishing
62#[derive(Debug, Clone)]
63pub struct LocalArtifact {
64    /// Path to the artifact file
65    pub path: std::path::PathBuf,
66    /// Artifact metadata parsed from filename
67    pub metadata: ArtifactMetadata,
68    /// Computed SHA256 checksum
69    pub checksum: String,
70}
71
72impl ArtifactPublisher {
73    /// Create a new artifact publisher
74    pub fn new(config: PublishConfig) -> Self {
75        Self { config }
76    }
77
78    /// Validate an artifact without publishing
79    ///
80    /// Returns metadata about the artifact if valid
81    pub fn validate(&self, artifact_path: &Path) -> Result<LocalArtifact> {
82        if !artifact_path.exists() {
83            return Err(anyhow!("Artifact not found: {}", artifact_path.display()));
84        }
85
86        let filename = artifact_path
87            .file_name()
88            .and_then(|n| n.to_str())
89            .context("Invalid artifact filename")?;
90
91        let metadata = ArtifactMetadata::parse(filename)?;
92
93        crate::verify_artifact(artifact_path, None)?;
94
95        let checksum = compute_artifact_checksum(artifact_path)?;
96
97        Ok(LocalArtifact {
98            path: artifact_path.to_path_buf(),
99            metadata,
100            checksum,
101        })
102    }
103
104    /// Check if the publisher has valid authentication
105    pub fn is_authenticated(&self) -> bool {
106        !self.config.auth_token.is_empty()
107    }
108
109    /// Get the configured registry URL
110    pub fn registry_url(&self) -> &str {
111        &self.config.registry_url
112    }
113}
114
115/// Compute SHA256 checksum of an artifact
116fn compute_artifact_checksum(path: &Path) -> Result<String> {
117    use sha2::{Digest, Sha256};
118    use std::fs::File;
119    use std::io::Read;
120
121    let mut file = File::open(path)?;
122    let mut hasher = Sha256::new();
123    let mut buf = [0u8; 8192];
124
125    loop {
126        let n = file.read(&mut buf)?;
127        if n == 0 {
128            break;
129        }
130        hasher.update(&buf[..n]);
131    }
132
133    Ok(hex::encode(hasher.finalize()))
134}
135
136/// Extract plugin metadata from an artifact
137#[allow(dead_code)]
138fn extract_plugin_metadata(artifact_path: &Path) -> Result<PluginMetadataExtract> {
139    use flate2::read::GzDecoder;
140    use std::fs::File;
141    use std::io::Read;
142
143    let file = File::open(artifact_path)?;
144    let decoder = GzDecoder::new(file);
145    let mut archive = tar::Archive::new(decoder);
146
147    // Find and extract plugin.toml
148    for entry in archive.entries()? {
149        let mut entry = entry?;
150        let path = entry.path()?;
151
152        if path.file_name().and_then(|n| n.to_str()) == Some("plugin.toml") {
153            let mut content = String::new();
154            entry.read_to_string(&mut content)?;
155
156            return parse_plugin_toml(&content);
157        }
158    }
159
160    Err(anyhow!("plugin.toml not found in artifact"))
161}
162
163/// Plugin metadata extracted from artifact
164#[derive(Debug, Clone, Default)]
165#[allow(dead_code)]
166struct PluginMetadataExtract {
167    abi_version: String,
168    description: Option<String>,
169    author: Option<String>,
170    license: Option<String>,
171    repository: Option<String>,
172    keywords: Option<Vec<String>>,
173    categories: Option<Vec<String>>,
174    homepage: Option<String>,
175    documentation: Option<String>,
176    plugin_type: Option<String>,
177    maturity: Option<String>,
178    provides_services: Option<Vec<String>>,
179    requires_services: Option<Vec<String>>,
180}
181
182/// Parse plugin.toml content
183#[allow(dead_code)]
184fn parse_plugin_toml(content: &str) -> Result<PluginMetadataExtract> {
185    let value: toml::Value = toml::from_str(content)?;
186
187    let get_str = |key: &str| -> Option<String> {
188        value
189            .get("package")
190            .and_then(|p| p.get(key))
191            .or_else(|| value.get(key))
192            .and_then(|v| v.as_str())
193            .map(|s| s.to_string())
194    };
195
196    let get_vec = |key: &str| -> Option<Vec<String>> {
197        value
198            .get("package")
199            .and_then(|p| p.get(key))
200            .or_else(|| value.get(key))
201            .and_then(|v| v.as_array())
202            .map(|arr| {
203                arr.iter()
204                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
205                    .collect()
206            })
207    };
208
209    Ok(PluginMetadataExtract {
210        abi_version: get_str("abi_version").unwrap_or_else(|| "1".to_string()),
211        description: get_str("description"),
212        author: get_str("author"),
213        license: get_str("license"),
214        repository: get_str("repository"),
215        keywords: get_vec("keywords"),
216        categories: get_vec("categories"),
217        homepage: get_str("homepage"),
218        documentation: get_str("documentation"),
219        plugin_type: get_str("plugin_type"),
220        maturity: get_str("maturity"),
221        provides_services: get_vec("provides_services"),
222        requires_services: get_vec("requires_services"),
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_publish_config() {
232        let config = PublishConfig {
233            registry_url: "https://registry.example.com".to_string(),
234            auth_token: "secret-token".to_string(),
235            skip_verify: false,
236            as_draft: false,
237            sign: false,
238            key_id: None,
239        };
240
241        assert_eq!(config.registry_url, "https://registry.example.com");
242        assert!(!config.skip_verify);
243    }
244
245    #[test]
246    fn test_artifact_publisher_creation() {
247        let config = PublishConfig {
248            registry_url: "https://registry.example.com".to_string(),
249            auth_token: "secret-token".to_string(),
250            skip_verify: false,
251            as_draft: false,
252            sign: false,
253            key_id: None,
254        };
255
256        let publisher = ArtifactPublisher::new(config);
257        assert!(publisher.is_authenticated());
258    }
259
260    #[test]
261    fn test_parse_plugin_toml() {
262        let content = r#"
263[package]
264name = "test-plugin"
265version = "1.0.0"
266abi_version = "2"
267description = "A test plugin"
268author = "Test Author"
269license = "MIT"
270keywords = ["test", "plugin"]
271"#;
272
273        let metadata = parse_plugin_toml(content).unwrap();
274        assert_eq!(metadata.abi_version, "2");
275        assert_eq!(metadata.description, Some("A test plugin".to_string()));
276        assert_eq!(metadata.author, Some("Test Author".to_string()));
277        assert_eq!(metadata.license, Some("MIT".to_string()));
278        assert_eq!(
279            metadata.keywords,
280            Some(vec!["test".to_string(), "plugin".to_string()])
281        );
282    }
283
284    #[test]
285    fn test_parse_plugin_toml_flat() {
286        let content = r#"
287name = "flat-plugin"
288version = "0.1.0"
289abi_version = "1"
290description = "Flat format plugin"
291"#;
292
293        let metadata = parse_plugin_toml(content).unwrap();
294        assert_eq!(metadata.abi_version, "1");
295        assert_eq!(metadata.description, Some("Flat format plugin".to_string()));
296    }
297}