Skip to main content

provenant/parsers/
conan.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
7//! Parser for Conan C/C++ package manager manifests.
8//!
9//! Extracts package metadata and dependencies from Conan manifest files.
10//!
11//! # Supported Formats
12//! - conanfile.py (Recipe files with Python AST parsing)
13//! - conanfile.txt (Simple dependency specification format)
14//! - conan.lock (Lockfile with resolved dependency graph)
15//!
16//! # Key Features
17//! - AST-based conanfile.py parsing (NO code execution)
18//! - Dependency extraction from [requires] and [build_requires] sections
19//! - Version constraint parsing for Conan reference format (name/version@user/channel)
20//! - Package URL (purl) generation for resolved dependencies
21//! - Lockfile dependency graph parsing
22//!
23//! # Implementation Notes
24//! - conanfile.py: AST extracts class attributes and self.requires() calls
25//! - conanfile.txt sections: [requires] = runtime, [build_requires] = build-time
26//! - conan.lock uses JSON format with graph_lock.nodes structure
27//! - Version constraints use Conan-specific operators: [>, <, ranges]
28//! - Only exact versions (without operators) are extracted as pinned versions
29
30use std::path::Path;
31
32use crate::parser_warn as warn;
33use packageurl::PackageUrl;
34use ruff_python_ast as ast;
35use ruff_python_parser::parse_module;
36use serde_json::Value;
37
38use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
39
40use super::PackageParser;
41use super::license_normalization::{
42    DeclaredLicenseMatchMetadata, build_declared_license_data, normalize_declared_license_key,
43};
44use super::utils::{CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field};
45
46const MAX_AST_DEPTH: usize = 50;
47const MAX_AST_NODES: usize = 10_000;
48
49/// Conan conanfile.py recipe parser.
50///
51/// Parses Python-based Conan recipe files using AST analysis (no code execution).
52/// Extracts package metadata and dependencies from ConanFile class attributes.
53pub struct ConanFilePyParser;
54
55impl PackageParser for ConanFilePyParser {
56    const PACKAGE_TYPE: PackageType = PackageType::Conan;
57
58    fn is_match(path: &Path) -> bool {
59        path.file_name().is_some_and(|name| name == "conanfile.py")
60    }
61
62    fn extract_packages(path: &Path) -> Vec<PackageData> {
63        let contents = match read_file_to_string(path, None) {
64            Ok(c) => c,
65            Err(e) => {
66                warn!("Failed to read {}: {}", path.display(), e);
67                return vec![default_package_data(DatasourceId::ConanConanFilePy)];
68            }
69        };
70
71        vec![match parse_module(&contents) {
72            Ok(parsed) => parse_conanfile_py(parsed.suite()),
73            Err(e) => {
74                warn!("Failed to parse Python AST in {}: {}", path.display(), e);
75                default_package_data(DatasourceId::ConanConanFilePy)
76            }
77        }]
78    }
79
80    fn metadata() -> Vec<super::metadata::ParserMetadata> {
81        vec![super::metadata::ParserMetadata {
82            description: "Conan C/C++ package manifest",
83            file_patterns: &["**/conanfile.py", "**/conanfile.txt", "**/conan.lock"],
84            package_type: "conan",
85            primary_language: "C++",
86            documentation_url: Some("https://docs.conan.io/"),
87        }]
88    }
89}
90
91/// Parse conanfile.py AST to extract ConanFile class attributes
92fn parse_conanfile_py(statements: &[ast::Stmt]) -> PackageData {
93    for stmt in statements {
94        if let ast::Stmt::ClassDef(class_def) = stmt
95            && has_conanfile_base(class_def)
96        {
97            return extract_conanfile_data(class_def);
98        }
99    }
100
101    default_package_data(DatasourceId::ConanConanFilePy)
102}
103
104/// Check if class inherits from ConanFile
105fn has_conanfile_base(class_def: &ast::StmtClassDef) -> bool {
106    class_def.bases().iter().any(|base| {
107        if let ast::Expr::Name(ast::ExprName { id, .. }) = base {
108            id.as_str() == "ConanFile"
109        } else {
110            false
111        }
112    })
113}
114
115/// Extract package data from ConanFile class definition
116fn extract_conanfile_data(class_def: &ast::StmtClassDef) -> PackageData {
117    let mut name = None;
118    let mut version = None;
119    let mut description = None;
120    let mut _author = None;
121    let mut homepage_url = None;
122    let mut vcs_url = None;
123    let mut license_list = Vec::new();
124    let mut keywords = Vec::new();
125    let mut requires_list = Vec::new();
126    let mut tool_requires_list = Vec::new();
127
128    let limit = capped_iteration_limit(class_def.body.len(), "conanfile.py class body");
129    for stmt in class_def.body.iter().take(limit) {
130        match stmt {
131            ast::Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
132                if let Some(target_name) = get_assignment_target(targets) {
133                    match target_name.as_str() {
134                        "name" => name = get_string_value(value).map(truncate_field),
135                        "version" => version = get_string_value(value).map(truncate_field),
136                        "description" => description = get_string_value(value).map(truncate_field),
137                        "author" => _author = get_string_value(value).map(truncate_field),
138                        "homepage" => homepage_url = get_string_value(value).map(truncate_field),
139                        "url" => vcs_url = get_string_value(value).map(truncate_field),
140                        "license" => {
141                            license_list = get_list_values(value)
142                                .into_iter()
143                                .map(truncate_field)
144                                .collect()
145                        }
146                        "topics" => {
147                            keywords = get_list_values(value)
148                                .into_iter()
149                                .map(truncate_field)
150                                .collect()
151                        }
152                        "requires" => {
153                            requires_list = get_list_values(value)
154                                .into_iter()
155                                .map(truncate_field)
156                                .collect()
157                        }
158                        _ => {}
159                    }
160                }
161            }
162            ast::Stmt::FunctionDef(ast::StmtFunctionDef { body, .. }) => {
163                if let Some(requires) = extract_self_requires_calls(body, "requires") {
164                    requires_list.extend(requires);
165                }
166                if let Some(tool_requires) = extract_self_requires_calls(body, "tool_requires") {
167                    tool_requires_list.extend(tool_requires);
168                }
169            }
170            _ => {}
171        }
172    }
173
174    let mut dependencies = requires_list
175        .into_iter()
176        .filter_map(|req| parse_conan_reference(&req))
177        .collect::<Vec<_>>();
178    dependencies.extend(
179        tool_requires_list
180            .into_iter()
181            .filter_map(|req| parse_conan_reference(&req))
182            .map(|dep| Dependency {
183                scope: Some("build".to_string()),
184                is_runtime: Some(false),
185                ..dep
186            }),
187    );
188
189    let extracted_license = if !license_list.is_empty() {
190        Some(truncate_field(license_list.join(", ")))
191    } else {
192        None
193    };
194    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
195        if license_list.len() == 1 {
196            if let Some(normalized) = normalize_declared_license_key(&license_list[0]) {
197                let (expr, spdx, detections) = build_declared_license_data(
198                    normalized,
199                    DeclaredLicenseMatchMetadata::single_line(&license_list[0]),
200                );
201                (
202                    expr.map(truncate_field),
203                    spdx.map(truncate_field),
204                    detections,
205                )
206            } else {
207                (None, None, Vec::new())
208            }
209        } else {
210            (None, None, Vec::new())
211        };
212
213    PackageData {
214        name,
215        version,
216        description,
217        homepage_url,
218        vcs_url,
219        keywords,
220        dependencies,
221        declared_license_expression,
222        declared_license_expression_spdx,
223        license_detections,
224        extracted_license_statement: extracted_license,
225        datasource_id: Some(DatasourceId::ConanConanFilePy),
226        ..default_package_data(DatasourceId::ConanConanFilePy)
227    }
228}
229
230/// Get assignment target name (e.g., "name" from "name = 'foo'")
231fn get_assignment_target(targets: &[ast::Expr]) -> Option<String> {
232    targets.first().and_then(|target| {
233        if let ast::Expr::Name(ast::ExprName { id, .. }) = target {
234            Some(id.to_string())
235        } else {
236            None
237        }
238    })
239}
240
241/// Extract string value from AST expression
242fn get_string_value(expr: &ast::Expr) -> Option<String> {
243    match expr {
244        ast::Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) => {
245            Some(value.to_str().to_string())
246        }
247        _ => None,
248    }
249}
250
251/// Extract list of strings from tuple or list expression
252fn get_list_values(expr: &ast::Expr) -> Vec<String> {
253    match expr {
254        ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
255            elts.iter().filter_map(get_string_value).collect()
256        }
257        ast::Expr::List(ast::ExprList { elts, .. }) => {
258            elts.iter().filter_map(get_string_value).collect()
259        }
260        _ => {
261            if let Some(s) = get_string_value(expr) {
262                vec![s]
263            } else {
264                Vec::new()
265            }
266        }
267    }
268}
269
270/// Extract self.requires() method calls from function body
271fn extract_self_requires_calls(body: &[ast::Stmt], method_name: &str) -> Option<Vec<String>> {
272    let mut requires = Vec::new();
273    let mut node_count = 0usize;
274
275    for stmt in body {
276        collect_self_method_calls(stmt, method_name, &mut requires, 0, &mut node_count);
277        if node_count >= MAX_AST_NODES {
278            warn!(
279                "Exceeded MAX_AST_NODES ({}) in extract_self_requires_calls",
280                MAX_AST_NODES
281            );
282            break;
283        }
284    }
285
286    if requires.is_empty() {
287        None
288    } else {
289        Some(requires)
290    }
291}
292
293fn collect_self_method_calls(
294    stmt: &ast::Stmt,
295    method_name: &str,
296    out: &mut Vec<String>,
297    depth: usize,
298    node_count: &mut usize,
299) {
300    if depth > MAX_AST_DEPTH {
301        warn!(
302            "Exceeded MAX_AST_DEPTH ({}) in collect_self_method_calls",
303            MAX_AST_DEPTH
304        );
305        return;
306    }
307    *node_count += 1;
308    if *node_count > MAX_AST_NODES {
309        return;
310    }
311
312    match stmt {
313        ast::Stmt::Expr(ast::StmtExpr { value, .. }) => {
314            if let ast::Expr::Call(call) = value.as_ref()
315                && is_self_method_call(call, method_name)
316                && let Some(arg) = call.arguments.args.first()
317                && let Some(req) = get_string_value(arg)
318            {
319                out.push(truncate_field(req));
320            }
321        }
322        ast::Stmt::If(ast::StmtIf {
323            body,
324            elif_else_clauses,
325            ..
326        }) => {
327            for nested in body {
328                collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
329            }
330            for clause in elif_else_clauses {
331                for nested in &clause.body {
332                    collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
333                }
334            }
335        }
336        ast::Stmt::With(ast::StmtWith { body, .. })
337        | ast::Stmt::While(ast::StmtWhile { body, .. })
338        | ast::Stmt::For(ast::StmtFor { body, .. }) => {
339            for nested in body {
340                collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
341            }
342        }
343        ast::Stmt::Try(ast::StmtTry {
344            body,
345            handlers,
346            orelse,
347            finalbody,
348            ..
349        }) => {
350            for nested in body.iter().chain(orelse.iter()).chain(finalbody.iter()) {
351                collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
352            }
353            for handler in handlers {
354                let ast::ExceptHandler::ExceptHandler(handler) = handler;
355                for nested in &handler.body {
356                    collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
357                }
358            }
359        }
360        ast::Stmt::Match(ast::StmtMatch { cases, .. }) => {
361            for case in cases {
362                for nested in &case.body {
363                    collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
364                }
365            }
366        }
367        _ => {}
368    }
369}
370
371fn is_self_method_call(call: &ast::ExprCall, method_name: &str) -> bool {
372    if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = call.func.as_ref()
373        && let ast::Expr::Name(ast::ExprName { id, .. }) = value.as_ref()
374    {
375        return id.as_str() == "self" && attr.as_str() == method_name;
376    }
377    false
378}
379
380/// Conan conanfile.txt manifest parser.
381///
382/// Extracts dependencies from the simple conanfile.txt format, which uses
383/// INI-style sections to specify runtime and build-time dependencies.
384pub struct ConanfileTxtParser;
385
386impl PackageParser for ConanfileTxtParser {
387    const PACKAGE_TYPE: PackageType = PackageType::Conan;
388
389    fn is_match(path: &Path) -> bool {
390        path.file_name().is_some_and(|name| name == "conanfile.txt")
391    }
392
393    fn extract_packages(path: &Path) -> Vec<PackageData> {
394        let contents = match read_file_to_string(path, None) {
395            Ok(c) => c,
396            Err(e) => {
397                warn!("Failed to read {}: {}", path.display(), e);
398                return vec![default_package_data(DatasourceId::ConanConanFileTxt)];
399            }
400        };
401
402        let dependencies = parse_conanfile_txt(&contents);
403
404        vec![PackageData {
405            package_type: Some(Self::PACKAGE_TYPE),
406            dependencies,
407            primary_language: Some("C++".to_string()),
408            datasource_id: Some(DatasourceId::ConanConanFileTxt),
409            ..default_package_data(DatasourceId::ConanConanFileTxt)
410        }]
411    }
412}
413
414/// Conan lockfile (conan.lock) parser.
415///
416/// Extracts resolved dependencies from Conan lockfiles, which capture the
417/// complete dependency graph with exact versions and revisions.
418pub struct ConanLockParser;
419
420impl PackageParser for ConanLockParser {
421    const PACKAGE_TYPE: PackageType = PackageType::Conan;
422
423    fn is_match(path: &Path) -> bool {
424        path.file_name().is_some_and(|name| name == "conan.lock")
425    }
426
427    fn extract_packages(path: &Path) -> Vec<PackageData> {
428        let contents = match read_file_to_string(path, None) {
429            Ok(c) => c,
430            Err(e) => {
431                warn!("Failed to read {}: {}", path.display(), e);
432                return vec![default_package_data(DatasourceId::ConanLock)];
433            }
434        };
435
436        let json: Value = match serde_json::from_str(&contents) {
437            Ok(j) => j,
438            Err(e) => {
439                warn!("Failed to parse JSON in {}: {}", path.display(), e);
440                return vec![default_package_data(DatasourceId::ConanLock)];
441            }
442        };
443
444        let dependencies = parse_conan_lock(&json);
445
446        vec![PackageData {
447            package_type: Some(Self::PACKAGE_TYPE),
448            dependencies,
449            primary_language: Some("C++".to_string()),
450            datasource_id: Some(DatasourceId::ConanLock),
451            ..default_package_data(DatasourceId::ConanLock)
452        }]
453    }
454}
455
456fn parse_conan_reference(ref_str: &str) -> Option<Dependency> {
457    let (name, version_spec) = if let Some((n, v)) = ref_str.split_once('/') {
458        // conan 2.x references carry a recipe revision (`#...`) and a lockfile
459        // timestamp (`%...`) after the version; strip both so the version/requirement
460        // is the bare version (or version range).
461        let version = v.trim().split(['#', '%']).next().unwrap_or("").trim();
462        (
463            n.trim(),
464            (!version.is_empty()).then(|| truncate_field(version.to_string())),
465        )
466    } else {
467        (ref_str.trim(), None)
468    };
469
470    let version = version_spec.as_ref().and_then(|v| {
471        if !v.contains('[') && !v.contains('>') && !v.contains('<') {
472            Some(v.clone())
473        } else {
474            None
475        }
476    });
477
478    let purl = if let Some(v) = version.as_deref() {
479        PackageUrl::new("conan", name)
480            .map(|mut p| {
481                let _ = p.with_version(v);
482                p.to_string()
483            })
484            .unwrap_or_else(|_| format!("pkg:conan/{}", name))
485    } else {
486        format!("pkg:conan/{}", name)
487    };
488
489    let is_pinned = version_spec
490        .as_ref()
491        .map(|v| !v.contains('[') && !v.contains('>') && !v.contains('<'))
492        .unwrap_or(false);
493
494    Some(Dependency {
495        purl: Some(truncate_field(purl)),
496        extracted_requirement: version_spec,
497        scope: Some("install".to_string()),
498        is_runtime: Some(true),
499        is_optional: Some(false),
500        is_pinned: Some(is_pinned),
501        is_direct: Some(true),
502        resolved_package: None,
503        extra_data: None,
504    })
505}
506
507fn parse_conanfile_txt(contents: &str) -> Vec<Dependency> {
508    let mut dependencies = Vec::new();
509    let mut current_section = None;
510
511    for line in contents.lines().capped("conanfile.txt lines") {
512        let trimmed = line.trim();
513
514        if trimmed.is_empty() || trimmed.starts_with('#') {
515            continue;
516        }
517
518        if trimmed.starts_with('[') && trimmed.ends_with(']') {
519            current_section = Some(trimmed.trim_matches(|c| c == '[' || c == ']').to_string());
520            continue;
521        }
522
523        if let Some(ref section) = current_section {
524            let (scope, is_runtime) = match section.as_str() {
525                "requires" => ("install", true),
526                "build_requires" => ("build", false),
527                _ => continue,
528            };
529
530            if let Some(dep) = parse_conan_reference(trimmed) {
531                dependencies.push(Dependency {
532                    scope: Some(scope.to_string()),
533                    is_runtime: Some(is_runtime),
534                    ..dep
535                });
536            }
537        }
538    }
539
540    dependencies
541}
542
543fn parse_conan_lock(json: &Value) -> Vec<Dependency> {
544    let mut dependencies = Vec::new();
545
546    // conan 1.x lockfiles (format 0.4): graph_lock.nodes[].ref
547    if let Some(graph_lock) = json.get("graph_lock")
548        && let Some(nodes) = graph_lock.get("nodes").and_then(|n| n.as_object())
549    {
550        let limit = capped_iteration_limit(nodes.len(), "conan.lock graph_lock nodes");
551        for (_node_id, node_data) in nodes.iter().take(limit) {
552            if let Some(ref_str) = node_data.get("ref").and_then(|r| r.as_str())
553                && !ref_str.is_empty()
554                && ref_str != "conanfile"
555                && let Some(mut dep) = parse_conan_reference(ref_str)
556            {
557                // The graph lock captures the full resolved graph without marking
558                // direct vs transitive, so leave is_direct unset (same as the v0.5 path).
559                dep.is_direct = None;
560                dependencies.push(dep);
561            }
562        }
563    }
564
565    // conan 2.x lockfiles (format 0.5+): top-level requires / build_requires /
566    // python_requires arrays of "name/version#revision%timestamp" strings. The lockfile
567    // captures the full resolved graph without marking direct vs transitive, so leave
568    // is_direct unset rather than guessing.
569    for (key, is_runtime, scope) in [
570        ("requires", true, "install"),
571        ("build_requires", false, "build"),
572        ("python_requires", false, "python_requires"),
573    ] {
574        if let Some(refs) = json.get(key).and_then(|v| v.as_array()) {
575            let limit = capped_iteration_limit(refs.len(), "conan.lock requires");
576            for entry in refs.iter().take(limit) {
577                if let Some(ref_str) = entry.as_str()
578                    && !ref_str.is_empty()
579                    && let Some(mut dep) = parse_conan_reference(ref_str)
580                {
581                    dep.is_runtime = Some(is_runtime);
582                    dep.scope = Some(scope.to_string());
583                    dep.is_direct = None;
584                    dependencies.push(dep);
585                }
586            }
587        }
588    }
589
590    dependencies
591}
592
593fn default_package_data(datasource_id: DatasourceId) -> PackageData {
594    PackageData {
595        package_type: Some(ConanFilePyParser::PACKAGE_TYPE),
596        primary_language: Some("C++".to_string()),
597        datasource_id: Some(datasource_id),
598        ..Default::default()
599    }
600}