- 322
let mut warnings = Vec::new(); - 323
for (index, plugin) in plugins.iter().enumerate() { - 324
let plugin = plugin.as_object().ok_or_else(|| { - 325
PluginError::InvalidManifest(format!( - 326
"marketplace plugin entry {index} must be an object" - 327
)) - 328
})?; - 329
let entry_name = required_string(plugin, "name")?; - 330
let entry_name = normalize_plugin_id(&entry_name).ok_or_else(|| { - 331
PluginError::InvalidManifest(format!( - 332
"marketplace plugin entry {index} has an invalid name" - 333
)) - 334
})?; - 335
if !names.insert(entry_name.clone()) { - 336
return Err(PluginError::InvalidManifest(format!( - 337
"marketplace repeats plugin name {entry_name:?}" - 338
))); - 339
} - 340
let source = plugin.get("source").ok_or_else(|| { - 341
PluginError::InvalidManifest(format!("marketplace plugin {entry_name:?} has no source")) - 342
})?; - 343
validate_catalog_source(source, &entry_name)?; - 344
let license = optional_string(plugin, "license")?; - 345
if license.is_none() { - 346
warnings.push(format!( - 347
"plugin {entry_name:?} declares no license; catalog presence grants no redistribution right" - 348
)); - 349
} - 350
entries.push(CatalogEntry { - 351
name: entry_name, - 352
source: source.clone(), - 353
version: optional_string(plugin, "version")?, - 354
description: optional_string(plugin, "description")?, - 355
license, - 356
}); - 357
} - 358
let digest = hex::encode(Sha256::digest(&bytes)); - 359
let trace_id = format!("catalog:{name}:{digest}"); - 360
Ok(CatalogInspection { - 361
name, - 362
format, - 363
path, - 364
digest, - 365
trace_id, - 366
entries, - 367
warnings, - 368
}) - 369
} - 370
- 371
/// Materialize one catalog entry without running any repository content. - 372
/// Local paths are confined to the catalog root; remote entries must resolve - 373
/// to an HTTPS Git repository and a full commit SHA before `git` is invoked. - 374
pub fn materialize_catalog_entry( - 375
catalog_root: &Path, - 376
entry: &CatalogEntry, - 377
destination_root: &Path, - 378
) -> Result<PathBuf, PluginError> { - 379
let source = &entry.source; - 380
if let Some(path) = source.as_str() { - 381
if path.starts_with("https://") || path.starts_with("git@") { - 382
return Err(PluginError::UnsafePackage(format!( - 383
"catalog entry {:?} remote sources require an object with a pinned commit sha", - 384
entry.name - 385
))); - 386
} - 387
let candidate = catalog_root.join(path); - 388
let canonical = - 389
fs::canonicalize(&candidate).map_err(|error| io_error(&candidate, error))?; - 390
let catalog = - 391
fs::canonicalize(catalog_root).map_err(|error| io_error(catalog_root, error))?; - 392
if !canonical.starts_with(&catalog) { - 393
return Err(PluginError::UnsafePackage(format!( - 394
"catalog entry {:?} escapes its catalog root", - 395
entry.name - 396
))); - 397
} - 398
return Ok(canonical); - 399
} - 400
let object = source.as_object().ok_or_else(|| { - 401
PluginError::InvalidManifest(format!( - 402
"catalog entry {:?} has an invalid source", - 403
entry.name - 404
)) - 405
})?; - 406
if let Some(path) = object.get("path").and_then(serde_json::Value::as_str) { - 407
let candidate = catalog_root.join(path); - 408
let canonical = - 409
fs::canonicalize(&candidate).map_err(|error| io_error(&candidate, error))?; - 410
let catalog = - 411
fs::canonicalize(catalog_root).map_err(|error| io_error(catalog_root, error))?; - 412
if !canonical.starts_with(&catalog) { - 413
return Err(PluginError::UnsafePackage(format!( - 414
"catalog entry {:?} escapes its catalog root", - 415
entry.name - 416
))); - 417
} - 418
return Ok(canonical); - 419
} - 420
let sha = object - 421
.get("sha") - 422
.and_then(serde_json::Value::as_str) - 423
.filter(|sha| sha.len() == 40 && sha.bytes().all(|byte| byte.is_ascii_hexdigit())) - 424
.ok_or_else(|| { - 425
PluginError::UnsafePackage(format!( - 426
"catalog entry {:?} needs a full pinned commit sha", - 427
entry.name - 428
)) - 429
})?; - 430
let url = if let Some(repo) = object.get("repo").and_then(serde_json::Value::as_str) { - 431
if !repo.contains('/') || repo.starts_with('/') || repo.contains("..") { - 432
return Err(PluginError::UnsafePackage(format!( - 433
"catalog entry {:?} has an invalid repository", - 434
entry.name - 435
))); - 436
} - 437
format!("https://github.com/{repo}.git") - 438
} else { - 439
object - 440
.get("url") - 441
.or_else(|| object.get("source")) - 442
.and_then(serde_json::Value::as_str) - 443
.filter(|url| url.starts_with("https://")) - 444
.ok_or_else(|| { - 445
PluginError::UnsafePackage(format!( - 446
"catalog entry {:?} only supports HTTPS Git URLs", - 447
entry.name - 448
)) - 449
})? - 450
.to_string() - 451
}; - 452
fs::create_dir_all(destination_root).map_err(|error| io_error(destination_root, error))?; - 453
let destination = destination_root.join(format!("{}-{sha}", entry.name)); - 454
if destination.exists() { - 455
let checked = git_checked_out_commit(&destination)?; - 456
if checked == sha { - 457
return Ok(destination); - 458
} - 459
return Err(PluginError::UnsafePackage(format!( - 460
"existing catalog checkout has unexpected commit: {}", - 461
destination.display() - 462
))); - 463
} - 464
let clone = std::process::Command::new("git") - 465
.args(["clone", "--filter=blob:none", "--no-checkout", "--", &url]) - 466
.arg(&destination) - 467
.output() - 468
.map_err(|error| io_error(&destination, error))?; - 469
if !clone.status.success() { - 470
return Err(PluginError::UnsafePackage(format!( - 471
"git clone failed for catalog entry {:?}: {}", - 472
entry.name, - 473
String::from_utf8_lossy(&clone.stderr).trim() - 474
))); - 475
} - 476
let fetch = std::process::Command::new("git") - 477
.args(["-C"]) - 478
.arg(&destination) - 479
.args(["fetch", "--depth=1", "origin", sha]) - 480
.output() - 481
.map_err(|error| io_error(&destination, error))?; - 482
if !fetch.status.success() { - 483
let _ = fs::remove_dir_all(&destination); - 484
return Err(PluginError::UnsafePackage(format!( - 485
"git fetch of pinned commit failed for {:?}: {}", - 486
entry.name, - 487
String::from_utf8_lossy(&fetch.stderr).trim() - 488
))); - 489
} - 490
let checkout = std::process::Command::new("git") - 491
.args(["-C"]) - 492
.arg(&destination) - 493
.args(["checkout", "--detach", sha]) - 494
.output() - 495
.map_err(|error| io_error(&destination, error))?; - 496
if !checkout.status.success() || git_checked_out_commit(&destination)? != sha { - 497
let _ = fs::remove_dir_all(&destination); - 498
return Err(PluginError::UnsafePackage(format!( - 499
"git checkout did not produce pinned commit for {:?}", - 500
entry.name - 501
))); - 502
} - 503
Ok(destination) - 504
} - 505
- 506
fn git_checked_out_commit(path: &Path) -> Result<String, PluginError> { - 507
let output = std::process::Command::new("git") - 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)?;
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.