|
|
@@ -940,6 +940,27 @@ print(json.dumps({"not-found": [r for r in refs if r not in provided], "packages
|
|
|
PY
|
|
|
;;
|
|
|
plan)
|
|
|
+ # Optional exclusivity pair (USM_SPM_PLAN_CONFLICT_PAIR="a b"):
|
|
|
+ # when BOTH names appear in the requested set, fail the way the apk
|
|
|
+ # solver fails a dropbear+openssh mix — exit 3 (resolve) with an
|
|
|
+ # "unable to select packages" diagnostic. Provider-selection
|
|
|
+ # fixtures use this to model conflicts the query metadata never
|
|
|
+ # reported.
|
|
|
+ if [ -n "$USM_SPM_PLAN_CONFLICT_PAIR" ]; then
|
|
|
+ pair_a="${USM_SPM_PLAN_CONFLICT_PAIR%% *}"
|
|
|
+ pair_b="${USM_SPM_PLAN_CONFLICT_PAIR##* }"
|
|
|
+ have_a=0
|
|
|
+ have_b=0
|
|
|
+ for name in "$@"; do
|
|
|
+ if [ "$name" = "$pair_a" ]; then have_a=1; fi
|
|
|
+ if [ "$name" = "$pair_b" ]; then have_b=1; fi
|
|
|
+ done
|
|
|
+ if [ $have_a -eq 1 ] && [ $have_b -eq 1 ]; then
|
|
|
+ echo "ERROR: unable to select packages:" >&2
|
|
|
+ echo " $pair_a: conflicts: $pair_b" >&2
|
|
|
+ exit 3
|
|
|
+ fi
|
|
|
+ fi
|
|
|
# Every material package: dependencies first, chosen names after
|
|
|
printf '{"packages":['
|
|
|
first=1
|
|
|
@@ -2914,6 +2935,286 @@ echo "Installing libmeson-pkg.so to /usr/lib"
|
|
|
check(!Usm.TransactionInterrupt.requested, "restore clears the flag");
|
|
|
}
|
|
|
|
|
|
+ // ---- provider selection ----------------------------------------------------
|
|
|
+
|
|
|
+ /** One SPM query candidate for the selection fixtures. */
|
|
|
+ private Usm.SystemPackageCandidate selection_candidate(string name, string[] resources,
|
|
|
+ int dependency_count, int installed_count, string[] conflicts = {}) {
|
|
|
+ var candidate = new Usm.SystemPackageCandidate();
|
|
|
+ candidate.name = name;
|
|
|
+ candidate.resources = new Vector<Usm.ResourceRef>();
|
|
|
+ foreach(var resource in resources) {
|
|
|
+ candidate.resources.add(new Usm.ResourceRef(resource));
|
|
|
+ }
|
|
|
+ candidate.dependency_count = dependency_count;
|
|
|
+ candidate.installed_dependency_count = installed_count;
|
|
|
+ candidate.conflicts = new Vector<string>();
|
|
|
+ foreach(var conflict in conflicts) {
|
|
|
+ candidate.conflicts.add(conflict);
|
|
|
+ }
|
|
|
+ return candidate;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Usm.SystemQueryResult selection_query(Usm.SystemPackageCandidate[] candidates) {
|
|
|
+ var result = new Usm.SystemQueryResult();
|
|
|
+ result.not_found = new Vector<string>();
|
|
|
+ result.packages = new Vector<Usm.SystemPackageCandidate>();
|
|
|
+ foreach(var candidate in candidates) {
|
|
|
+ result.packages.add(candidate);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Vector<Usm.ResourceRef> selection_refs(string[] refs) {
|
|
|
+ var missing = new Vector<Usm.ResourceRef>();
|
|
|
+ foreach(var reference in refs) {
|
|
|
+ missing.add(new Usm.ResourceRef(reference));
|
|
|
+ }
|
|
|
+ return missing;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Whether a provider set mixes the dropbear and openssh families —
|
|
|
+ * the conflicting-union shape that kills apk transactions.
|
|
|
+ */
|
|
|
+ private static bool selection_mixes_families(Vector<string> chosen) {
|
|
|
+ var has_dropbear = false;
|
|
|
+ var has_openssh = false;
|
|
|
+ foreach(var name in chosen) {
|
|
|
+ has_dropbear = has_dropbear || name.has_prefix("dropbear");
|
|
|
+ has_openssh = has_openssh || name.has_prefix("openssh");
|
|
|
+ }
|
|
|
+ return has_dropbear && has_openssh;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The Alpine dropbear/openssh fixture: bin:ssh has three providers,
|
|
|
+ * bin:scp two, bin:rsync one, and the two families cannot coexist.
|
|
|
+ * The apk world declares the conflict asymmetrically (dropbear
|
|
|
+ * carries `!openssh-client`) — the fixtures mirror both shapes.
|
|
|
+ */
|
|
|
+ private Usm.SystemQueryResult dropbear_openssh_query(bool with_metadata) {
|
|
|
+ var openssh = new string[] { "openssh-client-common", "openssh-client-default", "openssh-client-krb5" };
|
|
|
+ var dropbear_conflicts = with_metadata ? openssh : new string[] {};
|
|
|
+ return selection_query({
|
|
|
+ selection_candidate("dropbear-ssh", { "bin:ssh" }, 6, 4, dropbear_conflicts),
|
|
|
+ selection_candidate("dropbear-scp", { "bin:scp" }, 6, 4, dropbear_conflicts),
|
|
|
+ selection_candidate("openssh-client-common", { "bin:scp" }, 7, 6),
|
|
|
+ selection_candidate("openssh-client-default", { "bin:ssh" }, 9, 6),
|
|
|
+ selection_candidate("openssh-client-krb5", { "bin:ssh" }, 15, 7),
|
|
|
+ selection_candidate("rsync", { "bin:rsync" }, 8, 5)
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * A fake SPM planner with the dropbear/openssh exclusion the apk
|
|
|
+ * solver enforces: any set mixing the families fails (null); any
|
|
|
+ * consistent set plans to itself plus one shared dependency.
|
|
|
+ */
|
|
|
+ private Vector<string>? family_aware_planner(Vector<string> names) {
|
|
|
+ if(selection_mixes_families(names)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ var planned = new Vector<string>();
|
|
|
+ planned.add("shared-dep");
|
|
|
+ planned.add_all(names);
|
|
|
+ return planned;
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_provider_selection_conflict_mapping() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+ // Walk-time metadata filter: alpha covers bin:x; beta is the
|
|
|
+ // cheapest bin:y provider but DECLARES a conflict on alpha, so
|
|
|
+ // the walk must take the costlier gamma instead — a set mixing
|
|
|
+ // alpha and beta would not install.
|
|
|
+ var candidates = string.joinv(",", {
|
|
|
+ spm_candidate("alpha", "bin:x", 3, 1),
|
|
|
+ spm_candidate("beta", "bin:y", 2, 0).replace("}", ", \"conflicts\": [\"alpha\"]}"),
|
|
|
+ spm_candidate("gamma", "bin:y", 9, 0)});
|
|
|
+ var spm = stub_manager(scratch, candidates);
|
|
|
+ var result = resolve_manifest(spm, APP_MANIFEST.printf("conflicted", "conflicted",
|
|
|
+ depends_with("runtime", "[\"bin:x\", \"bin:y\"]")));
|
|
|
+ check(result.system_packages.length == 2, "both refs still resolve through the SPM");
|
|
|
+ check(result.system_packages.any(c => c.name == "alpha") && result.system_packages.any(c => c.name == "gamma"),
|
|
|
+ "a declaring-conflict candidate is skipped for a coinstallable one");
|
|
|
+ check(!result.system_packages.any(c => c.name == "beta"),
|
|
|
+ "the candidate declaring the conflict never enters the set");
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_provider_selection_repairs_unplannable_mix() throws Error {
|
|
|
+ var scratch = make_scratch();
|
|
|
+ // Metadata gap: NO conflicts reported, but the SPM's own solver
|
|
|
+ // (the stub's plan mode with USM_SPM_PLAN_CONFLICT_PAIR)
|
|
|
+ // rejects any set mixing beta and alpha. The walk greedily
|
|
|
+ // picks alpha (bin:x) and beta (cheapest bin:y); the post-walk
|
|
|
+ // plan verification catches the mix and the shared selector
|
|
|
+ // repairs it to alpha + gamma without failing the resolve.
|
|
|
+ var candidates = string.joinv(",", {
|
|
|
+ spm_candidate("alpha", "bin:x", 3, 1),
|
|
|
+ spm_candidate("beta", "bin:y", 2, 0),
|
|
|
+ spm_candidate("gamma", "bin:y", 9, 0)});
|
|
|
+ var spm = stub_manager(scratch, candidates);
|
|
|
+ Environment.set_variable("USM_SPM_PLAN_CONFLICT_PAIR", "alpha beta", true);
|
|
|
+ try {
|
|
|
+ var result = resolve_manifest(spm, APP_MANIFEST.printf("gap", "gap",
|
|
|
+ depends_with("runtime", "[\"bin:x\", \"bin:y\"]")));
|
|
|
+ check(result.system_packages.length == 2
|
|
|
+ && result.system_packages.any(c => c.name == "alpha")
|
|
|
+ && result.system_packages.any(c => c.name == "gamma"),
|
|
|
+ "the unplannable greedy mix is repaired to a coinstallable set");
|
|
|
+ check(!result.system_packages.any(c => c.name == "beta"),
|
|
|
+ "the solver-rejected candidate is dropped by the repair");
|
|
|
+ }
|
|
|
+ catch(Error e) {
|
|
|
+ Environment.unset_variable("USM_SPM_PLAN_CONFLICT_PAIR");
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ Environment.unset_variable("USM_SPM_PLAN_CONFLICT_PAIR");
|
|
|
+ Usm.Util.delete_tree(scratch);
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_provider_selection_avoids_conflicting_families() throws Error {
|
|
|
+ // With full asymmetric metadata AND a planner: exactly one
|
|
|
+ // provider per ref, never a family mix.
|
|
|
+ var selection = Usm.ProviderSelection.select(
|
|
|
+ selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }),
|
|
|
+ dropbear_openssh_query(true), family_aware_planner);
|
|
|
+ check(selection.chosen.length == 3, "one provider per ref is chosen (3 refs, %u chosen)".printf(selection.chosen.length));
|
|
|
+ check(selection.unresolved.length == 0, "every ref resolves");
|
|
|
+ check(!selection_mixes_families(selection.chosen), "the chosen set never mixes dropbear and openssh");
|
|
|
+ check(selection.chosen.any(name => name == "rsync"), "the sole provider of a ref is always chosen");
|
|
|
+
|
|
|
+ // Metadata gap — NO conflicts reported, planner only: the
|
|
|
+ // greedy per-ref cost heuristic alone WOULD mix families
|
|
|
+ // (openssh-client-common for scp, dropbear-ssh for ssh); the
|
|
|
+ // planner rejects the mix and a consistent set results.
|
|
|
+ selection = Usm.ProviderSelection.select(
|
|
|
+ selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }),
|
|
|
+ dropbear_openssh_query(false), family_aware_planner);
|
|
|
+ check(selection.chosen.length == 3 && selection.unresolved.length == 0,
|
|
|
+ "metadata-free selection with a planner still covers every ref");
|
|
|
+ check(!selection_mixes_families(selection.chosen),
|
|
|
+ "the planner catches the family mix the metadata never reported");
|
|
|
+
|
|
|
+ // No planner at all: metadata alone still prevents the mix —
|
|
|
+ // the asymmetric dropbear declaration filters the openssh
|
|
|
+ // candidates once any dropbear package is chosen, and vice
|
|
|
+ // versa the walk keeps whichever family it entered first.
|
|
|
+ selection = Usm.ProviderSelection.select(
|
|
|
+ selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }),
|
|
|
+ dropbear_openssh_query(true), null);
|
|
|
+ check(selection.chosen.length == 3 && selection.unresolved.length == 0,
|
|
|
+ "planner-free selection covers every ref via metadata");
|
|
|
+ check(!selection_mixes_families(selection.chosen),
|
|
|
+ "the conflict metadata alone keeps the families separate");
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_provider_selection_unresolved_and_reuse() throws Error {
|
|
|
+ // A ref whose every candidate conflicts with the chosen set
|
|
|
+ // lands in unresolved instead of aborting the selection.
|
|
|
+ var query = selection_query({
|
|
|
+ selection_candidate("alpha", { "bin:a" }, 2, 0),
|
|
|
+ selection_candidate("beta", { "bin:b" }, 2, 0, { "alpha" }),
|
|
|
+ selection_candidate("gamma", { "bin:b" }, 9, 0, { "alpha" })
|
|
|
+ });
|
|
|
+ var selection = Usm.ProviderSelection.select(selection_refs({ "bin:a", "bin:b" }), query, null);
|
|
|
+ check(selection.chosen.length == 1 && selection.chosen[0] == "alpha",
|
|
|
+ "the viable ref still resolves");
|
|
|
+ check(selection.unresolved.length == 1 && selection.unresolved[0].to_string() == "bin:b",
|
|
|
+ "a ref with only conflicting candidates is reported unresolved");
|
|
|
+
|
|
|
+ // A candidate chosen for one ref is reused for another it also
|
|
|
+ // covers (no second package for the same provider).
|
|
|
+ query = selection_query({
|
|
|
+ selection_candidate("multi", { "bin:a", "bin:b" }, 3, 0),
|
|
|
+ selection_candidate("single", { "bin:b" }, 1, 0)
|
|
|
+ });
|
|
|
+ selection = Usm.ProviderSelection.select(selection_refs({ "bin:a", "bin:b" }), query, null);
|
|
|
+ check(selection.chosen.length == 1 && selection.chosen[0] == "multi",
|
|
|
+ "a multi-ref provider satisfies later refs for free");
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_provider_selection_plans_cumulative_sets() throws Error {
|
|
|
+ // The planner sees the CHOSEN SET plus the candidate each
|
|
|
+ // round — never a bare candidate — so its verdicts are exactly
|
|
|
+ // the co-installability questions the SPM will face.
|
|
|
+ var seen_by_planner = new Vector<string>();
|
|
|
+ Usm.SystemProviderPlan recorder = names => {
|
|
|
+ seen_by_planner.add(string.joinv("+", names.to_array()));
|
|
|
+ return family_aware_planner(names);
|
|
|
+ };
|
|
|
+ Usm.ProviderSelection.select(
|
|
|
+ selection_refs({ "bin:rsync", "bin:ssh" }),
|
|
|
+ dropbear_openssh_query(true), recorder);
|
|
|
+ check(seen_by_planner.length >= 2
|
|
|
+ && seen_by_planner[0].contains("rsync")
|
|
|
+ && !seen_by_planner[0].contains("+"),
|
|
|
+ "the first round plans the sole candidate alone");
|
|
|
+ check(seen_by_planner.any(seen => seen.contains("rsync+") || seen.contains("+rsync")),
|
|
|
+ "later rounds plan the cumulative chosen set");
|
|
|
+ }
|
|
|
+
|
|
|
+ void test_system_candidate_conflicts_mapping() throws Error {
|
|
|
+ // The query contract's conflicts member is optional: helpers
|
|
|
+ // predating it (or SPMs that cannot tell) omit it, and the
|
|
|
+ // mapper must yield an empty vector rather than fail.
|
|
|
+ var absent = Usm.SystemPackageCandidate.get_mapper().materialise(
|
|
|
+ new JsonElement.from_string("""{"name":"pkg","resources":["bin:x"],"dependency-count":1,"installed-dependency-count":0}""")
|
|
|
+ .as<Invercargill.Properties>());
|
|
|
+ check(absent.conflicts != null && absent.conflicts.length == 0,
|
|
|
+ "an absent conflicts member maps to an empty vector");
|
|
|
+
|
|
|
+ var present = Usm.SystemPackageCandidate.get_mapper().materialise(
|
|
|
+ new JsonElement.from_string("""{"name":"pkg","resources":["bin:x"],"dependency-count":1,"installed-dependency-count":0,"conflicts":["other-pkg"]}""")
|
|
|
+ .as<Invercargill.Properties>());
|
|
|
+ check(present.conflicts.length == 1 && present.conflicts[0] == "other-pkg",
|
|
|
+ "a present conflicts member maps to its names");
|
|
|
+
|
|
|
+ // The real patched apk helper's answer for bin:scp + bin:ssh +
|
|
|
+ // bin:rsync on alpine:latest (2026-10): the dropbear `!openssh-
|
|
|
+ // client` blocker resolves to the openssh-client providers, the
|
|
|
+ // openssh split packages carry their own pairwise conflicts,
|
|
|
+ // and rsync none. Pinned so the contract cannot drift from the
|
|
|
+ // helper — and note the real gap this fixture documents:
|
|
|
+ // openssh-client-common declares nothing against dropbear, the
|
|
|
+ // pair only the SPM's own solver rejects.
|
|
|
+ var apk_query = Usm.SystemQueryResult.get_mapper().materialise(
|
|
|
+ new JsonElement.from_string("""{"not-found":[],"packages":[
|
|
|
+ {"name":"dropbear-scp","resources":["bin:scp"],"dependency-count":6,"installed-dependency-count":4,"conflicts":["openssh-client-default","openssh-client-krb5"]},
|
|
|
+ {"name":"dropbear-ssh","resources":["bin:ssh"],"dependency-count":6,"installed-dependency-count":4,"conflicts":["openssh-client-default","openssh-client-krb5"]},
|
|
|
+ {"name":"openssh-client-common","resources":["bin:scp"],"dependency-count":7,"installed-dependency-count":6,"conflicts":[]},
|
|
|
+ {"name":"openssh-client-default","resources":["bin:ssh"],"dependency-count":9,"installed-dependency-count":6,"conflicts":["openssh-client-krb5"]},
|
|
|
+ {"name":"openssh-client-krb5","resources":["bin:ssh"],"dependency-count":15,"installed-dependency-count":7,"conflicts":["openssh-client-default"]},
|
|
|
+ {"name":"rsync","resources":["bin:rsync"],"dependency-count":8,"installed-dependency-count":5,"conflicts":[]}
|
|
|
+ ]}""")
|
|
|
+ .as<Invercargill.Properties>());
|
|
|
+ check(apk_query.packages.length == 6, "the pinned apk query parses");
|
|
|
+ check(apk_query.packages.any(c => c.name == "dropbear-ssh" && c.conflicts.length == 2),
|
|
|
+ "the dropbear blocker maps to its two openssh conflict targets");
|
|
|
+
|
|
|
+ // Selection over the REAL apk metadata, metadata-only (no
|
|
|
+ // planner): every ref still resolves — but the set MIXES
|
|
|
+ // families (openssh-client-common wins bin:scp on cost, then
|
|
|
+ // dropbear-ssh wins bin:ssh; common declares nothing against
|
|
|
+ // dropbear). This gap is why planner verification is not
|
|
|
+ // optional: with the family-aware planner the mix is
|
|
|
+ // impossible.
|
|
|
+ var selection = Usm.ProviderSelection.select(
|
|
|
+ selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }), apk_query, null);
|
|
|
+ check(selection.chosen.length == 3 && selection.unresolved.length == 0,
|
|
|
+ "the real apk metadata selects one provider per ref");
|
|
|
+ check(selection_mixes_families(selection.chosen),
|
|
|
+ "the real apk metadata alone leaves the common/dropbear gap (the planner layer exists for exactly this)");
|
|
|
+
|
|
|
+ selection = Usm.ProviderSelection.select(
|
|
|
+ selection_refs({ "bin:scp", "bin:ssh", "bin:rsync" }), apk_query, family_aware_planner);
|
|
|
+ check(selection.chosen.length == 3 && selection.unresolved.length == 0
|
|
|
+ && !selection_mixes_families(selection.chosen),
|
|
|
+ "the planner over the real apk metadata selects a coinstallable family set");
|
|
|
+ }
|
|
|
+
|
|
|
// ---- usmrad: configuration parsing ---------------------------------------------
|
|
|
|
|
|
const string[] USMRAD_VARIABLES = {
|
|
|
@@ -3093,6 +3394,18 @@ echo "Installing libmeson-pkg.so to /usr/lib"
|
|
|
int main() {
|
|
|
test_default_ignore();
|
|
|
test_interrupt_flag_cycle();
|
|
|
+ try {
|
|
|
+ test_provider_selection_avoids_conflicting_families();
|
|
|
+ test_provider_selection_conflict_mapping();
|
|
|
+ test_provider_selection_repairs_unplannable_mix();
|
|
|
+ test_provider_selection_unresolved_and_reuse();
|
|
|
+ test_provider_selection_plans_cumulative_sets();
|
|
|
+ test_system_candidate_conflicts_mapping();
|
|
|
+ }
|
|
|
+ catch(Error e) {
|
|
|
+ failures++;
|
|
|
+ print("FAIL provider selection test threw: %s\n", e.message);
|
|
|
+ }
|
|
|
try {
|
|
|
test_version_ordering();
|
|
|
}
|