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_a — incorrectly 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
Describe the bug
When a package has both a direct dependency on
Aand also reachesAtransitively through another dependencyB(whereBhastransitive_headers=True), thetransitive_headerstrait leaks fromB's dependency chain intoAvia theaggregate()method.Dependency structure
Expected behavior
For a shared-library chain,
pkg_d'sinfo.requiresshould ideally contain only its direct dependency:Since
pkg_cusestransitive_headers=Trueonpkg_b, the following is also reasonable:The extra package
pkg_ais the bug. It appears only because it has two paths (direct + viapkg_b). The controlpkg_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— reachespkg_dviatransitive_headers=True(arguably intended)pkg_a— incorrectly appears (bug)pkg_a_2— correctly excluded (proof that single-path packages work fine)Root cause analysis
Three interactions in
conan/internal/model/requires.pychain together to produce this behavior:Step 1 —
transform_downstream()L329-L330When
pkg_c's dependency onpkg_b(transitive_headers=True) propagatespkg_b -> pkg_adownstream, thedownstream_requireforpkg_ainheritstransitive_headers=True— a trait belonging to a different relationship (pkg_c -> pkg_b, notpkg_a's).Step 2 —
aggregate()L255When this propagated
pkg_a(with leakedtransitive_headers=True) arrives atpkg_c, it aggregates withpkg_c's existing direct require onpkg_a(transitive_headers=None). The OR merges them totransitive_headers=True.Step 3 —
transform_downstream()L327-L328When
pkg_cpropagates the aggregatedpkg_a(nowtransitive_headers=True) topkg_d, line 328 overrides theheaders=False(set by shared->shared rules at L299) back toTrue, makingpkg_avisible topkg_d.Cascade flow
Possible fix (needs discussion)
The most straightforward fix is removing the
transitive_headers/transitive_libsmerge inaggregate()(L255-256):However, it is unclear whether this is a bug or by design. The
transitive_headerspropagation throughaggregate()may be intentional for loop-closing scenarios. Discussion with maintainers is needed on:transitive_headersshould be aggregated across paths at alltransform_downstream()L327-328 should checktransitive_headersin shared->shared propagationHow to reproduce it
A conan_transitive_header.zip archive is attached with the full project.
Environment
Export and inspect