From 2b48ae7bcf857bc35de95968513750c2d6e6de7b Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 22:47:33 +0900 Subject: fix(resolver): honor config.audit.block-insecure security-advisory filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mozart silently ignored the `security-advisories` block on inline `type: package` repositories and the `config.audit.block-insecure` audit flag, so a `composer update` succeeded with packages a Composer run would have refused to load. Mirror Composer's `SecurityAdvisoryPoolFilter` for the slice that feeds the pool: - Plumb a `security-advisories` field through `RawRepository` and a `block_insecure` flag through `ResolveRequest`, lifted off `composer.json`'s `config.audit.block-insecure`. - Collect every advisory's `affectedVersions` constraint at resolve time. When `block_insecure` is set and an inline package's normalized version satisfies the constraint, drop it from the pool before solving — root requires with no unaffected candidate then fail with the standard "could not be resolved" error. --- crates/mozart-core/src/package.rs | 14 ++++++ crates/mozart-registry/src/composer_repo.rs | 2 + crates/mozart-registry/src/inline_package.rs | 60 +++++++++++++++++++++++++ crates/mozart-registry/src/lockfile.rs | 1 + crates/mozart-registry/src/repository_filter.rs | 1 + crates/mozart-registry/src/resolver.rs | 32 ++++++++++++- crates/mozart/src/commands/create_project.rs | 1 + crates/mozart/src/commands/init.rs | 2 + crates/mozart/src/commands/remove.rs | 4 ++ crates/mozart/src/commands/require.rs | 3 ++ crates/mozart/src/commands/update.rs | 20 +++++++-- crates/mozart/tests/installer.rs | 12 ++--- 12 files changed, 138 insertions(+), 14 deletions(-) (limited to 'crates') diff --git a/crates/mozart-core/src/package.rs b/crates/mozart-core/src/package.rs index 0a5c0fb..18714ec 100644 --- a/crates/mozart-core/src/package.rs +++ b/crates/mozart-core/src/package.rs @@ -566,6 +566,19 @@ pub struct RawRepository { /// `FilterRepository::loadPackages`'s `namesFound = []` reset. #[serde(default, skip_serializing_if = "Option::is_none")] pub canonical: Option, + + /// Inline `security-advisories` block on a repository entry. Maps package + /// name → list of advisory objects whose `affectedVersions` constraint + /// (and `advisoryId`) is read by the resolver when + /// `config.audit.block-insecure` is set: matching versions are filtered + /// out of the pool before solving, mirroring Composer's + /// `SecurityAdvisoryPoolFilter`. + #[serde( + rename = "security-advisories", + default, + skip_serializing_if = "Option::is_none" + )] + pub security_advisories: Option, } /// Default root-package name when `composer.json` omits the `name` field. @@ -677,6 +690,7 @@ mod tests { only: None, exclude: None, canonical: None, + security_advisories: None, }]; let mut psr4 = BTreeMap::new(); diff --git a/crates/mozart-registry/src/composer_repo.rs b/crates/mozart-registry/src/composer_repo.rs index 6594668..ef091ef 100644 --- a/crates/mozart-registry/src/composer_repo.rs +++ b/crates/mozart-registry/src/composer_repo.rs @@ -120,6 +120,7 @@ mod tests { only: None, exclude: None, canonical: None, + security_advisories: None, } } @@ -157,6 +158,7 @@ mod tests { only: None, exclude: None, canonical: None, + security_advisories: None, }]; assert!(collect_composer_packages(&repos).is_empty()); } diff --git a/crates/mozart-registry/src/inline_package.rs b/crates/mozart-registry/src/inline_package.rs index 509193b..a7df987 100644 --- a/crates/mozart-registry/src/inline_package.rs +++ b/crates/mozart-registry/src/inline_package.rs @@ -77,6 +77,64 @@ pub fn collect_inline_packages(repositories: &[RawRepository]) -> Vec indexmap::IndexMap> { + let mut out: indexmap::IndexMap> = indexmap::IndexMap::new(); + for repo in repositories { + let Some(advisories) = &repo.security_advisories else { + continue; + }; + let Some(map) = advisories.as_object() else { + continue; + }; + for (pkg_name, list) in map { + let Some(arr) = list.as_array() else { + continue; + }; + for entry in arr { + let Some(obj) = entry.as_object() else { + continue; + }; + let Some(affected) = obj + .get("affectedVersions") + .and_then(|v| v.as_str()) + .map(String::from) + else { + continue; + }; + let advisory_id = obj + .get("advisoryId") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + out.entry(pkg_name.to_lowercase()) + .or_default() + .push(SecurityAdvisory { + advisory_id, + affected_versions: affected, + }); + } + } + } + out +} + fn parse_inline_package(value: &serde_json::Value) -> Option { let obj = value.as_object()?; let name = obj.get("name")?.as_str()?.to_string(); @@ -114,6 +172,7 @@ mod tests { only: None, exclude: None, canonical: None, + security_advisories: None, } } @@ -151,6 +210,7 @@ mod tests { only: None, exclude: None, canonical: None, + security_advisories: None, }]; assert!(collect_inline_packages(&repos).is_empty()); } diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index 197335f..98c0fdc 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -1681,6 +1681,7 @@ mod tests { block_abandoned: false, root_branch_alias: None, preferred_versions: IndexMap::new(), + block_insecure: false, }; let resolved = resolve(&resolve_request) diff --git a/crates/mozart-registry/src/repository_filter.rs b/crates/mozart-registry/src/repository_filter.rs index fcdcfeb..facbb36 100644 --- a/crates/mozart-registry/src/repository_filter.rs +++ b/crates/mozart-registry/src/repository_filter.rs @@ -81,6 +81,7 @@ mod tests { only, exclude, canonical, + security_advisories: None, } } diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index 9fb5cec..b323764 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -16,7 +16,7 @@ use mozart_sat_resolver::{ DefaultPolicy, PoolBuilder, PoolLink, PoolPackageInput, RuleSetGenerator, Solver, make_pool_links, }; -use mozart_semver::Version; +use mozart_semver::{Version, VersionConstraint}; // ───────────────────────────────────────────────────────────────────────────── // Version helpers @@ -757,6 +757,10 @@ pub struct ResolveRequest { /// a package when a constraint actually forces a different version. /// Empty for a normal full update. pub preferred_versions: IndexMap, + /// When true, drop versions the repositories advertise as covered by an + /// active security advisory before solving. Mirrors Composer's + /// `SecurityAdvisoryPoolFilter` under `config.audit.block-insecure: true`. + pub block_insecure: bool, } /// Full data for a lock-pinned package, used in partial updates. Carried on @@ -1150,6 +1154,28 @@ pub async fn resolve(request: &ResolveRequest) -> Result, R .or_default() .push(ipkg); } + // Build the security-advisory filter once. Mirrors Composer's + // `SecurityAdvisoryPoolFilter`: when `block-insecure` is on, every + // version listed by a repository's `security-advisories` is removed + // from the pool before solving. + let security_advisories = + crate::inline_package::collect_security_advisories(&request.raw_repositories); + let security_blocks_version = |name: &str, version_normalized: &str| -> bool { + if !request.block_insecure { + return false; + } + let Some(advisories) = security_advisories.get(&name.to_lowercase()) else { + return false; + }; + let Ok(parsed) = Version::parse(version_normalized) else { + return false; + }; + advisories.iter().any(|adv| { + VersionConstraint::parse(&adv.affected_versions) + .map(|c| c.matches(&parsed)) + .unwrap_or(false) + }) + }; let add_inline_for = |name: &str, builder: &mut PoolBuilder| -> bool { let Some(packages) = inline_packages_by_name.get(name) else { return false; @@ -1158,6 +1184,9 @@ pub async fn resolve(request: &ResolveRequest) -> Result, R if request.block_abandoned && is_abandoned(&ipkg.version) { continue; } + if security_blocks_version(&ipkg.name, &ipkg.version.version_normalized) { + continue; + } let inputs = packagist_to_pool_inputs( &ipkg.name, &ipkg.version, @@ -1861,6 +1890,7 @@ mod tests { block_abandoned: false, root_branch_alias: None, preferred_versions: IndexMap::new(), + block_insecure: false, }; let result = resolve(&request).await; diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index a7964ae..61cb886 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -445,6 +445,7 @@ pub async fn execute( block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; console.info("Resolving dependencies..."); diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs index 1a6df37..209be4b 100644 --- a/crates/mozart/src/commands/init.rs +++ b/crates/mozart/src/commands/init.rs @@ -707,6 +707,7 @@ fn parse_repositories(repos: &[String]) -> anyhow::Result> { only: None, exclude: None, canonical: None, + security_advisories: None, }); } else { // Plain URL @@ -717,6 +718,7 @@ fn parse_repositories(repos: &[String]) -> anyhow::Result> { only: None, exclude: None, canonical: None, + security_advisories: None, }); } } diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index c52d410..d4b3aef 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -279,6 +279,7 @@ pub async fn execute( block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; // Print header messages @@ -567,6 +568,7 @@ async fn remove_unused( block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; console.info("Resolving dependencies to detect unused packages..."); @@ -925,6 +927,7 @@ mod tests { block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; let resolved = resolve(&request) .await @@ -986,6 +989,7 @@ mod tests { block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; let resolved2 = resolve(&request2) .await diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 3ff5ced..b302ed9 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -667,6 +667,7 @@ pub async fn execute( block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; // Print header messages @@ -1079,6 +1080,7 @@ mod tests { block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; let resolved = resolver::resolve(&request) @@ -1158,6 +1160,7 @@ mod tests { block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), + block_insecure: false, }; let resolved = resolver::resolve(&request) diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 3736266..0439cfa 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -1249,6 +1249,17 @@ pub async fn run( .and_then(|a| a.get("block-abandoned")) .and_then(|v| v.as_bool()) .unwrap_or(false); + // Mirrors `Composer\Advisory\AuditConfig::fromConfig`: `block-insecure` + // turns the security-advisory data into a hard filter — affected + // versions are dropped from the pool, so a root require with no + // unaffected candidates fails resolution before any side effects. + let block_insecure = composer_json + .extra_fields + .get("config") + .and_then(|c| c.get("audit")) + .and_then(|a| a.get("block-insecure")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); // For `--minimal-changes`, feed the lock's pinned versions into the // resolver as preferred-version overrides. The packages the user @@ -1327,6 +1338,7 @@ pub async fn run( block_abandoned, root_branch_alias: extract_root_branch_alias(&composer_json), preferred_versions, + block_insecure, }; // Step 6: Print header and run resolver @@ -1496,10 +1508,9 @@ pub async fn run( // doesn't masquerade as a content update. When the source or dist type // changed (`hg` → `git`, etc.), the new entry is left as-is so the // change still emits the install-step Update operation. - if update_mirrors - && let Some(old) = &old_lock { - apply_mirror_ref_overrides(&mut new_lock, old); - } + if update_mirrors && let Some(old) = &old_lock { + apply_mirror_ref_overrides(&mut new_lock, old); + } // Step 10: Compute and print change report let changes = compute_update_changes(old_lock.as_ref(), &new_lock, dev_mode); @@ -2476,6 +2487,7 @@ mod tests { block_abandoned: false, root_branch_alias: None, preferred_versions: IndexMap::new(), + block_insecure: false, }; let resolved = resolve(&request).await.expect("Resolution should succeed"); diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index f304b72..198dd9f 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -294,10 +294,7 @@ installer_fixture!(partial_update_keeps_older_dep_if_still_required); installer_fixture!(partial_update_keeps_older_dep_if_still_required_with_provide); installer_fixture!(partial_update_loads_root_aliases_for_path_repos, ignore); installer_fixture!(partial_update_security_advisory_matching_locked_dep); -installer_fixture!( - partial_update_security_advisory_matching_locked_dep_with_dependencies, - ignore -); +installer_fixture!(partial_update_security_advisory_matching_locked_dep_with_dependencies); installer_fixture!(partial_update_with_dependencies_provide); installer_fixture!(partial_update_with_dependencies_replace); installer_fixture!(partial_update_with_deps_warns_root); @@ -402,11 +399,8 @@ installer_fixture!(update_reference); installer_fixture!(update_reference_picks_latest); installer_fixture!(update_removes_unused_locked_dep); installer_fixture!(update_requiring_decision_reverts_and_learning_positive_literals); -installer_fixture!(update_security_advisory_matching_direct_dependency, ignore); -installer_fixture!( - update_security_advisory_matching_indirect_dependency, - ignore -); +installer_fixture!(update_security_advisory_matching_direct_dependency); +installer_fixture!(update_security_advisory_matching_indirect_dependency); installer_fixture!(update_syncs_outdated); installer_fixture!(update_to_empty_from_blank); installer_fixture!(update_to_empty_from_locked); -- cgit v1.3.1