1use 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
27const SCHEMA_VERSION: u32 = 1;
29
30pub const LOCKFILE_NAME: &str = "plugins.lock";
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PluginLockfile {
37 schema_version: u32,
39 #[serde(default, rename = "plugin")]
41 entries: Vec<LockedPlugin>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LockedPlugin {
48 pub id: PluginId,
50 pub version: String,
52 pub source: PluginSource,
54 pub wasm_hash: String,
56 pub installed_at: DateTime<Utc>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(into = "String", try_from = "String")]
63pub enum PluginSource {
64 Local(String),
66 OpenClaw(String),
68 Git {
70 url: String,
72 commit: Option<String>,
74 },
75 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 #[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 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#[derive(Debug, Clone)]
139pub enum IntegrityViolation {
140 Missing {
142 plugin_id: PluginId,
144 },
145 HashMismatch {
147 plugin_id: PluginId,
149 expected: String,
151 actual: String,
153 },
154 VersionMismatch {
156 plugin_id: PluginId,
158 expected: String,
160 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 #[must_use]
198 pub fn new() -> Self {
199 Self {
200 schema_version: SCHEMA_VERSION,
201 entries: Vec::new(),
202 }
203 }
204
205 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 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 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 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 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 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 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 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 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 pub fn add(&mut self, entry: LockedPlugin) {
386 self.remove(&entry.id);
387 self.entries.push(entry);
388 }
389
390 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 #[must_use]
401 pub fn get(&self, id: &PluginId) -> Option<&LockedPlugin> {
402 self.entries.iter().find(|e| e.id == *id)
403 }
404
405 #[must_use]
407 pub fn entries(&self) -> &[LockedPlugin] {
408 &self.entries
409 }
410
411 #[must_use]
413 pub fn is_empty(&self) -> bool {
414 self.entries.is_empty()
415 }
416
417 #[must_use]
419 pub fn len(&self) -> usize {
420 self.entries.len()
421 }
422
423 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 if !manifest_path.exists() {
440 violations.push(IntegrityViolation::Missing {
441 plugin_id: entry.id.clone(),
442 });
443 continue;
444 }
445
446 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 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 #[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 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 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 "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#[derive(Clone, Copy)]
579enum LockMode {
580 Shared,
581 Exclusive,
582}
583
584fn 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 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 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)); }
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 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 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 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 assert_eq!(hash.len(), 7 + 64); }
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 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}