Skip to content

[bug] transitive_headers leaks across different dependency chains through Requirement.aggregate() #20072

Description

@gnaloaixey

Describe the bug

When a package has both a direct dependency on A and also reaches A transitively through another dependency B (where B has transitive_headers=True), the transitive_headers trait leaks from B's dependency chain into A via the aggregate() method.

Dependency structure

pkg_d (shared-library)
  └─ pkg_c (shared-library)
       ├─ pkg_a (shared-library)              # direct dependency, also reached via pkg_b
       ├─ pkg_a_2 (shared-library)            # direct dependency (control group, single path)
       └─ pkg_b (shared-library, transitive_headers=True)
            └─ pkg_a (shared-library)         # same as above, transitively

Expected behavior

For a shared-library chain, pkg_d's info.requires should ideally contain only its direct dependency:

"requires": ["pkg_c/0.1.Z"]

Since pkg_c uses transitive_headers=True on pkg_b, the following is also reasonable:

"requires": ["pkg_c/0.1.Z", "pkg_b/0.1.Z"]

The extra package pkg_a is the bug. It appears only because it has two paths (direct + via pkg_b). The control pkg_a_2 (single path) is correctly excluded, proving shared->shared propagation works for non-duplicated dependencies.

Actual behavior

{
    "Local Cache": {
        "pkg_d/0.1": {
            "revisions": {
                "3cc42339c1dd276a6ce348de034db0a7": {
                    "timestamp": 1781075212.285881,
                    "packages": {
                        "deb3e52ab812c47e795d7b0cdff374997c83ce89": {
                            "info": {
                                "settings": {
                                    "arch": "x86_64",
                                    "build_type": "Release",
                                    "compiler": "msvc",
                                    "compiler.cppstd": "14",
                                    "compiler.runtime": "dynamic",
                                    "compiler.runtime_type": "Release",
                                    "compiler.version": "190",
                                    "os": "Windows"
                                },
                                "requires": [
                                    "pkg_a/0.1.Z",
                                    "pkg_b/0.1.Z",
                                    "pkg_c/0.1.Z"
                                ]
                            }
                        }
                    }
                }
            }
        }
    }
}
  • pkg_c — expected (direct dependency)
  • pkg_b — reaches pkg_d via transitive_headers=True (arguably intended)
  • pkg_aincorrectly appears (bug)
  • pkg_a_2 — correctly excluded (proof that single-path packages work fine)

Root cause analysis

Three interactions in conan/internal/model/requires.py chain together to produce this behavior:

Step 1 — transform_downstream() L329-L330
if self.transitive_headers is not None:
    downstream_require.transitive_headers = self.transitive_headers

When pkg_c's dependency on pkg_b (transitive_headers=True) propagates pkg_b -> pkg_a downstream, the downstream_require for pkg_a inherits transitive_headers=True — a trait belonging to a different relationship (pkg_c -> pkg_b, not pkg_a's).

Step 2 — aggregate() L255
self.transitive_headers = self.transitive_headers or other.transitive_headers

When this propagated pkg_a (with leaked transitive_headers=True) arrives at pkg_c, it aggregates with pkg_c's existing direct require on pkg_a (transitive_headers=None). The OR merges them to transitive_headers=True.

Step 3 — transform_downstream() L327-L328
if require.transitive_headers is not None:
    downstream_require.headers = require.headers and require.transitive_headers

When pkg_c propagates the aggregated pkg_a (now transitive_headers=True) to pkg_d, line 328 overrides the headers=False (set by shared->shared rules at L299) back to True, making pkg_a visible to pkg_d.

Cascade flow
pkg_c -> pkg_b (transitive_headers=True)
  +-> pkg_b -> pkg_a
       +-> transform_downstream L330: downstream_require.transitive_headers = True
            +-> propagates to pkg_c
                 +-> aggregate L255: transitive_headers = True or None = True
                      +-> transform_downstream L328: headers = True
                           +-> pkg_d sees pkg_a as a dependency (INCORRECT)

Possible fix (needs discussion)

The most straightforward fix is removing the transitive_headers/transitive_libs merge in aggregate() (L255-256):

# Remove these two lines:
# self.transitive_headers = self.transitive_headers or other.transitive_headers
# self.transitive_libs = self.transitive_libs or other.transitive_libs

However, it is unclear whether this is a bug or by design. The transitive_headers propagation through aggregate() may be intentional for loop-closing scenarios. Discussion with maintainers is needed on:

  • Whether transitive_headers should be aggregated across paths at all
  • If so, which path should take precedence
  • Whether transform_downstream() L327-328 should check transitive_headers in shared->shared propagation

How to reproduce it

A conan_transitive_header.zip archive is attached with the full project.

Environment

  • Conan version: 2.21, 2.29 (both affected)
  • OS: Windows

Export and inspect

conan export-pkg pkg_a -pr default
conan export-pkg pkg_a_2 -pr default
conan export-pkg pkg_b -pr default
conan export-pkg pkg_c -pr default
conan export-pkg pkg_d -pr default
conan list pkg_d/0.1:* --format=json

conan export-pkg pkg_c_2 -pr default
conan export-pkg pkg_d_2 -pr default
conan list pkg_d_2/0.1:* --format=json

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions