diff --git a/crates/hypercolor-cli/src/install/payload/manifest.rs b/crates/hypercolor-cli/src/install/payload/manifest.rs index ed838be54..468b9974c 100644 --- a/crates/hypercolor-cli/src/install/payload/manifest.rs +++ b/crates/hypercolor-cli/src/install/payload/manifest.rs @@ -186,15 +186,32 @@ struct RawAssets { docs: u64, #[serde(rename = "skill_files")] skills: u64, + #[serde( + default, + rename = "user_skill_files", + deserialize_with = "present_asset_count" + )] + user_skills: Option, #[serde(rename = "agent_files")] agents: u64, #[serde(rename = "site_files")] site: u64, } +fn present_asset_count<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + u64::deserialize(deserializer).map(Some) +} + impl RawAssets { fn validate_minimums(&self) -> Result<(), ReleasePayloadError> { - if self.ui == 0 || self.bundled_effects == 0 || self.skills == 0 || self.agents == 0 { + if self.ui == 0 + || self.bundled_effects == 0 + || self.skills == 0 + || self.user_skills == Some(0) + || self.agents == 0 + { return Err(ReleasePayloadError::InvalidManifest( "required release asset counts must be nonzero".to_owned(), )); @@ -311,7 +328,11 @@ fn validate_asset_counts( ("share/hypercolor/agents/agents", assets.agents), ("share/hypercolor/site", assets.site), ]; - for (prefix, expected_count) in expected { + // Older releases predate the separate user-facing skills directory. + let user_skills = assets + .user_skills + .map(|count| ("share/hypercolor/skills", count)); + for (prefix, expected_count) in expected.into_iter().chain(user_skills) { if !members .get(prefix) .is_some_and(ValidatedMember::is_directory) diff --git a/crates/hypercolor-cli/tests/install_payload_tests.rs b/crates/hypercolor-cli/tests/install_payload_tests.rs index 035e268fd..9428fcc16 100644 --- a/crates/hypercolor-cli/tests/install_payload_tests.rs +++ b/crates/hypercolor-cli/tests/install_payload_tests.rs @@ -576,6 +576,76 @@ fn verified_manifest_digest_mismatch_fails_before_install_state_mutation() { assert_no_private_residue(&store); } +#[test] +fn user_skill_asset_count_validates_shipped_tree_and_preserves_legacy_manifests() { + let fixture = ReleaseFixture::new(); + validate_release_payload(fixture.path(), &fixture.candidate, &fixture.expected_unit()) + .expect("legacy manifest without separate user skills remains valid"); + + let root = "share/hypercolor/skills"; + let file = "share/hypercolor/skills/SKILL.md"; + fs::create_dir(fixture.path().join(root)).expect("create user skills root"); + fs::set_permissions(fixture.path().join(root), fs::Permissions::from_mode(0o755)) + .expect("set root mode"); + fs::write(fixture.path().join(file), b"user skill").expect("write user skill"); + fs::set_permissions(fixture.path().join(file), fs::Permissions::from_mode(0o644)) + .expect("set skill mode"); + let mut manifest = fixture.manifest_value(); + manifest["assets"]["user_skill_files"] = json!(1); + let members = manifest["members"].as_array_mut().expect("member list"); + members.push(json!({"path": root, "type": "directory", "mode": 0o755})); + members.push(json!({"path": file, "type": "file", "mode": 0o644, + "size": 10, "sha256": sha256(b"user skill")})); + members.sort_by(|left, right| left["path"].as_str().cmp(&right["path"].as_str())); + fixture.write_manifest(&manifest); + validate_release_payload(fixture.path(), &fixture.candidate, &fixture.expected_unit()) + .expect("current user skills manifest validates"); + + manifest["assets"]["user_skill_files"] = json!(2); + fixture.write_manifest(&manifest); + let error = + validate_release_payload(fixture.path(), &fixture.candidate, &fixture.expected_unit()) + .expect_err("incorrect user skill count must fail"); + assert!(error.to_string().contains("asset count is wrong")); +} + +#[test] +fn user_skill_asset_field_requires_a_root_and_rejects_null() { + for count in [json!(1), Value::Null] { + let fixture = ReleaseFixture::new(); + let mut manifest = fixture.manifest_value(); + manifest["assets"]["user_skill_files"] = count.clone(); + fixture.write_manifest(&manifest); + let error = + validate_release_payload(fixture.path(), &fixture.candidate, &fixture.expected_unit()) + .expect_err("present user skills count must be valid and have a root"); + if count.is_null() { + assert!(error.to_string().contains("strict JSON")); + } else { + assert!(error.to_string().contains("asset root is missing")); + } + } +} + +#[test] +fn user_skill_asset_field_rejects_an_empty_present_root() { + let fixture = ReleaseFixture::new(); + let root = "share/hypercolor/skills"; + fs::create_dir(fixture.path().join(root)).expect("create user skills root"); + fs::set_permissions(fixture.path().join(root), fs::Permissions::from_mode(0o755)) + .expect("set root mode"); + let mut manifest = fixture.manifest_value(); + manifest["assets"]["user_skill_files"] = json!(0); + let members = manifest["members"].as_array_mut().expect("member list"); + members.push(json!({"path": root, "type": "directory", "mode": 0o755})); + members.sort_by(|left, right| left["path"].as_str().cmp(&right["path"].as_str())); + fixture.write_manifest(&manifest); + let error = + validate_release_payload(fixture.path(), &fixture.candidate, &fixture.expected_unit()) + .expect_err("declared user skills must be nonempty like other required assets"); + assert!(error.to_string().contains("counts must be nonzero")); +} + #[test] fn release_preflight_validates_before_store_bootstrap() { let fixture = ReleaseFixture::new();