Skip to main content

provenant/parsers/
conan.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Conan C/C++ package manager manifests.
7//!
8//! Extracts package metadata and dependencies from Conan manifest files.
9//!
10//! # Supported Formats
11//! - conanfile.py (Recipe files with Python AST parsing)
12//! - conanfile.txt (Simple dependency specification format)
13//! - conan.lock (Lockfile with resolved dependency graph)
14//!
15//! # Key Features
16//! - AST-based conanfile.py parsing (NO code execution)
17//! - Dependency extraction from [requires] and [build_requires] sections
18//! - Version constraint parsing for Conan reference format (name/version@user/channel)
19//! - Package URL (purl) generation for resolved dependencies
20//! - Lockfile dependency graph parsing
21//!
22//! # Implementation Notes
23//! - conanfile.py: AST extracts class attributes and self.requires() calls
24//! - conanfile.txt sections: [requires] = runtime, [build_requires] = build-time
25//! - conan.lock uses JSON format with graph_lock.nodes structure
26//! - Version constraints use Conan-specific operators: [>, <, ranges]
27//! - Only exact versions (without operators) are extracted as pinned versions
28
29use std::path::Path;
30
31use crate::parser_warn as warn;
32use packageurl::PackageUrl;
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    let purl = if let Some(v) = version.as_deref() {
478        PackageUrl::new("conan", name)
479            .map(|mut p| {
480                let _ = p.with_version(v);
481                p.to_string()
482            })
483            .unwrap_or_else(|_| format!("pkg:conan/{}", name))
484    } else {
485        format!("pkg:conan/{}", name)
486    };
487
488    let is_pinned = version_spec
489        .as_ref()
490        .map(|v| !v.contains('[') && !v.contains('>') && !v.contains('<'))
491        .unwrap_or(false);
492
493    Some(Dependency {
494        purl: Some(truncate_field(purl)),
495        extracted_requirement: version_spec,
496        scope: Some("install".to_string()),
497        is_runtime: Some(true),
498        is_optional: Some(false),
499        is_pinned: Some(is_pinned),
500        is_direct: Some(true),
501        resolved_package: None,
502        extra_data: None,
503    })
504}
505
506fn parse_conanfile_txt(contents: &str) -> Vec<Dependency> {
507    let mut dependencies = Vec::new();
508    let mut current_section = None;
509
510    for line in contents.lines().capped("conanfile.txt lines") {
511        let trimmed = line.trim();
512
513        if trimmed.is_empty() || trimmed.starts_with('#') {
514            continue;
515        }
516
517        if trimmed.starts_with('[') && trimmed.ends_with(']') {
518            current_section = Some(trimmed.trim_matches(|c| c == '[' || c == ']').to_string());
519            continue;
520        }
521
522        if let Some(ref section) = current_section {
523            let (scope, is_runtime) = match section.as_str() {
524                "requires" => ("install", true),
525                "build_requires" => ("build", false),
526                _ => continue,
527            };
528
529            if let Some(dep) = parse_conan_reference(trimmed) {
530                dependencies.push(Dependency {
531                    scope: Some(scope.to_string()),
532                    is_runtime: Some(is_runtime),
533                    ..dep
534                });
535            }
536        }
537    }
538
539    dependencies
540}
541
542fn parse_conan_lock(json: &Value) -> Vec<Dependency> {
543    let mut dependencies = Vec::new();
544
545    // conan 1.x lockfiles (format 0.4): graph_lock.nodes[].ref
546    if let Some(graph_lock) = json.get("graph_lock")
547        && let Some(nodes) = graph_lock.get("nodes").and_then(|n| n.as_object())
548    {
549        let limit = capped_iteration_limit(nodes.len(), "conan.lock graph_lock nodes");
550        for (_node_id, node_data) in nodes.iter().take(limit) {
551            if let Some(ref_str) = node_data.get("ref").and_then(|r| r.as_str())
552                && !ref_str.is_empty()
553                && ref_str != "conanfile"
554                && let Some(mut dep) = parse_conan_reference(ref_str)
555            {
556                // The graph lock captures the full resolved graph without marking
557                // direct vs transitive, so leave is_direct unset (same as the v0.5 path).
558                dep.is_direct = None;
559                dependencies.push(dep);
560            }
561        }
562    }
563
564    // conan 2.x lockfiles (format 0.5+): top-level requires / build_requires /
565    // python_requires arrays of "name/version#revision%timestamp" strings. The lockfile
566    // captures the full resolved graph without marking direct vs transitive, so leave
567    // is_direct unset rather than guessing.
568    for (key, is_runtime, scope) in [
569        ("requires", true, "install"),
570        ("build_requires", false, "build"),
571        ("python_requires", false, "python_requires"),
572    ] {
573        if let Some(refs) = json.get(key).and_then(|v| v.as_array()) {
574            let limit = capped_iteration_limit(refs.len(), "conan.lock requires");
575            for entry in refs.iter().take(limit) {
576                if let Some(ref_str) = entry.as_str()
577                    && !ref_str.is_empty()
578                    && let Some(mut dep) = parse_conan_reference(ref_str)
579                {
580                    dep.is_runtime = Some(is_runtime);
581                    dep.scope = Some(scope.to_string());
582                    dep.is_direct = None;
583                    dependencies.push(dep);
584                }
585            }
586        }
587    }
588
589    dependencies
590}
591
592fn default_package_data(datasource_id: DatasourceId) -> PackageData {
593    PackageData {
594        package_type: Some(ConanFilePyParser::PACKAGE_TYPE),
595        primary_language: Some("C++".to_string()),
596        datasource_id: Some(datasource_id),
597        ..Default::default()
598    }
599}