- 508
.args(["-C"]) - 509
.arg(path) - 510
.args(["rev-parse", "HEAD"]) - 511
.output() - 512
.map_err(|error| io_error(path, error))?; - 513
if !output.status.success() { - 514
return Err(PluginError::UnsafePackage(format!( - 515
"unable to verify checkout commit: {}", - 516
path.display() - 517
))); - 518
} - 519
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - 520
} - 521
- 522
fn find_catalog(root: &Path) -> Result<(PathBuf, MarketplaceFormat), PluginError> { - 523
let candidates = [ - 524
(".agents/plugins/marketplace.json", MarketplaceFormat::Codex), - 525
(".claude-plugin/marketplace.json", MarketplaceFormat::Claude), - 526
( - 527
".github/plugin/marketplace.json", - 528
MarketplaceFormat::Copilot, - 529
), - 530
(".plugin/marketplace.json", MarketplaceFormat::Copilot), - 531
(".cursor-plugin/marketplace.json", MarketplaceFormat::Cursor), - 532
("marketplace.json", MarketplaceFormat::Copilot), - 533
]; - 534
for (relative, format) in candidates { - 535
let path = root.join(relative); - 536
if path.is_file() { - 537
return Ok((path, format)); - 538
} - 539
} - 540
Err(PluginError::ManifestMissing(root.to_path_buf())) - 541
} - 542
- 543
fn optional_string( - 544
object: &serde_json::Map<String, serde_json::Value>, - 545
field: &str, - 546
) -> Result<Option<String>, PluginError> { - 547
match object.get(field) { - 548
None | Some(serde_json::Value::Null) => Ok(None), - 549
Some(value) => value - 550
.as_str() - 551
.map(|value| Some(value.to_string())) - 552
.ok_or_else(|| { - 553
PluginError::InvalidManifest(format!( - 554
"marketplace field {field:?} must be a string" - 555
)) - 556
}), - 557
} - 558
} - 559
- 560
fn validate_catalog_source(source: &serde_json::Value, name: &str) -> Result<(), PluginError> { - 561
if let Some(locator) = source.as_str() { - 562
if locator.trim().is_empty() { - 563
return Err(PluginError::InvalidManifest(format!( - 564
"marketplace plugin {name:?} source must not be empty" - 565
))); - 566
} - 567
if locator.starts_with("./") || locator.starts_with("../") { - 568
validate_relative_catalog_path(locator, name)?; - 569
} - 570
if locator.starts_with("https://") || locator.starts_with("git@") { - 571
return Err(PluginError::UnsafePackage(format!( - 572
"marketplace plugin {name:?} remote sources must include a pinned commit sha" - 573
))); - 574
} - 575
return Ok(()); - 576
} - 577
let Some(object) = source.as_object() else { - 578
return Err(PluginError::InvalidManifest(format!( - 579
"marketplace plugin {name:?} source must be a non-empty string or object" - 580
))); - 581
}; - 582
if object.is_empty() { - 583
return Err(PluginError::InvalidManifest(format!( - 584
"marketplace plugin {name:?} source object is empty" - 585
))); - 586
} - 587
let has_locator = ["repo", "url", "path", "source"] - 588
.iter() - 589
.any(|field| object.get(*field).is_some_and(serde_json::Value::is_string)); - 590
if !has_locator { - 591
return Err(PluginError::InvalidManifest(format!( - 592
"marketplace plugin {name:?} source object has no recognized locator" - 593
))); - 594
} - 595
if let Some(sha) = object.get("sha") { - 596
let valid = sha - 597
.as_str() - 598
.is_some_and(|sha| sha.len() == 40 && sha.bytes().all(|byte| byte.is_ascii_hexdigit())); - 599
if !valid { - 600
return Err(PluginError::InvalidManifest(format!( - 601
"marketplace plugin {name:?} source sha must be a full 40-character commit hash" - 602
))); - 603
} - 604
} - 605
if let Some(path) = object.get("path").and_then(serde_json::Value::as_str) { - 606
validate_relative_catalog_path(path, name)?; - 607
} - 608
let remote = ["repo", "url", "source"].iter().any(|field| { - 609
object - 610
.get(*field) - 611
.and_then(serde_json::Value::as_str) - 612
.is_some_and(|value| value != "github" && value != "git") - 613
}) || object.get("repo").is_some(); - 614
if remote && object.get("sha").is_none() { - 615
return Err(PluginError::UnsafePackage(format!( - 616
"marketplace plugin {name:?} remote sources require a pinned commit sha" - 617
))); - 618
} - 619
Ok(()) - 620
} - 621
- 622
fn validate_relative_catalog_path(path: &str, name: &str) -> Result<(), PluginError> { - 623
let candidate = Path::new(path); - 624
if candidate.is_absolute() - 625
|| candidate - 626
.components() - 627
.any(|component| matches!(component, Component::ParentDir)) - 628
{ - 629
return Err(PluginError::UnsafePackage(format!( - 630
"marketplace plugin {name:?} source path must stay inside its catalog" - 631
))); - 632
} - 633
Ok(()) - 634
} - 635
- 636
#[derive(Debug, Clone, Copy)] - 637
pub struct InspectLimits { - 638
pub max_files: usize, - 639
pub max_total_bytes: u64, - 640
pub max_file_bytes: u64, - 641
pub max_depth: usize, - 642
} - 643
- 644
impl Default for InspectLimits { - 645
fn default() -> Self { - 646
Self { - 647
max_files: 4_096, - 648
max_total_bytes: 64 * 1024 * 1024, - 649
max_file_bytes: 16 * 1024 * 1024, - 650
max_depth: 16, - 651
} - 652
} - 653
} - 654
- 655
pub fn inspect_package(root: &Path) -> Result<PackageInspection, PluginError> { - 656
inspect_package_with_limits(root, InspectLimits::default()) - 657
} - 658
- 659
pub fn inspect_package_with_limits( - 660
root: &Path, - 661
limits: InspectLimits, - 662
) -> Result<PackageInspection, PluginError> { - 663
if !root.exists() { - 664
return Err(PluginError::NotFound(root.to_path_buf())); - 665
} - 666
if !root.is_dir() { - 667
return Err(PluginError::UnsafePackage(format!( - 668
"package root must be a directory: {}", - 669
root.display() - 670
))); - 671
} - 672
let root = fs::canonicalize(root).map_err(|error| io_error(root, error))?; - 673
let (mut manifest, format, manifest_warnings) = load_manifest(&root)?; - 674
normalize_and_validate_manifest(&mut manifest, &root)?; - 675
let files = collect_files(&root, limits)?; - 676
let total_bytes = files.iter().map(|file| file.bytes).sum(); - 677
let digest = digest_files(&root, &files)?; - 678
let capabilities = inventory_capabilities(&root, &manifest.components, &files)?; - 679
let mut warnings = manifest_warnings; - 680
if manifest.license.is_none() { - 681
warnings.push( - 682
"no license declared; marketplace availability does not grant redistribution rights" - 683
.into(), - 684
); - 685
} - 686
if manifest.publisher.is_none() { - 687
warnings.push("no publisher identity declared".into()); - 688
} - 689
Ok(PackageInspection { - 690
manifest, - 691
format, - 692
root, - 693
digest, - 694
file_count: files.len(), - 695
total_bytes, - 696
files, - 697
capabilities, - 698
warnings, - 699
}) - 700
} - 701
- 702
fn load_manifest( - 703
root: &Path, - 704
) -> Result<(PluginManifest, ManifestFormat, Vec<String>), PluginError> { - 705
let native_path = root.join("vak-plugin.json"); - 706
if native_path.is_file() { - 707
let bytes = fs::read(&native_path).map_err(|error| io_error(&native_path, error))?; - 708
let manifest = serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 709
path: native_path, - 710
source, - 711
})?; - 712
return Ok((manifest, ManifestFormat::Vak, Vec::new())); - 713
} - 714
- 715
let codex_path = root.join(".codex-plugin/plugin.json"); - 716
if codex_path.is_file() { - 717
let bytes = fs::read(&codex_path).map_err(|error| io_error(&codex_path, error))?; - 718
let value: serde_json::Value = - 719
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 720
path: codex_path, - 721
source, - 722
})?; - 723
return normalize_client_manifest( - 724
root, - 725
&value, - 726
ManifestFormat::Codex, - 727
".codex-plugin/plugin.json", - 728
); - 729
} - 730
- 731
for (relative, format) in [ - 732
(".claude-plugin/plugin.json", ManifestFormat::Claude), - 733
(".cursor-plugin/plugin.json", ManifestFormat::Cursor), - 734
(".github/plugin/plugin.json", ManifestFormat::Copilot), - 735
(".plugin/plugin.json", ManifestFormat::Copilot), - 736
] { - 737
let path = root.join(relative); - 738
if path.is_file() { - 739
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 740
let value = serde_json::from_slice(&bytes) - 741
.map_err(|source| PluginError::Json { path, source })?; - 742
return normalize_client_manifest(root, &value, format, relative); - 743
} - 744
} - 745
- 746
let agent_path = root.join("plugin.json"); - 747
if agent_path.is_file() { - 748
let bytes = fs::read(&agent_path).map_err(|error| io_error(&agent_path, error))?; - 749
let value: serde_json::Value = - 750
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 751
path: agent_path, - 752
source, - 753
})?; - 754
let format = if value.get("$schema").and_then(serde_json::Value::as_str) - 755
== Some("https://agent-plugins.org/schemas/1.0.0/plugin.schema.json") - 756
{ - 757
ManifestFormat::AgentPlugin - 758
} else { - 759
ManifestFormat::Copilot - 760
}; - 761
return normalize_client_manifest(root, &value, format, "plugin.json"); - 762
} - 763
- 764
let gemini_path = root.join("gemini-extension.json"); - 765
if gemini_path.is_file() { - 766
let bytes = fs::read(&gemini_path).map_err(|error| io_error(&gemini_path, error))?; - 767
let value = serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 768
path: gemini_path, - 769
source, - 770
})?; - 771
return normalize_client_manifest( - 772
root, - 773
&value, - 774
ManifestFormat::Gemini, - 775
"gemini-extension.json", - 776
); - 777
} - 778
- 779
let skill_path = root.join("SKILL.md"); - 780
if skill_path.is_file() { - 781
let (name, description) = parse_skill_header(&skill_path)?; - 782
return Ok(( - 783
PluginManifest { - 784
schema: REGISTRY_SCHEMA, - 785
name, - 786
version: "0.0.0+local".into(), - 787
description, - 788
license: None, - 789
publisher: None, - 790
homepage: None, - 791
components: Components { - 792
skills: vec![".".into()], - 793
..Components::default() - 794
}, - 795
}, - 796
ManifestFormat::AgentSkill, - 797
vec!["standalone Agent Skill normalized as a local skills-only plugin".into()], - 798
)); - 799
} - 800
- 801
Err(PluginError::ManifestMissing(root.to_path_buf())) - 802
} - 803
- 804
fn normalize_client_manifest( - 805
root: &Path, - 806
value: &serde_json::Value, - 807
format: ManifestFormat, - 808
manifest_path: &str, - 809
) -> Result<(PluginManifest, ManifestFormat, Vec<String>), PluginError> { - 810
let object = value - 811
.as_object() - 812
.ok_or_else(|| PluginError::InvalidManifest("plugin manifest must be an object".into()))?; - 813
if format == ManifestFormat::AgentPlugin { - 814
let schema = object.get("$schema").and_then(serde_json::Value::as_str); - 815
if schema != Some("https://agent-plugins.org/schemas/1.0.0/plugin.schema.json") { - 816
return Err(PluginError::InvalidManifest( - 817
"Agent Plugins 1.0 requires its canonical $schema".into(), - 818
)); - 819
} - 820
} - 821
let name = required_string(object, "name")?; - 822
let source_version = object - 823
.get("version") - 824
.and_then(serde_json::Value::as_str) - 825
.unwrap_or("0.0.0+local") - 826
.to_string(); - 827
let (version, version_warning) = normalize_external_version(&source_version); - 828
let description = object - 829
.get("description") - 830
.and_then(serde_json::Value::as_str) - 831
.unwrap_or("") - 832
.to_string(); - 833
let license = object - 834
.get("license") - 835
.and_then(serde_json::Value::as_str) - 836
.map(str::to_string); - 837
let homepage = object - 838
.get("homepage") - 839
.and_then(serde_json::Value::as_str) - 840
.map(str::to_string); - 841
let publisher = normalize_publisher(object.get("publisher").or_else(|| object.get("author")))?; - 842
let mut components = Components { - 843
skills: string_or_strings(object.get("skills"), "skills")?, - 844
commands: string_or_strings(object.get("commands"), "commands")?, - 845
mcp: string_or_strings(object.get("mcp"), "mcp")?, - 846
hooks: paths_or_inline(object.get("hooks"), "hooks", manifest_path)?, - 847
agents: string_or_strings(object.get("agents"), "agents")?, - 848
rules: string_or_strings(object.get("rules"), "rules")?, - 849
lsp: paths_or_inline(object.get("lspServers"), "lspServers", manifest_path)?, - 850
policies: string_or_strings(object.get("policies"), "policies")?, - 851
themes: paths_or_inline(object.get("themes"), "themes", manifest_path)?, - 852
presentation: string_or_strings(object.get("presentation"), "presentation")?, - 853
assets: string_or_strings(object.get("assets"), "assets")?, - 854
}; - 855
if object.get("mcpServers").is_some() { - 856
components.mcp.push(manifest_path.to_string()); - 857
} - 858
if format == ManifestFormat::AgentPlugin { - 859
components = Components::default(); - 860
add_conventional_component(root, "skills", &mut components.skills); - 861
add_conventional_component(root, "mcp.json", &mut components.mcp); - 862
} - 863
add_conventional_component(root, "skills", &mut components.skills); - 864
add_conventional_component(root, "commands", &mut components.commands); - 865
add_conventional_component(root, ".mcp.json", &mut components.mcp); - 866
add_conventional_component(root, ".github/mcp.json", &mut components.mcp); - 867
add_conventional_component(root, "mcp.json", &mut components.mcp); - 868
add_conventional_component(root, "hooks.json", &mut components.hooks); - 869
add_conventional_component(root, "hooks/hooks.json", &mut components.hooks); - 870
add_conventional_component(root, "agents", &mut components.agents); - 871
add_conventional_component(root, "rules", &mut components.rules); - 872
add_conventional_component(root, "lsp.json", &mut components.lsp); - 873
add_conventional_component(root, ".github/lsp.json", &mut components.lsp); - 874
add_conventional_component(root, "policies", &mut components.policies); - 875
add_conventional_component(root, "themes", &mut components.themes); - 876
add_conventional_component(root, "presentation", &mut components.presentation); - 877
add_conventional_component(root, "assets", &mut components.assets); - 878
let known: BTreeSet<&str> = [ - 879
"name", - 880
"version", - 881
"description", - 882
"license", - 883
"publisher", - 884
"author", - 885
"homepage", - 886
"skills", - 887
"commands", - 888
"mcp", - 889
"mcpServers", - 890
"hooks", - 891
"agents", - 892
"rules", - 893
"lspServers", - 894
"policies", - 895
"themes", - 896
"presentation", - 897
"assets", - 898
"apps", - 899
"$schema", - 900
"extensions", - 901
"category", - 902
"tags", - 903
"variables", - 904
"settings", - 905
"contextFileName", - 906
"excludeTools", - 907
"migratedTo", - 908
"plan", - 909
] - 910
.into_iter() - 911
.collect(); - 912
let unknown = object - 913
.keys() - 914
.filter(|key| !known.contains(key.as_str())) - 915
.cloned() - 916
.collect::<Vec<_>>(); - 917
let mut warnings = if unknown.is_empty() { - 918
Vec::new() - 919
} else { - 920
vec![format!( - 921
"ignored unsupported declarative plugin manifest keys: {}", - 922
unknown.join(", ") - 923
)] - 924
}; - 925
if let Some(warning) = version_warning { - 926
warnings.push(warning); - 927
} - 928
Ok(( - 929
PluginManifest { - 930
schema: REGISTRY_SCHEMA, - 931
name, - 932
version, - 933
description, - 934
license, - 935
publisher, - 936
homepage, - 937
components, - 938
}, - 939
format, - 940
warnings, - 941
)) - 942
} - 943
- 944
fn normalize_external_version(source: &str) -> (String, Option<String>) { - 945
if Version::parse(source).is_ok() { - 946
return (source.to_string(), None); - 947
} - 948
let digest = Sha256::digest(source.as_bytes()); - 949
let normalized = format!("0.0.0+source.{}", &hex::encode(digest)[..12]); - 950
( - 951
normalized.clone(), - 952
Some(format!( - 953
"source version {source:?} is not semantic; normalized internally as {normalized}" - 954
)), - 955
) - 956
} - 957
- 958
fn required_string( - 959
object: &serde_json::Map<String, serde_json::Value>, - 960
field: &str, - 961
) -> Result<String, PluginError> { - 962
object - 963
.get(field) - 964
.and_then(serde_json::Value::as_str) - 965
.filter(|value| !value.trim().is_empty()) - 966
.map(str::to_string) - 967
.ok_or_else(|| PluginError::InvalidManifest(format!("missing non-empty '{field}'"))) - 968
} - 969
- 970
fn normalize_publisher( - 971
value: Option<&serde_json::Value>, - 972
) -> Result<Option<Publisher>, PluginError> { - 973
let Some(value) = value else { - 974
return Ok(None); - 975
}; - 976
if let Some(name) = value.as_str() { - 977
let id = normalize_id(name).ok_or_else(|| { - 978
PluginError::InvalidManifest("publisher string cannot form a stable id".into()) - 979
})?; - 980
return Ok(Some(Publisher { - 981
id, - 982
name: name.to_string(), - 983
url: None, - 984
})); - 985
} - 986
let object = value.as_object().ok_or_else(|| { - 987
PluginError::InvalidManifest("publisher must be a string or object".into()) - 988
})?; - 989
let name = required_string(object, "name")?; - 990
let id = object - 991
.get("id") - 992
.and_then(serde_json::Value::as_str) - 993
.and_then(normalize_id) - 994
.or_else(|| normalize_id(&name)) - 995
.ok_or_else(|| PluginError::InvalidManifest("publisher needs a valid id".into()))?; - 996
let url = object - 997
.get("url") - 998
.and_then(serde_json::Value::as_str) - 999
.map(str::to_string); - 1000
Ok(Some(Publisher { id, name, url })) - 1001
} - 1002
- 1003
fn string_or_strings( - 1004
value: Option<&serde_json::Value>, - 1005
field: &str, - 1006
) -> Result<Vec<String>, PluginError> { - 1007
let Some(value) = value else { - 1008
return Ok(Vec::new()); - 1009
}; - 1010
if let Some(value) = value.as_str() { - 1011
return Ok(vec![value.to_string()]); - 1012
} - 1013
value - 1014
.as_array() - 1015
.ok_or_else(|| { - 1016
PluginError::InvalidManifest(format!("'{field}' must be a string or string array")) - 1017
})? - 1018
.iter() - 1019
.map(|item| { - 1020
item.as_str().map(str::to_string).ok_or_else(|| { - 1021
PluginError::InvalidManifest(format!("'{field}' contains a non-string path")) - 1022
}) - 1023
}) - 1024
.collect() - 1025
} - 1026
- 1027
fn paths_or_inline( - 1028
value: Option<&serde_json::Value>, - 1029
field: &str, - 1030
manifest_path: &str, - 1031
) -> Result<Vec<String>, PluginError> { - 1032
let Some(value) = value else { - 1033
return Ok(Vec::new()); - 1034
}; - 1035
if value.is_object() - 1036
|| value - 1037
.as_array() - 1038
.is_some_and(|items| items.iter().any(|item| !item.is_string())) - 1039
{ - 1040
return Ok(vec![manifest_path.to_string()]); - 1041
} - 1042
string_or_strings(Some(value), field) - 1043
} - 1044
- 1045
fn add_conventional_component(root: &Path, relative: &str, values: &mut Vec<String>) { - 1046
if root.join(relative).exists() && !values.iter().any(|value| value == relative) { - 1047
values.push(relative.to_string()); - 1048
} - 1049
} - 1050
- 1051
fn normalize_and_validate_manifest( - 1052
manifest: &mut PluginManifest, - 1053
root: &Path, - 1054
) -> Result<(), PluginError> { - 1055
manifest.name = normalize_plugin_id(&manifest.name).ok_or_else(|| { - 1056
PluginError::InvalidManifest( - 1057
"name must use lowercase letters, digits, dashes, or non-repeated dots".into(), - 1058
) - 1059
})?; - 1060
Version::parse(&manifest.version).map_err(|error| { - 1061
PluginError::InvalidManifest(format!( - 1062
"version '{}' is not semver: {error}", - 1063
manifest.version - 1064
)) - 1065
})?; - 1066
if manifest.schema > REGISTRY_SCHEMA { - 1067
return Err(PluginError::InvalidManifest(format!( - 1068
"schema {} is newer than supported schema {REGISTRY_SCHEMA}", - 1069
manifest.schema - 1070
))); - 1071
} - 1072
for (kind, declared) in manifest.components.declared_paths() { - 1073
let relative = validate_relative_path(declared)?; - 1074
let path = root.join(relative); - 1075
if !path.exists() { - 1076
return Err(PluginError::InvalidManifest(format!( - 1077
"declared {kind} path does not exist: {declared}" - 1078
))); - 1079
} - 1080
} - 1081
Ok(()) - 1082
} - 1083
- 1084
fn normalize_id(value: &str) -> Option<String> { - 1085
let value = value.trim(); - 1086
if value.is_empty() - 1087
|| value.starts_with('-') - 1088
|| value.ends_with('-') - 1089
|| !value.chars().all(|character| { - 1090
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' - 1091
}) - 1092
{ - 1093
return None; - 1094
} - 1095
Some(value.to_string()) - 1096
} - 1097
- 1098
fn normalize_plugin_id(value: &str) -> Option<String> { - 1099
let value = value.trim(); - 1100
if value.is_empty() - 1101
|| value.len() > 64 - 1102
|| !value - 1103
.chars() - 1104
.next() - 1105
.is_some_and(|character| character.is_ascii_alphanumeric()) - 1106
|| !value - 1107
.chars() - 1108
.last() - 1109
.is_some_and(|character| character.is_ascii_alphanumeric()) - 1110
|| value.contains("--") - 1111
|| value.contains("..") - 1112
|| !value.chars().all(|character| { - 1113
character.is_ascii_lowercase() - 1114
|| character.is_ascii_digit() - 1115
|| character == '-' - 1116
|| character == '.' - 1117
}) - 1118
{ - 1119
return None; - 1120
} - 1121
Some(value.to_string()) - 1122
} - 1123
- 1124
fn validate_relative_path(value: &str) -> Result<&Path, PluginError> { - 1125
let path = Path::new(value); - 1126
if value.is_empty() || path.is_absolute() { - 1127
return Err(PluginError::UnsafePackage(format!( - 1128
"component path must be relative: {value:?}" - 1129
))); - 1130
} - 1131
for component in path.components() { - 1132
if !matches!(component, Component::Normal(_) | Component::CurDir) { - 1133
return Err(PluginError::UnsafePackage(format!( - 1134
"component path escapes package root: {value:?}" - 1135
))); - 1136
} - 1137
} - 1138
Ok(path) - 1139
} - 1140
- 1141
fn collect_files(root: &Path, limits: InspectLimits) -> Result<Vec<PackageFile>, PluginError> { - 1142
let mut files = Vec::new(); - 1143
let mut total_bytes = 0_u64; - 1144
for entry in WalkDir::new(root).follow_links(false) { - 1145
let entry = entry.map_err(|error| { - 1146
PluginError::UnsafePackage(format!("could not walk package: {error}")) - 1147
})?; - 1148
let depth = entry.depth(); - 1149
if depth > limits.max_depth { - 1150
return Err(PluginError::LimitExceeded(format!( - 1151
"path depth {depth} exceeds {} at {}", - 1152
limits.max_depth, - 1153
entry.path().display() - 1154
))); - 1155
} - 1156
if depth == 0 { - 1157
continue; - 1158
} - 1159
let metadata = - 1160
fs::symlink_metadata(entry.path()).map_err(|error| io_error(entry.path(), error))?; - 1161
if metadata.file_type().is_symlink() { - 1162
return Err(PluginError::UnsafePackage(format!( - 1163
"symbolic links are not allowed: {}", - 1164
entry.path().display() - 1165
))); - 1166
} - 1167
if metadata.is_dir() { - 1168
continue; - 1169
} - 1170
if !metadata.is_file() { - 1171
return Err(PluginError::UnsafePackage(format!( - 1172
"special files are not allowed: {}", - 1173
entry.path().display() - 1174
))); - 1175
} - 1176
reject_hard_link(entry.path(), &metadata)?; - 1177
if metadata.len() > limits.max_file_bytes { - 1178
return Err(PluginError::LimitExceeded(format!( - 1179
"file {} is {} bytes; maximum is {}", - 1180
entry.path().display(), - 1181
metadata.len(), - 1182
limits.max_file_bytes - 1183
))); - 1184
} - 1185
total_bytes = total_bytes - 1186
.checked_add(metadata.len()) - 1187
.ok_or_else(|| PluginError::LimitExceeded("expanded size overflow".into()))?; - 1188
if total_bytes > limits.max_total_bytes { - 1189
return Err(PluginError::LimitExceeded(format!( - 1190
"expanded size {total_bytes} exceeds {} bytes", - 1191
limits.max_total_bytes - 1192
))); - 1193
} - 1194
if files.len() >= limits.max_files { - 1195
return Err(PluginError::LimitExceeded(format!( - 1196
"file count exceeds {}", - 1197
limits.max_files - 1198
))); - 1199
} - 1200
let relative = entry.path().strip_prefix(root).map_err(|_| { - 1201
PluginError::UnsafePackage(format!( - 1202
"walked path escaped package root: {}", - 1203
entry.path().display() - 1204
)) - 1205
})?; - 1206
files.push(PackageFile { - 1207
path: portable_path(relative)?, - 1208
bytes: metadata.len(), - 1209
executable: is_executable(&metadata), - 1210
}); - 1211
} - 1212
files.sort_by(|left, right| left.path.cmp(&right.path)); - 1213
if files.is_empty() { - 1214
return Err(PluginError::InvalidManifest( - 1215
"package contains no files".into(), - 1216
)); - 1217
} - 1218
Ok(files) - 1219
} - 1220
- 1221
#[cfg(unix)] - 1222
fn reject_hard_link(path: &Path, metadata: &fs::Metadata) -> Result<(), PluginError> { - 1223
use std::os::unix::fs::MetadataExt as _; - 1224
if metadata.nlink() > 1 { - 1225
return Err(PluginError::UnsafePackage(format!( - 1226
"hard-linked files are not allowed: {}", - 1227
path.display() - 1228
))); - 1229
} - 1230
Ok(()) - 1231
} - 1232
- 1233
#[cfg(not(unix))] - 1234
fn reject_hard_link(_path: &Path, _metadata: &fs::Metadata) -> Result<(), PluginError> { - 1235
Ok(()) - 1236
} - 1237
- 1238
#[cfg(unix)] - 1239
fn is_executable(metadata: &fs::Metadata) -> bool { - 1240
use std::os::unix::fs::PermissionsExt as _; - 1241
metadata.permissions().mode() & 0o111 != 0 - 1242
} - 1243
- 1244
#[cfg(not(unix))] - 1245
fn is_executable(_metadata: &fs::Metadata) -> bool { - 1246
false - 1247
} - 1248
- 1249
fn portable_path(path: &Path) -> Result<String, PluginError> { - 1250
let mut parts = Vec::new(); - 1251
for component in path.components() { - 1252
match component { - 1253
Component::Normal(value) => parts.push(value.to_string_lossy().into_owned()), - 1254
Component::CurDir => {} - 1255
_ => { - 1256
return Err(PluginError::UnsafePackage(format!( - 1257
"invalid relative path: {}", - 1258
path.display() - 1259
))); - 1260
} - 1261
} - 1262
} - 1263
Ok(parts.join("/")) - 1264
} - 1265
- 1266
fn digest_files(root: &Path, files: &[PackageFile]) -> Result<String, PluginError> { - 1267
let mut digest = Sha256::new(); - 1268
for file in files { - 1269
let path_bytes = file.path.as_bytes(); - 1270
digest.update((path_bytes.len() as u64).to_le_bytes()); - 1271
digest.update(path_bytes); - 1272
digest.update(file.bytes.to_le_bytes()); - 1273
let path = root.join(path_from_portable(&file.path)); - 1274
let mut input = File::open(&path).map_err(|error| io_error(&path, error))?; - 1275
let mut buffer = [0_u8; 64 * 1024]; - 1276
loop { - 1277
let read = input - 1278
.read(&mut buffer) - 1279
.map_err(|error| io_error(&path, error))?; - 1280
if read == 0 { - 1281
break; - 1282
} - 1283
digest.update(&buffer[..read]); - 1284
} - 1285
} - 1286
Ok(hex::encode(digest.finalize())) - 1287
} - 1288
- 1289
fn path_from_portable(path: &str) -> PathBuf { - 1290
path.split('/').collect() - 1291
} - 1292
- 1293
fn inventory_capabilities( - 1294
root: &Path, - 1295
components: &Components, - 1296
files: &[PackageFile], - 1297
) -> Result<CapabilityInventory, PluginError> { - 1298
let mut inventory = CapabilityInventory::default(); - 1299
for skill_root in &components.skills { - 1300
let relative = validate_relative_path(skill_root)?; - 1301
let absolute = root.join(relative); - 1302
if absolute.join("SKILL.md").is_file() { - 1303
let (name, _) = parse_skill_header(&absolute.join("SKILL.md"))?; - 1304
inventory.skills.push(name); - 1305
} else if absolute.is_dir() { - 1306
let mut names = fs::read_dir(&absolute) - 1307
.map_err(|error| io_error(&absolute, error))? - 1308
.filter_map(Result::ok) - 1309
.filter(|entry| entry.path().join("SKILL.md").is_file()) - 1310
.map(|entry| { - 1311
parse_skill_header(&entry.path().join("SKILL.md")).map(|header| header.0) - 1312
}) - 1313
.collect::<Result<Vec<_>, _>>()?; - 1314
inventory.skills.append(&mut names); - 1315
} - 1316
} - 1317
inventory.skills.sort(); - 1318
inventory.skills.dedup(); - 1319
inventory.commands = component_files(root, &components.commands, Some("md"))?; - 1320
inventory.mcp_manifests = component_files(root, &components.mcp, None)?; - 1321
inventory.hooks = component_files(root, &components.hooks, None)?; - 1322
inventory.agents = component_files(root, &components.agents, Some("md"))?; - 1323
inventory.rules = component_files(root, &components.rules, None)?; - 1324
inventory.lsp_manifests = component_files(root, &components.lsp, None)?; - 1325
inventory.policies = component_files(root, &components.policies, Some("toml"))?; - 1326
inventory.themes = component_files(root, &components.themes, None)?; - 1327
inventory.presentation = component_files(root, &components.presentation, None)?; - 1328
inventory.assets = component_files(root, &components.assets, None)?; - 1329
for file in files { - 1330
let path = Path::new(&file.path); - 1331
if path - 1332
.components() - 1333
.any(|component| matches!(component, Component::Normal(value) if value == "scripts")) - 1334
{ - 1335
inventory.scripts.push(file.path.clone()); - 1336
} - 1337
if file.executable { - 1338
inventory.executables.push(file.path.clone()); - 1339
} - 1340
} - 1341
Ok(inventory) - 1342
} - 1343
- 1344
fn component_files( - 1345
root: &Path, - 1346
declared: &[String], - 1347
extension: Option<&str>, - 1348
) -> Result<Vec<String>, PluginError> { - 1349
let mut output = Vec::new(); - 1350
for value in declared { - 1351
let path = root.join(validate_relative_path(value)?); - 1352
if path.is_file() { - 1353
output.push(portable_path(path.strip_prefix(root).map_err(|_| { - 1354
PluginError::UnsafePackage(format!("component escaped root: {}", path.display())) - 1355
})?)?); - 1356
continue; - 1357
} - 1358
for entry in WalkDir::new(&path).follow_links(false) { - 1359
let entry = entry.map_err(|error| { - 1360
PluginError::UnsafePackage(format!("could not inventory component: {error}")) - 1361
})?; - 1362
if !entry.file_type().is_file() - 1363
|| extension.is_some_and(|expected| { - 1364
entry - 1365
.path() - 1366
.extension() - 1367
.is_none_or(|actual| actual != expected) - 1368
}) - 1369
{ - 1370
continue; - 1371
} - 1372
output.push(portable_path(entry.path().strip_prefix(root).map_err( - 1373
|_| { - 1374
PluginError::UnsafePackage(format!( - 1375
"component escaped root: {}", - 1376
entry.path().display() - 1377
)) - 1378
}, - 1379
)?)?); - 1380
} - 1381
} - 1382
output.sort(); - 1383
output.dedup(); - 1384
Ok(output) - 1385
} - 1386
- 1387
fn parse_skill_header(path: &Path) -> Result<(String, String), PluginError> { - 1388
let text = fs::read_to_string(path).map_err(|error| io_error(path, error))?; - 1389
let rest = text.strip_prefix("---").ok_or_else(|| { - 1390
PluginError::InvalidManifest(format!("skill has no YAML frontmatter: {}", path.display())) - 1391
})?; - 1392
let (frontmatter, _) = rest.split_once("---").ok_or_else(|| { - 1393
PluginError::InvalidManifest(format!( - 1394
"skill frontmatter is not closed: {}", - 1395
path.display() - 1396
)) - 1397
})?; - 1398
let mut name = None; - 1399
let mut description = String::new(); - 1400
for line in frontmatter.lines() { - 1401
let line = line.trim(); - 1402
if let Some(value) = line.strip_prefix("name:") { - 1403
name = Some(value.trim().trim_matches(['\'', '"']).to_string()); - 1404
} else if let Some(value) = line.strip_prefix("description:") { - 1405
description = value.trim().trim_matches(['\'', '"']).to_string(); - 1406
} - 1407
} - 1408
let name = name - 1409
.or_else(|| { - 1410
path.parent() - 1411
.and_then(Path::file_name) - 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,
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.