Skip to main content

provenant/parsers/
conan.rs

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