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 system_include_dirs: Vec<Utf8PathBuf>,
200 pub extra_compile_args: Vec<String>,
203 pub cflags: Vec<String>,
207 pub cxxflags: Vec<String>,
212 pub ldflags: Vec<String>,
213 pub link_libs: Vec<String>,
219}
220
221impl ResolvedProfileFlags {
222 pub fn is_empty(&self) -> bool {
223 self.defines.is_empty()
224 && self.include_dirs.is_empty()
225 && self.system_include_dirs.is_empty()
226 && self.extra_compile_args.is_empty()
227 && self.cflags.is_empty()
228 && self.cxxflags.is_empty()
229 && self.ldflags.is_empty()
230 && self.link_libs.is_empty()
231 }
232
233 pub fn as_json(&self) -> serde_json::Value {
235 serde_json::json!({
236 "defines": self.defines,
237 "include_dirs": self
238 .include_dirs
239 .iter()
240 .map(|p| p.as_str().to_owned())
241 .collect::<Vec<_>>(),
242 "system_include_dirs": self
243 .system_include_dirs
244 .iter()
245 .map(|p| p.as_str().to_owned())
246 .collect::<Vec<_>>(),
247 "extra_compile_args": self.extra_compile_args,
248 "cflags": self.cflags,
249 "cxxflags": self.cxxflags,
250 "ldflags": self.ldflags,
251 "link_libs": self.link_libs,
252 })
253 }
254}
255
256pub fn resolve_build_flags(
293 package: &ProfileSettings,
294 profile: Option<&ProfileFlags>,
295 ctx: &crate::condition::ConditionContext<'_>,
296 package_trusted: bool,
297) -> ResolvedProfileFlags {
298 let mut out = ResolvedProfileFlags::default();
299
300 apply_layer(&mut out, &package.general);
301 for conditional in &package.conditional {
302 if conditional.condition.evaluate(ctx) {
303 apply_layer(&mut out, &conditional.flags);
304 }
305 }
306 if !package_trusted {
307 out.cflags.clear();
318 out.cxxflags.clear();
319 out.ldflags.clear();
320 }
321 if let Some(prof) = profile {
322 apply_layer(&mut out, prof);
323 }
324
325 finalize(&mut out);
326 out
327}
328
329macro_rules! append_profile_flag_layer {
344 ($target:expr, $layer:expr) => {{
345 let target = $target;
346 let layer = $layer;
347 target.defines.extend(layer.defines.iter().cloned());
354 for inc in &layer.include_dirs {
355 if !target.include_dirs.iter().any(|existing| existing == inc) {
356 target.include_dirs.push(inc.clone());
357 }
358 }
359 target.cflags.extend(layer.cflags.iter().cloned());
360 target.cxxflags.extend(layer.cxxflags.iter().cloned());
361 target.ldflags.extend(layer.ldflags.iter().cloned());
362 for lib in &layer.link_libs {
367 if !target.link_libs.iter().any(|existing| existing == lib) {
368 target.link_libs.push(lib.clone());
369 }
370 }
371 }};
372}
373
374impl ProfileFlags {
375 pub(crate) fn append_layer(&mut self, layer: &ProfileFlags) {
387 append_profile_flag_layer!(self, layer);
388 }
389}
390
391fn apply_layer(target: &mut ResolvedProfileFlags, layer: &ProfileFlags) {
392 append_profile_flag_layer!(target, layer);
393}
394
395fn finalize(target: &mut ResolvedProfileFlags) {
396 let dedup: BTreeSet<String> = target.defines.drain(..).collect();
401 target.defines = dedup.into_iter().collect();
402 }
406
407#[derive(Debug, Error, Clone, PartialEq, Eq)]
410pub enum BuildFlagsValidationError {
411 #[error("[profile] declares an empty define entry")]
412 EmptyDefine,
413 #[error("[profile] define entry {raw:?} is missing a name")]
414 DefineMissingName { raw: String },
415 #[error(
416 "[profile] link library {raw:?} is not a valid library name; use a bare name like \"pthread\" (no leading `-`, path separators, or whitespace)"
417 )]
418 InvalidLinkLib { raw: String },
419 #[error(
420 "[profile] include directory {path:?} must be a relative path; absolute paths are not allowed"
421 )]
422 AbsoluteIncludeDir { path: String },
423 #[error(
424 "[profile] include directory {path:?} must not contain `..`; include search paths cannot escape the package root"
425 )]
426 IncludeDirHasParent { path: String },
427 #[error("[profile] include directory {path:?} contains a non-UTF-8 component")]
428 NonUtf8IncludeDir { path: String },
429}
430
431fn validate_include_dir(dir: &Path) -> Result<(), BuildFlagsValidationError> {
432 if dir.is_absolute() {
433 return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
434 path: display_path(dir),
435 });
436 }
437 for component in dir.components() {
438 match component {
439 std::path::Component::ParentDir => {
440 return Err(BuildFlagsValidationError::IncludeDirHasParent {
441 path: display_path(dir),
442 });
443 }
444 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
445 return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
446 path: display_path(dir),
447 });
448 }
449 std::path::Component::Normal(part) => {
450 if part.to_str().is_none() {
451 return Err(BuildFlagsValidationError::NonUtf8IncludeDir {
452 path: display_path(dir),
453 });
454 }
455 }
456 std::path::Component::CurDir => {}
457 }
458 }
459 Ok(())
460}
461
462fn display_path(dir: &Path) -> String {
463 dir.display().to_string()
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::compiler::{CompilerIdentity, CompilerKind, CompilerVersion};
470 use crate::condition::{ConditionContext, ConditionKey, TargetPlatform};
471
472 fn host_for(os: &str) -> TargetPlatform {
473 let mut p = TargetPlatform::current();
474 p.os = os.to_owned();
475 p
476 }
477
478 #[test]
479 fn empty_settings_resolve_to_empty_flags() {
480 let p = ProfileSettings::default();
481 let r = resolve_build_flags(
482 &p,
483 None,
484 &ConditionContext::platform_only(&host_for("linux")),
485 true,
486 );
487 assert!(r.is_empty());
488 }
489
490 #[test]
491 fn defines_merge_dedup_and_sort() {
492 let mut p = ProfileSettings::default();
493 p.general.defines = vec!["B".into(), "A".into(), "B".into()];
494 let r = resolve_build_flags(
495 &p,
496 None,
497 &ConditionContext::platform_only(&host_for("linux")),
498 true,
499 );
500 assert_eq!(r.defines, vec!["A".to_owned(), "B".to_owned()]);
501 }
502
503 #[test]
504 fn include_dirs_keep_first_occurrence_order() {
505 let mut p = ProfileSettings::default();
506 p.general.include_dirs = vec![
507 Utf8PathBuf::from("include"),
508 Utf8PathBuf::from("third_party/include"),
509 Utf8PathBuf::from("include"),
510 ];
511 let r = resolve_build_flags(
512 &p,
513 None,
514 &ConditionContext::platform_only(&host_for("linux")),
515 true,
516 );
517 assert_eq!(
518 r.include_dirs,
519 vec![
520 Utf8PathBuf::from("include"),
521 Utf8PathBuf::from("third_party/include"),
522 ]
523 );
524 }
525
526 #[test]
527 fn matching_conditional_layer_is_applied() {
528 let mut p = ProfileSettings::default();
529 p.general.defines = vec!["BASE".into()];
530 p.conditional.push(ConditionalProfileFlags {
531 condition: Condition::KeyValue {
532 key: ConditionKey::Os,
533 value: "linux".into(),
534 },
535 flags: ProfileFlags {
536 defines: vec!["LINUX_ONLY".into()],
537 ..Default::default()
538 },
539 });
540 let r = resolve_build_flags(
541 &p,
542 None,
543 &ConditionContext::platform_only(&host_for("linux")),
544 true,
545 );
546 assert_eq!(r.defines, vec!["BASE".to_owned(), "LINUX_ONLY".to_owned()]);
547 }
548
549 #[test]
550 fn non_matching_conditional_layer_is_skipped() {
551 let mut p = ProfileSettings::default();
552 p.general.defines = vec!["BASE".into()];
553 p.conditional.push(ConditionalProfileFlags {
554 condition: Condition::KeyValue {
555 key: ConditionKey::Os,
556 value: "macos".into(),
557 },
558 flags: ProfileFlags {
559 defines: vec!["MAC_ONLY".into()],
560 ..Default::default()
561 },
562 });
563 let r = resolve_build_flags(
564 &p,
565 None,
566 &ConditionContext::platform_only(&host_for("linux")),
567 true,
568 );
569 assert_eq!(r.defines, vec!["BASE".to_owned()]);
570 }
571
572 #[test]
573 fn profile_layer_appends_after_target_conditional() {
574 let mut p = ProfileSettings::default();
575 p.general.cxxflags = vec!["-fPIC".into()];
576 p.conditional.push(ConditionalProfileFlags {
577 condition: Condition::KeyValue {
578 key: ConditionKey::Os,
579 value: "linux".into(),
580 },
581 flags: ProfileFlags {
582 cxxflags: vec!["-flto=thin".into()],
583 ..Default::default()
584 },
585 });
586 let prof = ProfileFlags {
587 cxxflags: vec!["-Wall".into()],
588 ..Default::default()
589 };
590 let r = resolve_build_flags(
591 &p,
592 Some(&prof),
593 &ConditionContext::platform_only(&host_for("linux")),
594 true,
595 );
596 assert_eq!(
597 r.cxxflags,
598 vec![
599 "-fPIC".to_owned(),
600 "-flto=thin".to_owned(),
601 "-Wall".to_owned(),
602 ]
603 );
604 }
605
606 #[test]
607 fn untrusted_package_drops_command_flags_but_keeps_defines_and_includes() {
608 let mut p = ProfileSettings::default();
609 p.general.defines = vec!["DEP_DEFINE".into()];
610 p.general.include_dirs = vec![Utf8PathBuf::from("dep/include")];
611 p.general.cflags = vec!["-fplugin=evil.so".into()];
612 p.general.cxxflags = vec!["-Xclang".into(), "-load".into()];
613 p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
614 p.conditional.push(ConditionalProfileFlags {
617 condition: Condition::KeyValue {
618 key: ConditionKey::Os,
619 value: "linux".into(),
620 },
621 flags: ProfileFlags {
622 cxxflags: vec!["-B.".into()],
623 ldflags: vec!["-specs=evil.specs".into()],
624 ..Default::default()
625 },
626 });
627
628 let untrusted = resolve_build_flags(
629 &p,
630 None,
631 &ConditionContext::platform_only(&host_for("linux")),
632 false,
633 );
634 assert!(
635 untrusted.cflags.is_empty(),
636 "untrusted cflags must be dropped"
637 );
638 assert!(
639 untrusted.cxxflags.is_empty(),
640 "untrusted cxxflags must be dropped"
641 );
642 assert!(
643 untrusted.ldflags.is_empty(),
644 "untrusted ldflags must be dropped"
645 );
646 assert_eq!(untrusted.defines, vec!["DEP_DEFINE".to_owned()]);
649 assert_eq!(
650 untrusted.include_dirs,
651 vec![Utf8PathBuf::from("dep/include")]
652 );
653
654 let trusted = resolve_build_flags(
656 &p,
657 None,
658 &ConditionContext::platform_only(&host_for("linux")),
659 true,
660 );
661 assert_eq!(trusted.cflags, vec!["-fplugin=evil.so".to_owned()]);
662 assert_eq!(
663 trusted.cxxflags,
664 vec!["-Xclang".to_owned(), "-load".to_owned(), "-B.".to_owned()]
665 );
666 assert_eq!(
667 trusted.ldflags,
668 vec![
669 "-fuse-ld=/tmp/evil".to_owned(),
670 "-specs=evil.specs".to_owned()
671 ]
672 );
673 }
674
675 #[test]
676 fn untrusted_package_still_receives_trusted_profile_layer() {
677 let mut p = ProfileSettings::default();
678 p.general.cxxflags = vec!["-fplugin=evil.so".into()];
679 let prof = ProfileFlags {
680 cxxflags: vec!["-O2".into()],
681 ldflags: vec!["-s".into()],
682 ..Default::default()
683 };
684 let r = resolve_build_flags(
685 &p,
686 Some(&prof),
687 &ConditionContext::platform_only(&host_for("linux")),
688 false,
689 );
690 assert_eq!(r.cxxflags, vec!["-O2".to_owned()]);
694 assert_eq!(r.ldflags, vec!["-s".to_owned()]);
695 }
696
697 #[test]
698 fn feature_conditional_layer_gated_by_enabled_features() {
699 let mut p = ProfileSettings::default();
703 p.conditional.push(ConditionalProfileFlags {
704 condition: Condition::Feature("single-threaded".into()),
705 flags: ProfileFlags {
706 defines: vec!["SQLITE_THREADSAFE=0".into()],
707 ..Default::default()
708 },
709 });
710 let enabled: BTreeSet<String> = BTreeSet::from(["single-threaded".to_owned()]);
711 let on = resolve_build_flags(
712 &p,
713 None,
714 &ConditionContext::with_features(&host_for("linux"), &enabled),
715 true,
716 );
717 assert_eq!(on.defines, vec!["SQLITE_THREADSAFE=0".to_owned()]);
718 let off = resolve_build_flags(
719 &p,
720 None,
721 &ConditionContext::platform_only(&host_for("linux")),
722 true,
723 );
724 assert!(
725 off.defines.is_empty(),
726 "feature-off must not apply the layer: {:?}",
727 off.defines
728 );
729 }
730
731 #[test]
732 fn compiler_conditional_layer_gated_by_detected_identity() {
733 let mut p = ProfileSettings::default();
734 p.conditional.push(ConditionalProfileFlags {
735 condition: Condition::parse_inner(r#"all(cxx = "clang", cxx_version = ">=18")"#)
736 .unwrap(),
737 flags: ProfileFlags {
738 cxxflags: vec!["-stdlib=libc++".into()],
739 ..Default::default()
740 },
741 });
742 let host = host_for("linux");
743 let clang18 = CompilerIdentity {
744 kind: CompilerKind::Clang,
745 version: CompilerVersion::parse("18.1.3"),
746 target: None,
747 raw_version_line: "clang version 18.1.3".into(),
748 };
749 let gcc13 = CompilerIdentity {
750 kind: CompilerKind::Gcc,
751 version: CompilerVersion::parse("13.3.0"),
752 target: None,
753 raw_version_line: "g++ 13.3.0".into(),
754 };
755
756 let matching = ConditionContext::platform_only(&host).with_compilers(None, Some(&clang18));
757 let on = resolve_build_flags(&p, None, &matching, true);
758 assert_eq!(on.cxxflags, vec!["-stdlib=libc++".to_owned()]);
759
760 let other = ConditionContext::platform_only(&host).with_compilers(None, Some(&gcc13));
761 let off = resolve_build_flags(&p, None, &other, true);
762 assert!(off.cxxflags.is_empty());
763
764 let undetected = ConditionContext::platform_only(&host);
766 assert!(
767 resolve_build_flags(&p, None, &undetected, true)
768 .cxxflags
769 .is_empty()
770 );
771 }
772
773 #[test]
774 fn link_libs_merge_dedup_preserving_order() {
775 let mut p = ProfileSettings::default();
776 p.general.link_libs = vec!["pthread".into(), "m".into()];
777 p.conditional.push(ConditionalProfileFlags {
778 condition: Condition::KeyValue {
779 key: ConditionKey::Family,
780 value: "unix".into(),
781 },
782 flags: ProfileFlags {
783 link_libs: vec!["dl".into(), "m".into()],
784 ..Default::default()
785 },
786 });
787 let mut host = host_for("linux");
788 host.family = "unix".into();
789 let r = resolve_build_flags(&p, None, &ConditionContext::platform_only(&host), true);
790 assert_eq!(
791 r.link_libs,
792 vec!["pthread".to_owned(), "m".to_owned(), "dl".to_owned()]
793 );
794 }
795
796 #[test]
797 fn link_libs_survive_untrusted_packages() {
798 let mut p = ProfileSettings::default();
801 p.general.link_libs = vec!["pthread".into()];
802 p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
803 let r = resolve_build_flags(
804 &p,
805 None,
806 &ConditionContext::platform_only(&host_for("linux")),
807 false,
808 );
809 assert_eq!(r.link_libs, vec!["pthread".to_owned()]);
810 assert!(r.ldflags.is_empty(), "untrusted ldflags must be dropped");
811 }
812
813 #[test]
814 fn validate_rejects_flag_like_link_lib() {
815 for bad in ["-lm", "-Wl,--foo", "../escape", "a/b", "has space", ""] {
816 let decl = ProfileFlags {
817 link_libs: vec![bad.into()],
818 ..Default::default()
819 };
820 assert!(
821 matches!(
822 decl.validate(),
823 Err(BuildFlagsValidationError::InvalidLinkLib { .. })
824 ),
825 "expected {bad:?} to be rejected"
826 );
827 }
828 }
829
830 #[test]
831 fn validate_accepts_real_link_lib_names() {
832 let decl = ProfileFlags {
833 link_libs: vec!["pthread".into(), "dl".into(), "m".into(), "stdc++".into()],
834 ..Default::default()
835 };
836 assert!(decl.validate().is_ok());
837 }
838
839 #[test]
840 fn validate_rejects_absolute_include_dir() {
841 let decl = ProfileFlags {
842 include_dirs: vec![Utf8PathBuf::from("/etc/include")],
843 ..Default::default()
844 };
845 let err = decl.validate().unwrap_err();
846 assert!(matches!(
847 err,
848 BuildFlagsValidationError::AbsoluteIncludeDir { .. }
849 ));
850 }
851
852 #[test]
853 fn validate_rejects_parent_traversal_include_dir() {
854 let decl = ProfileFlags {
855 include_dirs: vec![Utf8PathBuf::from("../sneaky")],
856 ..Default::default()
857 };
858 let err = decl.validate().unwrap_err();
859 assert!(matches!(
860 err,
861 BuildFlagsValidationError::IncludeDirHasParent { .. }
862 ));
863 }
864
865 #[test]
866 fn validate_rejects_empty_define() {
867 let decl = ProfileFlags {
868 defines: vec![String::new()],
869 ..Default::default()
870 };
871 assert!(matches!(
872 decl.validate().unwrap_err(),
873 BuildFlagsValidationError::EmptyDefine
874 ));
875 }
876
877 #[test]
878 fn validate_rejects_define_missing_name() {
879 let decl = ProfileFlags {
880 defines: vec!["=oops".into()],
881 ..Default::default()
882 };
883 assert!(matches!(
884 decl.validate().unwrap_err(),
885 BuildFlagsValidationError::DefineMissingName { .. }
886 ));
887 }
888
889 #[test]
890 fn resolved_flags_with_only_system_include_dirs_are_not_empty() {
891 let flags = ResolvedProfileFlags {
895 system_include_dirs: vec![Utf8PathBuf::from("/opt/dep/include")],
896 ..Default::default()
897 };
898 assert!(!flags.is_empty());
899 }
900
901 #[test]
902 fn resolved_flags_json_includes_system_include_dirs() {
903 let flags = ResolvedProfileFlags {
904 system_include_dirs: vec![Utf8PathBuf::from("/opt/dep/include")],
905 ..Default::default()
906 };
907 assert_eq!(
908 flags.as_json()["system_include_dirs"],
909 serde_json::json!(["/opt/dep/include"]),
910 );
911 }
912}