Skip to main content

cabin_core/
build_flags.rs

1//! Typed semantic build flags.
2//!
3//! Cabin recognizes explicit, semantic build flags that compose
4//! across these layers, in order:
5//!
6//! 1. Built-in backend defaults (currently empty for `[profile]`:
7//!    the effective per-target standard flag comes from the
8//!    language-standard model in [`crate::language_standard`], and
9//!    the profile's `-O0` / `-g` etc. come from the resolved
10//!    profile - neither is a `[profile]` layer).
11//! 2. Per-package general `[profile]` flags from the manifest.
12//! 3. Per-package matching `[target.'cfg(...)'.profile]` flags.
13//! 4. For each profile in the selected root-to-leaf inheritance
14//!    chain, workspace-root `[profile.<name>]` flags followed by
15//!    matching package
16//!    `[target.'cfg(...)'.profile.<name>]` overlays.
17//!
18//! Manifest-declared fields are intentionally explicit: defines,
19//! include directories, C-only compile arguments, C++-only compile
20//! arguments, and link arguments.  The C/C++ argv spaces stay
21//! separate all the way to the planner.
22
23use 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/// Manifest-shape build-flag declaration.  One per `[profile]` /
35/// `[target.'cfg(...)'.profile]` / `[profile.<name>]` table.
36///
37/// Every field is optional so omission means "no contribution at
38/// this layer".  The TOML parser rejects unknown fields explicitly
39/// so a future field cannot silently slip through.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct ProfileFlags {
42    /// Preprocessor macro definitions, one per entry.  Each value
43    /// is either `"NAME"` (defines without a value) or
44    /// `"NAME=value"` (defines with an explicit value).  Names are
45    /// validated at parse time; the planner emits `-DNAME` /
46    /// `-DNAME=value` directly.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub defines: Vec<String>,
49    /// Additional include directories.  Paths are validated at
50    /// parse time: absolute paths and any path containing a `..`
51    /// component are rejected so include-search can never escape
52    /// a published source archive.
53    #[serde(
54        default,
55        rename = "include-dirs",
56        skip_serializing_if = "Vec::is_empty"
57    )]
58    pub include_dirs: Vec<Utf8PathBuf>,
59    /// Escape-hatch list of arguments appended verbatim to every
60    /// **C** compile command this layer applies to.  Use this for
61    /// flags that are valid only when compiling C translation
62    /// units (e.g. `-std=c99`).  Empty by default.
63    #[serde(default, rename = "cflags", skip_serializing_if = "Vec::is_empty")]
64    pub cflags: Vec<String>,
65    /// Escape-hatch list of arguments appended verbatim to every
66    /// **C++** compile command this layer applies to.  Use this
67    /// for flags that are valid only when compiling C++
68    /// translation units (e.g. `-fno-rtti`, `-std=c++20`).  Empty
69    /// by default.
70    #[serde(default, rename = "cxxflags", skip_serializing_if = "Vec::is_empty")]
71    pub cxxflags: Vec<String>,
72    /// Escape-hatch list of arguments appended verbatim to every
73    /// link command this layer applies to.
74    #[serde(default, rename = "ldflags", skip_serializing_if = "Vec::is_empty")]
75    pub ldflags: Vec<String>,
76    /// System libraries this target's objects require, as bare
77    /// library names (e.g. `"pthread"`, `"dl"`, `"m"`).  Unlike
78    /// `ldflags` - which are raw, unvalidated, and applied only to
79    /// the declaring package's own link - `link_libs` are validated
80    /// safe library names that **propagate** to the final link of
81    /// every executable that depends on this target (transitively),
82    /// emitted as `-l<name>` after the archives so GNU `ld`'s
83    /// left-to-right resolution finds them.  Because they are
84    /// validated (no leading `-`, no path separators, no spaces)
85    /// they cannot inject linker flags, so they are kept even for
86    /// untrusted (registry) packages.
87    #[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    /// Run the validation rules that apply at manifest parse time.
102    ///
103    /// - Defines must be non-empty and must not start with `=`.
104    /// - Include directories must be relative and must not contain
105    ///   any `..` component.
106    /// - Link libraries must be safe bare library names (see
107    ///   [`is_safe_link_lib`]).
108    ///
109    /// # Errors
110    /// Returns [`BuildFlagsValidationError::EmptyDefine`] for an empty define,
111    /// [`BuildFlagsValidationError::DefineMissingName`] for a define starting
112    /// with `=`, [`BuildFlagsValidationError::InvalidLinkLib`] for a malformed
113    /// link-library name, and propagates any error from validating an include
114    /// directory (a non-relative directory or one containing a `..` component).
115    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
138/// Whether `name` is a safe bare library name for a `link-libs`
139/// entry.  The grammar is deliberately strict because `link_libs`
140/// propagate to consumers' link lines and are kept even for
141/// untrusted dependencies: a value that began with `-` or carried
142/// a path / whitespace could smuggle a linker flag (`-Wl,...`,
143/// `-fuse-ld=...`) or an arbitrary object path onto the link
144/// command.  The accepted set - an alphanumeric/underscore first
145/// character followed by alphanumerics and `_`, `.`, `+`, `-` -
146/// covers real library names like `pthread`, `dl`, `m`, `stdc++`,
147/// and `c++` while rejecting everything that could be a flag or a
148/// path.
149pub 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/// Conditional `[target.'cfg(...)'.profile]` or
161/// `[target.'cfg(...)'.profile.<name>]` block.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ConditionalProfileFlags {
164    pub condition: Condition,
165    /// `None` for a general target profile layer; `Some` for a named
166    /// overlay that applies when the selected profile chain contains it.
167    #[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/// Per-package build-flags settings.  Holds the unconditional
174/// `[profile]` table plus any `[target.'cfg(...)'.profile]`
175/// overrides declared in the same manifest.
176#[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/// Final, deterministic build-flag set fed to the planner.
191///
192/// `defines` is sorted-and-deduplicated (defines are commutative
193/// for our purposes); include and argv lists keep user-visible
194/// order, with first-occurrence dedup for include dirs to mirror
195/// the existing planner behavior.
196#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
197pub struct ResolvedProfileFlags {
198    pub defines: Vec<String>,
199    pub include_dirs: Vec<Utf8PathBuf>,
200    /// Include directories the compile commands mark as *system*
201    /// search paths (`-isystem` in the GCC/Clang dialect), so
202    /// diagnostics inside their headers are suppressed.  Populated
203    /// from third-party contributions the user does not control -
204    /// today the `pkg-config` probe of `system = true`
205    /// dependencies - never from the package's own manifest
206    /// declarations, which stay in [`Self::include_dirs`].
207    pub system_include_dirs: Vec<Utf8PathBuf>,
208    /// Language-neutral compile-time escape-hatch arguments.
209    /// Applied to every compile command, both C/C++.
210    pub extra_compile_args: Vec<String>,
211    /// C-only compile-time escape-hatch arguments.  Applied only
212    /// when the compile command produces an object from a `.c`
213    /// translation unit.
214    pub cflags: Vec<String>,
215    /// C++-only compile-time escape-hatch arguments.  Applied only
216    /// when the compile command produces an object from a C++
217    /// translation unit (`.cc` / `.cpp` / `.cxx` / `.c++` /
218    /// `.C`).
219    pub cxxflags: Vec<String>,
220    pub ldflags: Vec<String>,
221    /// Validated bare system-library names that propagate to the
222    /// link of every executable depending on this package.  The
223    /// build planner walks the dependency closure, collects these,
224    /// and emits `-l<name>` (after the archives) on the consumer's
225    /// link command.
226    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    /// Compact JSON view used by `cabin metadata`.
242    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
264/// Resolve build flags by merging package-owned layers and the selected
265/// workspace profile chain in documented order.
266///
267/// `profile` is the resolved selection and `definitions` is the same
268/// workspace-root definition map used to resolve it. Passing `None` skips
269/// ordinary and named profile-specific layers.
270///
271/// `ctx` is the platform / feature / detected-compiler context the
272/// conditional layer evaluates against - passing the same
273/// [`crate::ConditionContext`] inputs Cabin uses elsewhere keeps
274/// the cfg semantics consistent with target dependencies.
275///
276/// `package_trusted` says whether `package` comes from code the
277/// user controls - the workspace root, a member, or a `path`
278/// dependency.  When it is `false` (a registry / downloaded
279/// dependency) the `cflags` / `cxxflags` / `ldflags` arrays that
280/// `package` declares for its own sources are dropped from every
281/// package-owned layer: those arrays are
282/// unvalidated and a `-fplugin=` / `-B<dir>` / `-specs=` /
283/// `-Xclang -load` / `-fuse-ld=<path>` entry would make the
284/// compiler or linker execute attacker-supplied code at build
285/// time. `defines` / `include_dirs` are validated at parse time
286/// (see [`ProfileFlags::validate`]) and are kept regardless of trust.
287/// Workspace-root profile definitions are trusted and always apply.
288pub 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
341/// Append every field of a [`ProfileFlags`] layer into a target
342/// whose fields are structurally identical to `ProfileFlags` -
343/// either a [`ProfileFlags`] accumulator (used by the
344/// inherits-chain merge in
345/// [`crate::profile::resolve_profile`]) or a
346/// [`ResolvedProfileFlags`] accumulator (used by
347/// [`resolve_build_flags`]'s package / conditional / profile
348/// layer chain).
349///
350/// One canonical field list lives here so a future array field
351/// added to [`ProfileFlags`] needs exactly one update site.
352/// Both [`ProfileFlags::append_layer`] and [`apply_layer`]
353/// delegate to this macro; do not duplicate the per-field walk
354/// elsewhere.
355macro_rules! append_profile_flag_layer {
356    ($target:expr, $layer:expr) => {{
357        let target = $target;
358        let layer = $layer;
359        // `defines` are appended verbatim here. `resolve_build_flags`'s
360        // `finalize` step sort-and-dedups them once at the end, so a
361        // second normalization path inside the per-layer append would
362        // be a double-pass on the resolved side and break the
363        // semantics expected by the inherits-chain merge accumulator,
364        // which is itself a layer for that same finalize step.
365        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        // Link libraries dedup by first occurrence and keep order:
371        // `-l` resolution is order-sensitive, and a duplicate `-lm`
372        // is noise, so we mirror the include-dir treatment rather
373        // than the append-verbatim used for the raw flag arrays.
374        extend_dedup_first_occurrence(&mut target.link_libs, &layer.link_libs);
375    }};
376}
377
378/// Append `items` into `target`, skipping any item already present,
379/// so the first occurrence wins and order is preserved.
380fn 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    /// Append every field of `layer` into `self`, using the
390    /// same per-field semantics as the package / conditional /
391    /// profile layer chain in [`resolve_build_flags`].
392    ///
393    /// The merged accumulator is what
394    /// [`crate::profile::resolve_profile`] builds when it walks
395    /// a custom profile's `inherits` chain root → selected.
396    /// [`resolve_build_flags`] walks the chain and original
397    /// definitions separately so named conditional overlays can
398    /// be interleaved at each profile step.
399    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    // Defines are commutative: `-DA -DB` and `-DB -DA` produce the
410    // same preprocessor state, so a stable sort + dedup gives us a
411    // deterministic shape that does not depend on declaration
412    // order across layers.
413    let dedup: BTreeSet<String> = target.defines.drain(..).collect();
414    target.defines = dedup.into_iter().collect();
415    // Include dirs already deduplicated by `apply_layer` while
416    // preserving first-seen order; nothing more to do here.
417    // Argument lists preserve user order; no sorting.
418}
419
420/// Errors produced while validating a manifest-side build-flags
421/// declaration.
422#[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        // A matching conditional layer must not be able to sneak flags past
865        // the drop either.
866        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        // Validated, non-injection fields survive so dependencies can still
897        // declare their own defines / include search paths.
898        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        // The very same settings are kept verbatim for a trusted package.
905        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        // The dependency's own flag is dropped, but the trusted root profile
954        // layer is still applied so the dependency builds with the user's
955        // selected flags.
956        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        // `[target.'cfg(feature = "single-threaded")'.profile]
1006        // defines = ["SQLITE_THREADSAFE=0"]` applies iff the feature
1007        // is enabled - the sqlite threadsafe-toggle wiring.
1008        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        // No detection at all (fail-soft commands): the layer stays off.
1071        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        // Unlike ldflags, validated link_libs are kept for untrusted
1106        // (registry) packages because they cannot smuggle a flag.
1107        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        // System include dirs (e.g. a pkg-config contribution) must
1223        // count as a non-empty flag set, or consumers keyed on
1224        // `is_empty` would drop them from metadata views.
1225        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}