1use std::collections::HashMap;
24use std::path::Path;
25
26use crate::parser_warn as warn;
27use crate::parsers::utils::{capped_iteration_limit, read_file_to_string, truncate_field};
28use packageurl::PackageUrl;
29use starlark_syntax::syntax::ast;
30use starlark_syntax::syntax::{AstModule, Dialect};
31
32use crate::models::{DatasourceId, PackageData, PackageType, Party, PartyType, Sha1Digest};
33
34use super::PackageParser;
35use super::metadata::ParserMetadata;
36
37type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
38
39struct StarlarkCall<'a> {
40 func: &'a ast::AstExpr,
41 args: &'a StarlarkCallArgs,
42}
43
44pub struct BuckBuildParser;
46
47impl PackageParser for BuckBuildParser {
48 const PACKAGE_TYPE: PackageType = PackageType::Buck;
49
50 fn metadata() -> Vec<ParserMetadata> {
51 vec![ParserMetadata {
52 description: "Buck build file and METADATA.bzl",
53 file_patterns: &["**/BUCK", "**/METADATA.bzl"],
54 package_type: "buck",
55 primary_language: "",
56 documentation_url: Some("https://buck.build/"),
57 }]
58 }
59
60 fn is_match(path: &Path) -> bool {
61 path.file_name()
62 .and_then(|name| name.to_str())
63 .is_some_and(|name| name == "BUCK")
64 }
65
66 fn extract_packages(path: &Path) -> Vec<PackageData> {
67 match parse_buck_build(path) {
68 Ok(packages) if !packages.is_empty() => packages,
69 Ok(_) => vec![fallback_package_data(path)],
70 Err(e) => {
71 warn!("Failed to parse Buck BUCK file {:?}: {}", path, e);
72 vec![fallback_package_data(path)]
73 }
74 }
75 }
76}
77
78pub struct BuckMetadataBzlParser;
80
81impl PackageParser for BuckMetadataBzlParser {
82 const PACKAGE_TYPE: PackageType = PackageType::Buck;
83
84 fn is_match(path: &Path) -> bool {
85 path.file_name()
86 .and_then(|name| name.to_str())
87 .is_some_and(|name| name == "METADATA.bzl")
88 }
89
90 fn extract_packages(path: &Path) -> Vec<PackageData> {
91 vec![match parse_metadata_bzl(path) {
92 Ok(pkg) => pkg,
93 Err(e) => {
94 warn!("Failed to parse Buck METADATA.bzl {:?}: {}", path, e);
95 PackageData {
96 datasource_id: Some(DatasourceId::BuckMetadata),
97 ..Default::default()
98 }
99 }
100 }]
101 }
102}
103
104fn parse_buck_build(path: &Path) -> Result<Vec<PackageData>, String> {
106 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
107 let module = parse_starlark_module("<BUCK>", content)?;
108
109 let mut packages = Vec::new();
110
111 let statements = top_level_statements(&module);
112 let limit = capped_iteration_limit(statements.len(), "BUCK top-level statements");
113 for statement in statements.iter().take(limit) {
114 if let Some(package_data) = extract_build_package_from_statement(statement) {
115 packages.push(package_data);
116 }
117 }
118
119 Ok(packages)
120}
121
122fn parse_metadata_bzl(path: &Path) -> Result<PackageData, String> {
124 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
125 let module = parse_starlark_module("<METADATA.bzl>", content)?;
126
127 let statements = top_level_statements(&module);
128 let limit = capped_iteration_limit(statements.len(), "METADATA.bzl top-level statements");
129 for statement in statements.iter().take(limit) {
130 if let Some(dict) = extract_metadata_assignment_dict(statement) {
131 return Ok(extract_metadata_dict(dict));
132 }
133 }
134
135 Ok(PackageData {
137 datasource_id: Some(DatasourceId::BuckMetadata),
138 ..Default::default()
139 })
140}
141
142fn parse_starlark_module(filename: &str, content: String) -> Result<AstModule, String> {
143 let content = preprocess_starlark_content(&content);
144 let dialect = Dialect {
145 enable_top_level_stmt: true,
146 ..Dialect::Standard
147 };
148 AstModule::parse(filename, content, &dialect).map_err(|error| error.to_string())
149}
150
151fn preprocess_starlark_content(content: &str) -> String {
152 let mut normalized = String::with_capacity(content.len());
153 let mut pending_oss_disable_indent: Option<String> = None;
154
155 for raw_line in content.lines() {
156 let trimmed_start = raw_line.trim_start();
157 let indent_len = raw_line.len() - trimmed_start.len();
158 let indent = &raw_line[..indent_len];
159
160 if trimmed_start.starts_with('#') && trimmed_start.contains("@oss-disable") {
161 pending_oss_disable_indent = Some(indent.to_string());
162 continue;
163 }
164
165 if let Some(marker_index) = raw_line.find("# @oss-enable") {
166 let code = raw_line[..marker_index].trim_end();
167 if !code.is_empty() {
168 if let Some(disabled_indent) = pending_oss_disable_indent.take() {
169 normalized.push_str(&disabled_indent);
170 normalized.push_str(code.trim_start());
171 } else {
172 normalized.push_str(code);
173 }
174 normalized.push('\n');
175 }
176 continue;
177 }
178
179 pending_oss_disable_indent = None;
180 normalized.push_str(raw_line);
181 normalized.push('\n');
182 }
183
184 if !content.ends_with('\n') && normalized.ends_with('\n') {
185 normalized.pop();
186 }
187
188 normalized
189}
190
191fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
192 match &module.statement().node {
193 ast::StmtP::Statements(statements) => statements,
194 _ => std::slice::from_ref(module.statement()),
195 }
196}
197
198fn extract_metadata_assignment_dict(
199 statement: &ast::AstStmt,
200) -> Option<&[(ast::AstExpr, ast::AstExpr)]> {
201 let ast::StmtP::Assign(assign) = &statement.node else {
202 return None;
203 };
204 let ast::AssignTargetP::Identifier(target) = &assign.lhs.node else {
205 return None;
206 };
207 if target.node.ident != "METADATA" {
208 return None;
209 }
210 match &assign.rhs.node {
211 ast::ExprP::Dict(items) => Some(items.as_slice()),
212 _ => None,
213 }
214}
215
216fn extract_metadata_dict(dict: &[(ast::AstExpr, ast::AstExpr)]) -> PackageData {
218 let mut fields: HashMap<String, MetadataValue> = HashMap::new();
219
220 let limit = capped_iteration_limit(dict.len(), "BUCK metadata dict entries");
221 for (key, value) in dict.iter().take(limit) {
222 let Some(key_name) = expr_as_string(key) else {
223 continue;
224 };
225 let Some(metadata_value) = metadata_value_from_expr(value) else {
226 continue;
227 };
228
229 fields.insert(key_name, metadata_value);
230 }
231
232 build_package_from_metadata(fields)
233}
234
235fn get_metadata_string(fields: &HashMap<String, MetadataValue>, keys: &[&str]) -> Option<String> {
236 keys.iter().find_map(|key| match fields.get(*key) {
237 Some(MetadataValue::String(value)) => Some(value.clone()),
238 _ => None,
239 })
240}
241
242fn get_metadata_list(
243 fields: &HashMap<String, MetadataValue>,
244 keys: &[&str],
245) -> Option<Vec<String>> {
246 keys.iter().find_map(|key| match fields.get(*key) {
247 Some(MetadataValue::List(values)) => Some(values.clone()),
248 _ => None,
249 })
250}
251
252enum MetadataValue {
254 String(String),
255 List(Vec<String>),
256}
257
258fn split_buck_license_values(values: &[String]) -> (Vec<String>, Vec<String>) {
259 let mut statements = Vec::new();
260 let mut references = Vec::new();
261
262 for value in values {
263 if is_probable_local_license_reference(value) {
264 references.push(value.clone());
265 } else {
266 statements.push(value.clone());
267 }
268 }
269
270 (statements, references)
271}
272
273fn is_probable_local_license_reference(value: &str) -> bool {
274 let trimmed = value.trim();
275 if trimmed.is_empty() {
276 return false;
277 }
278
279 let lower = trimmed.to_ascii_lowercase();
280 lower.contains('/')
281 || lower.contains('\\')
282 || lower.starts_with("license")
283 || lower.starts_with("licence")
284 || lower.starts_with("copying")
285 || lower.starts_with("notice")
286 || lower.starts_with("copyright")
287 || lower.ends_with(".txt")
288 || lower.ends_with(".md")
289 || lower.ends_with(".rst")
290 || lower.ends_with(".html")
291}
292
293fn insert_license_reference_extra_data(
294 extra_data: &mut HashMap<String, serde_json::Value>,
295 references: &[String],
296) {
297 match references {
298 [] => {}
299 [reference] => {
300 extra_data.insert(
301 "license_file".to_string(),
302 serde_json::Value::String(reference.clone()),
303 );
304 }
305 _ => {
306 extra_data.insert(
307 "license_files".to_string(),
308 serde_json::Value::Array(
309 references
310 .iter()
311 .cloned()
312 .map(serde_json::Value::String)
313 .collect(),
314 ),
315 );
316 }
317 }
318}
319
320fn build_buck_purl(
324 namespace: Option<&str>,
325 name: Option<&str>,
326 version: Option<&str>,
327) -> Option<String> {
328 let name = name.filter(|value| !value.is_empty())?;
329 let mut package_url = match PackageUrl::new(PackageType::Buck.as_str(), name) {
330 Ok(purl) => purl,
331 Err(e) => {
332 warn!(
333 "Failed to create PackageUrl for buck package '{}': {}",
334 name, e
335 );
336 return None;
337 }
338 };
339
340 if let Some(namespace) = namespace.filter(|value| !value.is_empty())
341 && let Err(e) = package_url.with_namespace(namespace)
342 {
343 warn!(
344 "Failed to set namespace '{}' for buck package '{}': {}",
345 namespace, name, e
346 );
347 return None;
348 }
349
350 if let Some(version) = version.filter(|value| !value.is_empty())
351 && let Err(e) = package_url.with_version(version)
352 {
353 warn!(
354 "Failed to set version '{}' for buck package '{}': {}",
355 version, name, e
356 );
357 return None;
358 }
359
360 Some(truncate_field(package_url.to_string()))
361}
362
363fn build_package_from_metadata(fields: HashMap<String, MetadataValue>) -> PackageData {
365 let mut pkg = PackageData {
366 datasource_id: Some(DatasourceId::BuckMetadata),
367 ..Default::default()
368 };
369 let mut license_references = Vec::new();
370
371 if let Some(name) = get_metadata_string(&fields, &["name"]) {
373 pkg.name = Some(truncate_field(name));
374 }
375
376 if let Some(version) = get_metadata_string(&fields, &["version"]) {
378 pkg.version = Some(truncate_field(version));
379 }
380
381 if let Some(namespace) = get_metadata_string(&fields, &["namespace"]) {
383 pkg.namespace = Some(truncate_field(namespace));
384 }
385
386 if let Some(ecosystem) = get_metadata_string(&fields, &["ecosystem", "type", "package_type"])
389 && let Ok(package_type) = ecosystem.parse::<PackageType>()
390 {
391 pkg.package_type = Some(package_type);
392 }
393
394 if let Some(licenses) = get_metadata_list(&fields, &["licenses"]) {
396 let (license_statements, references) = split_buck_license_values(&licenses);
397 license_references = references;
398 let extracted_license_statement = if !license_statements.is_empty() {
399 Some(license_statements.join(", "))
400 } else if !license_references.is_empty() {
401 Some(license_references.join(", "))
402 } else {
403 None
404 };
405 pkg.extracted_license_statement = extracted_license_statement.map(truncate_field);
406 } else if let Some(license_expression) = get_metadata_string(&fields, &["license_expression"]) {
407 pkg.extracted_license_statement = Some(truncate_field(license_expression));
408 }
409
410 if let Some(copyright) = get_metadata_list(&fields, &["copyrights"]) {
411 if !copyright.is_empty() {
412 pkg.copyright = Some(truncate_field(copyright.join("\n")));
413 }
414 } else if let Some(copyright) = get_metadata_string(&fields, &["copyright"]) {
415 pkg.copyright = Some(truncate_field(copyright));
416 }
417
418 if let Some(homepage_url) = get_metadata_string(
420 &fields,
421 &["upstream_address", "upstream_url", "homepage_url"],
422 ) {
423 pkg.homepage_url = Some(truncate_field(homepage_url));
424 }
425
426 if let Some(download_url) = get_metadata_string(&fields, &["download_url"]) {
428 pkg.download_url = Some(truncate_field(download_url));
429 }
430
431 if let Some(vcs_url) = get_metadata_string(&fields, &["vcs_url"]) {
433 pkg.vcs_url = Some(truncate_field(vcs_url));
434 }
435
436 if let Some(sha1) = get_metadata_string(&fields, &["download_archive_sha1"]) {
438 pkg.sha1 = Sha1Digest::from_hex(&sha1).ok();
439 }
440
441 if let Some(maintainers) = get_metadata_list(&fields, &["maintainers"]) {
443 pkg.parties.extend(maintainers.iter().map(|name| Party {
444 r#type: Some(PartyType::Organization),
445 name: Some(name.clone()),
446 role: Some("maintainer".to_string()),
447 email: None,
448 url: None,
449 organization: None,
450 organization_url: None,
451 timezone: None,
452 }));
453 }
454
455 if let Some(vendor) = get_metadata_string(&fields, &["vendor", "publisher"]) {
456 pkg.parties.push(Party {
457 r#type: None,
458 name: Some(vendor),
459 role: Some("publisher".to_string()),
460 email: None,
461 url: None,
462 organization: None,
463 organization_url: None,
464 timezone: None,
465 });
466 }
467
468 let mut extra_data = HashMap::new();
470 if let Some(vcs_commit_hash) = get_metadata_string(&fields, &["vcs_commit_hash"]) {
471 extra_data.insert(
472 "vcs_commit_hash".to_string(),
473 serde_json::Value::String(vcs_commit_hash),
474 );
475 }
476 if let Some(upstream_hash) =
477 get_metadata_string(&fields, &["upstream_hash", "upstream_commit_hash"])
478 {
479 extra_data.insert(
480 "upstream_hash".to_string(),
481 serde_json::Value::String(upstream_hash),
482 );
483 }
484 if let Some(upstream_branch) = get_metadata_string(&fields, &["upstream_branch"]) {
485 extra_data.insert(
486 "upstream_branch".to_string(),
487 serde_json::Value::String(upstream_branch),
488 );
489 }
490 insert_license_reference_extra_data(&mut extra_data, &license_references);
491 if !extra_data.is_empty() {
492 pkg.extra_data = Some(extra_data);
493 }
494
495 if let Some(purl_str) = get_metadata_string(&fields, &["package_url"])
497 && let Ok(purl) = purl_str.parse::<PackageUrl>()
498 {
499 pkg.purl = Some(truncate_field(purl.to_string()));
500
501 if let Ok(package_type) = purl.ty().parse::<PackageType>() {
502 pkg.package_type = Some(package_type);
503 }
504 if let Some(ns) = purl.namespace() {
505 pkg.namespace = Some(truncate_field(ns.to_string()));
506 }
507 pkg.name = Some(truncate_field(purl.name().to_string()));
508 if let Some(ver) = purl.version() {
509 pkg.version = Some(truncate_field(ver.to_string()));
510 }
511 if !purl.qualifiers().is_empty() {
513 let quals: HashMap<String, String> = purl
514 .qualifiers()
515 .iter()
516 .map(|(k, v)| (k.to_string(), v.to_string()))
517 .collect();
518 pkg.qualifiers = Some(quals);
519 }
520 if let Some(sp) = purl.subpath() {
522 pkg.subpath = Some(sp.to_string());
523 }
524 }
525
526 if pkg.purl.is_none() && matches!(pkg.package_type, None | Some(PackageType::Buck)) {
532 pkg.purl = build_buck_purl(
533 pkg.namespace.as_deref(),
534 pkg.name.as_deref(),
535 pkg.version.as_deref(),
536 );
537 }
538
539 pkg
540}
541
542fn metadata_value_from_expr(expr: &ast::AstExpr) -> Option<MetadataValue> {
543 if let Some(string) = expr_as_string(expr) {
544 return Some(MetadataValue::String(string));
545 }
546
547 let items = match &expr.node {
548 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
549 _ => return None,
550 };
551 let limit = capped_iteration_limit(items.len(), "BUCK metadata list items");
552 let values: Vec<_> = items
553 .iter()
554 .take(limit)
555 .filter_map(expr_as_string)
556 .collect();
557 (!values.is_empty()).then_some(MetadataValue::List(values))
558}
559
560fn extract_build_package_from_statement(statement: &ast::AstStmt) -> Option<PackageData> {
562 let call = extract_call(statement)?;
563 let rule_name = match &call.func.node {
564 ast::ExprP::Identifier(identifier) => identifier.node.ident.as_str(),
565 _ => return None,
566 };
567
568 if !check_rule_name_ending(rule_name) {
569 return None;
570 }
571
572 let name = extract_named_kwarg_string(&call, "name");
573 let licenses = extract_named_kwarg_string_list(&call, "licenses");
574
575 let package_name = name?;
576 let (license_statements, license_references) = licenses
577 .as_deref()
578 .map(split_buck_license_values)
579 .unwrap_or_default();
580 let extracted_license_statement = if !license_statements.is_empty() {
581 Some(truncate_field(license_statements.join(", ")))
582 } else if !license_references.is_empty() {
583 Some(truncate_field(license_references.join(", ")))
584 } else {
585 None
586 };
587 let mut extra_data = HashMap::new();
588 insert_license_reference_extra_data(&mut extra_data, &license_references);
589
590 let purl = build_buck_purl(None, Some(&package_name), None);
591
592 Some(PackageData {
593 package_type: Some(BuckBuildParser::PACKAGE_TYPE),
594 name: Some(truncate_field(package_name)),
595 purl,
596 extracted_license_statement,
597 extra_data: (!extra_data.is_empty()).then_some(extra_data),
598 datasource_id: Some(DatasourceId::BuckFile),
599 ..Default::default()
600 })
601}
602
603fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
604 match &statement.node {
605 ast::StmtP::Expression(expr) => extract_call_expr(expr),
606 ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
607 _ => None,
608 }
609}
610
611fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
612 match &expr.node {
613 ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
614 _ => None,
615 }
616}
617
618fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
619 call.args
620 .args
621 .iter()
622 .find_map(|argument| match &argument.node {
623 ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
624 _ => None,
625 })
626}
627
628fn extract_named_kwarg_string(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
629 extract_named_kwarg(call, key).and_then(expr_as_string)
630}
631
632fn extract_named_kwarg_string_list(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
633 let expr = extract_named_kwarg(call, key)?;
634 let items = match &expr.node {
635 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
636 _ => return None,
637 };
638 let limit = capped_iteration_limit(items.len(), "BUCK kwarg string-list items");
639 let values: Vec<_> = items
640 .iter()
641 .take(limit)
642 .filter_map(expr_as_string)
643 .collect();
644 (!values.is_empty()).then_some(values)
645}
646
647fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
648 match &expr.node {
649 ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
650 _ => None,
651 }
652}
653
654fn check_rule_name_ending(rule_name: &str) -> bool {
656 rule_name.ends_with("binary") || rule_name.ends_with("library")
657}
658
659fn fallback_package_data(path: &Path) -> PackageData {
661 let name = path
662 .parent()
663 .and_then(|p| p.file_name())
664 .and_then(|n| n.to_str())
665 .map(|s| s.to_string());
666
667 let purl = build_buck_purl(None, name.as_deref(), None);
668
669 PackageData {
670 package_type: Some(BuckBuildParser::PACKAGE_TYPE),
671 name,
672 purl,
673 datasource_id: Some(DatasourceId::BuckFile),
674 ..Default::default()
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use std::path::PathBuf;
682
683 #[test]
684 fn test_buck_build_is_match() {
685 assert!(BuckBuildParser::is_match(&PathBuf::from("BUCK")));
686 assert!(BuckBuildParser::is_match(&PathBuf::from("path/to/BUCK")));
687 assert!(!BuckBuildParser::is_match(&PathBuf::from("BUILD")));
688 assert!(!BuckBuildParser::is_match(&PathBuf::from("buck")));
689 }
690
691 #[test]
692 fn test_metadata_bzl_is_match() {
693 assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
694 "METADATA.bzl"
695 )));
696 assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
697 "path/to/METADATA.bzl"
698 )));
699 assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from(
700 "metadata.bzl"
701 )));
702 assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from("METADATA")));
703 }
704
705 #[test]
706 fn test_check_rule_name_ending() {
707 assert!(check_rule_name_ending("android_binary"));
708 assert!(check_rule_name_ending("android_library"));
709 assert!(check_rule_name_ending("java_binary"));
710 assert!(!check_rule_name_ending("filegroup"));
711 }
712
713 #[test]
714 fn test_preprocess_starlark_content_handles_oss_guarded_alternatives() {
715 let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
716prelude = native
717
718# @oss-disable: rust_unittest(
719 rust_test( # @oss-enable
720 name = "test",
721 )
722
723platform_utils = None # @oss-enable
724"#;
725
726 let normalized = preprocess_starlark_content(content);
727
728 assert!(!normalized.contains("@oss-disable"));
729 assert!(!normalized.contains("@oss-enable"));
730 assert!(normalized.contains("rust_test("));
731 assert!(normalized.contains("platform_utils = None"));
732 assert!(!normalized.contains(" rust_test("));
733 }
734
735 #[test]
736 fn test_parse_buck_build_with_oss_guarded_rule() {
737 let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_library.bzl", "rust_library")
738# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
739
740oncall("build_infra")
741
742rust_library(
743 name = "library",
744 srcs = ["src/lib.rs"],
745)
746
747# @oss-disable: rust_unittest(
748 rust_test( # @oss-enable
749 name = "test",
750 srcs = ["tests/test.rs"],
751)
752"#;
753
754 let temp_dir = tempfile::tempdir().unwrap();
755 let buck_path = temp_dir.path().join("BUCK");
756 std::fs::write(&buck_path, content).unwrap();
757
758 let packages = parse_buck_build(&buck_path).expect("BUCK file should parse");
759
760 assert_eq!(packages.len(), 1);
761 assert_eq!(packages[0].package_type, Some(PackageType::Buck));
762 assert_eq!(packages[0].name.as_deref(), Some("library"));
763 }
764}