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