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::fs;
25use std::path::Path;
26
27use log::warn;
28use packageurl::PackageUrl;
29use rustpython_parser::{Parse, ast};
30use serde_json::Value;
31
32use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
33
34use super::PackageParser;
35
36/// Conan conanfile.py recipe parser.
37///
38/// Parses Python-based Conan recipe files using AST analysis (no code execution).
39/// Extracts package metadata and dependencies from ConanFile class attributes.
40pub struct ConanFilePyParser;
41
42impl PackageParser for ConanFilePyParser {
43    const PACKAGE_TYPE: PackageType = PackageType::Conan;
44
45    fn is_match(path: &Path) -> bool {
46        path.file_name().is_some_and(|name| name == "conanfile.py")
47    }
48
49    fn extract_packages(path: &Path) -> Vec<PackageData> {
50        let contents = match fs::read_to_string(path) {
51            Ok(c) => c,
52            Err(e) => {
53                warn!("Failed to read {}: {}", path.display(), e);
54                return vec![default_package_data()];
55            }
56        };
57
58        vec![match ast::Suite::parse(&contents, "<conanfile.py>") {
59            Ok(statements) => parse_conanfile_py(&statements),
60            Err(e) => {
61                warn!("Failed to parse Python AST in {}: {}", path.display(), e);
62                default_package_data()
63            }
64        }]
65    }
66}
67
68/// Parse conanfile.py AST to extract ConanFile class attributes
69fn parse_conanfile_py(statements: &[ast::Stmt]) -> PackageData {
70    for stmt in statements {
71        if let ast::Stmt::ClassDef(class_def) = stmt
72            && has_conanfile_base(class_def)
73        {
74            return extract_conanfile_data(class_def);
75        }
76    }
77
78    default_package_data()
79}
80
81/// Check if class inherits from ConanFile
82fn has_conanfile_base(class_def: &ast::StmtClassDef) -> bool {
83    class_def.bases.iter().any(|base| {
84        if let ast::Expr::Name(ast::ExprName { id, .. }) = base {
85            id.as_str() == "ConanFile"
86        } else {
87            false
88        }
89    })
90}
91
92/// Extract package data from ConanFile class definition
93fn extract_conanfile_data(class_def: &ast::StmtClassDef) -> PackageData {
94    let mut name = None;
95    let mut version = None;
96    let mut description = None;
97    let mut _author = None;
98    let mut homepage_url = None;
99    let mut vcs_url = None;
100    let mut license_list = Vec::new();
101    let mut keywords = Vec::new();
102    let mut requires_list = Vec::new();
103
104    for stmt in class_def.body.iter() {
105        match stmt {
106            ast::Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
107                if let Some(target_name) = get_assignment_target(targets) {
108                    match target_name.as_str() {
109                        "name" => name = get_string_value(value),
110                        "version" => version = get_string_value(value),
111                        "description" => description = get_string_value(value),
112                        "author" => _author = get_string_value(value),
113                        "homepage" => homepage_url = get_string_value(value),
114                        "url" => vcs_url = get_string_value(value),
115                        "license" => license_list = get_list_values(value),
116                        "topics" => keywords = get_list_values(value),
117                        "requires" => requires_list = get_list_values(value),
118                        _ => {}
119                    }
120                }
121            }
122            ast::Stmt::FunctionDef(ast::StmtFunctionDef { body, .. }) => {
123                if let Some(requires) = extract_self_requires_calls(body) {
124                    requires_list.extend(requires);
125                }
126            }
127            _ => {}
128        }
129    }
130
131    let dependencies = requires_list
132        .into_iter()
133        .filter_map(|req| parse_conan_reference(&req))
134        .collect();
135
136    let extracted_license = if !license_list.is_empty() {
137        Some(license_list.join(", "))
138    } else {
139        None
140    };
141
142    PackageData {
143        name,
144        version,
145        description,
146        homepage_url,
147        vcs_url,
148        keywords,
149        dependencies,
150        extracted_license_statement: extracted_license,
151        datasource_id: Some(DatasourceId::ConanConanFilePy),
152        ..default_package_data()
153    }
154}
155
156/// Get assignment target name (e.g., "name" from "name = 'foo'")
157fn get_assignment_target(targets: &[ast::Expr]) -> Option<String> {
158    targets.first().and_then(|target| {
159        if let ast::Expr::Name(ast::ExprName { id, .. }) = target {
160            Some(id.to_string())
161        } else {
162            None
163        }
164    })
165}
166
167/// Extract string value from AST expression
168fn get_string_value(expr: &ast::Expr) -> Option<String> {
169    if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = expr {
170        match value {
171            ast::Constant::Str(s) => Some(s.to_string()),
172            _ => None,
173        }
174    } else {
175        None
176    }
177}
178
179/// Extract list of strings from tuple or list expression
180fn get_list_values(expr: &ast::Expr) -> Vec<String> {
181    match expr {
182        ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
183            elts.iter().filter_map(get_string_value).collect()
184        }
185        ast::Expr::List(ast::ExprList { elts, .. }) => {
186            elts.iter().filter_map(get_string_value).collect()
187        }
188        _ => {
189            if let Some(s) = get_string_value(expr) {
190                vec![s]
191            } else {
192                Vec::new()
193            }
194        }
195    }
196}
197
198/// Extract self.requires() method calls from function body
199fn extract_self_requires_calls(body: &[ast::Stmt]) -> Option<Vec<String>> {
200    let mut requires = Vec::new();
201
202    for stmt in body {
203        if let ast::Stmt::Expr(ast::StmtExpr { value, .. }) = stmt
204            && let ast::Expr::Call(call) = value.as_ref()
205            && is_self_requires_call(call)
206            && let Some(arg) = call.args.first()
207            && let Some(req) = get_string_value(arg)
208        {
209            requires.push(req);
210        }
211    }
212
213    if requires.is_empty() {
214        None
215    } else {
216        Some(requires)
217    }
218}
219
220/// Check if call is self.requires()
221fn is_self_requires_call(call: &ast::ExprCall) -> bool {
222    if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = call.func.as_ref()
223        && let ast::Expr::Name(ast::ExprName { id, .. }) = value.as_ref()
224    {
225        return id.as_str() == "self" && attr.as_str() == "requires";
226    }
227    false
228}
229
230/// Conan conanfile.txt manifest parser.
231///
232/// Extracts dependencies from the simple conanfile.txt format, which uses
233/// INI-style sections to specify runtime and build-time dependencies.
234pub struct ConanfileTxtParser;
235
236impl PackageParser for ConanfileTxtParser {
237    const PACKAGE_TYPE: PackageType = PackageType::Conan;
238
239    fn is_match(path: &Path) -> bool {
240        path.file_name().is_some_and(|name| name == "conanfile.txt")
241    }
242
243    fn extract_packages(path: &Path) -> Vec<PackageData> {
244        let contents = match fs::read_to_string(path) {
245            Ok(c) => c,
246            Err(e) => {
247                warn!("Failed to read {}: {}", path.display(), e);
248                return vec![default_package_data()];
249            }
250        };
251
252        let dependencies = parse_conanfile_txt(&contents);
253
254        vec![PackageData {
255            package_type: Some(Self::PACKAGE_TYPE),
256            dependencies,
257            primary_language: Some("C++".to_string()),
258            datasource_id: Some(DatasourceId::ConanConanFileTxt),
259            ..default_package_data()
260        }]
261    }
262}
263
264/// Conan lockfile (conan.lock) parser.
265///
266/// Extracts resolved dependencies from Conan lockfiles, which capture the
267/// complete dependency graph with exact versions and revisions.
268pub struct ConanLockParser;
269
270impl PackageParser for ConanLockParser {
271    const PACKAGE_TYPE: PackageType = PackageType::Conan;
272
273    fn is_match(path: &Path) -> bool {
274        path.file_name().is_some_and(|name| name == "conan.lock")
275    }
276
277    fn extract_packages(path: &Path) -> Vec<PackageData> {
278        let contents = match fs::read_to_string(path) {
279            Ok(c) => c,
280            Err(e) => {
281                warn!("Failed to read {}: {}", path.display(), e);
282                return vec![default_package_data()];
283            }
284        };
285
286        let json: Value = match serde_json::from_str(&contents) {
287            Ok(j) => j,
288            Err(e) => {
289                warn!("Failed to parse JSON in {}: {}", path.display(), e);
290                return vec![default_package_data()];
291            }
292        };
293
294        let dependencies = parse_conan_lock(&json);
295
296        vec![PackageData {
297            package_type: Some(Self::PACKAGE_TYPE),
298            dependencies,
299            primary_language: Some("C++".to_string()),
300            datasource_id: Some(DatasourceId::ConanLock),
301            ..default_package_data()
302        }]
303    }
304}
305
306fn parse_conan_reference(ref_str: &str) -> Option<Dependency> {
307    let (name, version_spec) = if let Some((n, v)) = ref_str.split_once('/') {
308        (n.trim(), Some(v.trim().to_string()))
309    } else {
310        (ref_str.trim(), None)
311    };
312
313    let version = version_spec.as_ref().and_then(|v| {
314        if !v.contains('[') && !v.contains('>') && !v.contains('<') {
315            Some(v.clone())
316        } else {
317            None
318        }
319    });
320
321    let purl = if let Some(v) = version.as_deref() {
322        PackageUrl::new("conan", name)
323            .map(|mut p| {
324                let _ = p.with_version(v);
325                p.to_string()
326            })
327            .unwrap_or_else(|_| format!("pkg:conan/{}", name))
328    } else {
329        format!("pkg:conan/{}", name)
330    };
331
332    let is_pinned = version_spec
333        .as_ref()
334        .map(|v| !v.contains('[') && !v.contains('>') && !v.contains('<'))
335        .unwrap_or(false);
336
337    Some(Dependency {
338        purl: Some(purl),
339        extracted_requirement: version_spec,
340        scope: Some("install".to_string()),
341        is_runtime: Some(true),
342        is_optional: Some(false),
343        is_pinned: Some(is_pinned),
344        is_direct: Some(true),
345        resolved_package: None,
346        extra_data: None,
347    })
348}
349
350fn parse_conanfile_txt(contents: &str) -> Vec<Dependency> {
351    let mut dependencies = Vec::new();
352    let mut current_section = None;
353
354    for line in contents.lines() {
355        let trimmed = line.trim();
356
357        if trimmed.is_empty() || trimmed.starts_with('#') {
358            continue;
359        }
360
361        if trimmed.starts_with('[') && trimmed.ends_with(']') {
362            current_section = Some(trimmed.trim_matches(|c| c == '[' || c == ']').to_string());
363            continue;
364        }
365
366        if let Some(ref section) = current_section {
367            let (scope, is_runtime) = match section.as_str() {
368                "requires" => ("install", true),
369                "build_requires" => ("build", false),
370                _ => continue,
371            };
372
373            if let Some(dep) = parse_conan_reference(trimmed) {
374                dependencies.push(Dependency {
375                    scope: Some(scope.to_string()),
376                    is_runtime: Some(is_runtime),
377                    ..dep
378                });
379            }
380        }
381    }
382
383    dependencies
384}
385
386fn parse_conan_lock(json: &Value) -> Vec<Dependency> {
387    let mut dependencies = Vec::new();
388
389    if let Some(graph_lock) = json.get("graph_lock")
390        && let Some(nodes) = graph_lock.get("nodes").and_then(|n| n.as_object())
391    {
392        for (_node_id, node_data) in nodes {
393            if let Some(ref_str) = node_data.get("ref").and_then(|r| r.as_str())
394                && !ref_str.is_empty()
395                && ref_str != "conanfile"
396                && let Some(dep) = parse_conan_reference(ref_str)
397            {
398                dependencies.push(dep);
399            }
400        }
401    }
402
403    dependencies
404}
405
406fn default_package_data() -> PackageData {
407    PackageData {
408        package_type: Some(ConanFilePyParser::PACKAGE_TYPE),
409        primary_language: Some("C++".to_string()),
410        ..Default::default()
411    }
412}
413
414crate::register_parser!(
415    "Conan C/C++ package manifest",
416    &["**/conanfile.py", "**/conanfile.txt", "**/conan.lock"],
417    "conan",
418    "C++",
419    Some("https://docs.conan.io/"),
420);