Skip to main content

astrid_plugins/
lockfile.rs

1//! Plugin lockfile for version pinning and integrity verification.
2//!
3//! The lockfile (`.astrid/plugins.lock` at workspace level,
4//! `~/.astrid/plugins.lock` at user level) tracks exactly
5//! what was installed, from where, and its integrity hash. This
6//! enables reproducible builds and supply chain auditing.
7//!
8//! # Format
9//!
10//! The lockfile uses TOML with `schema_version = 1` and a flat
11//! `[[plugin]]` array of [`LockedPlugin`] entries.
12
13use std::fmt;
14use std::io::Write;
15use std::path::Path;
16
17use chrono::{DateTime, Utc};
18use fs2::FileExt;
19use serde::{Deserialize, Serialize};
20use tracing::{debug, warn};
21
22use crate::discovery::MANIFEST_FILE_NAME;
23use crate::error::{PluginError, PluginResult};
24use crate::manifest::PluginManifest;
25use crate::plugin::PluginId;
26
27/// Current lockfile schema version.
28const SCHEMA_VERSION: u32 = 1;
29
30/// Standard lockfile file name.
31pub const LOCKFILE_NAME: &str = "plugins.lock";
32
33/// A plugin lockfile that tracks installed plugins, their sources,
34/// and integrity hashes.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PluginLockfile {
37    /// Schema version for forward compatibility.
38    schema_version: u32,
39    /// Locked plugin entries.
40    #[serde(default, rename = "plugin")]
41    entries: Vec<LockedPlugin>,
42}
43
44/// A single locked plugin entry recording what was installed and
45/// its cryptographic integrity hash.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LockedPlugin {
48    /// Unique plugin identifier.
49    pub id: PluginId,
50    /// Semantic version string from the manifest at install time.
51    pub version: String,
52    /// Where the plugin was installed from.
53    pub source: PluginSource,
54    /// Blake3 hex digest of the WASM module (prefixed with `blake3:`).
55    pub wasm_hash: String,
56    /// When the plugin was installed/last updated.
57    pub installed_at: DateTime<Utc>,
58}
59
60/// Where a plugin was installed from.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(into = "String", try_from = "String")]
63pub enum PluginSource {
64    /// Installed from a local directory path.
65    Local(String),
66    /// Fetched from the `OpenClaw` npm registry.
67    OpenClaw(String),
68    /// Fetched from a git repository (URL + optional commit).
69    Git {
70        /// Repository URL.
71        url: String,
72        /// Commit hash, if pinned.
73        commit: Option<String>,
74    },
75    /// Fetched from the Astrid plugin registry.
76    Registry(String),
77}
78
79impl fmt::Display for PluginSource {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Local(path) => write!(f, "local:{path}"),
83            Self::OpenClaw(spec) => write!(f, "openclaw:{spec}"),
84            Self::Git { url, commit: None } => write!(f, "git:{url}"),
85            Self::Git {
86                url,
87                commit: Some(c),
88            } => write!(f, "git:{url}#{c}"),
89            Self::Registry(spec) => write!(f, "registry:{spec}"),
90        }
91    }
92}
93
94impl From<PluginSource> for String {
95    fn from(source: PluginSource) -> Self {
96        source.to_string()
97    }
98}
99
100impl TryFrom<String> for PluginSource {
101    type Error = String;
102
103    fn try_from(s: String) -> Result<Self, Self::Error> {
104        Self::parse(&s).ok_or_else(|| format!("invalid plugin source: {s}"))
105    }
106}
107
108impl PluginSource {
109    /// Parse a source string like `local:./path`, `openclaw:@scope/pkg@1.0`,
110    /// `git:https://...#commit`, or `registry:name@version`.
111    #[must_use]
112    pub fn parse(s: &str) -> Option<Self> {
113        let (prefix, value) = s.split_once(':')?;
114        match prefix {
115            "local" => Some(Self::Local(value.to_string())),
116            "openclaw" => Some(Self::OpenClaw(value.to_string())),
117            "git" => {
118                // git:url or git:url#commit — note URL may contain ':'
119                if let Some((url, commit)) = value.rsplit_once('#') {
120                    Some(Self::Git {
121                        url: url.to_string(),
122                        commit: Some(commit.to_string()),
123                    })
124                } else {
125                    Some(Self::Git {
126                        url: value.to_string(),
127                        commit: None,
128                    })
129                }
130            },
131            "registry" => Some(Self::Registry(value.to_string())),
132            _ => None,
133        }
134    }
135}
136
137/// An integrity violation found during lockfile verification.
138#[derive(Debug, Clone)]
139pub enum IntegrityViolation {
140    /// Plugin exists in lockfile but is missing from disk.
141    Missing {
142        /// The plugin that's missing.
143        plugin_id: PluginId,
144    },
145    /// The WASM module hash doesn't match the lockfile.
146    HashMismatch {
147        /// The plugin with mismatched hash.
148        plugin_id: PluginId,
149        /// Hash recorded in the lockfile.
150        expected: String,
151        /// Hash computed from the file on disk.
152        actual: String,
153    },
154    /// The manifest version doesn't match the lockfile.
155    VersionMismatch {
156        /// The plugin with mismatched version.
157        plugin_id: PluginId,
158        /// Version recorded in the lockfile.
159        expected: String,
160        /// Version found in the manifest on disk.
161        actual: String,
162    },
163}
164
165impl fmt::Display for IntegrityViolation {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        match self {
168            Self::Missing { plugin_id } => {
169                write!(f, "plugin {plugin_id} is in lockfile but missing from disk")
170            },
171            Self::HashMismatch {
172                plugin_id,
173                expected,
174                actual,
175            } => {
176                write!(
177                    f,
178                    "plugin {plugin_id}: WASM hash mismatch (expected {expected}, got {actual})"
179                )
180            },
181            Self::VersionMismatch {
182                plugin_id,
183                expected,
184                actual,
185            } => {
186                write!(
187                    f,
188                    "plugin {plugin_id}: version mismatch (expected {expected}, got {actual})"
189                )
190            },
191        }
192    }
193}
194
195impl PluginLockfile {
196    /// Create an empty lockfile.
197    #[must_use]
198    pub fn new() -> Self {
199        Self {
200            schema_version: SCHEMA_VERSION,
201            entries: Vec::new(),
202        }
203    }
204
205    /// Load a lockfile from disk.
206    ///
207    /// Acquires a shared (read) lock on a `.lk` sibling file to coordinate
208    /// with concurrent writers.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if the file cannot be read or parsed.
213    pub fn load(path: &Path) -> PluginResult<Self> {
214        let _lock_guard = acquire_lock_file(path, LockMode::Shared)?;
215
216        let content = std::fs::read_to_string(path).map_err(|e| PluginError::LockfileError {
217            path: path.to_path_buf(),
218            message: format!("failed to read lockfile: {e}"),
219        })?;
220
221        Self::parse_content(path, &content)
222    }
223
224    /// Load a lockfile from disk, returning an empty lockfile if the file
225    /// doesn't exist.
226    ///
227    /// Uses an atomic read pattern to avoid TOCTOU races: attempts to read
228    /// the file and handles `NotFound` instead of checking `exists()` first.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if the file exists but cannot be read or parsed.
233    pub fn load_or_default(path: &Path) -> PluginResult<Self> {
234        let _lock_guard = acquire_lock_file(path, LockMode::Shared)?;
235
236        match std::fs::read_to_string(path) {
237            Ok(content) => Self::parse_content(path, &content),
238            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
239            Err(e) => Err(PluginError::LockfileError {
240                path: path.to_path_buf(),
241                message: format!("failed to read lockfile: {e}"),
242            }),
243        }
244    }
245
246    /// Parse lockfile content from a string (shared between load methods).
247    fn parse_content(path: &Path, content: &str) -> PluginResult<Self> {
248        let lockfile: Self = toml::from_str(content).map_err(|e| PluginError::LockfileError {
249            path: path.to_path_buf(),
250            message: format!("failed to parse lockfile: {e}"),
251        })?;
252
253        if lockfile.schema_version != SCHEMA_VERSION {
254            warn!(
255                path = %path.display(),
256                found = lockfile.schema_version,
257                expected = SCHEMA_VERSION,
258                "Lockfile schema version mismatch — attempting best-effort load"
259            );
260        }
261
262        debug!(
263            path = %path.display(),
264            entries = lockfile.entries.len(),
265            "Loaded plugin lockfile"
266        );
267
268        Ok(lockfile)
269    }
270
271    /// Atomically load, mutate, and save the lockfile under a single
272    /// exclusive lock.
273    ///
274    /// Prevents TOCTOU races between concurrent `plugin install` / `plugin
275    /// remove` operations that would otherwise load → drop lock → re-acquire
276    /// → save, allowing another process to interleave and lose entries.
277    ///
278    /// If the lockfile doesn't exist, the closure receives an empty lockfile.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error if the file cannot be read, parsed, or written, or
283    /// if the closure returns an error.
284    pub fn update<F>(path: &Path, f: F) -> PluginResult<()>
285    where
286        F: FnOnce(&mut Self) -> PluginResult<()>,
287    {
288        if let Some(parent) = path.parent() {
289            std::fs::create_dir_all(parent).map_err(|e| PluginError::LockfileError {
290                path: path.to_path_buf(),
291                message: format!("failed to create parent directory: {e}"),
292            })?;
293        }
294
295        // Hold the exclusive lock across both load and save.
296        let _lock_guard = acquire_lock_file(path, LockMode::Exclusive)?;
297
298        let mut lockfile = match std::fs::read_to_string(path) {
299            Ok(content) => Self::parse_content(path, &content)?,
300            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::new(),
301            Err(e) => {
302                return Err(PluginError::LockfileError {
303                    path: path.to_path_buf(),
304                    message: format!("failed to read lockfile: {e}"),
305                });
306            },
307        };
308
309        f(&mut lockfile)?;
310
311        lockfile.save_inner(path)?;
312        Ok(())
313    }
314
315    /// Save the lockfile to disk atomically.
316    ///
317    /// Writes to a temporary file in the same directory (same filesystem),
318    /// then atomically renames it into place. Acquires an exclusive lock
319    /// on a `.lk` sibling file to coordinate with concurrent readers/writers.
320    ///
321    /// Creates parent directories if needed.
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if the file cannot be written.
326    pub fn save(&self, path: &Path) -> PluginResult<()> {
327        if let Some(parent) = path.parent() {
328            std::fs::create_dir_all(parent).map_err(|e| PluginError::LockfileError {
329                path: path.to_path_buf(),
330                message: format!("failed to create parent directory: {e}"),
331            })?;
332        }
333
334        let _lock_guard = acquire_lock_file(path, LockMode::Exclusive)?;
335        self.save_inner(path)
336    }
337
338    /// Inner save logic (caller must already hold the exclusive lock).
339    fn save_inner(&self, path: &Path) -> PluginResult<()> {
340        let header = "# Auto-generated by astrid. Do not edit manually.\n\n";
341        let body = toml::to_string_pretty(self).map_err(|e| PluginError::LockfileError {
342            path: path.to_path_buf(),
343            message: format!("failed to serialize lockfile: {e}"),
344        })?;
345
346        let content = format!("{header}{body}");
347
348        // Write to a temp file in the same directory, then atomically rename.
349        let parent = path.parent().unwrap_or(Path::new("."));
350        let mut tmp =
351            tempfile::NamedTempFile::new_in(parent).map_err(|e| PluginError::LockfileError {
352                path: path.to_path_buf(),
353                message: format!("failed to create temp file for atomic write: {e}"),
354            })?;
355
356        tmp.write_all(content.as_bytes())
357            .map_err(|e| PluginError::LockfileError {
358                path: path.to_path_buf(),
359                message: format!("failed to write temp lockfile: {e}"),
360            })?;
361
362        // Sync data to disk before renaming. Without this, a power loss
363        // between persist() and the OS flushing dirty pages could leave
364        // the lockfile empty or truncated. Worth the fsync cost for a file
365        // that guards supply-chain integrity hashes.
366        tmp.as_file()
367            .sync_all()
368            .map_err(|e| PluginError::LockfileError {
369                path: path.to_path_buf(),
370                message: format!("failed to sync temp lockfile to disk: {e}"),
371            })?;
372
373        tmp.persist(path).map_err(|e| PluginError::LockfileError {
374            path: path.to_path_buf(),
375            message: format!("failed to atomically replace lockfile: {e}"),
376        })?;
377
378        debug!(path = %path.display(), entries = self.entries.len(), "Saved plugin lockfile");
379        Ok(())
380    }
381
382    /// Add or update a locked plugin entry.
383    ///
384    /// If a plugin with the same ID already exists, it is replaced.
385    pub fn add(&mut self, entry: LockedPlugin) {
386        self.remove(&entry.id);
387        self.entries.push(entry);
388    }
389
390    /// Remove a plugin entry by ID.
391    ///
392    /// Returns `true` if an entry was removed.
393    pub fn remove(&mut self, id: &PluginId) -> bool {
394        let before = self.entries.len();
395        self.entries.retain(|e| e.id != *id);
396        self.entries.len() < before
397    }
398
399    /// Look up a locked plugin entry by ID.
400    #[must_use]
401    pub fn get(&self, id: &PluginId) -> Option<&LockedPlugin> {
402        self.entries.iter().find(|e| e.id == *id)
403    }
404
405    /// Get all locked plugin entries.
406    #[must_use]
407    pub fn entries(&self) -> &[LockedPlugin] {
408        &self.entries
409    }
410
411    /// Check whether the lockfile has any entries.
412    #[must_use]
413    pub fn is_empty(&self) -> bool {
414        self.entries.is_empty()
415    }
416
417    /// Number of locked plugin entries.
418    #[must_use]
419    pub fn len(&self) -> usize {
420        self.entries.len()
421    }
422
423    /// Verify the integrity of installed plugins against the lockfile.
424    ///
425    /// For each entry, checks:
426    /// 1. The plugin directory and manifest exist on disk.
427    /// 2. The manifest version matches the lockfile version.
428    /// 3. The WASM module blake3 hash matches the lockfile hash.
429    ///
430    /// Returns a list of violations (empty if everything is consistent).
431    pub fn verify_integrity(&self, plugin_dir: &Path) -> Vec<IntegrityViolation> {
432        let mut violations = Vec::new();
433
434        for entry in &self.entries {
435            let plugin_path = plugin_dir.join(entry.id.as_str());
436            let manifest_path = plugin_path.join(MANIFEST_FILE_NAME);
437
438            // 1. Check plugin directory exists
439            if !manifest_path.exists() {
440                violations.push(IntegrityViolation::Missing {
441                    plugin_id: entry.id.clone(),
442                });
443                continue;
444            }
445
446            // 2. Load and check manifest version
447            match crate::discovery::load_manifest(&manifest_path) {
448                Ok(manifest) => {
449                    if manifest.version != entry.version {
450                        violations.push(IntegrityViolation::VersionMismatch {
451                            plugin_id: entry.id.clone(),
452                            expected: entry.version.clone(),
453                            actual: manifest.version.clone(),
454                        });
455                    }
456
457                    // 3. Check WASM hash if entry point is Wasm
458                    if let crate::manifest::PluginEntryPoint::Wasm { path, .. } =
459                        &manifest.entry_point
460                    {
461                        let wasm_path = if path.is_absolute() {
462                            path.clone()
463                        } else {
464                            plugin_path.join(path)
465                        };
466
467                        match std::fs::read(&wasm_path) {
468                            Ok(wasm_bytes) => {
469                                let actual_hash =
470                                    format!("blake3:{}", blake3::hash(&wasm_bytes).to_hex());
471                                if actual_hash != entry.wasm_hash {
472                                    violations.push(IntegrityViolation::HashMismatch {
473                                        plugin_id: entry.id.clone(),
474                                        expected: entry.wasm_hash.clone(),
475                                        actual: actual_hash,
476                                    });
477                                }
478                            },
479                            Err(e) => {
480                                warn!(
481                                    plugin = %entry.id,
482                                    path = %wasm_path.display(),
483                                    error = %e,
484                                    "Failed to read WASM file for integrity check"
485                                );
486                                violations.push(IntegrityViolation::Missing {
487                                    plugin_id: entry.id.clone(),
488                                });
489                            },
490                        }
491                    }
492                },
493                Err(e) => {
494                    warn!(
495                        plugin = %entry.id,
496                        error = %e,
497                        "Failed to load manifest for integrity check"
498                    );
499                    violations.push(IntegrityViolation::Missing {
500                        plugin_id: entry.id.clone(),
501                    });
502                },
503            }
504        }
505
506        violations
507    }
508}
509
510impl Default for PluginLockfile {
511    fn default() -> Self {
512        Self::new()
513    }
514}
515
516impl LockedPlugin {
517    /// Create a new locked plugin entry with the current timestamp.
518    #[must_use]
519    pub fn new(id: PluginId, version: String, source: PluginSource, wasm_hash: String) -> Self {
520        Self {
521            id,
522            version,
523            source,
524            wasm_hash,
525            installed_at: Utc::now(),
526        }
527    }
528
529    /// Compute the blake3 hash of a WASM file and return it in lockfile
530    /// format (`blake3:<hex>`).
531    ///
532    /// # Errors
533    ///
534    /// Returns an I/O error if the file cannot be read.
535    pub fn compute_wasm_hash(wasm_path: &Path) -> PluginResult<String> {
536        let bytes = std::fs::read(wasm_path)?;
537        Ok(format!("blake3:{}", blake3::hash(&bytes).to_hex()))
538    }
539
540    /// Create a locked entry from a manifest on disk.
541    ///
542    /// Reads the WASM file (if the entry point is WASM) and computes its
543    /// blake3 hash.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error if the WASM file can't be read.
548    pub fn from_manifest(
549        manifest: &PluginManifest,
550        plugin_dir: &Path,
551        source: PluginSource,
552    ) -> PluginResult<Self> {
553        let wasm_hash = match &manifest.entry_point {
554            crate::manifest::PluginEntryPoint::Wasm { path, .. } => {
555                let wasm_path = if path.is_absolute() {
556                    path.clone()
557                } else {
558                    plugin_dir.join(path)
559                };
560                Self::compute_wasm_hash(&wasm_path)?
561            },
562            crate::manifest::PluginEntryPoint::Mcp { .. } => {
563                // MCP plugins don't have a WASM file; use an empty sentinel.
564                "none".to_string()
565            },
566        };
567
568        Ok(Self::new(
569            manifest.id.clone(),
570            manifest.version.clone(),
571            source,
572            wasm_hash,
573        ))
574    }
575}
576
577/// Whether to acquire a shared (read) or exclusive (write) lock.
578#[derive(Clone, Copy)]
579enum LockMode {
580    Shared,
581    Exclusive,
582}
583
584/// Acquire an advisory file lock on a `.lk` sibling of the given path.
585///
586/// Returns `Some(file)` holding the lock (dropped = released), or `None`
587/// if the lock file doesn't exist and we're in shared (read) mode —
588/// there's nothing to coordinate with, so no lock is needed.
589///
590/// In exclusive (write) mode, the lock file and parent directories are
591/// created if they don't exist.
592fn acquire_lock_file(lockfile_path: &Path, mode: LockMode) -> PluginResult<Option<std::fs::File>> {
593    let lock_path = lockfile_path.with_extension("lk");
594
595    match mode {
596        LockMode::Shared => {
597            // Read path: don't create any artifacts. If the lock file
598            // doesn't exist, there's no concurrent writer to coordinate
599            // with, so skip locking entirely.
600            match std::fs::OpenOptions::new().read(true).open(&lock_path) {
601                Ok(lock_file) => {
602                    lock_file
603                        .lock_shared()
604                        .map_err(|e| PluginError::LockfileError {
605                            path: lockfile_path.to_path_buf(),
606                            message: format!("failed to acquire shared file lock: {e}"),
607                        })?;
608                    Ok(Some(lock_file))
609                },
610                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
611                Err(e) => Err(PluginError::LockfileError {
612                    path: lockfile_path.to_path_buf(),
613                    message: format!("failed to open lock file: {e}"),
614                }),
615            }
616        },
617        LockMode::Exclusive => {
618            // Write path: create directories and lock file as needed.
619            if let Some(parent) = lock_path.parent() {
620                std::fs::create_dir_all(parent).map_err(|e| PluginError::LockfileError {
621                    path: lockfile_path.to_path_buf(),
622                    message: format!("failed to create lock file directory: {e}"),
623                })?;
624            }
625
626            let lock_file = std::fs::OpenOptions::new()
627                .create(true)
628                .truncate(false)
629                .write(true)
630                .read(true)
631                .open(&lock_path)
632                .map_err(|e| PluginError::LockfileError {
633                    path: lockfile_path.to_path_buf(),
634                    message: format!("failed to open lock file: {e}"),
635                })?;
636
637            lock_file
638                .lock_exclusive()
639                .map_err(|e| PluginError::LockfileError {
640                    path: lockfile_path.to_path_buf(),
641                    message: format!("failed to acquire exclusive file lock: {e}"),
642                })?;
643
644            Ok(Some(lock_file))
645        },
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use std::path::PathBuf;
652
653    use super::*;
654    use tempfile::TempDir;
655
656    #[test]
657    fn source_parse_local() {
658        let s = PluginSource::parse("local:./plugins/my-plugin").unwrap();
659        assert_eq!(s, PluginSource::Local("./plugins/my-plugin".to_string()));
660        assert_eq!(s.to_string(), "local:./plugins/my-plugin");
661    }
662
663    #[test]
664    fn source_parse_openclaw() {
665        let s = PluginSource::parse("openclaw:@unicitylabs/hello-tool@1.0.0").unwrap();
666        assert_eq!(
667            s,
668            PluginSource::OpenClaw("@unicitylabs/hello-tool@1.0.0".to_string())
669        );
670        assert_eq!(s.to_string(), "openclaw:@unicitylabs/hello-tool@1.0.0");
671    }
672
673    #[test]
674    fn source_parse_git_with_commit() {
675        let s = PluginSource::parse("git:https://github.com/user/repo#abc123").unwrap();
676        assert_eq!(
677            s,
678            PluginSource::Git {
679                url: "https://github.com/user/repo".to_string(),
680                commit: Some("abc123".to_string()),
681            }
682        );
683        assert_eq!(s.to_string(), "git:https://github.com/user/repo#abc123");
684    }
685
686    #[test]
687    fn source_parse_git_without_commit() {
688        let s = PluginSource::parse("git:https://github.com/user/repo").unwrap();
689        assert_eq!(
690            s,
691            PluginSource::Git {
692                url: "https://github.com/user/repo".to_string(),
693                commit: None,
694            }
695        );
696        assert_eq!(s.to_string(), "git:https://github.com/user/repo");
697    }
698
699    #[test]
700    fn source_parse_registry() {
701        let s = PluginSource::parse("registry:my-plugin@1.0.0").unwrap();
702        assert_eq!(s, PluginSource::Registry("my-plugin@1.0.0".to_string()));
703        assert_eq!(s.to_string(), "registry:my-plugin@1.0.0");
704    }
705
706    #[test]
707    fn source_parse_invalid() {
708        assert!(PluginSource::parse("ftp:something").is_none());
709        assert!(PluginSource::parse("no-colon").is_none());
710    }
711
712    #[test]
713    fn source_serde_round_trip() {
714        let sources = vec![
715            PluginSource::Local("./path".into()),
716            PluginSource::OpenClaw("@scope/pkg@1.0".into()),
717            PluginSource::Git {
718                url: "https://github.com/user/repo".into(),
719                commit: Some("abc".into()),
720            },
721            PluginSource::Registry("name@1.0".into()),
722        ];
723
724        for source in sources {
725            let json = serde_json::to_string(&source).unwrap();
726            let parsed: PluginSource = serde_json::from_str(&json).unwrap();
727            assert_eq!(parsed, source);
728        }
729    }
730
731    #[test]
732    fn empty_lockfile() {
733        let lf = PluginLockfile::new();
734        assert!(lf.is_empty());
735        assert_eq!(lf.len(), 0);
736    }
737
738    #[test]
739    fn add_and_get() {
740        let mut lf = PluginLockfile::new();
741        let id = PluginId::from_static("test-plugin");
742        let entry = LockedPlugin::new(
743            id.clone(),
744            "1.0.0".into(),
745            PluginSource::Local("./plugins/test".into()),
746            "blake3:abc123".into(),
747        );
748        lf.add(entry);
749
750        assert_eq!(lf.len(), 1);
751        let found = lf.get(&id).unwrap();
752        assert_eq!(found.version, "1.0.0");
753        assert_eq!(found.wasm_hash, "blake3:abc123");
754    }
755
756    #[test]
757    fn add_replaces_existing() {
758        let mut lf = PluginLockfile::new();
759        let id = PluginId::from_static("test-plugin");
760
761        lf.add(LockedPlugin::new(
762            id.clone(),
763            "1.0.0".into(),
764            PluginSource::Local("./old".into()),
765            "blake3:old".into(),
766        ));
767        lf.add(LockedPlugin::new(
768            id.clone(),
769            "2.0.0".into(),
770            PluginSource::Local("./new".into()),
771            "blake3:new".into(),
772        ));
773
774        assert_eq!(lf.len(), 1);
775        assert_eq!(lf.get(&id).unwrap().version, "2.0.0");
776    }
777
778    #[test]
779    fn remove_entry() {
780        let mut lf = PluginLockfile::new();
781        let id = PluginId::from_static("test-plugin");
782        lf.add(LockedPlugin::new(
783            id.clone(),
784            "1.0.0".into(),
785            PluginSource::Local("./path".into()),
786            "blake3:hash".into(),
787        ));
788
789        assert!(lf.remove(&id));
790        assert!(lf.is_empty());
791        assert!(!lf.remove(&id)); // already removed
792    }
793
794    #[test]
795    fn save_and_load_round_trip() {
796        let dir = TempDir::new().unwrap();
797        let lockfile_path = dir.path().join(LOCKFILE_NAME);
798
799        let mut lf = PluginLockfile::new();
800        lf.add(LockedPlugin::new(
801            PluginId::from_static("hello-tool"),
802            "1.0.0".into(),
803            PluginSource::OpenClaw("@unicitylabs/hello-tool@1.0.0".into()),
804            "blake3:abc123def456".into(),
805        ));
806        lf.add(LockedPlugin::new(
807            PluginId::from_static("github-tools"),
808            "0.3.1".into(),
809            PluginSource::Local("./plugins/github-tools".into()),
810            "blake3:def456abc789".into(),
811        ));
812
813        lf.save(&lockfile_path).unwrap();
814
815        // Verify the file starts with the comment header
816        let content = std::fs::read_to_string(&lockfile_path).unwrap();
817        assert!(content.starts_with("# Auto-generated by astrid."));
818        assert!(content.contains("schema_version = 1"));
819
820        // Load and verify
821        let loaded = PluginLockfile::load(&lockfile_path).unwrap();
822        assert_eq!(loaded.len(), 2);
823
824        let hello = loaded.get(&PluginId::from_static("hello-tool")).unwrap();
825        assert_eq!(hello.version, "1.0.0");
826        assert_eq!(hello.wasm_hash, "blake3:abc123def456");
827        assert_eq!(
828            hello.source,
829            PluginSource::OpenClaw("@unicitylabs/hello-tool@1.0.0".into())
830        );
831
832        let github = loaded.get(&PluginId::from_static("github-tools")).unwrap();
833        assert_eq!(github.version, "0.3.1");
834    }
835
836    #[test]
837    fn load_nonexistent_file() {
838        let dir = TempDir::new().unwrap();
839        let nonexistent = dir.path().join("does_not_exist.lock");
840        let result = PluginLockfile::load(&nonexistent);
841        assert!(result.is_err());
842    }
843
844    #[test]
845    fn load_or_default_nonexistent() {
846        let dir = TempDir::new().unwrap();
847        let nonexistent = dir.path().join("does_not_exist.lock");
848        let lf = PluginLockfile::load_or_default(&nonexistent).unwrap();
849        assert!(lf.is_empty());
850    }
851
852    #[test]
853    fn verify_integrity_all_good() {
854        let dir = TempDir::new().unwrap();
855        let plugin_dir = dir.path();
856
857        // Create a plugin on disk
858        let plugin_path = plugin_dir.join("my-plugin");
859        std::fs::create_dir(&plugin_path).unwrap();
860
861        let wasm_data = b"fake wasm module bytes";
862        let wasm_hash = format!("blake3:{}", blake3::hash(wasm_data).to_hex());
863        std::fs::write(plugin_path.join("plugin.wasm"), wasm_data).unwrap();
864        std::fs::write(
865            plugin_path.join("plugin.toml"),
866            r#"
867id = "my-plugin"
868name = "My Plugin"
869version = "1.0.0"
870
871[entry_point]
872type = "wasm"
873path = "plugin.wasm"
874"#,
875        )
876        .unwrap();
877
878        let mut lf = PluginLockfile::new();
879        lf.add(LockedPlugin::new(
880            PluginId::from_static("my-plugin"),
881            "1.0.0".into(),
882            PluginSource::Local("./plugins/my-plugin".into()),
883            wasm_hash,
884        ));
885
886        let violations = lf.verify_integrity(plugin_dir);
887        assert!(
888            violations.is_empty(),
889            "expected no violations, got: {violations:?}"
890        );
891    }
892
893    #[test]
894    fn verify_integrity_missing_plugin() {
895        let dir = TempDir::new().unwrap();
896        let mut lf = PluginLockfile::new();
897        lf.add(LockedPlugin::new(
898            PluginId::from_static("ghost-plugin"),
899            "1.0.0".into(),
900            PluginSource::Local("./nowhere".into()),
901            "blake3:doesntmatter".into(),
902        ));
903
904        let violations = lf.verify_integrity(dir.path());
905        assert_eq!(violations.len(), 1);
906        assert!(matches!(
907            &violations[0],
908            IntegrityViolation::Missing { plugin_id } if plugin_id.as_str() == "ghost-plugin"
909        ));
910    }
911
912    #[test]
913    fn verify_integrity_hash_mismatch() {
914        let dir = TempDir::new().unwrap();
915        let plugin_path = dir.path().join("tampered-plugin");
916        std::fs::create_dir(&plugin_path).unwrap();
917
918        std::fs::write(plugin_path.join("plugin.wasm"), b"original bytes").unwrap();
919        std::fs::write(
920            plugin_path.join("plugin.toml"),
921            r#"
922id = "tampered-plugin"
923name = "Tampered"
924version = "1.0.0"
925
926[entry_point]
927type = "wasm"
928path = "plugin.wasm"
929"#,
930        )
931        .unwrap();
932
933        let mut lf = PluginLockfile::new();
934        lf.add(LockedPlugin::new(
935            PluginId::from_static("tampered-plugin"),
936            "1.0.0".into(),
937            PluginSource::Local("./plugins/tampered".into()),
938            "blake3:0000000000000000000000000000000000000000000000000000000000000000".into(),
939        ));
940
941        let violations = lf.verify_integrity(dir.path());
942        assert_eq!(violations.len(), 1);
943        assert!(matches!(
944            &violations[0],
945            IntegrityViolation::HashMismatch { plugin_id, .. } if plugin_id.as_str() == "tampered-plugin"
946        ));
947    }
948
949    #[test]
950    fn verify_integrity_version_mismatch() {
951        let dir = TempDir::new().unwrap();
952        let plugin_path = dir.path().join("outdated-plugin");
953        std::fs::create_dir(&plugin_path).unwrap();
954
955        let wasm_data = b"some wasm";
956        let wasm_hash = format!("blake3:{}", blake3::hash(wasm_data).to_hex());
957        std::fs::write(plugin_path.join("plugin.wasm"), wasm_data).unwrap();
958        std::fs::write(
959            plugin_path.join("plugin.toml"),
960            r#"
961id = "outdated-plugin"
962name = "Outdated"
963version = "2.0.0"
964
965[entry_point]
966type = "wasm"
967path = "plugin.wasm"
968"#,
969        )
970        .unwrap();
971
972        let mut lf = PluginLockfile::new();
973        lf.add(LockedPlugin::new(
974            PluginId::from_static("outdated-plugin"),
975            "1.0.0".into(),
976            PluginSource::Local("./plugins/outdated".into()),
977            wasm_hash,
978        ));
979
980        let violations = lf.verify_integrity(dir.path());
981        assert_eq!(violations.len(), 1);
982        assert!(matches!(
983            &violations[0],
984            IntegrityViolation::VersionMismatch { plugin_id, expected, actual }
985            if plugin_id.as_str() == "outdated-plugin" && expected == "1.0.0" && actual == "2.0.0"
986        ));
987    }
988
989    #[test]
990    fn compute_wasm_hash_format() {
991        let dir = TempDir::new().unwrap();
992        let wasm_path = dir.path().join("test.wasm");
993        std::fs::write(&wasm_path, b"test data").unwrap();
994
995        let hash = LockedPlugin::compute_wasm_hash(&wasm_path).unwrap();
996        assert!(hash.starts_with("blake3:"));
997        // blake3 hex is 64 chars
998        assert_eq!(hash.len(), 7 + 64); // "blake3:" + 64 hex chars
999    }
1000
1001    #[test]
1002    fn locked_plugin_from_manifest() {
1003        let dir = TempDir::new().unwrap();
1004        let plugin_dir = dir.path();
1005        let wasm_data = b"wasm module content";
1006        std::fs::write(plugin_dir.join("plugin.wasm"), wasm_data).unwrap();
1007
1008        let manifest = PluginManifest {
1009            id: PluginId::from_static("from-manifest"),
1010            name: "From Manifest".into(),
1011            version: "1.0.0".into(),
1012            description: None,
1013            author: None,
1014            entry_point: crate::manifest::PluginEntryPoint::Wasm {
1015                path: PathBuf::from("plugin.wasm"),
1016                hash: None,
1017            },
1018            capabilities: vec![],
1019            config: std::collections::HashMap::new(),
1020        };
1021
1022        let entry = LockedPlugin::from_manifest(
1023            &manifest,
1024            plugin_dir,
1025            PluginSource::Local("./plugins/from-manifest".into()),
1026        )
1027        .unwrap();
1028
1029        assert_eq!(entry.id.as_str(), "from-manifest");
1030        assert_eq!(entry.version, "1.0.0");
1031        assert!(entry.wasm_hash.starts_with("blake3:"));
1032        let expected_hash = format!("blake3:{}", blake3::hash(wasm_data).to_hex());
1033        assert_eq!(entry.wasm_hash, expected_hash);
1034    }
1035
1036    #[test]
1037    fn toml_format_matches_spec() {
1038        let mut lf = PluginLockfile::new();
1039        lf.add(LockedPlugin {
1040            id: PluginId::from_static("hello-tool"),
1041            version: "1.0.0".into(),
1042            source: PluginSource::OpenClaw("@unicitylabs/hello-tool@1.0.0".into()),
1043            wasm_hash: "blake3:abc123".into(),
1044            installed_at: DateTime::parse_from_rfc3339("2025-01-15T10:30:00Z")
1045                .unwrap()
1046                .with_timezone(&Utc),
1047        });
1048
1049        let toml_str = toml::to_string_pretty(&lf).unwrap();
1050        // Should contain the expected TOML structure
1051        assert!(toml_str.contains("schema_version = 1"));
1052        assert!(toml_str.contains("[[plugin]]"));
1053        assert!(toml_str.contains("id = \"hello-tool\""));
1054        assert!(toml_str.contains("version = \"1.0.0\""));
1055        assert!(toml_str.contains("source = \"openclaw:@unicitylabs/hello-tool@1.0.0\""));
1056        assert!(toml_str.contains("wasm_hash = \"blake3:abc123\""));
1057    }
1058}