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