Skip to main content

provenant/parsers/
cargo.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 Cargo.toml manifest files.
7//!
8//! Extracts package metadata, dependencies, and license information from
9//! Rust Cargo.toml files.
10//!
11//! # Supported Formats
12//! - Cargo.toml (manifest)
13//!
14//! # Key Features
15//! - Dependency extraction with feature flags and optional dependencies
16//! - `is_pinned` analysis (exact version vs range specifiers)
17//! - Package URL (purl) generation
18//! - Workspace inheritance detection (stores `"workspace"` markers in extra_data)
19//!
20//! # Implementation Notes
21//! - Uses toml crate for parsing
22//! - Version pinning: `"1.0.0"` is pinned, `"^1.0.0"` is not
23//! - Graceful error handling with `warn!()` logs
24//! - Direct dependencies: all in manifest are direct (no lockfile)
25
26use crate::models::{DatasourceId, Dependency, FileReference, PackageData, PackageType, Party};
27use crate::parser_warn as warn;
28use crate::parsers::utils::{
29    MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, split_name_email, truncate_field,
30};
31use packageurl::PackageUrl;
32use std::path::Path;
33use toml::Value;
34
35use super::PackageParser;
36use super::license_normalization::{
37    DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
38    normalize_spdx_expression,
39};
40
41const FIELD_PACKAGE: &str = "package";
42const FIELD_NAME: &str = "name";
43const FIELD_VERSION: &str = "version";
44const FIELD_LICENSE: &str = "license";
45const FIELD_LICENSE_FILE: &str = "license-file";
46const FIELD_AUTHORS: &str = "authors";
47const FIELD_REPOSITORY: &str = "repository";
48const FIELD_HOMEPAGE: &str = "homepage";
49const FIELD_DEPENDENCIES: &str = "dependencies";
50const FIELD_DEV_DEPENDENCIES: &str = "dev-dependencies";
51const FIELD_DEV_DEPENDENCIES_LEGACY: &str = "dev_dependencies";
52const FIELD_BUILD_DEPENDENCIES: &str = "build-dependencies";
53const FIELD_BUILD_DEPENDENCIES_LEGACY: &str = "build_dependencies";
54const FIELD_DESCRIPTION: &str = "description";
55const FIELD_KEYWORDS: &str = "keywords";
56const FIELD_CATEGORIES: &str = "categories";
57const FIELD_RUST_VERSION: &str = "rust-version";
58const FIELD_EDITION: &str = "edition";
59const FIELD_README: &str = "readme";
60const FIELD_PUBLISH: &str = "publish";
61
62/// Rust Cargo.toml manifest parser.
63///
64/// Extracts package metadata including dependencies (regular, dev, build),
65/// license information, and crate-specific fields.
66pub struct CargoParser;
67
68impl PackageParser for CargoParser {
69    const PACKAGE_TYPE: PackageType = PackageType::Cargo;
70
71    fn extract_packages(path: &Path) -> Vec<PackageData> {
72        let toml_content = match read_cargo_toml(path) {
73            Ok(content) => content,
74            Err(_) => return Vec::new(),
75        };
76
77        let package = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table());
78
79        let name = package
80            .and_then(|p| p.get(FIELD_NAME))
81            .and_then(|v| v.as_str())
82            .map(|s| truncate_field(s.to_string()));
83
84        let version = package
85            .and_then(|p| p.get(FIELD_VERSION))
86            .and_then(|v| v.as_str())
87            .map(|s| truncate_field(s.to_string()));
88
89        let raw_license = package
90            .and_then(|p| p.get(FIELD_LICENSE))
91            .and_then(|v| v.as_str())
92            .map(|s| truncate_field(s.to_string()));
93        let file_references = extract_file_references(&toml_content);
94        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
95            raw_license
96                .as_deref()
97                .and_then(normalize_spdx_expression)
98                .map(|normalized| {
99                    build_declared_license_data(
100                        normalized,
101                        DeclaredLicenseMatchMetadata::single_line(
102                            raw_license.as_deref().unwrap_or_default(),
103                        ),
104                    )
105                })
106                .unwrap_or_else(empty_declared_license_data);
107
108        let extracted_license_statement = raw_license.clone();
109
110        let dependencies = extract_dependencies_for_scopes(&toml_content, &[FIELD_DEPENDENCIES]);
111        let dev_dependencies = extract_dependencies_for_scopes(
112            &toml_content,
113            &[FIELD_DEV_DEPENDENCIES, FIELD_DEV_DEPENDENCIES_LEGACY],
114        );
115        let build_dependencies = extract_dependencies_for_scopes(
116            &toml_content,
117            &[FIELD_BUILD_DEPENDENCIES, FIELD_BUILD_DEPENDENCIES_LEGACY],
118        );
119
120        let purl = create_package_url(&name, &version);
121
122        let homepage_url = package
123            .and_then(|p| p.get(FIELD_HOMEPAGE))
124            .and_then(|v| v.as_str())
125            .map(|s| truncate_field(s.to_string()))
126            .or_else(|| {
127                name.as_ref()
128                    .map(|n| format!("https://crates.io/crates/{}", n))
129            });
130
131        let repository_url = package
132            .and_then(|p| p.get(FIELD_REPOSITORY))
133            .and_then(|v| v.as_str())
134            .map(|s| truncate_field(s.to_string()));
135        let download_url = None;
136
137        let api_data_url = generate_cargo_api_url(&name, &version);
138
139        let repository_homepage_url = name
140            .as_ref()
141            .map(|n| format!("https://crates.io/crates/{}", n));
142
143        let repository_download_url = match (&name, &version) {
144            (Some(n), Some(v)) => Some(format!(
145                "https://crates.io/api/v1/crates/{}/{}/download",
146                n, v
147            )),
148            _ => None,
149        };
150
151        let description = package
152            .and_then(|p| p.get(FIELD_DESCRIPTION))
153            .and_then(|v| v.as_str())
154            .map(|s| truncate_field(s.trim().to_string()));
155
156        let keywords = extract_keywords_and_categories(&toml_content);
157
158        let extra_data = extract_extra_data(&toml_content);
159        let is_private = package
160            .and_then(|p| p.get(FIELD_PUBLISH))
161            .is_some_and(|value| matches!(value, Value::Boolean(false)));
162        vec![PackageData {
163            package_type: Some(Self::PACKAGE_TYPE),
164            namespace: None,
165            name,
166            version,
167            qualifiers: None,
168            subpath: None,
169            primary_language: Some("Rust".to_string()),
170            description,
171            release_date: None,
172            parties: extract_parties(&toml_content),
173            keywords,
174            homepage_url,
175            download_url,
176            size: None,
177            sha1: None,
178            md5: None,
179            sha256: None,
180            sha512: None,
181            bug_tracking_url: None,
182            code_view_url: None,
183            vcs_url: repository_url,
184            copyright: None,
185            holder: None,
186            declared_license_expression,
187            declared_license_expression_spdx,
188            license_detections,
189            other_license_expression: None,
190            other_license_expression_spdx: None,
191            other_license_detections: Vec::new(),
192            extracted_license_statement,
193            notice_text: None,
194            source_packages: Vec::new(),
195            file_references,
196            is_private,
197            is_virtual: false,
198            extra_data,
199            dependencies: [dependencies, dev_dependencies, build_dependencies].concat(),
200            repository_homepage_url,
201            repository_download_url,
202            api_data_url,
203            datasource_id: Some(DatasourceId::CargoToml),
204            purl,
205        }]
206    }
207
208    fn is_match(path: &Path) -> bool {
209        path.file_name()
210            .and_then(|name| name.to_str())
211            .is_some_and(|name| name.eq_ignore_ascii_case("cargo.toml"))
212    }
213
214    fn metadata() -> Vec<super::metadata::ParserMetadata> {
215        vec![super::metadata::ParserMetadata {
216            description: "Rust Cargo.toml manifest",
217            file_patterns: &["**/Cargo.toml", "**/cargo.toml"],
218            package_type: "cargo",
219            primary_language: "Rust",
220            documentation_url: Some("https://doc.rust-lang.org/cargo/reference/manifest.html"),
221        }]
222    }
223}
224
225/// Reads and parses a TOML file
226fn read_cargo_toml(path: &Path) -> Result<Value, String> {
227    let content =
228        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
229
230    toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
231}
232
233fn generate_cargo_api_url(name: &Option<String>, _version: &Option<String>) -> Option<String> {
234    const REGISTRY: &str = "https://crates.io/api/v1/crates";
235    name.as_ref().map(|name| format!("{}/{}", REGISTRY, name))
236}
237
238fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
239    name.as_ref().and_then(|name| {
240        let mut package_url = match PackageUrl::new(CargoParser::PACKAGE_TYPE.as_str(), name) {
241            Ok(p) => p,
242            Err(e) => {
243                warn!(
244                    "Failed to create PackageUrl for cargo package '{}': {}",
245                    name, e
246                );
247                return None;
248            }
249        };
250
251        if let Some(v) = version
252            && let Err(e) = package_url.with_version(v)
253        {
254            warn!(
255                "Failed to set version '{}' for cargo package '{}': {}",
256                v, name, e
257            );
258            return None;
259        }
260
261        Some(package_url.to_string())
262    })
263}
264
265/// Extracts party information from the `authors` field
266fn extract_parties(toml_content: &Value) -> Vec<Party> {
267    let mut parties = Vec::new();
268
269    if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table())
270        && let Some(authors) = package.get(FIELD_AUTHORS).and_then(|v| v.as_array())
271    {
272        for author in authors.iter().take(MAX_ITERATION_COUNT) {
273            if let Some(author_str) = author.as_str() {
274                let (name, email) = split_name_email(author_str);
275                parties.push(Party {
276                    r#type: None,
277                    role: Some("author".to_string()),
278                    name,
279                    email,
280                    url: None,
281                    organization: None,
282                    organization_url: None,
283                    timezone: None,
284                });
285            }
286        }
287        if authors.len() > MAX_ITERATION_COUNT {
288            warn!(
289                "Authors array has {} entries, capping at MAX_ITERATION_COUNT ({})",
290                authors.len(),
291                MAX_ITERATION_COUNT
292            );
293        }
294    }
295
296    parties
297}
298
299/// Determines if a Cargo version specifier is pinned to an exact version.
300///
301/// A version is considered pinned if it specifies an exact version (full semver)
302/// without range operators. Examples:
303/// - Pinned: "1.0.0", "0.8.1"
304/// - NOT pinned: "0.8" (allows patch), "^1.0.0", "~1.0.0", ">=1.0.0", "*"
305fn is_cargo_version_pinned(version_str: &str) -> bool {
306    let trimmed = version_str.trim();
307
308    // Empty version is not pinned
309    if trimmed.is_empty() {
310        return false;
311    }
312
313    // Check for range operators that indicate unpinned versions
314    if trimmed.contains('^')
315        || trimmed.contains('~')
316        || trimmed.contains('>')
317        || trimmed.contains('<')
318        || trimmed.contains('*')
319        || trimmed.contains('=')
320    {
321        return false;
322    }
323
324    // Count dots to check if it's a full semver (major.minor.patch)
325    // Pinned versions must have at least 2 dots (e.g., "1.0.0")
326    // Partial versions like "0.8" or "1" are not pinned
327    trimmed.matches('.').count() >= 2
328}
329
330fn extract_dependencies(toml_content: &Value, scope: &str) -> Vec<Dependency> {
331    use serde_json::json;
332
333    let mut dependencies = Vec::new();
334
335    // Determine is_runtime based on scope
336    let is_runtime = !scope.ends_with("dev-dependencies") && !scope.ends_with("build-dependencies");
337
338    if let Some(deps_table) = toml_content.get(scope).and_then(|v| v.as_table()) {
339        if deps_table.len() > MAX_ITERATION_COUNT {
340            warn!(
341                "Dependency table '{}' has {} entries, capping at MAX_ITERATION_COUNT ({})",
342                scope,
343                deps_table.len(),
344                MAX_ITERATION_COUNT
345            );
346        }
347        for (name, value) in deps_table.iter().take(MAX_ITERATION_COUNT) {
348            let (extracted_requirement, is_optional, extra_data_map, is_pinned) = match value {
349                Value::String(version_str) => {
350                    // Simple string version: "1.0"
351                    let pinned = is_cargo_version_pinned(version_str);
352                    (
353                        Some(version_str.to_string()),
354                        false,
355                        std::collections::HashMap::new(),
356                        pinned,
357                    )
358                }
359                Value::Table(table) => {
360                    // Complex table format: { version = "1.0", optional = true, features = [...] }
361                    let version = table
362                        .get("version")
363                        .and_then(|v| v.as_str())
364                        .map(String::from);
365
366                    let pinned = version.as_ref().is_some_and(|v| is_cargo_version_pinned(v));
367
368                    let is_optional = table
369                        .get("optional")
370                        .and_then(|v| v.as_bool())
371                        .unwrap_or(false);
372
373                    let mut extra_data = std::collections::HashMap::new();
374
375                    // Extract all table fields into extra_data
376                    for (key, val) in table {
377                        match key.as_str() {
378                            "version" => {
379                                // Store version in extra_data
380                                if let Some(v) = val.as_str() {
381                                    extra_data.insert("version".to_string(), json!(v));
382                                }
383                            }
384                            "features" => {
385                                // Extract features array
386                                if let Some(features_array) = val.as_array() {
387                                    let features: Vec<String> = features_array
388                                        .iter()
389                                        .filter_map(|f| f.as_str().map(String::from))
390                                        .collect();
391                                    extra_data.insert("features".to_string(), json!(features));
392                                }
393                            }
394                            "optional" => {
395                                // Skip optional flag, it's handled separately
396                            }
397                            _ => {
398                                // Store other fields (workspace, path, git, branch, tag, rev, etc.)
399                                if let Some(s) = val.as_str() {
400                                    extra_data.insert(key.clone(), json!(s));
401                                } else if let Some(b) = val.as_bool() {
402                                    extra_data.insert(key.clone(), json!(b));
403                                } else if let Some(i) = val.as_integer() {
404                                    extra_data.insert(key.clone(), json!(i));
405                                }
406                            }
407                        }
408                    }
409
410                    (version, is_optional, extra_data, pinned)
411                }
412                _ => {
413                    // Unknown format, skip
414                    continue;
415                }
416            };
417
418            // Only create dependency if we have a version or it's a table with other data
419            if extracted_requirement.is_some() || !extra_data_map.is_empty() {
420                let purl = match PackageUrl::new(CargoParser::PACKAGE_TYPE.as_str(), name) {
421                    Ok(p) => p.to_string(),
422                    Err(e) => {
423                        warn!(
424                            "Failed to create PackageUrl for cargo dependency '{}': {}",
425                            name, e
426                        );
427                        continue; // Skip this dependency
428                    }
429                };
430
431                dependencies.push(Dependency {
432                    purl: Some(purl),
433                    extracted_requirement,
434                    scope: Some(scope.to_string()),
435                    is_runtime: Some(is_runtime),
436                    is_optional: Some(is_optional),
437                    is_pinned: Some(is_pinned),
438                    is_direct: Some(true),
439                    resolved_package: None,
440                    extra_data: if extra_data_map.is_empty() {
441                        None
442                    } else {
443                        Some(extra_data_map)
444                    },
445                });
446            }
447        }
448    }
449
450    dependencies
451}
452
453fn extract_dependencies_for_scopes(toml_content: &Value, scopes: &[&str]) -> Vec<Dependency> {
454    scopes
455        .iter()
456        .flat_map(|scope| extract_dependencies(toml_content, scope))
457        .collect()
458}
459
460/// Extracts keywords and categories, merging them into a single keywords array
461fn extract_keywords_and_categories(toml_content: &Value) -> Vec<String> {
462    let mut keywords = Vec::new();
463
464    if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table()) {
465        if let Some(kw_array) = package.get(FIELD_KEYWORDS).and_then(|v| v.as_array()) {
466            if kw_array.len() > MAX_ITERATION_COUNT {
467                warn!(
468                    "Keywords array has {} entries, capping at MAX_ITERATION_COUNT ({})",
469                    kw_array.len(),
470                    MAX_ITERATION_COUNT
471                );
472            }
473            for kw in kw_array.iter().take(MAX_ITERATION_COUNT) {
474                if let Some(kw_str) = kw.as_str() {
475                    keywords.push(truncate_field(kw_str.to_string()));
476                }
477            }
478        }
479
480        if let Some(cat_array) = package.get(FIELD_CATEGORIES).and_then(|v| v.as_array()) {
481            if cat_array.len() > MAX_ITERATION_COUNT {
482                warn!(
483                    "Categories array has {} entries, capping at MAX_ITERATION_COUNT ({})",
484                    cat_array.len(),
485                    MAX_ITERATION_COUNT
486                );
487            }
488            for cat in cat_array.iter().take(MAX_ITERATION_COUNT) {
489                if let Some(cat_str) = cat.as_str() {
490                    keywords.push(truncate_field(cat_str.to_string()));
491                }
492            }
493        }
494    }
495
496    keywords
497}
498
499fn extract_file_references(toml_content: &Value) -> Vec<FileReference> {
500    let mut file_references = Vec::new();
501
502    if let Some(package) = toml_content
503        .get(FIELD_PACKAGE)
504        .and_then(|value| value.as_table())
505    {
506        for path in [
507            package
508                .get(FIELD_LICENSE_FILE)
509                .and_then(|value| value.as_str()),
510            package.get(FIELD_README).and_then(|value| value.as_str()),
511        ]
512        .into_iter()
513        .flatten()
514        {
515            if file_references
516                .iter()
517                .any(|reference: &FileReference| reference.path == path)
518            {
519                continue;
520            }
521
522            file_references.push(FileReference {
523                path: path.to_string(),
524                size: None,
525                sha1: None,
526                md5: None,
527                sha256: None,
528                sha512: None,
529                extra_data: None,
530            });
531        }
532    }
533
534    file_references
535}
536
537fn toml_to_json(value: &toml::Value, guard: &mut RecursionGuard<()>) -> serde_json::Value {
538    if guard.descend() {
539        warn!("TOML nesting depth exceeded, returning Null");
540        return serde_json::Value::Null;
541    }
542    let result = match value {
543        toml::Value::String(s) => serde_json::json!(s),
544        toml::Value::Integer(i) => serde_json::json!(i),
545        toml::Value::Float(f) => serde_json::json!(f),
546        toml::Value::Boolean(b) => serde_json::json!(b),
547        toml::Value::Array(a) => {
548            serde_json::Value::Array(a.iter().map(|v| toml_to_json(v, guard)).collect())
549        }
550        toml::Value::Table(t) => {
551            let map: serde_json::Map<String, serde_json::Value> = t
552                .iter()
553                .map(|(k, v)| (k.clone(), toml_to_json(v, guard)))
554                .collect();
555            serde_json::Value::Object(map)
556        }
557        toml::Value::Datetime(d) => serde_json::json!(d.to_string()),
558    };
559    guard.ascend();
560    result
561}
562
563/// Extracts extra_data fields (rust-version, edition, documentation, license-file, workspace)
564fn extract_extra_data(
565    toml_content: &Value,
566) -> Option<std::collections::HashMap<String, serde_json::Value>> {
567    use serde_json::json;
568    let mut extra_data = std::collections::HashMap::new();
569
570    if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table()) {
571        if package.len() > MAX_ITERATION_COUNT {
572            warn!(
573                "Package table has {} entries, exceeding MAX_ITERATION_COUNT ({})",
574                package.len(),
575                MAX_ITERATION_COUNT
576            );
577        }
578        if let Some(rust_version_value) = package.get(FIELD_RUST_VERSION) {
579            if let Some(rust_version_str) = rust_version_value.as_str() {
580                extra_data.insert("rust_version".to_string(), json!(rust_version_str));
581            } else if rust_version_value
582                .as_table()
583                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
584            {
585                extra_data.insert("rust-version".to_string(), json!("workspace"));
586            }
587        }
588
589        // Extract edition (or detect workspace inheritance)
590        if let Some(edition_value) = package.get(FIELD_EDITION) {
591            if let Some(edition_str) = edition_value.as_str() {
592                extra_data.insert("rust_edition".to_string(), json!(edition_str));
593            } else if edition_value
594                .as_table()
595                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
596            {
597                extra_data.insert("edition".to_string(), json!("workspace"));
598            }
599        }
600
601        // Extract documentation URL
602        if let Some(documentation) = package.get("documentation").and_then(|v| v.as_str()) {
603            extra_data.insert("documentation_url".to_string(), json!(documentation));
604        }
605
606        // Extract license-file path
607        if let Some(license_file) = package.get(FIELD_LICENSE_FILE).and_then(|v| v.as_str()) {
608            extra_data.insert("license_file".to_string(), json!(license_file));
609        }
610
611        if let Some(readme_value) = package.get(FIELD_README) {
612            if let Some(readme_file) = readme_value.as_str() {
613                extra_data.insert("readme_file".to_string(), json!(readme_file));
614            } else if let Some(readme_enabled) = readme_value.as_bool() {
615                extra_data.insert("readme".to_string(), json!(readme_enabled));
616            } else if readme_value
617                .as_table()
618                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
619            {
620                extra_data.insert("readme".to_string(), json!("workspace"));
621            }
622        }
623
624        if let Some(publish_value) = package.get(FIELD_PUBLISH) {
625            extra_data.insert(
626                "publish".to_string(),
627                toml_to_json(publish_value, &mut RecursionGuard::depth_only()),
628            );
629        }
630
631        // Check for workspace inheritance markers for other fields
632        // version
633        if let Some(version_value) = package.get(FIELD_VERSION)
634            && version_value
635                .as_table()
636                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
637        {
638            extra_data.insert("version".to_string(), json!("workspace"));
639        }
640
641        // license
642        if let Some(license_value) = package.get(FIELD_LICENSE)
643            && license_value
644                .as_table()
645                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
646        {
647            extra_data.insert("license".to_string(), json!("workspace"));
648        }
649
650        // homepage
651        if let Some(homepage_value) = package.get(FIELD_HOMEPAGE)
652            && homepage_value
653                .as_table()
654                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
655        {
656            extra_data.insert("homepage".to_string(), json!("workspace"));
657        }
658
659        // repository
660        if let Some(repository_value) = package.get(FIELD_REPOSITORY)
661            && repository_value
662                .as_table()
663                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
664        {
665            extra_data.insert("repository".to_string(), json!("workspace"));
666        }
667
668        // categories
669        if let Some(categories_value) = package.get(FIELD_CATEGORIES)
670            && categories_value
671                .as_table()
672                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
673        {
674            extra_data.insert("categories".to_string(), json!("workspace"));
675        }
676
677        // authors
678        if let Some(authors_value) = package.get(FIELD_AUTHORS)
679            && authors_value
680                .as_table()
681                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
682        {
683            extra_data.insert("authors".to_string(), json!("workspace"));
684        }
685    }
686
687    // Extract workspace table if it exists
688    if let Some(workspace_value) = toml_content.get("workspace") {
689        extra_data.insert(
690            "workspace".to_string(),
691            toml_to_json(workspace_value, &mut RecursionGuard::depth_only()),
692        );
693    }
694
695    if extra_data.is_empty() {
696        None
697    } else {
698        Some(extra_data)
699    }
700}