Skip to content

Commit da570ec

Browse files
committed
fix: resolve overlinking false positives for staging outputs
When a package output inherits from a staging cache, the staging cache's host dependencies aren't installed in the prefix during overlinking checks. This means libraries like libz.so.1 can't be attributed to zlib, even though zlib is a run dependency via inherited run_exports. Fix: at staging build time, capture a LibraryNameMap (filename to package mapping) from PrefixInfo while conda-meta still exists. Store it in the staging cache metadata, thread it to BuildOutput, and use it as a fallback in perform_linking_checks when resolve_libraries can't find the library on disk. Fixes #2186 Supersedes #2210
1 parent 809e112 commit da570ec

7 files changed

Lines changed: 279 additions & 8 deletions

File tree

crates/rattler_build_core/src/build.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,12 @@ pub async fn run_build(
136136
// This will build or restore staging caches and return their dependencies/sources if inherited
137137
let staging_result = output.process_staging_caches(tool_configuration).await?;
138138

139-
// If we inherit from a staging cache, store its dependencies and sources
140-
if let Some((deps, sources)) = staging_result {
139+
// If we inherit from a staging cache, store its dependencies, sources, and
140+
// library name map for overlinking checks
141+
if let Some((deps, sources, library_name_map)) = staging_result {
141142
output.finalized_cache_dependencies = Some(deps);
142143
output.finalized_cache_sources = Some(sources);
144+
output.staging_library_name_map = Some(library_name_map);
143145
}
144146

145147
// Fetch sources for this output

crates/rattler_build_core/src/post_process/checks.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ pub fn perform_linking_checks(
528528
let system_libs = find_system_libs(output)?;
529529

530530
let prefix_info = PrefixInfo::from_prefix(output.prefix())?;
531+
let staging_lib_map = output.staging_library_name_map.as_ref();
531532

532533
let host_dso_packages = host_run_export_dso_packages(output, &prefix_info.package_to_nature);
533534
tracing::trace!("Host run_export DSO packages: {host_dso_packages:#?}",);
@@ -644,6 +645,26 @@ pub fn perform_linking_checks(
644645
);
645646
}
646647

648+
// Fallback: if the library couldn't be resolved on disk (e.g. from
649+
// a staging cache whose host deps are not installed), try to match
650+
// it by filename against the cached library name map.
651+
if let Some(lib_map) = staging_lib_map
652+
&& let Some(providing_package) = lib_map.find_package(lib)
653+
&& run_dependency_names.contains(&providing_package)
654+
{
655+
tracing::debug!(
656+
"Library {lib:?} matched to '{}' via staging library name map",
657+
providing_package.as_normalized()
658+
);
659+
link_info.linked_packages.push(LinkedPackage {
660+
name: lib.to_path_buf(),
661+
link_origin: LinkOrigin::ForeignPackage(
662+
providing_package.as_normalized().to_string(),
663+
),
664+
});
665+
continue;
666+
}
667+
647668
// Check if the library is one of the system libraries (i.e. comes from sysroot).
648669
if system_libs.allow.is_match(lib) && !system_libs.deny.is_match(lib) {
649670
link_info.linked_packages.push(LinkedPackage {
@@ -730,7 +751,21 @@ pub fn perform_linking_checks(
730751

731752
#[cfg(test)]
732753
mod tests {
754+
use std::collections::BTreeMap;
755+
use std::sync::{Arc, Mutex};
756+
757+
use rattler_conda_types::{MatchSpec, package::CondaArchiveType};
758+
use rattler_solve::{ChannelPriority, SolveStrategy};
759+
733760
use super::*;
761+
use crate::render::resolved_dependencies::{
762+
DependencyInfo, FinalizedDependencies, FinalizedRunDependencies, SourceDependency,
763+
};
764+
use crate::system_tools::SystemTools;
765+
use crate::types::{
766+
BuildConfiguration, BuildSummary, Directories, PackagingSettings,
767+
PlatformWithVirtualPackages,
768+
};
734769
use fs_err;
735770

736771
#[test]
@@ -1088,4 +1123,136 @@ mod tests {
10881123
assert!(result.is_err());
10891124
assert!(result.unwrap_err().to_string().contains("Failed to parse"));
10901125
}
1126+
1127+
/// Creates a minimal `Output` for testing `perform_linking_checks`.
1128+
///
1129+
/// The recipe is parsed from YAML. The remaining fields (`BuildConfiguration`,
1130+
/// `FinalizedDependencies`, etc.) are not part of the recipe format and must
1131+
/// be constructed manually.
1132+
fn create_test_output(
1133+
target_platform: Platform,
1134+
host_prefix: PathBuf,
1135+
build_prefix: PathBuf,
1136+
run_deps: Vec<&str>,
1137+
recipe_yaml: &str,
1138+
) -> crate::metadata::Output {
1139+
let recipe: rattler_build_recipe::Stage1Recipe =
1140+
serde_yaml::from_str(recipe_yaml).expect("failed to parse recipe YAML");
1141+
1142+
let pvp = PlatformWithVirtualPackages {
1143+
platform: target_platform,
1144+
virtual_packages: vec![],
1145+
};
1146+
1147+
let depends = run_deps
1148+
.into_iter()
1149+
.map(|name| {
1150+
DependencyInfo::Source(SourceDependency {
1151+
spec: MatchSpec::from_str(name, rattler_conda_types::ParseStrictness::Lenient)
1152+
.unwrap(),
1153+
})
1154+
})
1155+
.collect();
1156+
1157+
crate::metadata::Output {
1158+
recipe,
1159+
build_configuration: BuildConfiguration {
1160+
target_platform,
1161+
host_platform: pvp.clone(),
1162+
build_platform: pvp,
1163+
variant: BTreeMap::new(),
1164+
hash: rattler_build_recipe::stage1::HashInfo {
1165+
hash: "test".into(),
1166+
prefix: String::new(),
1167+
},
1168+
directories: Directories {
1169+
host_prefix,
1170+
build_prefix,
1171+
..Default::default()
1172+
},
1173+
channels: vec![],
1174+
channel_priority: ChannelPriority::Strict,
1175+
solve_strategy: SolveStrategy::Highest,
1176+
timestamp: chrono::Utc::now(),
1177+
subpackages: BTreeMap::new(),
1178+
packaging_settings: PackagingSettings {
1179+
archive_type: CondaArchiveType::Conda,
1180+
compression_level: 1,
1181+
},
1182+
store_recipe: false,
1183+
force_colors: false,
1184+
sandbox_config: None,
1185+
exclude_newer: None,
1186+
},
1187+
finalized_dependencies: Some(FinalizedDependencies {
1188+
build: None,
1189+
host: None,
1190+
run: FinalizedRunDependencies {
1191+
depends,
1192+
constraints: vec![],
1193+
run_exports: Default::default(),
1194+
},
1195+
}),
1196+
finalized_sources: None,
1197+
finalized_cache_dependencies: None,
1198+
finalized_cache_sources: None,
1199+
staging_library_name_map: None,
1200+
build_summary: Arc::new(Mutex::new(BuildSummary::default())),
1201+
system_tools: SystemTools::new("test", "0.0.0"),
1202+
extra_meta: None,
1203+
}
1204+
}
1205+
1206+
/// Simulates a staging output scenario: the binary (zlink) links against
1207+
/// libz.so.1, zlib IS in the run dependencies (via inherited run_exports),
1208+
/// but zlib is NOT installed in the host prefix (no conda-meta, no library
1209+
/// files). With the staging library name map providing the fallback
1210+
/// mapping, the overlinking check should pass.
1211+
#[test]
1212+
fn test_staging_overlinking() {
1213+
let test_data = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-data/binary_files");
1214+
1215+
let tmp = tempfile::tempdir().unwrap();
1216+
let tmp_prefix = tmp.path().join("tmp_prefix");
1217+
let host_prefix = tmp.path().join("host_prefix");
1218+
let build_prefix = tmp.path().join("build_prefix");
1219+
fs_err::create_dir_all(&tmp_prefix).unwrap();
1220+
fs_err::create_dir_all(&host_prefix).unwrap();
1221+
fs_err::create_dir_all(&build_prefix).unwrap();
1222+
1223+
let binary_dest = tmp_prefix.join("zlink");
1224+
fs_err::copy(test_data.join("zlink"), &binary_dest).unwrap();
1225+
1226+
let mut output = create_test_output(
1227+
Platform::Linux64,
1228+
host_prefix,
1229+
build_prefix,
1230+
vec!["zlib"],
1231+
r#"
1232+
package:
1233+
name: test-pkg
1234+
version: "1.0.0"
1235+
build:
1236+
dynamic_linking:
1237+
overlinking_behavior: error
1238+
missing_dso_allowlist:
1239+
- "libc*"
1240+
"#,
1241+
);
1242+
output.staging_library_name_map =
1243+
Some(crate::post_process::package_nature::LibraryNameMap {
1244+
library_to_package: [("libz.so.1".to_string(), "zlib".to_string())]
1245+
.into_iter()
1246+
.collect(),
1247+
});
1248+
1249+
let new_files: HashSet<PathBuf> = [binary_dest].into_iter().collect();
1250+
1251+
let result = perform_linking_checks(&output, &new_files, &tmp_prefix);
1252+
assert!(
1253+
result.is_ok(),
1254+
"Expected overlinking check to pass since zlib is a run dependency \
1255+
with a staging library name map, but got: {result:?}"
1256+
);
1257+
}
10911258
}

crates/rattler_build_core/src/post_process/package_nature.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,74 @@ impl PrefixInfo {
215215
}
216216
}
217217

218+
/// A mapping from shared library filenames to the package that provides them.
219+
///
220+
/// This is used as a fallback during overlinking checks when the staging
221+
/// cache's host dependencies are not physically installed in the prefix.
222+
/// Instead of requiring files on disk, this allows name-based attribution
223+
/// of libraries to packages.
224+
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
225+
pub struct LibraryNameMap {
226+
/// Maps library filenames (e.g. "libz.so.1", "libz.1.dylib") to the
227+
/// package name that provides them.
228+
pub library_to_package: HashMap<String, String>,
229+
}
230+
231+
impl LibraryNameMap {
232+
/// Build a `LibraryNameMap` from a `PrefixInfo` by extracting the
233+
/// filenames of all files that look like shared objects.
234+
pub(crate) fn from_prefix_info(prefix_info: &PrefixInfo) -> Self {
235+
let mut library_to_package = HashMap::new();
236+
237+
for (path, package_name) in &prefix_info.path_to_package {
238+
if is_dso(&path.path)
239+
&& let Some(file_name) = path.path.file_name()
240+
{
241+
library_to_package.insert(
242+
file_name.to_string_lossy().to_string(),
243+
package_name.as_normalized().to_string(),
244+
);
245+
}
246+
}
247+
248+
Self { library_to_package }
249+
}
250+
251+
/// Look up a library path by extracting its filename and checking the map.
252+
/// Returns the package name if found.
253+
///
254+
/// Handles various library path forms:
255+
/// - Plain filenames: `libz.so.1`
256+
/// - macOS @rpath references: `@rpath/libz.1.dylib`
257+
/// - Full or relative paths: `lib/libz.so.1`
258+
pub fn find_package(&self, library: &Path) -> Option<PackageName> {
259+
let path_str = library.to_string_lossy();
260+
261+
// Strip @rpath/ or @loader_path/ prefixes (macOS)
262+
let stripped = path_str
263+
.strip_prefix("@rpath/")
264+
.or_else(|| path_str.strip_prefix("@loader_path/"))
265+
.unwrap_or(&path_str);
266+
267+
// Try the stripped path directly (handles plain filenames)
268+
if let Some(pkg) = self.library_to_package.get(stripped) {
269+
return PackageName::try_from(pkg.as_str()).ok();
270+
}
271+
272+
// Try just the filename component
273+
let file_name = Path::new(stripped).file_name()?.to_string_lossy();
274+
275+
self.library_to_package
276+
.get(file_name.as_ref())
277+
.and_then(|n| PackageName::try_from(n.as_str()).ok())
278+
}
279+
280+
/// Returns true if the map is empty.
281+
pub fn is_empty(&self) -> bool {
282+
self.library_to_package.is_empty()
283+
}
284+
}
285+
218286
#[cfg(test)]
219287
mod tests {
220288
use super::*;

0 commit comments

Comments
 (0)