- 1412
.map(|value| value.to_string_lossy().into_owned()) - 1413
}) - 1414
.and_then(|value| normalize_id(&value)) - 1415
.ok_or_else(|| { - 1416
PluginError::InvalidManifest(format!( - 1417
"skill needs a valid kebab-case name: {}", - 1418
path.display() - 1419
)) - 1420
})?; - 1421
Ok((name, description)) - 1422
} - 1423
- 1424
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 1425
#[serde(rename_all = "kebab-case")] - 1426
pub enum InstallScope { - 1427
User, - 1428
Workspace, - 1429
} - 1430
- 1431
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1432
pub struct InstalledPlugin { - 1433
pub name: String, - 1434
pub version: String, - 1435
pub digest: String, - 1436
pub description: String, - 1437
pub license: Option<String>, - 1438
pub publisher: Option<Publisher>, - 1439
pub format: ManifestFormat, - 1440
pub source: String, - 1441
#[serde(default)] - 1442
pub trace_id: String, - 1443
pub package_path: PathBuf, - 1444
pub scope: InstallScope, - 1445
pub enabled: bool, - 1446
pub installed_at_unix: u64, - 1447
pub capabilities: CapabilityInventory, - 1448
pub warnings: Vec<String>, - 1449
} - 1450
- 1451
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1452
pub struct PluginRegistry { - 1453
pub schema: u32, - 1454
pub generation: u64, - 1455
pub plugins: BTreeMap<String, InstalledPlugin>, - 1456
#[serde(default)] - 1457
pub versions: BTreeMap<String, Vec<InstalledPlugin>>, - 1458
#[serde(default)] - 1459
pub audit: Vec<PluginAuditEvent>, - 1460
} - 1461
- 1462
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1463
#[serde(rename_all = "kebab-case")] - 1464
pub enum PluginAuditAction { - 1465
Installed, - 1466
Updated, - 1467
Enabled, - 1468
Disabled, - 1469
RolledBack, - 1470
Removed, - 1471
} - 1472
- 1473
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1474
pub struct PluginAuditEvent { - 1475
pub generation: u64, - 1476
pub at_unix: u64, - 1477
pub action: PluginAuditAction, - 1478
pub plugin: String, - 1479
pub version: String, - 1480
pub digest: String, - 1481
pub trace_id: String, - 1482
pub source: String, - 1483
} - 1484
- 1485
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1486
pub struct PluginInvocationEvent { - 1487
pub at_unix: u64, - 1488
pub trace_id: String, - 1489
pub plugin: String, - 1490
pub capability: String, - 1491
pub success: bool, - 1492
} - 1493
- 1494
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1495
pub struct PluginHook { - 1496
pub event: String, - 1497
#[serde(rename = "match", default)] - 1498
pub matcher: Option<String>, - 1499
pub command: String, - 1500
#[serde(default)] - 1501
pub timeout_ms: Option<u64>, - 1502
} - 1503
- 1504
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 1505
pub struct SignatureEvidence { - 1506
pub algorithm: String, - 1507
pub key_id: String, - 1508
pub public_key: String, - 1509
pub signature: String, - 1510
pub verified: bool, - 1511
pub revoked: bool, - 1512
} - 1513
- 1514
/// Verify detached Ed25519 evidence over an exact byte sequence. Key trust - 1515
/// and revocation policy remain caller-owned; a valid signature alone never - 1516
/// grants runtime capabilities. - 1517
pub fn verify_ed25519_signature( - 1518
message: &[u8], - 1519
public_key_base64: &str, - 1520
signature_base64: &str, - 1521
) -> Result<(), PluginError> { - 1522
let key = base64::engine::general_purpose::STANDARD - 1523
.decode(public_key_base64) - 1524
.map_err(|error| { - 1525
PluginError::UnsafePackage(format!("invalid Ed25519 public key encoding: {error}")) - 1526
})?; - 1527
let signature = base64::engine::general_purpose::STANDARD - 1528
.decode(signature_base64) - 1529
.map_err(|error| { - 1530
PluginError::UnsafePackage(format!("invalid Ed25519 signature encoding: {error}")) - 1531
})?; - 1532
let verifier = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, key); - 1533
verifier - 1534
.verify(message, &signature) - 1535
.map_err(|_| PluginError::UnsafePackage("Ed25519 signature verification failed".into())) - 1536
} - 1537
- 1538
impl Default for PluginRegistry { - 1539
fn default() -> Self { - 1540
Self { - 1541
schema: REGISTRY_SCHEMA, - 1542
generation: 0, - 1543
plugins: BTreeMap::new(), - 1544
versions: BTreeMap::new(), - 1545
audit: Vec::new(), - 1546
} - 1547
} - 1548
} - 1549
- 1550
#[derive(Debug, Clone, Copy)] - 1551
pub struct InstallOptions { - 1552
pub scope: InstallScope, - 1553
pub allow_unlicensed: bool, - 1554
} - 1555
- 1556
impl Default for InstallOptions { - 1557
fn default() -> Self { - 1558
Self { - 1559
scope: InstallScope::User, - 1560
allow_unlicensed: false, - 1561
} - 1562
} - 1563
} - 1564
- 1565
#[derive(Clone)] - 1566
pub struct PluginStore { - 1567
home: PathBuf, - 1568
} - 1569
- 1570
impl PluginStore { - 1571
pub fn new(home: impl Into<PathBuf>) -> Self { - 1572
Self { home: home.into() } - 1573
} - 1574
- 1575
pub fn registry_path(&self) -> PathBuf { - 1576
self.home.join("plugins/registry.json") - 1577
} - 1578
- 1579
pub fn packages_root(&self) -> PathBuf { - 1580
self.home.join("plugins/packages") - 1581
} - 1582
- 1583
pub fn source_registry_path(&self) -> PathBuf { - 1584
self.home.join("plugins/sources.json") - 1585
} - 1586
- 1587
pub fn invocation_log_path(&self) -> PathBuf { - 1588
self.home.join("plugins/invocations.jsonl") - 1589
} - 1590
- 1591
pub fn record_invocation( - 1592
&self, - 1593
trace_id: &str, - 1594
plugin: &str, - 1595
capability: &str, - 1596
success: bool, - 1597
) -> Result<(), PluginError> { - 1598
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1599
let path = self.invocation_log_path(); - 1600
if let Some(parent) = path.parent() { - 1601
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 1602
} - 1603
let event = PluginInvocationEvent { - 1604
at_unix: now_unix(), - 1605
trace_id: trace_id.to_string(), - 1606
plugin: plugin.to_string(), - 1607
capability: capability.to_string(), - 1608
success, - 1609
}; - 1610
let bytes = serde_json::to_vec(&event).map_err(|source| PluginError::Json { - 1611
path: path.clone(), - 1612
source, - 1613
})?; - 1614
let mut file = OpenOptions::new() - 1615
.create(true) - 1616
.append(true) - 1617
.open(&path) - 1618
.map_err(|error| io_error(&path, error))?; - 1619
file.write_all(&bytes) - 1620
.map_err(|error| io_error(&path, error))?; - 1621
file.write_all(b"\n") - 1622
.map_err(|error| io_error(&path, error))?; - 1623
file.sync_data().map_err(|error| io_error(&path, error)) - 1624
} - 1625
- 1626
pub fn invocations(&self) -> Result<Vec<PluginInvocationEvent>, PluginError> { - 1627
let path = self.invocation_log_path(); - 1628
if !path.exists() { - 1629
return Ok(Vec::new()); - 1630
} - 1631
let text = fs::read_to_string(&path).map_err(|error| io_error(&path, error))?; - 1632
text.lines() - 1633
.filter(|line| !line.trim().is_empty()) - 1634
.map(|line| { - 1635
serde_json::from_str(line).map_err(|source| PluginError::Json { - 1636
path: path.clone(), - 1637
source, - 1638
}) - 1639
}) - 1640
.collect() - 1641
} - 1642
- 1643
pub fn load_sources(&self) -> Result<MarketplaceSourceRegistry, PluginError> { - 1644
let path = self.source_registry_path(); - 1645
if !path.exists() { - 1646
return Ok(MarketplaceSourceRegistry { - 1647
schema: SOURCE_REGISTRY_SCHEMA, - 1648
..MarketplaceSourceRegistry::default() - 1649
}); - 1650
} - 1651
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1652
let registry: MarketplaceSourceRegistry = - 1653
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1654
path: path.clone(), - 1655
source, - 1656
})?; - 1657
if registry.schema > SOURCE_REGISTRY_SCHEMA { - 1658
return Err(PluginError::InvalidManifest(format!( - 1659
"marketplace source registry schema {} is newer than supported schema {SOURCE_REGISTRY_SCHEMA}", - 1660
registry.schema - 1661
))); - 1662
} - 1663
Ok(registry) - 1664
} - 1665
- 1666
pub fn register_catalog_source( - 1667
&self, - 1668
root: &Path, - 1669
label: &str, - 1670
trust: MarketplaceTrust, - 1671
) -> Result<MarketplaceSource, PluginError> { - 1672
self.register_catalog_source_with_signature(root, label, trust, None) - 1673
} - 1674
- 1675
pub fn register_catalog_source_with_signature( - 1676
&self, - 1677
root: &Path, - 1678
label: &str, - 1679
trust: MarketplaceTrust, - 1680
signature: Option<SignatureEvidence>, - 1681
) -> Result<MarketplaceSource, PluginError> { - 1682
let inspection = inspect_catalog(root)?; - 1683
let signature = match signature { - 1684
Some(mut evidence) => { - 1685
if evidence.revoked - 1686
|| evidence.algorithm != "ed25519" - 1687
|| verify_ed25519_signature( - 1688
inspection.digest.as_bytes(), - 1689
&evidence.public_key, - 1690
&evidence.signature, - 1691
) - 1692
.is_err() - 1693
{ - 1694
return Err(PluginError::UnsafePackage( - 1695
"catalog signature evidence is not valid or is revoked".into(), - 1696
)); - 1697
} - 1698
evidence.verified = true; - 1699
Some(evidence) - 1700
} - 1701
None => None, - 1702
}; - 1703
let canonical = fs::canonicalize(root).map_err(|error| io_error(root, error))?; - 1704
let id = format!("{}:{}", inspection.name, inspection.digest); - 1705
let source = MarketplaceSource { - 1706
id: id.clone(), - 1707
label: label.trim().to_string(), - 1708
root: canonical, - 1709
format: inspection.format, - 1710
catalog_digest: inspection.digest, - 1711
trace_id: inspection.trace_id, - 1712
trust, - 1713
enabled: false, - 1714
registered_at_unix: now_unix(), - 1715
signature, - 1716
}; - 1717
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1718
let mut registry = self.load_sources()?; - 1719
registry.schema = SOURCE_REGISTRY_SCHEMA; - 1720
registry.sources.insert(id, source.clone()); - 1721
self.save_sources(®istry)?; - 1722
Ok(source) - 1723
} - 1724
- 1725
pub fn set_key_revoked(&self, key_id: &str, revoked: bool) -> Result<(), PluginError> { - 1726
if key_id.trim().is_empty() { - 1727
return Err(PluginError::InvalidManifest( - 1728
"key id must not be empty".into(), - 1729
)); - 1730
} - 1731
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1732
let mut registry = self.load_sources()?; - 1733
if revoked { - 1734
registry.revoked_keys.insert(key_id.to_string()); - 1735
} else { - 1736
registry.revoked_keys.remove(key_id); - 1737
} - 1738
self.save_sources(®istry) - 1739
} - 1740
- 1741
pub fn list_sources(&self) -> Result<Vec<MarketplaceSource>, PluginError> { - 1742
Ok(self.load_sources()?.sources.into_values().collect()) - 1743
} - 1744
- 1745
pub fn set_source_enabled( - 1746
&self, - 1747
id: &str, - 1748
enabled: bool, - 1749
) -> Result<MarketplaceSource, PluginError> { - 1750
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1751
let mut registry = self.load_sources()?; - 1752
let source = registry - 1753
.sources - 1754
.get_mut(id) - 1755
.ok_or_else(|| PluginError::NotInstalled(id.to_string()))?; - 1756
let current = inspect_catalog(&source.root)?; - 1757
if current.digest != source.catalog_digest { - 1758
return Err(PluginError::UnsafePackage(format!( - 1759
"marketplace catalog changed since registration: {}", - 1760
source.root.display() - 1761
))); - 1762
} - 1763
if let Some(signature) = &source.signature { - 1764
if signature.revoked || registry.revoked_keys.contains(&signature.key_id) { - 1765
return Err(PluginError::UnsafePackage(format!( - 1766
"marketplace source key is revoked: {}", - 1767
signature.key_id - 1768
))); - 1769
} - 1770
if !signature.verified { - 1771
return Err(PluginError::UnsafePackage( - 1772
"marketplace source signature is not verified".into(), - 1773
)); - 1774
} - 1775
} - 1776
source.enabled = enabled; - 1777
let source = source.clone(); - 1778
self.save_sources(®istry)?; - 1779
Ok(source) - 1780
} - 1781
- 1782
fn save_sources(&self, registry: &MarketplaceSourceRegistry) -> Result<(), PluginError> { - 1783
let path = self.source_registry_path(); - 1784
if let Some(parent) = path.parent() { - 1785
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 1786
} - 1787
let bytes = serde_json::to_vec_pretty(registry).map_err(|source| PluginError::Json { - 1788
path: path.clone(), - 1789
source, - 1790
})?; - 1791
let temp = path.with_extension("json.tmp"); - 1792
let mut file = File::create(&temp).map_err(|error| io_error(&temp, error))?; - 1793
file.write_all(&bytes) - 1794
.map_err(|error| io_error(&temp, error))?; - 1795
file.sync_all().map_err(|error| io_error(&temp, error))?; - 1796
fs::rename(&temp, &path).map_err(|error| io_error(&path, error)) - 1797
} - 1798
- 1799
pub fn load(&self) -> Result<PluginRegistry, PluginError> { - 1800
let path = self.registry_path(); - 1801
if !path.exists() { - 1802
return Ok(PluginRegistry::default()); - 1803
} - 1804
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1805
let registry: PluginRegistry = - 1806
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1807
path: path.clone(), - 1808
source, - 1809
})?; - 1810
if registry.schema > REGISTRY_SCHEMA { - 1811
return Err(PluginError::InvalidManifest(format!( - 1812
"registry schema {} is newer than supported schema {REGISTRY_SCHEMA}", - 1813
registry.schema - 1814
))); - 1815
} - 1816
Ok(registry) - 1817
} - 1818
- 1819
pub fn list(&self) -> Result<Vec<InstalledPlugin>, PluginError> { - 1820
Ok(self.load()?.plugins.into_values().collect()) - 1821
} - 1822
- 1823
pub fn enabled(&self) -> Result<Vec<InstalledPlugin>, PluginError> { - 1824
Ok(self - 1825
.load()? - 1826
.plugins - 1827
.into_values() - 1828
.filter(|plugin| plugin.enabled) - 1829
.collect()) - 1830
} - 1831
- 1832
/// Scan installed (enabled or disabled) plugins for references to retired - 1833
/// tool names in their skill descriptions or bodies. Returns each - 1834
/// `(plugin_name, retired_tool_name)` pair found, so callers can remove - 1835
/// the offending plugin and warn the operator. - 1836
/// - 1837
/// A plugin is flagged when any of its `SKILL.md` files — either the - 1838
/// `description` frontmatter field or the body text — contains a - 1839
/// backtick-quoted reference to a retired tool name (e.g. - 1840
/// `` `python_eval` ``). - 1841
pub fn retired_plugins(&self) -> Result<Vec<(String, Vec<String>)>, PluginError> { - 1842
let retired_names: std::collections::HashSet<&str> = - 1843
vak_tools::retired::retired_names().into_iter().collect(); - 1844
let mut flagged: Vec<(String, Vec<String>)> = Vec::new(); - 1845
for plugin in self.list()? { - 1846
let mut hits: Vec<String> = Vec::new(); - 1847
// Walk all SKILL.md files in the package directory tree. - 1848
let walker = walkdir::WalkDir::new(&plugin.package_path) - 1849
.into_iter() - 1850
.filter_map(|e| e.ok()) - 1851
.filter(|e| e.file_name() == "SKILL.md"); - 1852
for entry in walker { - 1853
let Ok(text) = std::fs::read_to_string(entry.path()) else { - 1854
continue; - 1855
}; - 1856
let lower = text.to_lowercase(); - 1857
for retired in &retired_names { - 1858
let pattern = format!("`{}`", retired.to_lowercase()); - 1859
if lower.contains(&pattern) && !hits.iter().any(|h: &String| h == *retired) { - 1860
hits.push((*retired).to_string()); - 1861
} - 1862
} - 1863
} - 1864
if !hits.is_empty() { - 1865
flagged.push((plugin.name, hits)); - 1866
} - 1867
} - 1868
Ok(flagged) - 1869
} - 1870
- 1871
pub fn enabled_hooks(&self) -> Result<Vec<(InstalledPlugin, PluginHook)>, PluginError> { - 1872
let mut hooks = Vec::new(); - 1873
for plugin in self.enabled()? { - 1874
for relative in &plugin.capabilities.hooks { - 1875
let path = plugin.package_path.join(relative); - 1876
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1877
let value: serde_json::Value = - 1878
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1879
path: path.clone(), - 1880
source, - 1881
})?; - 1882
let entries = value.get("hooks").cloned().unwrap_or(value); - 1883
let entries = if let Some(array) = entries.as_array() { - 1884
array.clone() - 1885
} else { - 1886
vec![entries] - 1887
}; - 1888
for entry in entries { - 1889
let hook: PluginHook = serde_json::from_value(entry).map_err(|error| { - 1890
PluginError::InvalidManifest(format!( - 1891
"plugin '{}' hook {} is invalid: {error}", - 1892
plugin.name, relative - 1893
)) - 1894
})?; - 1895
if hook.command.trim().is_empty() { - 1896
return Err(PluginError::InvalidManifest(format!( - 1897
"plugin '{}' hook {} has an empty command", - 1898
plugin.name, relative - 1899
))); - 1900
} - 1901
hooks.push((plugin.clone(), hook)); - 1902
} - 1903
} - 1904
} - 1905
Ok(hooks) - 1906
} - 1907
- 1908
pub fn versions(&self, name: &str) -> Result<Vec<InstalledPlugin>, PluginError> { - 1909
let name = normalize_plugin_id(name) - 1910
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 1911
let registry = self.load()?; - 1912
if let Some(versions) = registry.versions.get(&name) { - 1913
return Ok(versions.clone()); - 1914
} - 1915
Ok(registry.plugins.get(&name).cloned().into_iter().collect()) - 1916
} - 1917
- 1918
pub fn install_local( - 1919
&self, - 1920
source: &Path, - 1921
options: InstallOptions, - 1922
) -> Result<InstalledPlugin, PluginError> { - 1923
let inspection = inspect_package(source)?; - 1924
if inspection.manifest.license.is_none() && !options.allow_unlicensed { - 1925
return Err(PluginError::Unlicensed(inspection.manifest.name)); - 1926
} - 1927
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1928
let mut registry = self.load()?; - 1929
if let Some(existing) = registry.plugins.get(&inspection.manifest.name) { - 1930
if existing.digest == inspection.digest { - 1931
return Ok(existing.clone()); - 1932
} - 1933
return Err(PluginError::AlreadyInstalled( - 1934
inspection.manifest.name.clone(), - 1935
)); - 1936
} - 1937
let package_path = self.package_path(&inspection); - 1938
if package_path.exists() { - 1939
let existing = inspect_package(&package_path)?; - 1940
if existing.digest != inspection.digest { - 1941
return Err(PluginError::UnsafePackage(format!( - 1942
"immutable package destination has unexpected content: {}", - 1943
package_path.display() - 1944
))); - 1945
} - 1946
} else { - 1947
self.copy_verified(&inspection, &package_path)?; - 1948
} - 1949
let installed_at_unix = now_unix(); - 1950
let source = inspection.root.display().to_string(); - 1951
let trace_id = format!("install:{}:{}", inspection.manifest.name, inspection.digest); - 1952
let installed = InstalledPlugin { - 1953
name: inspection.manifest.name.clone(), - 1954
version: inspection.manifest.version.clone(), - 1955
digest: inspection.digest.clone(), - 1956
description: inspection.manifest.description, - 1957
license: inspection.manifest.license, - 1958
publisher: inspection.manifest.publisher, - 1959
format: inspection.format, - 1960
source: source.clone(), - 1961
trace_id: trace_id.clone(), - 1962
package_path, - 1963
scope: options.scope, - 1964
enabled: false, - 1965
installed_at_unix, - 1966
capabilities: inspection.capabilities, - 1967
warnings: inspection.warnings, - 1968
}; - 1969
registry - 1970
.plugins - 1971
.insert(installed.name.clone(), installed.clone()); - 1972
registry - 1973
.versions - 1974
.entry(installed.name.clone()) - 1975
.or_default() - 1976
.push(installed.clone()); - 1977
registry.generation = registry.generation.saturating_add(1); - 1978
registry.audit.push(PluginAuditEvent { - 1979
generation: registry.generation, - 1980
at_unix: installed_at_unix, - 1981
action: PluginAuditAction::Installed, - 1982
plugin: installed.name.clone(), - 1983
version: installed.version.clone(), - 1984
digest: installed.digest.clone(), - 1985
trace_id, - 1986
source, - 1987
}); - 1988
self.save(®istry)?; - 1989
Ok(installed) - 1990
} - 1991
- 1992
pub fn update_local( - 1993
&self, - 1994
source: &Path, - 1995
options: InstallOptions, - 1996
) -> Result<InstalledPlugin, PluginError> { - 1997
let inspection = inspect_package(source)?; - 1998
if inspection.manifest.license.is_none() && !options.allow_unlicensed { - 1999
return Err(PluginError::Unlicensed(inspection.manifest.name)); - 2000
} - 2001
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2002
let mut registry = self.load()?; - 2003
let current = registry - 2004
.plugins - 2005
.get(&inspection.manifest.name) - 2006
.cloned() - 2007
.ok_or_else(|| PluginError::NotInstalled(inspection.manifest.name.clone()))?; - 2008
if current.digest == inspection.digest { - 2009
return Ok(current); - 2010
} - 2011
if registry - 2012
.versions - 2013
.get(&inspection.manifest.name) - 2014
.is_some_and(|items| items.iter().any(|item| item.digest == inspection.digest)) - 2015
{ - 2016
return Err(PluginError::AlreadyInstalled( - 2017
inspection.manifest.name.clone(), - 2018
)); - 2019
} - 2020
let package_path = self.package_path(&inspection); - 2021
if package_path.exists() { - 2022
let existing = inspect_package(&package_path)?; - 2023
if existing.digest != inspection.digest { - 2024
return Err(PluginError::UnsafePackage(format!( - 2025
"immutable package destination has unexpected content: {}", - 2026
package_path.display() - 2027
))); - 2028
} - 2029
} else { - 2030
self.copy_verified(&inspection, &package_path)?; - 2031
} - 2032
let installed_at_unix = now_unix(); - 2033
let source = inspection.root.display().to_string(); - 2034
let installed = InstalledPlugin { - 2035
name: inspection.manifest.name.clone(), - 2036
version: inspection.manifest.version.clone(), - 2037
digest: inspection.digest.clone(), - 2038
description: inspection.manifest.description, - 2039
license: inspection.manifest.license, - 2040
publisher: inspection.manifest.publisher, - 2041
format: inspection.format, - 2042
source: source.clone(), - 2043
trace_id: format!("update:{}:{}", inspection.manifest.name, inspection.digest), - 2044
package_path, - 2045
scope: options.scope, - 2046
enabled: false, - 2047
installed_at_unix, - 2048
capabilities: inspection.capabilities, - 2049
warnings: inspection.warnings, - 2050
}; - 2051
registry - 2052
.versions - 2053
.entry(installed.name.clone()) - 2054
.or_default() - 2055
.push(installed.clone()); - 2056
// Updating selects the new immutable generation, but it remains disabled - 2057
// until an operator explicitly enables the plugin. - 2058
registry - 2059
.plugins - 2060
.insert(installed.name.clone(), installed.clone()); - 2061
registry.generation = registry.generation.saturating_add(1); - 2062
registry.audit.push(PluginAuditEvent { - 2063
generation: registry.generation, - 2064
at_unix: installed_at_unix, - 2065
action: PluginAuditAction::Updated, - 2066
plugin: installed.name.clone(), - 2067
version: installed.version.clone(), - 2068
digest: installed.digest.clone(), - 2069
trace_id: installed.trace_id.clone(), - 2070
source, - 2071
}); - 2072
self.save(®istry)?; - 2073
Ok(installed) - 2074
} - 2075
- 2076
pub fn enable(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2077
self.set_enabled(name, true) - 2078
} - 2079
- 2080
pub fn disable(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2081
self.set_enabled(name, false) - 2082
} - 2083
- 2084
fn set_enabled(&self, name: &str, enabled: bool) -> Result<InstalledPlugin, PluginError> { - 2085
let name = normalize_plugin_id(name) - 2086
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2087
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2088
let mut registry = self.load()?; - 2089
let mut plugin = registry - 2090
.plugins - 2091
.get(&name) - 2092
.cloned() - 2093
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2094
if plugin.enabled == enabled { - 2095
return Ok(plugin); - 2096
} - 2097
plugin.enabled = enabled; - 2098
registry.plugins.insert(name.clone(), plugin.clone()); - 2099
registry.generation = registry.generation.saturating_add(1); - 2100
registry.audit.push(PluginAuditEvent { - 2101
generation: registry.generation, - 2102
at_unix: now_unix(), - 2103
action: if enabled { - 2104
PluginAuditAction::Enabled - 2105
} else { - 2106
PluginAuditAction::Disabled - 2107
}, - 2108
plugin: plugin.name.clone(), - 2109
version: plugin.version.clone(), - 2110
digest: plugin.digest.clone(), - 2111
trace_id: plugin.trace_id.clone(), - 2112
source: plugin.source.clone(), - 2113
}); - 2114
self.save(®istry)?; - 2115
Ok(plugin) - 2116
} - 2117
- 2118
pub fn rollback(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2119
let name = normalize_plugin_id(name) - 2120
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2121
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2122
let mut registry = self.load()?; - 2123
let current = registry - 2124
.plugins - 2125
.get(&name) - 2126
.cloned() - 2127
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2128
let previous = registry - 2129
.versions - 2130
.get(&name) - 2131
.and_then(|items| { - 2132
items - 2133
.iter() - 2134
.rev() - 2135
.find(|item| item.digest != current.digest) - 2136
}) - 2137
.cloned() - 2138
.ok_or_else(|| { - 2139
PluginError::InvalidManifest(format!("plugin {name:?} has no previous generation")) - 2140
})?; - 2141
let mut previous = previous; - 2142
previous.enabled = false; - 2143
registry.plugins.insert(name.clone(), previous.clone()); - 2144
registry.generation = registry.generation.saturating_add(1); - 2145
registry.audit.push(PluginAuditEvent { - 2146
generation: registry.generation, - 2147
at_unix: now_unix(), - 2148
action: PluginAuditAction::RolledBack, - 2149
plugin: previous.name.clone(), - 2150
version: previous.version.clone(), - 2151
digest: previous.digest.clone(), - 2152
trace_id: previous.trace_id.clone(), - 2153
source: previous.source.clone(), - 2154
}); - 2155
self.save(®istry)?; - 2156
Ok(previous) - 2157
} - 2158
- 2159
pub fn remove(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2160
let name = normalize_plugin_id(name) - 2161
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2162
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2163
let mut registry = self.load()?; - 2164
let installed = registry - 2165
.plugins - 2166
.remove(&name) - 2167
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2168
let packages_root = fs::canonicalize(self.packages_root()) - 2169
.map_err(|error| io_error(self.packages_root(), error))?; - 2170
let package_paths = registry - 2171
.versions - 2172
.remove(&name) - 2173
.unwrap_or_else(|| vec![installed.clone()]) - 2174
.into_iter() - 2175
.map(|item| item.package_path) - 2176
.collect::<Vec<_>>(); - 2177
for package_path in &package_paths { - 2178
let package_path = - 2179
fs::canonicalize(package_path).map_err(|error| io_error(package_path, error))?; - 2180
if !package_path.starts_with(&packages_root) || package_path == packages_root { - 2181
return Err(PluginError::UnsafePackage(format!( - 2182
"registry package path is outside the managed store: {}", - 2183
package_path.display() - 2184
))); - 2185
} - 2186
} - 2187
registry.generation = registry.generation.saturating_add(1); - 2188
registry.audit.push(PluginAuditEvent { - 2189
generation: registry.generation, - 2190
at_unix: now_unix(), - 2191
action: PluginAuditAction::Removed, - 2192
plugin: installed.name.clone(), - 2193
version: installed.version.clone(), - 2194
digest: installed.digest.clone(), - 2195
trace_id: installed.trace_id.clone(), - 2196
source: installed.source.clone(), - 2197
}); - 2198
self.save(®istry)?; - 2199
for package_path in package_paths { - 2200
let package_path = - 2201
fs::canonicalize(&package_path).map_err(|error| io_error(&package_path, error))?; - 2202
if package_path.exists() { - 2203
fs::remove_dir_all(&package_path) - 2204
.map_err(|error| io_error(&package_path, error))?; - 2205
prune_empty_parents(&package_path, &packages_root)?; - 2206
} - 2207
} - 2208
Ok(installed) - 2209
} - 2210
- 2211
fn package_path(&self, inspection: &PackageInspection) -> PathBuf { - 2212
self.packages_root() - 2213
.join(&inspection.manifest.name) - 2214
.join(&inspection.manifest.version) - 2215
.join(&inspection.digest) - 2216
} - 2217
- 2218
fn copy_verified( - 2219
&self, - 2220
inspection: &PackageInspection, - 2221
destination: &Path, - 2222
) -> Result<(), PluginError> { - 2223
let parent = destination.parent().ok_or_else(|| { - 2224
PluginError::UnsafePackage("package destination has no parent".into()) - 2225
})?; - 2226
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 2227
let staging = tempfile::Builder::new() - 2228
.prefix(".plugin-stage-") - 2229
.tempdir_in(parent) - 2230
.map_err(|error| io_error(parent, error))?; - 2231
for file in &inspection.files { - 2232
let relative = path_from_portable(&file.path); - 2233
let source = inspection.root.join(&relative); - 2234
let source_meta = - 2235
fs::symlink_metadata(&source).map_err(|error| io_error(&source, error))?; - 2236
if !source_meta.is_file() || source_meta.file_type().is_symlink() { - 2237
return Err(PluginError::UnsafePackage(format!( - 2238
"source changed during install: {}", - 2239
source.display() - 2240
))); - 2241
} - 2242
let canonical_source = - 2243
fs::canonicalize(&source).map_err(|error| io_error(&source, error))?; - 2244
if !canonical_source.starts_with(&inspection.root) { - 2245
return Err(PluginError::UnsafePackage(format!( - 2246
"source escaped package during install: {}", - 2247
source.display() - 2248
))); - 2249
} - 2250
let target = staging.path().join(&relative); - 2251
if let Some(directory) = target.parent() { - 2252
fs::create_dir_all(directory).map_err(|error| io_error(directory, error))?; - 2253
} - 2254
fs::copy(&source, &target).map_err(|error| io_error(&target, error))?; - 2255
} - 2256
let staged_inspection = inspect_package(staging.path())?; - 2257
if staged_inspection.digest != inspection.digest { - 2258
return Err(PluginError::UnsafePackage( - 2259
"package changed while it was being installed".into(), - 2260
)); - 2261
} - 2262
let staged_path = staging.keep(); - 2263
fs::rename(&staged_path, destination).map_err(|error| io_error(destination, error))?; - 2264
Ok(()) - 2265
} - 2266
- 2267
fn save(&self, registry: &PluginRegistry) -> Result<(), PluginError> { - 2268
let path = self.registry_path(); - 2269
let parent = path - 2270
.parent() - 2271
.ok_or_else(|| PluginError::UnsafePackage("registry path has no parent".into()))?; - 2272
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 2273
let bytes = serde_json::to_vec_pretty(registry).map_err(|source| PluginError::Json { - 2274
path: path.clone(), - 2275
source, - 2276
})?; - 2277
let mut staged = - 2278
tempfile::NamedTempFile::new_in(parent).map_err(|error| io_error(parent, error))?; - 2279
staged - 2280
.write_all(&bytes) - 2281
.map_err(|error| io_error(staged.path(), error))?; - 2282
staged - 2283
.as_file() - 2284
.sync_all() - 2285
.map_err(|error| io_error(staged.path(), error))?; - 2286
staged - 2287
.persist(&path) - 2288
.map_err(|error| io_error(&path, error.error))?; - 2289
Ok(()) - 2290
} - 2291
} - 2292
- 2293
fn prune_empty_parents(path: &Path, stop: &Path) -> Result<(), PluginError> { - 2294
let mut current = path.parent(); - 2295
while let Some(directory) = current { - 2296
if directory == stop { - 2297
break; - 2298
} - 2299
match fs::remove_dir(directory) { - 2300
Ok(()) => current = directory.parent(), - 2301
Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break, - 2302
Err(error) => return Err(io_error(directory, error)), - 2303
} - 2304
} - 2305
Ok(()) - 2306
} - 2307
- 2308
fn now_unix() -> u64 { - 2309
SystemTime::now() - 2310
.duration_since(UNIX_EPOCH) - 2311
.unwrap_or_default() - 2312
.as_secs() - 2313
} - 2314
- 2315
struct RegistryLock { - 2316
path: PathBuf, - 2317
} - 2318
- 2319
impl RegistryLock { - 2320
fn acquire(root: &Path) -> Result<Self, PluginError> { - 2321
fs::create_dir_all(root).map_err(|error| io_error(root, error))?; - 2322
let path = root.join(".registry.lock"); - 2323
match create_lock(&path) { - 2324
Ok(()) => Ok(Self { path }), - 2325
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - 2326
let stale = fs::metadata(&path) - 2327
.and_then(|metadata| metadata.modified()) - 2328
.ok() - 2329
.and_then(|modified| SystemTime::now().duration_since(modified).ok()) - 2330
.is_some_and(|age| age > Duration::from_secs(120)); - 2331
if !stale { - 2332
return Err(PluginError::RegistryBusy); - 2333
} - 2334
fs::remove_file(&path).map_err(|remove_error| io_error(&path, remove_error))?; - 2335
create_lock(&path).map_err(|retry_error| io_error(&path, retry_error))?; - 2336
Ok(Self { path }) - 2337
} - 2338
Err(error) => Err(io_error(&path, error)), - 2339
} - 2340
} - 2341
} - 2342
- 2343
impl Drop for RegistryLock { - 2344
fn drop(&mut self) { - 2345
let _ = fs::remove_file(&self.path); - 2346
} - 2347
} - 2348
- 2349
fn create_lock(path: &Path) -> std::io::Result<()> { - 2350
let mut lock = OpenOptions::new().write(true).create_new(true).open(path)?; - 2351
writeln!(lock, "{} {}", std::process::id(), now_unix())?; - 2352
lock.sync_all() - 2353
} - 2354
- 2355
#[cfg(test)] - 2356
mod tests { - 2357
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - 2358
- 2359
use super::*; - 2360
- 2361
fn write(path: &Path, text: &str) { - 2362
if let Some(parent) = path.parent() { - 2363
fs::create_dir_all(parent).unwrap(); - 2364
} - 2365
fs::write(path, text).unwrap(); - 2366
} - 2367
- 2368
fn package(root: &Path) { - 2369
write( - 2370
&root.join("vak-plugin.json"), - 2371
r#"{ - 2372
"schema": 1, - 2373
"name": "daily-brief", - 2374
"version": "1.2.3", - 2375
"description": "Prepare a daily brief.", - 2376
"license": "MIT", - 2377
"publisher": {"id":"vak-labs","name":"Vak Labs"}, - 2378
"components": {"skills":["skills"],"commands":["commands"]} - 2379
}"#, - 2380
); - 2381
write( - 2382
&root.join("skills/daily-brief/SKILL.md"), - 2383
"---\nname: daily-brief\ndescription: Prepare the brief.\n---\n\nDo it.\n", - 2384
); - 2385
write( - 2386
&root.join("skills/daily-brief/scripts/collect.sh"), - 2387
"#!/bin/sh\nprintf brief\n", - 2388
); - 2389
write(&root.join("commands/brief.md"), "Prepare a brief.\n"); - 2390
} - 2391
- 2392
#[test] - 2393
fn inspects_native_package_and_digest_is_deterministic() { - 2394
let temp = tempfile::tempdir().unwrap(); - 2395
package(temp.path()); - 2396
let first = inspect_package(temp.path()).unwrap(); - 2397
let second = inspect_package(temp.path()).unwrap(); - 2398
assert_eq!(first.digest, second.digest); - 2399
assert_eq!(first.manifest.name, "daily-brief"); - 2400
assert_eq!(first.capabilities.skills, ["daily-brief"]); - 2401
assert_eq!(first.capabilities.commands, ["commands/brief.md"]); - 2402
assert_eq!( - 2403
first.capabilities.scripts, - 2404
["skills/daily-brief/scripts/collect.sh"] - 2405
); - 2406
} - 2407
- 2408
#[test] - 2409
fn changing_content_changes_digest() { - 2410
let temp = tempfile::tempdir().unwrap(); - 2411
package(temp.path());
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.