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