Skip to content

Commit 9b760e1

Browse files
committed
Allow multi-version openssl/abseil alongside unicode
Generalize the unicode.org hydrate special-case so parallel-installable ABI lines (openssl 1.1 vs 3, abseil LTS namespaces) can coexist in one graph instead of failing constraint intersection.
1 parent 614dca6 commit 9b760e1

6 files changed

Lines changed: 258 additions & 33 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ jobs:
7272
with:
7373
tool: cargo-tarpaulin
7474
- run: cargo tarpaulin -o lcov --output-dir coverage
75+
- run: brew trust coverallsapp/coveralls
76+
if: matrix.os == 'macos-latest'
7577
- uses: coverallsapp/github-action@v2
7678
with:
7779
path-to-lcov: coverage/lcov.info
@@ -234,6 +236,8 @@ jobs:
234236
-Xdemangler=rustfilt \
235237
> lcov.info
236238
239+
- run: brew trust coverallsapp/coveralls
240+
if: matrix.os == 'macos-latest'
237241
- uses: coverallsapp/github-action@v2
238242
with:
239243
path-to-lcov: lcov.info

crates/cli/src/resolve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub async fn resolve(
8787
let mut installations = resolution.installed;
8888
if !resolution.pending.is_empty() {
8989
if env::var("PKGX_NO_INSTALL").is_ok() {
90-
return Err("PKGX_NO_INSTALL is set, refusing to install pending packages")?;
90+
Err("PKGX_NO_INSTALL is set, refusing to install pending packages")?;
9191
}
9292
let installed = install_multi(&resolution.pending, config, spinner.arc()).await?;
9393
installations.extend(installed);

crates/lib/src/env.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ pub fn expand_moustaches(input: &str, pkg: &Installation, deps: &Vec<Installatio
211211
}
212212

213213
output = output.replace("{{prefix}}", &pkg.path.to_string_lossy());
214-
output = output.replace("{{version}}", &format!("{}", &pkg.pkg.version));
214+
output = output.replace("{{version}}", &format!("{}", pkg.pkg.version));
215215
output = output.replace("{{version.major}}", &format!("{}", pkg.pkg.version.major));
216216
output = output.replace("{{version.minor}}", &format!("{}", pkg.pkg.version.minor));
217217
output = output.replace("{{version.patch}}", &format!("{}", pkg.pkg.version.patch));
@@ -228,7 +228,7 @@ pub fn expand_moustaches(input: &str, pkg: &Installation, deps: &Vec<Installatio
228228
);
229229
output = output.replace(
230230
&format!("{{{{{}.version}}}}", prefix),
231-
&format!("{}", &dep.pkg.version),
231+
&format!("{}", dep.pkg.version),
232232
);
233233
output = output.replace(
234234
&format!("{{{{{}.version.major}}}}", prefix),

crates/lib/src/hydrate.rs

Lines changed: 248 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@ use libsemverator::range::Range as VersionReq;
33
use std::collections::{HashMap, HashSet};
44
use std::error::Error;
55

6+
/// Projects whose distinct version lines are parallel-installable (different
7+
/// sonames / ICU majors / abseil LTS namespaces). When constraints cannot
8+
/// intersect we keep multiple nodes instead of failing the graph.
9+
///
10+
/// - unicode.org: ICU major ABI (see pantry#4104, pkgx#899)
11+
/// - openssl.org: libssl.so.1.1 vs libssl.so.3
12+
/// - abseil.io: LTS inline-namespace + soversion (20250127 vs 20250512, …)
13+
const MULTI_VERSION_PROJECTS: &[&str] = &["unicode.org", "openssl.org", "abseil.io"];
14+
15+
fn is_multi_version(project: &str) -> bool {
16+
MULTI_VERSION_PROJECTS.contains(&project)
17+
}
18+
19+
/// Record an extra constraint for a multi-version project, merging into an
20+
/// existing additional entry when the ranges intersect.
21+
fn push_additional(additional: &mut Vec<PackageReq>, pkg: PackageReq) {
22+
for existing in additional.iter_mut().filter(|p| p.project == pkg.project) {
23+
if let Ok(constraint) = intersect_constraints(&existing.constraint, &pkg.constraint) {
24+
existing.constraint = constraint;
25+
return;
26+
}
27+
}
28+
additional.push(pkg);
29+
}
30+
631
#[derive(Clone)]
732
struct Node {
833
parent: Option<Box<Node>>,
@@ -38,36 +63,57 @@ pub async fn hydrate<F>(
3863
where
3964
F: Fn(String) -> Result<Vec<PackageReq>, Box<dyn Error>>,
4065
{
41-
let dry = condense(input);
66+
let dry = condense(input)?;
4267
let mut graph: HashMap<String, Box<Node>> = HashMap::new();
4368
let mut stack: Vec<Box<Node>> = vec![];
44-
let mut additional_unicodes: Vec<VersionReq> = vec![];
69+
let mut additional: Vec<PackageReq> = vec![];
4570

4671
for pkg in dry.iter() {
47-
let node = graph
48-
.entry(pkg.project.clone())
49-
.or_insert_with(|| Box::new(Node::new(pkg.clone(), None)));
50-
node.pkg.constraint = intersect_constraints(&node.pkg.constraint, &pkg.constraint)
51-
.map_err(|e| format!("{} for {}", e, pkg.project))?;
52-
stack.push(node.clone());
72+
if let Some(node) = graph.get_mut(&pkg.project) {
73+
match intersect_constraints(&node.pkg.constraint, &pkg.constraint) {
74+
Ok(constraint) => {
75+
node.pkg.constraint = constraint;
76+
stack.push(node.clone());
77+
}
78+
Err(e) => {
79+
if is_multi_version(&pkg.project) {
80+
push_additional(&mut additional, pkg.clone());
81+
} else {
82+
return Err(format!("{} for {}", e, pkg.project).into());
83+
}
84+
}
85+
}
86+
} else {
87+
let node = Box::new(Node::new(pkg.clone(), None));
88+
graph.insert(pkg.project.clone(), node.clone());
89+
stack.push(node);
90+
}
5391
}
5492

5593
while let Some(mut current) = stack.pop() {
5694
for child_pkg in get_deps(current.pkg.project.clone())? {
95+
let was_new = !graph.contains_key(&child_pkg.project);
5796
let child_node = graph
5897
.entry(child_pkg.project.clone())
5998
.or_insert_with(|| Box::new(Node::new(child_pkg.clone(), Some(current.clone()))));
99+
100+
if was_new {
101+
// Fresh node already carries child_pkg.constraint.
102+
current.children.insert(child_node.pkg.project.clone());
103+
stack.push(child_node.clone());
104+
continue;
105+
}
106+
107+
// Already have a graph node: try the primary constraint, then any
108+
// additional lines for this multi-version project.
60109
let intersection =
61110
intersect_constraints(&child_node.pkg.constraint, &child_pkg.constraint);
62111
if let Ok(constraint) = intersection {
63112
child_node.pkg.constraint = constraint;
64113
current.children.insert(child_node.pkg.project.clone());
65114
stack.push(child_node.clone());
66-
} else if child_pkg.project == "unicode.org" {
67-
// we handle unicode.org for now to allow situations like:
68-
// https://github.com/pkgxdev/pantry/issues/4104
69-
// https://github.com/pkgxdev/pkgx/issues/899
70-
additional_unicodes.push(child_pkg.constraint);
115+
} else if is_multi_version(&child_pkg.project) {
116+
push_additional(&mut additional, child_pkg);
71117
} else {
72118
return Err(
73119
format!("{} for {}", intersection.unwrap_err(), child_pkg.project).into(),
@@ -80,33 +126,210 @@ where
80126
pkgs.sort_by_key(|node| node.count());
81127
let mut pkgs: Vec<PackageReq> = pkgs.into_iter().map(|node| node.pkg.clone()).collect();
82128

83-
// see above explanation
84-
for constraint in additional_unicodes {
85-
let pkg = PackageReq {
86-
project: "unicode.org".to_string(),
87-
constraint,
88-
};
89-
pkgs.push(pkg);
90-
}
129+
pkgs.extend(additional);
91130

92131
Ok(pkgs)
93132
}
94133

95134
/// Condenses a list of `PackageRequirement` by intersecting constraints for duplicates.
96-
fn condense(pkgs: &Vec<PackageReq>) -> Vec<PackageReq> {
135+
/// Multi-version projects keep non-intersecting constraints as separate entries.
136+
fn condense(pkgs: &Vec<PackageReq>) -> Result<Vec<PackageReq>, Box<dyn Error>> {
97137
let mut out: Vec<PackageReq> = vec![];
98138
for pkg in pkgs {
99139
if let Some(existing) = out.iter_mut().find(|p| p.project == pkg.project) {
100-
existing.constraint = intersect_constraints(&existing.constraint, &pkg.constraint)
101-
.expect("Failed to intersect constraints");
140+
match intersect_constraints(&existing.constraint, &pkg.constraint) {
141+
Ok(constraint) => existing.constraint = constraint,
142+
Err(e) => {
143+
if is_multi_version(&pkg.project) {
144+
// merge into a later non-intersecting sibling if possible
145+
let mut merged = false;
146+
for sibling in out.iter_mut().filter(|p| p.project == pkg.project).skip(1) {
147+
if let Ok(constraint) =
148+
intersect_constraints(&sibling.constraint, &pkg.constraint)
149+
{
150+
sibling.constraint = constraint;
151+
merged = true;
152+
break;
153+
}
154+
}
155+
if !merged {
156+
out.push(pkg.clone());
157+
}
158+
} else {
159+
return Err(format!("{} for {}", e, pkg.project).into());
160+
}
161+
}
162+
}
102163
} else {
103164
out.push(pkg.clone());
104165
}
105166
}
106-
out
167+
Ok(out)
107168
}
108169

109170
/// Intersects two version constraints.
110171
fn intersect_constraints(a: &VersionReq, b: &VersionReq) -> Result<VersionReq, Box<dyn Error>> {
111172
a.intersect(b).map_err(|e| e.into())
112173
}
174+
175+
#[cfg(test)]
176+
mod tests {
177+
use super::*;
178+
179+
fn req(project: &str, constraint: &str) -> PackageReq {
180+
PackageReq {
181+
project: project.to_string(),
182+
constraint: VersionReq::parse(constraint).unwrap(),
183+
}
184+
}
185+
186+
fn pkgs_for<'a>(pkgs: &'a [PackageReq], project: &str) -> Vec<&'a PackageReq> {
187+
pkgs.iter().filter(|p| p.project == project).collect()
188+
}
189+
190+
/// True if some hydrated line for `project` intersects `range` (same ABI line).
191+
fn has_line(pkgs: &[PackageReq], project: &str, range: &str) -> bool {
192+
let want = VersionReq::parse(range).unwrap();
193+
pkgs_for(pkgs, project)
194+
.into_iter()
195+
.any(|p| p.constraint.intersect(&want).is_ok())
196+
}
197+
198+
/// Assert two ranges remain disjoint (cannot be collapsed).
199+
fn assert_disjoint(a: &str, b: &str) {
200+
assert!(VersionReq::parse(a)
201+
.unwrap()
202+
.intersect(&VersionReq::parse(b).unwrap())
203+
.is_err());
204+
}
205+
206+
#[tokio::test]
207+
async fn hydrates_unicode_multi() {
208+
let input = vec![req("npmjs.com", "*"), req("python.org", "~3.9")];
209+
let pkgs = hydrate(&input, |project| match project.as_str() {
210+
"python.org" => Ok(vec![req("unicode.org", "^73")]),
211+
"npmjs.com" => Ok(vec![req("unicode.org", "^71")]),
212+
_ => Ok(vec![]),
213+
})
214+
.await
215+
.unwrap();
216+
217+
assert_eq!(pkgs_for(&pkgs, "unicode.org").len(), 2);
218+
assert!(has_line(&pkgs, "unicode.org", "^71"));
219+
assert!(has_line(&pkgs, "unicode.org", "^73"));
220+
assert_disjoint("^71", "^73");
221+
}
222+
223+
#[tokio::test]
224+
async fn hydrates_openssl_multi() {
225+
// python locks ^1.1; cryptography needs ^3 — must coexist
226+
let input = vec![req("python.org", "*"), req("cryptography.io", "*")];
227+
let pkgs = hydrate(&input, |project| match project.as_str() {
228+
"python.org" => Ok(vec![req("openssl.org", "^1.1")]),
229+
"cryptography.io" => Ok(vec![req("openssl.org", "^3")]),
230+
_ => Ok(vec![]),
231+
})
232+
.await
233+
.unwrap();
234+
235+
assert_eq!(pkgs_for(&pkgs, "openssl.org").len(), 2);
236+
assert!(has_line(&pkgs, "openssl.org", "^1.1"));
237+
assert!(has_line(&pkgs, "openssl.org", "^3"));
238+
assert_disjoint("^1.1", "^3");
239+
}
240+
241+
#[tokio::test]
242+
async fn hydrates_abseil_multi() {
243+
// re2 on one LTS line, grpc on another
244+
let input = vec![req("github.com/google/re2", "*"), req("grpc.io", "*")];
245+
let pkgs = hydrate(&input, |project| match project.as_str() {
246+
"github.com/google/re2" => Ok(vec![req("abseil.io", "^20250127")]),
247+
"grpc.io" => Ok(vec![req("abseil.io", ">=20250512")]),
248+
_ => Ok(vec![]),
249+
})
250+
.await
251+
.unwrap();
252+
253+
assert_eq!(pkgs_for(&pkgs, "abseil.io").len(), 2);
254+
assert!(has_line(&pkgs, "abseil.io", "^20250127"));
255+
assert!(has_line(&pkgs, "abseil.io", ">=20250512"));
256+
assert_disjoint("^20250127", ">=20250512");
257+
}
258+
259+
#[tokio::test]
260+
async fn hydrates_openssl_dry_input() {
261+
// explicit +openssl^1.1 +openssl^3
262+
let input = vec![req("openssl.org", "^1.1"), req("openssl.org", "^3")];
263+
let pkgs = hydrate(&input, |_| Ok(vec![])).await.unwrap();
264+
265+
assert_eq!(pkgs_for(&pkgs, "openssl.org").len(), 2);
266+
assert!(has_line(&pkgs, "openssl.org", "^1.1"));
267+
assert!(has_line(&pkgs, "openssl.org", "^3"));
268+
}
269+
270+
#[tokio::test]
271+
async fn hydrates_multi_version_three_way() {
272+
// three consumers, two openssl lines (two share ^1.1, one needs ^3)
273+
// order cryptography first so ^3 is the graph node and both ^1.1 merge
274+
// into a single additional entry.
275+
let input = vec![
276+
req("cryptography.io", "*"),
277+
req("python.org", "*"),
278+
req("curl.se", "*"),
279+
];
280+
let pkgs = hydrate(&input, |project| match project.as_str() {
281+
"python.org" | "curl.se" => Ok(vec![req("openssl.org", "^1.1")]),
282+
"cryptography.io" => Ok(vec![req("openssl.org", "^3")]),
283+
_ => Ok(vec![]),
284+
})
285+
.await
286+
.unwrap();
287+
288+
assert_eq!(pkgs_for(&pkgs, "openssl.org").len(), 2);
289+
assert!(has_line(&pkgs, "openssl.org", "^1.1"));
290+
assert!(has_line(&pkgs, "openssl.org", "^3"));
291+
}
292+
293+
#[tokio::test]
294+
async fn hydrates_cannot_intersect() {
295+
let input = vec![req("npmjs.com", "*"), req("python.org", "~3.9")];
296+
let err = hydrate(&input, |project| match project.as_str() {
297+
"python.org" => Ok(vec![req("nodejs.com", "^73")]),
298+
"npmjs.com" => Ok(vec![req("nodejs.com", "^71")]),
299+
_ => Ok(vec![]),
300+
})
301+
.await
302+
.unwrap_err();
303+
304+
assert!(err.to_string().contains("nodejs.com"));
305+
}
306+
307+
#[tokio::test]
308+
async fn hydrates_compatible_intersect() {
309+
let input = vec![req("pipenv.pypa.io", "*"), req("python.org", "~3.9")];
310+
let pkgs = hydrate(&input, |project| match project.as_str() {
311+
"pipenv.pypa.io" => Ok(vec![req("python.org", ">=3.7")]),
312+
_ => Ok(vec![]),
313+
})
314+
.await
315+
.unwrap();
316+
317+
let pythons = pkgs_for(&pkgs, "python.org");
318+
assert_eq!(pythons.len(), 1);
319+
// dry ~3.9 wins over looser >=3.7
320+
assert!(has_line(&pkgs, "python.org", "~3.9"));
321+
assert!(!has_line(&pkgs, "python.org", "~3.10"));
322+
assert!(!has_line(&pkgs, "python.org", "~3.8"));
323+
}
324+
325+
#[tokio::test]
326+
async fn hydrates_multi_version_dry_condense_compatible() {
327+
// two compatible dry openssl constraints still collapse to one
328+
let input = vec![req("openssl.org", "^1.1"), req("openssl.org", ">=1.1.1")];
329+
let pkgs = hydrate(&input, |_| Ok(vec![])).await.unwrap();
330+
331+
assert_eq!(pkgs_for(&pkgs, "openssl.org").len(), 1);
332+
assert!(has_line(&pkgs, "openssl.org", "^1.1"));
333+
assert!(!has_line(&pkgs, "openssl.org", "^3"));
334+
}
335+
}

crates/lib/src/sync.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,7 @@ pub async fn update(config: &Config, conn: &mut Connection) -> Result<(), Box<dy
3939
FileExt::unlock(&lockfile)?;
4040
return Ok(());
4141
} else {
42-
return Err(
43-
"PKGX_PANTRY_DIR is set but does not contain a pantry (missing projects/)",
44-
)?;
42+
Err("PKGX_PANTRY_DIR is set but does not contain a pantry (missing projects/)")?;
4543
}
4644
}
4745
replace(config, conn).await

crates/lib/src/types.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub struct Package {
1919

2020
impl fmt::Display for Package {
2121
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22-
write!(f, "{}={}", self.project, &self.version)
22+
write!(f, "{}={}", self.project, self.version)
2323
}
2424
}
2525

@@ -63,7 +63,7 @@ impl fmt::Display for PackageReq {
6363
if self.constraint.raw == "*" {
6464
write!(f, "{}", self.project)
6565
} else {
66-
write!(f, "{}{}", self.project, &self.constraint)
66+
write!(f, "{}{}", self.project, self.constraint)
6767
}
6868
}
6969
}

0 commit comments

Comments
 (0)