1use std::collections::{BTreeMap, BTreeSet};
24use std::path::Path;
25
26use camino::Utf8PathBuf;
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31use crate::condition::Condition;
32use crate::profile::{ProfileDefinition, ProfileName, ResolvedProfile};
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct ProfileFlags {
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub defines: Vec<String>,
49 #[serde(
54 default,
55 rename = "include-dirs",
56 skip_serializing_if = "Vec::is_empty"
57 )]
58 pub include_dirs: Vec<Utf8PathBuf>,
59 #[serde(default, rename = "cflags", skip_serializing_if = "Vec::is_empty")]
64 pub cflags: Vec<String>,
65 #[serde(default, rename = "cxxflags", skip_serializing_if = "Vec::is_empty")]
71 pub cxxflags: Vec<String>,
72 #[serde(default, rename = "ldflags", skip_serializing_if = "Vec::is_empty")]
75 pub ldflags: Vec<String>,
76 #[serde(default, rename = "link-libs", skip_serializing_if = "Vec::is_empty")]
88 pub link_libs: Vec<String>,
89}
90
91impl ProfileFlags {
92 pub fn is_empty(&self) -> bool {
93 self.defines.is_empty()
94 && self.include_dirs.is_empty()
95 && self.cflags.is_empty()
96 && self.cxxflags.is_empty()
97 && self.ldflags.is_empty()
98 && self.link_libs.is_empty()
99 }
100
101 pub fn validate(&self) -> Result<(), BuildFlagsValidationError> {
116 for define in &self.defines {
117 if define.is_empty() {
118 return Err(BuildFlagsValidationError::EmptyDefine);
119 }
120 if define.starts_with('=') {
121 return Err(BuildFlagsValidationError::DefineMissingName {
122 raw: define.clone(),
123 });
124 }
125 }
126 for dir in &self.include_dirs {
127 validate_include_dir(dir.as_std_path())?;
128 }
129 for lib in &self.link_libs {
130 if !is_safe_link_lib(lib) {
131 return Err(BuildFlagsValidationError::InvalidLinkLib { raw: lib.clone() });
132 }
133 }
134 Ok(())
135 }
136}
137
138pub fn is_safe_link_lib(name: &str) -> bool {
150 let mut chars = name.chars();
151 let Some(first) = chars.next() else {
152 return false;
153 };
154 if !(first.is_ascii_alphanumeric() || first == '_') {
155 return false;
156 }
157 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '+' | '-'))
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ConditionalProfileFlags {
164 pub condition: Condition,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub profile: Option<ProfileName>,
169 #[serde(flatten, default, skip_serializing_if = "ProfileFlags::is_empty")]
170 pub flags: ProfileFlags,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
177pub struct ProfileSettings {
178 #[serde(default, skip_serializing_if = "ProfileFlags::is_empty")]
179 pub general: ProfileFlags,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub conditional: Vec<ConditionalProfileFlags>,
182}
183
184impl ProfileSettings {
185 pub fn is_empty(&self) -> bool {
186 self.general.is_empty() && self.conditional.is_empty()
187 }
188}
189
190#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
197pub struct ResolvedProfileFlags {
198 pub defines: Vec<String>,
199 pub include_dirs: Vec<Utf8PathBuf>,
200 pub system_include_dirs: Vec<Utf8PathBuf>,
208 pub extra_compile_args: Vec<String>,
211 pub cflags: Vec<String>,
215 pub cxxflags: Vec<String>,
220 pub ldflags: Vec<String>,
221 pub link_libs: Vec<String>,
227}
228
229impl ResolvedProfileFlags {
230 pub fn is_empty(&self) -> bool {
231 self.defines.is_empty()
232 && self.include_dirs.is_empty()
233 && self.system_include_dirs.is_empty()
234 && self.extra_compile_args.is_empty()
235 && self.cflags.is_empty()
236 && self.cxxflags.is_empty()
237 && self.ldflags.is_empty()
238 && self.link_libs.is_empty()
239 }
240
241 pub fn as_json(&self) -> serde_json::Value {
243 serde_json::json!({
244 "defines": self.defines,
245 "include_dirs": self
246 .include_dirs
247 .iter()
248 .map(|p| p.as_str())
249 .collect::<Vec<_>>(),
250 "system_include_dirs": self
251 .system_include_dirs
252 .iter()
253 .map(|p| p.as_str())
254 .collect::<Vec<_>>(),
255 "extra_compile_args": self.extra_compile_args,
256 "cflags": self.cflags,
257 "cxxflags": self.cxxflags,
258 "ldflags": self.ldflags,
259 "link_libs": self.link_libs,
260 })
261 }
262}
263
264pub fn resolve_build_flags(
289 package: &ProfileSettings,
290 profile: Option<&ResolvedProfile>,
291 definitions: &BTreeMap<ProfileName, ProfileDefinition>,
292 ctx: &crate::condition::ConditionContext<'_>,
293 package_trusted: bool,
294) -> ResolvedProfileFlags {
295 let mut out = ResolvedProfileFlags::default();
296
297 apply_package_layer(&mut out, &package.general, package_trusted);
298 for conditional in package
299 .conditional
300 .iter()
301 .filter(|layer| layer.profile.is_none() && layer.condition.evaluate(ctx))
302 {
303 apply_package_layer(&mut out, &conditional.flags, package_trusted);
304 }
305 if let Some(profile) = profile {
306 for name in &profile.inherits_chain {
307 if let Some(flags) = definitions
308 .get(name)
309 .and_then(|definition| definition.build.as_ref())
310 {
311 apply_layer(&mut out, flags);
312 }
313 for conditional in package.conditional.iter().filter(|layer| {
314 layer.profile.as_ref() == Some(name) && layer.condition.evaluate(ctx)
315 }) {
316 apply_package_layer(&mut out, &conditional.flags, package_trusted);
317 }
318 }
319 }
320
321 finalize(&mut out);
322 out
323}
324
325fn apply_package_layer(
326 out: &mut ResolvedProfileFlags,
327 layer: &ProfileFlags,
328 package_trusted: bool,
329) {
330 if package_trusted {
331 apply_layer(out, layer);
332 return;
333 }
334 let mut safe = layer.clone();
335 safe.cflags.clear();
336 safe.cxxflags.clear();
337 safe.ldflags.clear();
338 apply_layer(out, &safe);
339}
340
341macro_rules! append_profile_flag_layer {
356 ($target:expr, $layer:expr) => {{
357 let target = $target;
358 let layer = $layer;
359 target.defines.extend(layer.defines.iter().cloned());
366 extend_dedup_first_occurrence(&mut target.include_dirs, &layer.include_dirs);
367 target.cflags.extend(layer.cflags.iter().cloned());
368 target.cxxflags.extend(layer.cxxflags.iter().cloned());
369 target.ldflags.extend(layer.ldflags.iter().cloned());
370 extend_dedup_first_occurrence(&mut target.link_libs, &layer.link_libs);
375 }};
376}
377
378fn extend_dedup_first_occurrence<T: PartialEq + Clone>(target: &mut Vec<T>, items: &[T]) {
381 for item in items {
382 if !target.iter().any(|existing| existing == item) {
383 target.push(item.clone());
384 }
385 }
386}
387
388impl ProfileFlags {
389 pub(crate) fn append_layer(&mut self, layer: &ProfileFlags) {
400 append_profile_flag_layer!(self, layer);
401 }
402}
403
404fn apply_layer(target: &mut ResolvedProfileFlags, layer: &ProfileFlags) {
405 append_profile_flag_layer!(target, layer);
406}
407
408fn finalize(target: &mut ResolvedProfileFlags) {
409 let dedup: BTreeSet<String> = target.defines.drain(..).collect();
414 target.defines = dedup.into_iter().collect();
415 }
419
420#[derive(Debug, Error, Clone, PartialEq, Eq)]
423pub enum BuildFlagsValidationError {
424 #[error("[profile] declares an empty define entry")]
425 EmptyDefine,
426 #[error("[profile] define entry {raw:?} is missing a name")]
427 DefineMissingName { raw: String },
428 #[error(
429 "[profile] link library {raw:?} is not a valid library name; use a bare name like \"pthread\" (no leading `-`, path separators, or whitespace)"
430 )]
431 InvalidLinkLib { raw: String },
432 #[error(
433 "[profile] include directory {path:?} must be a relative path; absolute paths are not allowed"
434 )]
435 AbsoluteIncludeDir { path: String },
436 #[error(
437 "[profile] include directory {path:?} must not contain `..`; include search paths cannot escape the package root"
438 )]
439 IncludeDirHasParent { path: String },
440 #[error("[profile] include directory {path:?} contains a non-UTF-8 component")]
441 NonUtf8IncludeDir { path: String },
442}
443
444fn validate_include_dir(dir: &Path) -> Result<(), BuildFlagsValidationError> {
445 if dir.is_absolute() {
446 return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
447 path: display_path(dir),
448 });
449 }
450 for component in dir.components() {
451 match component {
452 std::path::Component::ParentDir => {
453 return Err(BuildFlagsValidationError::IncludeDirHasParent {
454 path: display_path(dir),
455 });
456 }
457 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
458 return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
459 path: display_path(dir),
460 });
461 }
462 std::path::Component::Normal(part) => {
463 if part.to_str().is_none() {
464 return Err(BuildFlagsValidationError::NonUtf8IncludeDir {
465 path: display_path(dir),
466 });
467 }
468 }
469 std::path::Component::CurDir => {}
470 }
471 }
472 Ok(())
473}
474
475fn display_path(dir: &Path) -> String {
476 dir.display().to_string()
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::compiler::{CompilerIdentity, CompilerKind, CompilerVersion};
483 use crate::condition::{ConditionContext, ConditionKey, TargetPlatform};
484 use crate::profile::{
485 ProfileDefinition, ProfileName, ProfileSelection, ResolvedProfile, resolve_profile,
486 };
487 use std::collections::BTreeMap;
488
489 fn host_for(os: &str) -> TargetPlatform {
490 let mut p = TargetPlatform::current();
491 p.os = os.to_owned();
492 p
493 }
494
495 fn profile_name(value: &str) -> ProfileName {
496 ProfileName::new(value).unwrap()
497 }
498
499 fn profile_definition(
500 name: &str,
501 inherits: Option<&str>,
502 ldflags: &[&str],
503 ) -> (ProfileName, ProfileDefinition) {
504 let name = profile_name(name);
505 (
506 name.clone(),
507 ProfileDefinition {
508 name,
509 inherits: inherits.map(profile_name),
510 debug: None,
511 opt_level: None,
512 assertions: None,
513 build: Some(ProfileFlags {
514 ldflags: ldflags.iter().map(|flag| (*flag).to_owned()).collect(),
515 ..Default::default()
516 }),
517 },
518 )
519 }
520
521 fn profile_definitions() -> BTreeMap<ProfileName, ProfileDefinition> {
522 BTreeMap::from([
523 profile_definition("release", None, &["release"]),
524 profile_definition("static", Some("release"), &["static"]),
525 ])
526 }
527
528 fn selected_profile(
529 name: &str,
530 definitions: &BTreeMap<ProfileName, ProfileDefinition>,
531 ) -> ResolvedProfile {
532 resolve_profile(
533 &ProfileSelection::from_name(profile_name(name)),
534 definitions,
535 )
536 .unwrap()
537 }
538
539 fn os_condition(os: &str) -> Condition {
540 Condition::KeyValue {
541 key: ConditionKey::Os,
542 value: os.to_owned(),
543 }
544 }
545
546 fn resolve_without_profile(
547 package: &ProfileSettings,
548 ctx: &ConditionContext<'_>,
549 package_trusted: bool,
550 ) -> ResolvedProfileFlags {
551 resolve_build_flags(package, None, &BTreeMap::new(), ctx, package_trusted)
552 }
553
554 #[test]
555 fn named_target_profile_layers_interleave_with_profile_chain() {
556 let definitions = profile_definitions();
557 let selected = selected_profile("static", &definitions);
558 let mut settings = ProfileSettings::default();
559 settings.general.ldflags = vec!["base".into()];
560 settings.conditional = vec![
561 ConditionalProfileFlags {
562 condition: os_condition("linux"),
563 profile: None,
564 flags: ProfileFlags {
565 ldflags: vec!["linux-base".into()],
566 ..Default::default()
567 },
568 },
569 ConditionalProfileFlags {
570 condition: os_condition("linux"),
571 profile: Some(profile_name("release")),
572 flags: ProfileFlags {
573 ldflags: vec!["linux-release".into()],
574 ..Default::default()
575 },
576 },
577 ConditionalProfileFlags {
578 condition: os_condition("linux"),
579 profile: Some(profile_name("static")),
580 flags: ProfileFlags {
581 ldflags: vec!["linux-static".into()],
582 ..Default::default()
583 },
584 },
585 ];
586
587 let resolved = resolve_build_flags(
588 &settings,
589 Some(&selected),
590 &definitions,
591 &ConditionContext::platform_only(&host_for("linux")),
592 true,
593 );
594 assert_eq!(
595 resolved.ldflags,
596 vec![
597 "base",
598 "linux-base",
599 "release",
600 "linux-release",
601 "static",
602 "linux-static",
603 ],
604 );
605 }
606
607 #[test]
608 fn named_target_profile_layers_require_profile_and_target_matches() {
609 let definitions = profile_definitions();
610 let settings = ProfileSettings {
611 conditional: vec![
612 ConditionalProfileFlags {
613 condition: os_condition("linux"),
614 profile: Some(profile_name("release")),
615 flags: ProfileFlags {
616 ldflags: vec!["linux-release".into()],
617 ..Default::default()
618 },
619 },
620 ConditionalProfileFlags {
621 condition: os_condition("linux"),
622 profile: Some(profile_name("undeclared")),
623 flags: ProfileFlags {
624 ldflags: vec!["inert".into()],
625 ..Default::default()
626 },
627 },
628 ],
629 ..Default::default()
630 };
631
632 let release = selected_profile("release", &definitions);
633 let release_linux = resolve_build_flags(
634 &settings,
635 Some(&release),
636 &definitions,
637 &ConditionContext::platform_only(&host_for("linux")),
638 true,
639 );
640 assert_eq!(release_linux.ldflags, vec!["release", "linux-release"]);
641
642 let static_profile = selected_profile("static", &definitions);
643 let static_linux = resolve_build_flags(
644 &settings,
645 Some(&static_profile),
646 &definitions,
647 &ConditionContext::platform_only(&host_for("linux")),
648 true,
649 );
650 assert_eq!(
651 static_linux.ldflags,
652 vec!["release", "linux-release", "static"],
653 );
654
655 let dev = selected_profile("dev", &definitions);
656 let dev_linux = resolve_build_flags(
657 &settings,
658 Some(&dev),
659 &definitions,
660 &ConditionContext::platform_only(&host_for("linux")),
661 true,
662 );
663 assert!(dev_linux.ldflags.is_empty());
664
665 let static_macos = resolve_build_flags(
666 &settings,
667 Some(&static_profile),
668 &definitions,
669 &ConditionContext::platform_only(&host_for("macos")),
670 true,
671 );
672 assert_eq!(static_macos.ldflags, vec!["release", "static"]);
673 }
674
675 #[test]
676 fn matching_named_target_profile_layers_keep_manifest_order() {
677 let definitions = profile_definitions();
678 let release = selected_profile("release", &definitions);
679 let mut host = host_for("linux");
680 host.arch = "x86_64".into();
681 let settings = ProfileSettings {
682 conditional: vec![
683 ConditionalProfileFlags {
684 condition: os_condition("linux"),
685 profile: Some(profile_name("release")),
686 flags: ProfileFlags {
687 ldflags: vec!["linux".into()],
688 ..Default::default()
689 },
690 },
691 ConditionalProfileFlags {
692 condition: Condition::KeyValue {
693 key: ConditionKey::Arch,
694 value: "x86_64".into(),
695 },
696 profile: Some(profile_name("release")),
697 flags: ProfileFlags {
698 ldflags: vec!["x86_64".into()],
699 ..Default::default()
700 },
701 },
702 ],
703 ..Default::default()
704 };
705
706 let resolved = resolve_build_flags(
707 &settings,
708 Some(&release),
709 &definitions,
710 &ConditionContext::platform_only(&host),
711 true,
712 );
713 assert_eq!(resolved.ldflags, vec!["release", "linux", "x86_64"]);
714 }
715
716 #[test]
717 fn empty_settings_resolve_to_empty_flags() {
718 let p = ProfileSettings::default();
719 let r = resolve_without_profile(
720 &p,
721 &ConditionContext::platform_only(&host_for("linux")),
722 true,
723 );
724 assert!(r.is_empty());
725 }
726
727 #[test]
728 fn defines_merge_dedup_and_sort() {
729 let mut p = ProfileSettings::default();
730 p.general.defines = vec!["B".into(), "A".into(), "B".into()];
731 let r = resolve_without_profile(
732 &p,
733 &ConditionContext::platform_only(&host_for("linux")),
734 true,
735 );
736 assert_eq!(r.defines, vec!["A".to_owned(), "B".to_owned()]);
737 }
738
739 #[test]
740 fn include_dirs_keep_first_occurrence_order() {
741 let mut p = ProfileSettings::default();
742 p.general.include_dirs = vec![
743 Utf8PathBuf::from("include"),
744 Utf8PathBuf::from("third_party/include"),
745 Utf8PathBuf::from("include"),
746 ];
747 let r = resolve_without_profile(
748 &p,
749 &ConditionContext::platform_only(&host_for("linux")),
750 true,
751 );
752 assert_eq!(
753 r.include_dirs,
754 vec![
755 Utf8PathBuf::from("include"),
756 Utf8PathBuf::from("third_party/include"),
757 ]
758 );
759 }
760
761 #[test]
762 fn matching_conditional_layer_is_applied() {
763 let mut p = ProfileSettings::default();
764 p.general.defines = vec!["BASE".into()];
765 p.conditional.push(ConditionalProfileFlags {
766 condition: Condition::KeyValue {
767 key: ConditionKey::Os,
768 value: "linux".into(),
769 },
770 profile: None,
771 flags: ProfileFlags {
772 defines: vec!["LINUX_ONLY".into()],
773 ..Default::default()
774 },
775 });
776 let r = resolve_without_profile(
777 &p,
778 &ConditionContext::platform_only(&host_for("linux")),
779 true,
780 );
781 assert_eq!(r.defines, vec!["BASE".to_owned(), "LINUX_ONLY".to_owned()]);
782 }
783
784 #[test]
785 fn non_matching_conditional_layer_is_skipped() {
786 let mut p = ProfileSettings::default();
787 p.general.defines = vec!["BASE".into()];
788 p.conditional.push(ConditionalProfileFlags {
789 condition: Condition::KeyValue {
790 key: ConditionKey::Os,
791 value: "macos".into(),
792 },
793 profile: None,
794 flags: ProfileFlags {
795 defines: vec!["MAC_ONLY".into()],
796 ..Default::default()
797 },
798 });
799 let r = resolve_without_profile(
800 &p,
801 &ConditionContext::platform_only(&host_for("linux")),
802 true,
803 );
804 assert_eq!(r.defines, vec!["BASE".to_owned()]);
805 }
806
807 #[test]
808 fn profile_layer_appends_after_target_conditional() {
809 let mut p = ProfileSettings::default();
810 p.general.cxxflags = vec!["-fPIC".into()];
811 p.conditional.push(ConditionalProfileFlags {
812 condition: Condition::KeyValue {
813 key: ConditionKey::Os,
814 value: "linux".into(),
815 },
816 profile: None,
817 flags: ProfileFlags {
818 cxxflags: vec!["-flto=thin".into()],
819 ..Default::default()
820 },
821 });
822 let prof = ProfileFlags {
823 cxxflags: vec!["-Wall".into()],
824 ..Default::default()
825 };
826 let release_name = profile_name("release");
827 let definitions = BTreeMap::from([(
828 release_name.clone(),
829 ProfileDefinition {
830 name: release_name,
831 inherits: None,
832 debug: None,
833 opt_level: None,
834 assertions: None,
835 build: Some(prof),
836 },
837 )]);
838 let selected = selected_profile("release", &definitions);
839 let r = resolve_build_flags(
840 &p,
841 Some(&selected),
842 &definitions,
843 &ConditionContext::platform_only(&host_for("linux")),
844 true,
845 );
846 assert_eq!(
847 r.cxxflags,
848 vec![
849 "-fPIC".to_owned(),
850 "-flto=thin".to_owned(),
851 "-Wall".to_owned(),
852 ]
853 );
854 }
855
856 #[test]
857 fn untrusted_package_drops_command_flags_but_keeps_defines_and_includes() {
858 let mut p = ProfileSettings::default();
859 p.general.defines = vec!["DEP_DEFINE".into()];
860 p.general.include_dirs = vec![Utf8PathBuf::from("dep/include")];
861 p.general.cflags = vec!["-fplugin=evil.so".into()];
862 p.general.cxxflags = vec!["-Xclang".into(), "-load".into()];
863 p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
864 p.conditional.push(ConditionalProfileFlags {
867 condition: Condition::KeyValue {
868 key: ConditionKey::Os,
869 value: "linux".into(),
870 },
871 profile: None,
872 flags: ProfileFlags {
873 cxxflags: vec!["-B.".into()],
874 ldflags: vec!["-specs=evil.specs".into()],
875 ..Default::default()
876 },
877 });
878
879 let untrusted = resolve_without_profile(
880 &p,
881 &ConditionContext::platform_only(&host_for("linux")),
882 false,
883 );
884 assert!(
885 untrusted.cflags.is_empty(),
886 "untrusted cflags must be dropped"
887 );
888 assert!(
889 untrusted.cxxflags.is_empty(),
890 "untrusted cxxflags must be dropped"
891 );
892 assert!(
893 untrusted.ldflags.is_empty(),
894 "untrusted ldflags must be dropped"
895 );
896 assert_eq!(untrusted.defines, vec!["DEP_DEFINE".to_owned()]);
899 assert_eq!(
900 untrusted.include_dirs,
901 vec![Utf8PathBuf::from("dep/include")]
902 );
903
904 let trusted = resolve_without_profile(
906 &p,
907 &ConditionContext::platform_only(&host_for("linux")),
908 true,
909 );
910 assert_eq!(trusted.cflags, vec!["-fplugin=evil.so".to_owned()]);
911 assert_eq!(
912 trusted.cxxflags,
913 vec!["-Xclang".to_owned(), "-load".to_owned(), "-B.".to_owned()]
914 );
915 assert_eq!(
916 trusted.ldflags,
917 vec![
918 "-fuse-ld=/tmp/evil".to_owned(),
919 "-specs=evil.specs".to_owned()
920 ]
921 );
922 }
923
924 #[test]
925 fn untrusted_package_still_receives_trusted_profile_layer() {
926 let mut p = ProfileSettings::default();
927 p.general.cxxflags = vec!["-fplugin=evil.so".into()];
928 let prof = ProfileFlags {
929 cxxflags: vec!["-O2".into()],
930 ldflags: vec!["-s".into()],
931 ..Default::default()
932 };
933 let release_name = profile_name("release");
934 let definitions = BTreeMap::from([(
935 release_name.clone(),
936 ProfileDefinition {
937 name: release_name,
938 inherits: None,
939 debug: None,
940 opt_level: None,
941 assertions: None,
942 build: Some(prof),
943 },
944 )]);
945 let selected = selected_profile("release", &definitions);
946 let r = resolve_build_flags(
947 &p,
948 Some(&selected),
949 &definitions,
950 &ConditionContext::platform_only(&host_for("linux")),
951 false,
952 );
953 assert_eq!(r.cxxflags, vec!["-O2".to_owned()]);
957 assert_eq!(r.ldflags, vec!["-s".to_owned()]);
958 }
959
960 #[test]
961 fn untrusted_named_overlay_drops_command_flags_but_keeps_safe_fields() {
962 let mut package = ProfileSettings::default();
963 package.conditional.push(ConditionalProfileFlags {
964 condition: os_condition("linux"),
965 profile: Some(profile_name("release")),
966 flags: ProfileFlags {
967 defines: vec!["SAFE_NAMED_OVERLAY".into()],
968 cxxflags: vec!["-B.".into()],
969 ldflags: vec!["-specs=evil.specs".into()],
970 ..Default::default()
971 },
972 });
973 let release_name = profile_name("release");
974 let definitions = BTreeMap::from([(
975 release_name.clone(),
976 ProfileDefinition {
977 name: release_name,
978 inherits: None,
979 debug: None,
980 opt_level: None,
981 assertions: None,
982 build: Some(ProfileFlags {
983 cxxflags: vec!["-O2".into()],
984 ldflags: vec!["-s".into()],
985 ..Default::default()
986 }),
987 },
988 )]);
989 let selected = selected_profile("release", &definitions);
990
991 let resolved = resolve_build_flags(
992 &package,
993 Some(&selected),
994 &definitions,
995 &ConditionContext::platform_only(&host_for("linux")),
996 false,
997 );
998 assert_eq!(resolved.defines, vec!["SAFE_NAMED_OVERLAY"]);
999 assert_eq!(resolved.cxxflags, vec!["-O2"]);
1000 assert_eq!(resolved.ldflags, vec!["-s"]);
1001 }
1002
1003 #[test]
1004 fn feature_conditional_layer_gated_by_enabled_features() {
1005 let mut p = ProfileSettings::default();
1009 p.conditional.push(ConditionalProfileFlags {
1010 condition: Condition::Feature("single-threaded".into()),
1011 profile: None,
1012 flags: ProfileFlags {
1013 defines: vec!["SQLITE_THREADSAFE=0".into()],
1014 ..Default::default()
1015 },
1016 });
1017 let enabled: BTreeSet<String> = BTreeSet::from(["single-threaded".to_owned()]);
1018 let on = resolve_without_profile(
1019 &p,
1020 &ConditionContext::with_features(&host_for("linux"), &enabled),
1021 true,
1022 );
1023 assert_eq!(on.defines, vec!["SQLITE_THREADSAFE=0".to_owned()]);
1024 let off = resolve_without_profile(
1025 &p,
1026 &ConditionContext::platform_only(&host_for("linux")),
1027 true,
1028 );
1029 assert!(
1030 off.defines.is_empty(),
1031 "feature-off must not apply the layer: {:?}",
1032 off.defines
1033 );
1034 }
1035
1036 #[test]
1037 fn compiler_conditional_layer_gated_by_detected_identity() {
1038 let mut p = ProfileSettings::default();
1039 p.conditional.push(ConditionalProfileFlags {
1040 condition: Condition::parse_inner(r#"all(cxx = "clang", cxx_version = ">=18")"#)
1041 .unwrap(),
1042 profile: None,
1043 flags: ProfileFlags {
1044 cxxflags: vec!["-stdlib=libc++".into()],
1045 ..Default::default()
1046 },
1047 });
1048 let host = host_for("linux");
1049 let clang18 = CompilerIdentity {
1050 kind: CompilerKind::Clang,
1051 version: CompilerVersion::parse("18.1.3"),
1052 target: None,
1053 raw_version_line: "clang version 18.1.3".into(),
1054 };
1055 let gcc13 = CompilerIdentity {
1056 kind: CompilerKind::Gcc,
1057 version: CompilerVersion::parse("13.3.0"),
1058 target: None,
1059 raw_version_line: "g++ 13.3.0".into(),
1060 };
1061
1062 let matching = ConditionContext::platform_only(&host).with_compilers(None, Some(&clang18));
1063 let on = resolve_without_profile(&p, &matching, true);
1064 assert_eq!(on.cxxflags, vec!["-stdlib=libc++".to_owned()]);
1065
1066 let other = ConditionContext::platform_only(&host).with_compilers(None, Some(&gcc13));
1067 let off = resolve_without_profile(&p, &other, true);
1068 assert!(off.cxxflags.is_empty());
1069
1070 let undetected = ConditionContext::platform_only(&host);
1072 assert!(
1073 resolve_without_profile(&p, &undetected, true)
1074 .cxxflags
1075 .is_empty()
1076 );
1077 }
1078
1079 #[test]
1080 fn link_libs_merge_dedup_preserving_order() {
1081 let mut p = ProfileSettings::default();
1082 p.general.link_libs = vec!["pthread".into(), "m".into()];
1083 p.conditional.push(ConditionalProfileFlags {
1084 condition: Condition::KeyValue {
1085 key: ConditionKey::Family,
1086 value: "unix".into(),
1087 },
1088 profile: None,
1089 flags: ProfileFlags {
1090 link_libs: vec!["dl".into(), "m".into()],
1091 ..Default::default()
1092 },
1093 });
1094 let mut host = host_for("linux");
1095 host.family = "unix".into();
1096 let r = resolve_without_profile(&p, &ConditionContext::platform_only(&host), true);
1097 assert_eq!(
1098 r.link_libs,
1099 vec!["pthread".to_owned(), "m".to_owned(), "dl".to_owned()]
1100 );
1101 }
1102
1103 #[test]
1104 fn link_libs_survive_untrusted_packages() {
1105 let mut p = ProfileSettings::default();
1108 p.general.link_libs = vec!["pthread".into()];
1109 p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
1110 let r = resolve_without_profile(
1111 &p,
1112 &ConditionContext::platform_only(&host_for("linux")),
1113 false,
1114 );
1115 assert_eq!(r.link_libs, vec!["pthread".to_owned()]);
1116 assert!(r.ldflags.is_empty(), "untrusted ldflags must be dropped");
1117 }
1118
1119 #[test]
1120 fn is_safe_link_lib_accepts_bare_library_names() {
1121 for good in ["pthread", "m", "dl", "stdc++", "c++_shared", "libname_v1.2"] {
1122 assert!(is_safe_link_lib(good), "expected {good:?} to be accepted");
1123 }
1124 }
1125
1126 #[test]
1127 fn is_safe_link_lib_rejects_flags_paths_and_whitespace() {
1128 for bad in [
1129 "",
1130 "-lm",
1131 "-Wl,--foo",
1132 "+atomic",
1133 ".hidden",
1134 "../foo",
1135 "a/b",
1136 "/abs",
1137 "has space",
1138 "tab\tname",
1139 ] {
1140 assert!(!is_safe_link_lib(bad), "expected {bad:?} to be rejected");
1141 }
1142 }
1143
1144 #[test]
1145 fn validate_rejects_flag_like_link_lib() {
1146 for bad in ["-lm", "-Wl,--foo", "../escape", "a/b", "has space", ""] {
1147 let decl = ProfileFlags {
1148 link_libs: vec![bad.into()],
1149 ..Default::default()
1150 };
1151 assert!(
1152 matches!(
1153 decl.validate(),
1154 Err(BuildFlagsValidationError::InvalidLinkLib { .. })
1155 ),
1156 "expected {bad:?} to be rejected"
1157 );
1158 }
1159 }
1160
1161 #[test]
1162 fn validate_accepts_real_link_lib_names() {
1163 let decl = ProfileFlags {
1164 link_libs: vec!["pthread".into(), "dl".into(), "m".into(), "stdc++".into()],
1165 ..Default::default()
1166 };
1167 assert!(decl.validate().is_ok());
1168 }
1169
1170 #[test]
1171 fn validate_rejects_absolute_include_dir() {
1172 let decl = ProfileFlags {
1173 include_dirs: vec![Utf8PathBuf::from("/etc/include")],
1174 ..Default::default()
1175 };
1176 let err = decl.validate().unwrap_err();
1177 assert!(matches!(
1178 err,
1179 BuildFlagsValidationError::AbsoluteIncludeDir { .. }
1180 ));
1181 }
1182
1183 #[test]
1184 fn validate_rejects_parent_traversal_include_dir() {
1185 let decl = ProfileFlags {
1186 include_dirs: vec![Utf8PathBuf::from("../sneaky")],
1187 ..Default::default()
1188 };
1189 let err = decl.validate().unwrap_err();
1190 assert!(matches!(
1191 err,
1192 BuildFlagsValidationError::IncludeDirHasParent { .. }
1193 ));
1194 }
1195
1196 #[test]
1197 fn validate_rejects_empty_define() {
1198 let decl = ProfileFlags {
1199 defines: vec![String::new()],
1200 ..Default::default()
1201 };
1202 assert!(matches!(
1203 decl.validate().unwrap_err(),
1204 BuildFlagsValidationError::EmptyDefine
1205 ));
1206 }
1207
1208 #[test]
1209 fn validate_rejects_define_missing_name() {
1210 let decl = ProfileFlags {
1211 defines: vec!["=oops".into()],
1212 ..Default::default()
1213 };
1214 assert!(matches!(
1215 decl.validate().unwrap_err(),
1216 BuildFlagsValidationError::DefineMissingName { .. }
1217 ));
1218 }
1219
1220 #[test]
1221 fn resolved_flags_with_only_system_include_dirs_are_not_empty() {
1222 let flags = ResolvedProfileFlags {
1226 system_include_dirs: vec![Utf8PathBuf::from("/opt/dep/include")],
1227 ..Default::default()
1228 };
1229 assert!(!flags.is_empty());
1230 }
1231
1232 #[test]
1233 fn resolved_flags_json_includes_system_include_dirs() {
1234 let flags = ResolvedProfileFlags {
1235 system_include_dirs: vec![Utf8PathBuf::from("/opt/dep/include")],
1236 ..Default::default()
1237 };
1238 assert_eq!(
1239 flags.as_json()["system_include_dirs"],
1240 serde_json::json!(["/opt/dep/include"]),
1241 );
1242 }
1243}