Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions crates/hypercolor-cli/src/install/payload/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
#[serde(rename = "agent_files")]
agents: u64,
#[serde(rename = "site_files")]
site: u64,
}

fn present_asset_count<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, 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(),
));
Expand Down Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions crates/hypercolor-cli/tests/install_payload_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down