1use std::collections::BTreeSet;
21use std::path::Path;
22
23use camino::Utf8PathBuf;
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28use crate::condition::Condition;
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ProfileFlags {
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub defines: Vec<String>,
45 #[serde(
50 default,
51 rename = "include-dirs",
52 skip_serializing_if = "Vec::is_empty"
53 )]
54 pub include_dirs: Vec<Utf8PathBuf>,
55 #[serde(default, rename = "cflags", skip_serializing_if = "Vec::is_empty")]
60 pub cflags: Vec<String>,
61 #[serde(default, rename = "cxxflags", skip_serializing_if = "Vec::is_empty")]
67 pub cxxflags: Vec<String>,
68 #[serde(default, rename = "ldflags", skip_serializing_if = "Vec::is_empty")]
71 pub ldflags: Vec<String>,
72 #[serde(default, rename = "link-libs", skip_serializing_if = "Vec::is_empty")]
84 pub link_libs: Vec<String>,
85}
86
87impl ProfileFlags {
88 pub fn is_empty(&self) -> bool {
89 self.defines.is_empty()
90 && self.include_dirs.is_empty()
91 && self.cflags.is_empty()
92 && self.cxxflags.is_empty()
93 && self.ldflags.is_empty()
94 && self.link_libs.is_empty()
95 }
96
97 pub fn validate(&self) -> Result<(), BuildFlagsValidationError> {
112 for define in &self.defines {
113 if define.is_empty() {
114 return Err(BuildFlagsValidationError::EmptyDefine);
115 }
116 if define.starts_with('=') {
117 return Err(BuildFlagsValidationError::DefineMissingName {
118 raw: define.clone(),
119 });
120 }
121 }
122 for dir in &self.include_dirs {
123 validate_include_dir(dir.as_std_path())?;
124 }
125 for lib in &self.link_libs {
126 if !is_safe_link_lib(lib) {
127 return Err(BuildFlagsValidationError::InvalidLinkLib { raw: lib.clone() });
128 }
129 }
130 Ok(())
131 }
132}
133
134pub fn is_safe_link_lib(name: &str) -> bool {
146 let mut chars = name.chars();
147 let Some(first) = chars.next() else {
148 return false;
149 };
150 if !(first.is_ascii_alphanumeric() || first == '_') {
151 return false;
152 }
153 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '+' | '-'))
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct ConditionalProfileFlags {
160 pub condition: Condition,
161 #[serde(flatten, default, skip_serializing_if = "ProfileFlags::is_empty")]
162 pub flags: ProfileFlags,
163}
164
165#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169pub struct ProfileSettings {
170 #[serde(default, skip_serializing_if = "ProfileFlags::is_empty")]
171 pub general: ProfileFlags,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub conditional: Vec<ConditionalProfileFlags>,
174}
175
176impl ProfileSettings {
177 pub fn is_empty(&self) -> bool {
178 self.general.is_empty() && self.conditional.is_empty()
179 }
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
189pub struct ResolvedProfileFlags {
190 pub defines: Vec<String>,
191 pub include_dirs: Vec<Utf8PathBuf>,
192 pub extra_compile_args: Vec<String>,
195 pub cflags: Vec<String>,
199 pub cxxflags: Vec<String>,
204 pub ldflags: Vec<String>,
205 pub link_libs: Vec<String>,
211}
212
213impl ResolvedProfileFlags {
214 pub fn is_empty(&self) -> bool {
215 self.defines.is_empty()
216 && self.include_dirs.is_empty()
217 && self.extra_compile_args.is_empty()
218 && self.cflags.is_empty()
219 && self.cxxflags.is_empty()
220 && self.ldflags.is_empty()
221 && self.link_libs.is_empty()
222 }
223
224 pub fn as_json(&self) -> serde_json::Value {
226 serde_json::json!({
227 "defines": self.defines,
228 "include_dirs": self
229 .include_dirs
230 .iter()
231 .map(|p| p.as_str().to_owned())
232 .collect::<Vec<_>>(),
233 "extra_compile_args": self.extra_compile_args,
234 "cflags": self.cflags,
235 "cxxflags": self.cxxflags,
236 "ldflags": self.ldflags,
237 "link_libs": self.link_libs,
238 })
239 }
240}
241
242pub fn resolve_build_flags(
274 package: &ProfileSettings,
275 profile: Option<&ProfileFlags>,
276 host_platform: &crate::condition::TargetPlatform,
277 enabled_features: &BTreeSet<String>,
278 package_trusted: bool,
279) -> ResolvedProfileFlags {
280 let mut out = ResolvedProfileFlags::default();
281
282 apply_layer(&mut out, &package.general);
283 for conditional in &package.conditional {
284 if conditional
285 .condition
286 .evaluate(host_platform, enabled_features)
287 {
288 apply_layer(&mut out, &conditional.flags);
289 }
290 }
291 if !package_trusted {
292 out.cflags.clear();
303 out.cxxflags.clear();
304 out.ldflags.clear();
305 }
306 if let Some(prof) = profile {
307 apply_layer(&mut out, prof);
308 }
309
310 finalize(&mut out);
311 out
312}
313
314macro_rules! append_profile_flag_layer {
329 ($target:expr, $layer:expr) => {{
330 let target = $target;
331 let layer = $layer;
332 target.defines.extend(layer.defines.iter().cloned());
339 for inc in &layer.include_dirs {
340 if !target.include_dirs.iter().any(|existing| existing == inc) {
341 target.include_dirs.push(inc.clone());
342 }
343 }
344 target.cflags.extend(layer.cflags.iter().cloned());
345 target.cxxflags.extend(layer.cxxflags.iter().cloned());
346 target.ldflags.extend(layer.ldflags.iter().cloned());
347 for lib in &layer.link_libs {
352 if !target.link_libs.iter().any(|existing| existing == lib) {
353 target.link_libs.push(lib.clone());
354 }
355 }
356 }};
357}
358
359impl ProfileFlags {
360 pub(crate) fn append_layer(&mut self, layer: &ProfileFlags) {
372 append_profile_flag_layer!(self, layer);
373 }
374}
375
376fn apply_layer(target: &mut ResolvedProfileFlags, layer: &ProfileFlags) {
377 append_profile_flag_layer!(target, layer);
378}
379
380fn finalize(target: &mut ResolvedProfileFlags) {
381 let dedup: BTreeSet<String> = target.defines.drain(..).collect();
386 target.defines = dedup.into_iter().collect();
387 }
391
392#[derive(Debug, Error, Clone, PartialEq, Eq)]
395pub enum BuildFlagsValidationError {
396 #[error("[profile] declares an empty define entry")]
397 EmptyDefine,
398 #[error("[profile] define entry {raw:?} is missing a name")]
399 DefineMissingName { raw: String },
400 #[error(
401 "[profile] link library {raw:?} is not a valid library name; use a bare name like \"pthread\" (no leading `-`, path separators, or whitespace)"
402 )]
403 InvalidLinkLib { raw: String },
404 #[error(
405 "[profile] include directory {path:?} must be a relative path; absolute paths are not allowed"
406 )]
407 AbsoluteIncludeDir { path: String },
408 #[error(
409 "[profile] include directory {path:?} must not contain `..`; include search paths cannot escape the package root"
410 )]
411 IncludeDirHasParent { path: String },
412 #[error("[profile] include directory {path:?} contains a non-UTF-8 component")]
413 NonUtf8IncludeDir { path: String },
414}
415
416fn validate_include_dir(dir: &Path) -> Result<(), BuildFlagsValidationError> {
417 if dir.is_absolute() {
418 return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
419 path: display_path(dir),
420 });
421 }
422 for component in dir.components() {
423 match component {
424 std::path::Component::ParentDir => {
425 return Err(BuildFlagsValidationError::IncludeDirHasParent {
426 path: display_path(dir),
427 });
428 }
429 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
430 return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
431 path: display_path(dir),
432 });
433 }
434 std::path::Component::Normal(part) => {
435 if part.to_str().is_none() {
436 return Err(BuildFlagsValidationError::NonUtf8IncludeDir {
437 path: display_path(dir),
438 });
439 }
440 }
441 std::path::Component::CurDir => {}
442 }
443 }
444 Ok(())
445}
446
447fn display_path(dir: &Path) -> String {
448 dir.display().to_string()
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::condition::{ConditionKey, TargetPlatform};
455
456 fn host_for(os: &str) -> TargetPlatform {
457 let mut p = TargetPlatform::current();
458 p.os = os.to_owned();
459 p
460 }
461
462 #[test]
463 fn empty_settings_resolve_to_empty_flags() {
464 let p = ProfileSettings::default();
465 let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
466 assert!(r.is_empty());
467 }
468
469 #[test]
470 fn defines_merge_dedup_and_sort() {
471 let mut p = ProfileSettings::default();
472 p.general.defines = vec!["B".into(), "A".into(), "B".into()];
473 let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
474 assert_eq!(r.defines, vec!["A".to_owned(), "B".to_owned()]);
475 }
476
477 #[test]
478 fn include_dirs_keep_first_occurrence_order() {
479 let mut p = ProfileSettings::default();
480 p.general.include_dirs = vec![
481 Utf8PathBuf::from("include"),
482 Utf8PathBuf::from("third_party/include"),
483 Utf8PathBuf::from("include"),
484 ];
485 let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
486 assert_eq!(
487 r.include_dirs,
488 vec![
489 Utf8PathBuf::from("include"),
490 Utf8PathBuf::from("third_party/include"),
491 ]
492 );
493 }
494
495 #[test]
496 fn matching_conditional_layer_is_applied() {
497 let mut p = ProfileSettings::default();
498 p.general.defines = vec!["BASE".into()];
499 p.conditional.push(ConditionalProfileFlags {
500 condition: Condition::KeyValue {
501 key: ConditionKey::Os,
502 value: "linux".into(),
503 },
504 flags: ProfileFlags {
505 defines: vec!["LINUX_ONLY".into()],
506 ..Default::default()
507 },
508 });
509 let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
510 assert_eq!(r.defines, vec!["BASE".to_owned(), "LINUX_ONLY".to_owned()]);
511 }
512
513 #[test]
514 fn non_matching_conditional_layer_is_skipped() {
515 let mut p = ProfileSettings::default();
516 p.general.defines = vec!["BASE".into()];
517 p.conditional.push(ConditionalProfileFlags {
518 condition: Condition::KeyValue {
519 key: ConditionKey::Os,
520 value: "macos".into(),
521 },
522 flags: ProfileFlags {
523 defines: vec!["MAC_ONLY".into()],
524 ..Default::default()
525 },
526 });
527 let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
528 assert_eq!(r.defines, vec!["BASE".to_owned()]);
529 }
530
531 #[test]
532 fn profile_layer_appends_after_target_conditional() {
533 let mut p = ProfileSettings::default();
534 p.general.cxxflags = vec!["-fPIC".into()];
535 p.conditional.push(ConditionalProfileFlags {
536 condition: Condition::KeyValue {
537 key: ConditionKey::Os,
538 value: "linux".into(),
539 },
540 flags: ProfileFlags {
541 cxxflags: vec!["-flto=thin".into()],
542 ..Default::default()
543 },
544 });
545 let prof = ProfileFlags {
546 cxxflags: vec!["-Wall".into()],
547 ..Default::default()
548 };
549 let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), &BTreeSet::new(), true);
550 assert_eq!(
551 r.cxxflags,
552 vec![
553 "-fPIC".to_owned(),
554 "-flto=thin".to_owned(),
555 "-Wall".to_owned(),
556 ]
557 );
558 }
559
560 #[test]
561 fn untrusted_package_drops_command_flags_but_keeps_defines_and_includes() {
562 let mut p = ProfileSettings::default();
563 p.general.defines = vec!["DEP_DEFINE".into()];
564 p.general.include_dirs = vec![Utf8PathBuf::from("dep/include")];
565 p.general.cflags = vec!["-fplugin=evil.so".into()];
566 p.general.cxxflags = vec!["-Xclang".into(), "-load".into()];
567 p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
568 p.conditional.push(ConditionalProfileFlags {
571 condition: Condition::KeyValue {
572 key: ConditionKey::Os,
573 value: "linux".into(),
574 },
575 flags: ProfileFlags {
576 cxxflags: vec!["-B.".into()],
577 ldflags: vec!["-specs=evil.specs".into()],
578 ..Default::default()
579 },
580 });
581
582 let untrusted = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), false);
583 assert!(
584 untrusted.cflags.is_empty(),
585 "untrusted cflags must be dropped"
586 );
587 assert!(
588 untrusted.cxxflags.is_empty(),
589 "untrusted cxxflags must be dropped"
590 );
591 assert!(
592 untrusted.ldflags.is_empty(),
593 "untrusted ldflags must be dropped"
594 );
595 assert_eq!(untrusted.defines, vec!["DEP_DEFINE".to_owned()]);
598 assert_eq!(
599 untrusted.include_dirs,
600 vec![Utf8PathBuf::from("dep/include")]
601 );
602
603 let trusted = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
605 assert_eq!(trusted.cflags, vec!["-fplugin=evil.so".to_owned()]);
606 assert_eq!(
607 trusted.cxxflags,
608 vec!["-Xclang".to_owned(), "-load".to_owned(), "-B.".to_owned()]
609 );
610 assert_eq!(
611 trusted.ldflags,
612 vec![
613 "-fuse-ld=/tmp/evil".to_owned(),
614 "-specs=evil.specs".to_owned()
615 ]
616 );
617 }
618
619 #[test]
620 fn untrusted_package_still_receives_trusted_profile_layer() {
621 let mut p = ProfileSettings::default();
622 p.general.cxxflags = vec!["-fplugin=evil.so".into()];
623 let prof = ProfileFlags {
624 cxxflags: vec!["-O2".into()],
625 ldflags: vec!["-s".into()],
626 ..Default::default()
627 };
628 let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), &BTreeSet::new(), false);
629 assert_eq!(r.cxxflags, vec!["-O2".to_owned()]);
633 assert_eq!(r.ldflags, vec!["-s".to_owned()]);
634 }
635
636 #[test]
637 fn feature_conditional_layer_gated_by_enabled_features() {
638 let mut p = ProfileSettings::default();
642 p.conditional.push(ConditionalProfileFlags {
643 condition: Condition::Feature("single-threaded".into()),
644 flags: ProfileFlags {
645 defines: vec!["SQLITE_THREADSAFE=0".into()],
646 ..Default::default()
647 },
648 });
649 let enabled: BTreeSet<String> = BTreeSet::from(["single-threaded".to_owned()]);
650 let on = resolve_build_flags(&p, None, &host_for("linux"), &enabled, true);
651 assert_eq!(on.defines, vec!["SQLITE_THREADSAFE=0".to_owned()]);
652 let off = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
653 assert!(
654 off.defines.is_empty(),
655 "feature-off must not apply the layer: {:?}",
656 off.defines
657 );
658 }
659
660 #[test]
661 fn link_libs_merge_dedup_preserving_order() {
662 let mut p = ProfileSettings::default();
663 p.general.link_libs = vec!["pthread".into(), "m".into()];
664 p.conditional.push(ConditionalProfileFlags {
665 condition: Condition::KeyValue {
666 key: ConditionKey::Family,
667 value: "unix".into(),
668 },
669 flags: ProfileFlags {
670 link_libs: vec!["dl".into(), "m".into()],
671 ..Default::default()
672 },
673 });
674 let mut host = host_for("linux");
675 host.family = "unix".into();
676 let r = resolve_build_flags(&p, None, &host, &BTreeSet::new(), true);
677 assert_eq!(
678 r.link_libs,
679 vec!["pthread".to_owned(), "m".to_owned(), "dl".to_owned()]
680 );
681 }
682
683 #[test]
684 fn link_libs_survive_untrusted_packages() {
685 let mut p = ProfileSettings::default();
688 p.general.link_libs = vec!["pthread".into()];
689 p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
690 let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), false);
691 assert_eq!(r.link_libs, vec!["pthread".to_owned()]);
692 assert!(r.ldflags.is_empty(), "untrusted ldflags must be dropped");
693 }
694
695 #[test]
696 fn validate_rejects_flag_like_link_lib() {
697 for bad in ["-lm", "-Wl,--foo", "../escape", "a/b", "has space", ""] {
698 let decl = ProfileFlags {
699 link_libs: vec![bad.into()],
700 ..Default::default()
701 };
702 assert!(
703 matches!(
704 decl.validate(),
705 Err(BuildFlagsValidationError::InvalidLinkLib { .. })
706 ),
707 "expected {bad:?} to be rejected"
708 );
709 }
710 }
711
712 #[test]
713 fn validate_accepts_real_link_lib_names() {
714 let decl = ProfileFlags {
715 link_libs: vec!["pthread".into(), "dl".into(), "m".into(), "stdc++".into()],
716 ..Default::default()
717 };
718 assert!(decl.validate().is_ok());
719 }
720
721 #[test]
722 fn validate_rejects_absolute_include_dir() {
723 let decl = ProfileFlags {
724 include_dirs: vec![Utf8PathBuf::from("/etc/include")],
725 ..Default::default()
726 };
727 let err = decl.validate().unwrap_err();
728 assert!(matches!(
729 err,
730 BuildFlagsValidationError::AbsoluteIncludeDir { .. }
731 ));
732 }
733
734 #[test]
735 fn validate_rejects_parent_traversal_include_dir() {
736 let decl = ProfileFlags {
737 include_dirs: vec![Utf8PathBuf::from("../sneaky")],
738 ..Default::default()
739 };
740 let err = decl.validate().unwrap_err();
741 assert!(matches!(
742 err,
743 BuildFlagsValidationError::IncludeDirHasParent { .. }
744 ));
745 }
746
747 #[test]
748 fn validate_rejects_empty_define() {
749 let decl = ProfileFlags {
750 defines: vec![String::new()],
751 ..Default::default()
752 };
753 assert!(matches!(
754 decl.validate().unwrap_err(),
755 BuildFlagsValidationError::EmptyDefine
756 ));
757 }
758
759 #[test]
760 fn validate_rejects_define_missing_name() {
761 let decl = ProfileFlags {
762 defines: vec!["=oops".into()],
763 ..Default::default()
764 };
765 assert!(matches!(
766 decl.validate().unwrap_err(),
767 BuildFlagsValidationError::DefineMissingName { .. }
768 ));
769 }
770}