Skip to main content

provenant/assembly/
mod.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7mod assemblers;
8#[cfg(test)]
9mod assembly_test;
10mod bazel_prune;
11mod cargo_resource_assign;
12mod cargo_workspace_merge;
13mod clojure_deps_assign;
14mod cocoapods_merge;
15mod composer_resource_assign;
16mod conda_rootfs_merge;
17mod dart_workspace_merge;
18mod debian_source_merge;
19pub mod file_ref_resolve;
20mod go_workspace;
21mod gradle_multiproject;
22mod hackage_merge;
23mod huggingface_merge;
24mod ivy_dependencies_properties_assign;
25mod maven_reactor;
26mod mix_umbrella_merge;
27mod nested_merge;
28mod nix_flake_compat_merge;
29mod npm_resource_assign;
30mod npm_workspace_merge;
31mod nuget_cpm_resolve;
32mod path_identity;
33mod pixi_topology;
34mod project_dependency_assign;
35mod python_requirements_assign;
36mod resource_assign;
37mod ruby_resource_assign;
38mod sibling_merge;
39mod swift_merge;
40mod topology;
41mod uv_workspace;
42mod windows_update_merge;
43
44use std::collections::{BTreeSet, HashMap, HashSet};
45use std::path::PathBuf;
46use std::sync::LazyLock;
47
48use crate::models::{DatasourceId, Dependency, FileInfo, Package, PackageUid, TopLevelDependency};
49
50pub use assemblers::ASSEMBLERS;
51
52type DirectoryMergeOutput = (Option<Package>, Vec<TopLevelDependency>, Vec<usize>);
53
54/// Pre-computed lookup: DatasourceId → config key (first DatasourceId in config).
55/// Built once on first use, avoiding HashMap allocation on every `assemble()` call.
56static ASSEMBLER_LOOKUP: LazyLock<HashMap<DatasourceId, DatasourceId>> = LazyLock::new(|| {
57    let mut lookup = HashMap::new();
58    for config in ASSEMBLERS {
59        let key = *config
60            .datasource_ids
61            .first()
62            .expect("assembler must have at least one datasource_id");
63        for &dsid in config.datasource_ids {
64            lookup.insert(dsid, key);
65        }
66    }
67    lookup
68});
69
70static ASSEMBLER_CONFIG_LOOKUP: LazyLock<HashMap<DatasourceId, &'static AssemblerConfig>> =
71    LazyLock::new(|| {
72        let mut lookup = HashMap::new();
73        for config in ASSEMBLERS {
74            let key = *config
75                .datasource_ids
76                .first()
77                .expect("assembler must have at least one datasource_id");
78            lookup.insert(key, config);
79        }
80        lookup
81    });
82
83/// Result of the assembly phase: top-level packages and dependencies,
84/// plus updated file-to-package associations.
85pub struct AssemblyResult {
86    pub packages: Vec<Package>,
87    pub dependencies: Vec<TopLevelDependency>,
88}
89
90/// How an assembler groups PackageData into Packages.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum AssemblyMode {
93    /// Merge related files in the same directory (or nested) into one Package.
94    SiblingMerge,
95    /// Like [`AssemblyMode::SiblingMerge`], but a directory holding multiple
96    /// manifests with distinct package identities (purls) yields one package per
97    /// identity instead of collapsing them into one. A directory with a single
98    /// identity (a normal one-module directory plus its purl-less supplementary
99    /// siblings) falls back to the `SiblingMerge` result unchanged. Use this for
100    /// ecosystems whose directories can legitimately hold several independent
101    /// manifests, e.g. a flat set of standalone Maven `.pom` files.
102    SiblingMergePerIdentity,
103    /// Each PackageData becomes its own independent Package (e.g., database files
104    /// containing many installed packages like Alpine DB, RPM DB, Debian status).
105    OnePerPackageData,
106}
107
108/// A bespoke per-directory merge for a layout the generic sibling / per-identity
109/// / one-per engines cannot express.
110///
111/// Runs in place of the generic per-directory engine for the directories a
112/// config claims; the config's [`AssemblyMode`] still governs the later
113/// nested-merge pass, so a config keeps `SiblingMerge` even when it supplies a
114/// merger. Returning an empty result is an explicit "skip per-directory
115/// assembly" (used by Swift, which is assembled by a post-assembly pass).
116pub type DirectoryMergeFn =
117    fn(&AssemblerConfig, &[FileInfo], &[usize]) -> Vec<DirectoryMergeOutput>;
118
119pub struct AssemblerConfig {
120    pub datasource_ids: &'static [DatasourceId],
121    pub sibling_file_patterns: &'static [&'static str],
122    pub mode: AssemblyMode,
123    /// Optional bespoke per-directory merger. When set, it replaces the generic
124    /// [`AssemblyMode`] engine for this config's claimed directories (see
125    /// [`DirectoryMergeFn`]). Most configs leave this `None`.
126    pub directory_merger: Option<DirectoryMergeFn>,
127}
128
129/// Run the assembly phase over all scanned files.
130///
131/// Groups files by parent directory, finds related manifests/lockfiles,
132/// merges them into top-level `Package` objects, and hoists dependencies.
133/// Updates each `FileInfo.for_packages` with the UIDs of packages it belongs to.
134pub fn assemble(files: &mut [FileInfo]) -> AssemblyResult {
135    let assembler_lookup = &*ASSEMBLER_LOOKUP;
136    let assembler_config_lookup = &*ASSEMBLER_CONFIG_LOOKUP;
137    let mut packages = Vec::new();
138    let mut dependencies = Vec::new();
139
140    let dir_files = group_files_by_directory(files);
141    let topology_plan = topology::TopologyPlan::build(files, &dir_files);
142
143    for file_indices in dir_files.values() {
144        let groups = active_config_keys(files, file_indices, assembler_lookup);
145
146        for &config_key in &groups {
147            let config = assembler_config_lookup
148                .get(&config_key)
149                .copied()
150                .expect("assembler config must exist");
151
152            if topology_plan.claims_directory_assembly(config, file_indices, files) {
153                continue;
154            }
155
156            if let Some(directory_merger) = config.directory_merger {
157                let results = directory_merger(config, files, file_indices);
158                apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
159                continue;
160            }
161
162            match config.mode {
163                AssemblyMode::SiblingMerge => {
164                    let results = sibling_merge::assemble_siblings(config, files, file_indices);
165                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
166                }
167                AssemblyMode::SiblingMergePerIdentity => {
168                    let results =
169                        sibling_merge::assemble_siblings_per_identity(config, files, file_indices);
170                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
171                }
172                AssemblyMode::OnePerPackageData => {
173                    let results = assemble_one_per_package_data(config, files, file_indices);
174                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
175                }
176            }
177        }
178    }
179
180    topology_plan.apply_directory_scoped_domains(files, &mut packages, &mut dependencies);
181
182    for config in ASSEMBLERS {
183        if !matches!(
184            config.mode,
185            AssemblyMode::SiblingMerge | AssemblyMode::SiblingMergePerIdentity
186        ) {
187            continue;
188        }
189        if let Some((pkg, deps, affected_indices)) =
190            nested_merge::assemble_nested_patterns(files, config)
191        {
192            let package_uid = pkg.package_uid.clone();
193
194            // The nested merge subsumes exactly the per-directory packages that were
195            // previously created for the files it consumed. Those packages are recorded
196            // on the affected files' `for_packages`, so key dedup off those specific UIDs
197            // rather than a blanket purl match (which would also delete unrelated packages,
198            // including every purl-less package scan-wide when the merged purl is `None`).
199            let removed_package_uids: HashSet<PackageUid> = affected_indices
200                .iter()
201                .flat_map(|idx| files[*idx].for_packages.iter().cloned())
202                .filter(|uid| *uid != package_uid)
203                .collect();
204
205            packages.retain(|p| !removed_package_uids.contains(&p.package_uid));
206            dependencies.retain(|d| {
207                d.for_package_uid
208                    .as_ref()
209                    .is_none_or(|old_uid| !removed_package_uids.contains(old_uid))
210            });
211
212            for idx in &affected_indices {
213                files[*idx].for_packages.clear();
214                files[*idx].for_packages.push(package_uid.clone());
215            }
216
217            packages.push(pkg);
218            dependencies.extend(deps);
219        }
220    }
221
222    assemblers::run_post_assembly_passes(files, &mut packages, &mut dependencies, &topology_plan);
223    hoist_unassembled_file_dependencies(files, &mut dependencies);
224
225    for package in &mut packages {
226        package.datafile_paths.sort();
227        package.datafile_paths.dedup();
228        package.datasource_ids.sort_by_key(|left| left.to_string());
229        package.datasource_ids.dedup();
230    }
231
232    for file in files.iter_mut() {
233        file.for_packages
234            .sort_by(|left, right| left.stable_key().cmp(&right.stable_key()));
235        file.for_packages.dedup();
236    }
237
238    packages
239        .sort_by(|left, right| stable_package_sort_key(left).cmp(&stable_package_sort_key(right)));
240    dependencies.sort_by(|left, right| {
241        left.purl
242            .as_deref()
243            .cmp(&right.purl.as_deref())
244            .then_with(|| {
245                left.extracted_requirement
246                    .as_deref()
247                    .cmp(&right.extracted_requirement.as_deref())
248            })
249            .then_with(|| left.scope.as_deref().cmp(&right.scope.as_deref()))
250            .then_with(|| left.datafile_path.cmp(&right.datafile_path))
251            .then_with(|| {
252                left.datasource_id
253                    .to_string()
254                    .cmp(&right.datasource_id.to_string())
255            })
256            .then_with(|| {
257                left.for_package_uid
258                    .as_ref()
259                    .map(|uid| uid.stable_key())
260                    .cmp(&right.for_package_uid.as_ref().map(|uid| uid.stable_key()))
261            })
262    });
263
264    AssemblyResult {
265        packages,
266        dependencies,
267    }
268}
269
270fn apply_directory_merge_results(
271    files: &mut [FileInfo],
272    packages: &mut Vec<Package>,
273    dependencies: &mut Vec<TopLevelDependency>,
274    results: Vec<DirectoryMergeOutput>,
275) {
276    for (package, deps, affected_indices) in results {
277        if let Some(package) = package {
278            let package_uid = package.package_uid.clone();
279            for idx in &affected_indices {
280                if !files[*idx].for_packages.contains(&package_uid) {
281                    files[*idx].for_packages.push(package_uid.clone());
282                }
283            }
284            packages.push(package);
285        }
286        dependencies.extend(deps);
287    }
288}
289
290/// Last-resort visibility for datafiles that no assembler emitted dependencies
291/// for, so a dep-bearing manifest is never silently dropped.
292///
293/// An empty `for_packages` does not by itself mean "not assembled": an assembler
294/// can hoist a purl-less record's dependencies (`for_package_uid: None`) without
295/// creating a package to own them, which leaves the file unowned even though its
296/// dependencies are already in the list. A standalone `requirements.txt` is
297/// exactly that shape, and hoisting on the `for_packages` signal alone emitted
298/// every one of its dependencies twice. Skip any datafile+datasource that already
299/// contributed, and keep intra-file duplicates (a requirement genuinely listed
300/// twice) intact by never deduplicating individual entries.
301///
302/// Correctness rests on what was actually emitted, not on `for_packages`, which
303/// is why no separate "this datafile was handled" signal needs threading out of
304/// assembly: a hoisted-but-unowned record has no package to link to, so it
305/// cannot be represented in the file→package link by construction.
306fn hoist_unassembled_file_dependencies(
307    files: &[FileInfo],
308    dependencies: &mut Vec<TopLevelDependency>,
309) {
310    let already_emitted: HashSet<(&str, DatasourceId)> = dependencies
311        .iter()
312        .map(|dependency| (dependency.datafile_path.as_str(), dependency.datasource_id))
313        .collect();
314
315    let mut hoisted = Vec::new();
316    for file in files {
317        if !file.for_packages.is_empty() {
318            continue;
319        }
320
321        for pkg_data in &file.package_data {
322            let Some(datasource_id) = pkg_data.datasource_id else {
323                continue;
324            };
325
326            if !should_hoist_unassembled_dependencies(datasource_id) {
327                continue;
328            }
329
330            if already_emitted.contains(&(file.path.as_str(), datasource_id)) {
331                continue;
332            }
333
334            hoisted.extend(pkg_data.dependencies.iter().map(|dep| {
335                TopLevelDependency::from_dependency(dep, file.path.clone(), datasource_id, None)
336            }));
337        }
338    }
339
340    dependencies.extend(hoisted);
341}
342
343/// Whether a parsed dependency is worth reporting at the top level.
344///
345/// A dependency the parser could not resolve to a PURL is still a declared
346/// dependency as long as it carries the text it was declared with, and
347/// `extracted_requirement` is exactly that text. Dropping it discards the only
348/// record that the manifest asked for it.
349///
350/// Every assembler shares this rule. Testing the PURL alone — as most of them
351/// used to — made a purl-less dependency's visibility depend on which assembler
352/// happened to claim the datafile, so the same manifest reported it or not
353/// according to what sat next to it on disk.
354pub(super) fn is_reportable_dependency(dependency: &Dependency) -> bool {
355    dependency.purl.is_some() || dependency.extracted_requirement.is_some()
356}
357
358const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
359
360fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
361    if HOIST_IF_UNOWNED_DATASOURCE_IDS.contains(&datasource_id) {
362        return true;
363    }
364
365    if !assemblers::is_unassembled_datasource(datasource_id) {
366        return false;
367    }
368
369    !matches!(
370        datasource_id,
371        DatasourceId::NugetDirectoryBuildProps | DatasourceId::NugetDirectoryPackagesProps
372    )
373}
374
375fn stable_package_sort_key(package: &Package) -> (Option<&str>, Option<&str>, Option<&str>, &str) {
376    (
377        package.purl.as_deref(),
378        package.name.as_deref(),
379        package.version.as_deref(),
380        package
381            .datafile_paths
382            .first()
383            .map(String::as_str)
384            .unwrap_or(""),
385    )
386}
387
388fn assemble_one_per_package_data(
389    config: &AssemblerConfig,
390    files: &[FileInfo],
391    file_indices: &[usize],
392) -> Vec<DirectoryMergeOutput> {
393    let mut results = Vec::new();
394
395    for &idx in file_indices {
396        let file = &files[idx];
397        for pkg_data in &file.package_data {
398            let dsid_matches = pkg_data
399                .datasource_id
400                .is_some_and(|dsid| config.datasource_ids.contains(&dsid));
401
402            if !dsid_matches || should_skip_placeholder_only_cocoapods_podspec(pkg_data) {
403                continue;
404            }
405
406            let Some(datasource_id) = pkg_data.datasource_id else {
407                continue;
408            };
409            let datafile_path = file.path.clone();
410
411            // A record carrying an identity becomes its own package that owns its
412            // dependencies. A purl-less record cannot be a package, but its
413            // dependencies are still hoisted (unowned) rather than dropped — the
414            // same visibility they had before this datasource was assembled.
415            let (package, affected) = if pkg_data.purl.is_some() {
416                (
417                    Some(Package::from_package_data(pkg_data, datafile_path.clone())),
418                    vec![idx],
419                )
420            } else {
421                (None, Vec::new())
422            };
423            let for_package_uid = package.as_ref().map(|pkg| pkg.package_uid.clone());
424
425            let deps: Vec<TopLevelDependency> = pkg_data
426                .dependencies
427                .iter()
428                .filter(|dep| is_reportable_dependency(dep))
429                .map(|dep| {
430                    TopLevelDependency::from_dependency(
431                        dep,
432                        datafile_path.clone(),
433                        datasource_id,
434                        for_package_uid.clone(),
435                    )
436                })
437                .collect();
438
439            if package.is_none() && deps.is_empty() {
440                continue;
441            }
442
443            results.push((package, deps, affected));
444        }
445    }
446
447    results
448}
449
450pub(super) fn should_skip_placeholder_only_cocoapods_podspec(
451    pkg_data: &crate::models::PackageData,
452) -> bool {
453    pkg_data.datasource_id == Some(DatasourceId::CocoapodsPodspec)
454        && pkg_data
455            .extra_data
456            .as_ref()
457            .and_then(|data| data.get("dynamic_identity_placeholders"))
458            .and_then(|value| value.as_bool())
459            == Some(true)
460}
461
462/// Collect the assembler config keys active in a directory in a deterministic
463/// order.
464///
465/// The keys are gathered into a `BTreeSet` so iteration order is stable across
466/// runs and processes. A `HashSet` here made per-directory assembler execution
467/// order depend on hash seeding, which produced run-to-run nondeterministic
468/// output in polyglot directories (issue #1026).
469fn active_config_keys(
470    files: &[FileInfo],
471    file_indices: &[usize],
472    assembler_lookup: &HashMap<DatasourceId, DatasourceId>,
473) -> BTreeSet<DatasourceId> {
474    let mut groups: BTreeSet<DatasourceId> = BTreeSet::new();
475    for &idx in file_indices {
476        for pkg_data in &files[idx].package_data {
477            if let Some(dsid) = pkg_data.datasource_id
478                && let Some(&config_key) = assembler_lookup.get(&dsid)
479            {
480                groups.insert(config_key);
481            }
482        }
483    }
484    groups
485}
486
487/// Group file indices by their lexically normalized parent directory.
488///
489/// Keys use [`path_identity::scanned_file_dir`] so a scan root of `.` (paths
490/// like `./go.work`) buckets under the same empty/`module` keys as a
491/// path-rooted scan, matching topology collectors that store normalized roots.
492fn group_files_by_directory(files: &[FileInfo]) -> HashMap<PathBuf, Vec<usize>> {
493    let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
494    for (idx, file) in files.iter().enumerate() {
495        if let Some(parent) = path_identity::scanned_file_dir(&file.path) {
496            groups.entry(parent).or_default().push(idx);
497        }
498    }
499    groups
500}