Skip to main content

provenant/parsers/
conan.rs

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