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 four layers, in this order (later layers override or
5//! append to earlier ones):
6//!
7//! 1. Built-in backend defaults (today: the planner adds
8//!    `-std=c11` for C compiles and `-std=c++17` for C++
9//!    compiles).
10//! 2. Per-package general `[profile]` flags from the manifest.
11//! 3. Per-package matching `[target.'cfg(...)'.profile]` flags.
12//! 4. Workspace-root `[profile.<name>]` flags for the selected
13//!    profile.
14//!
15//! Manifest-declared fields are intentionally explicit: defines,
16//! include directories, C-only compile arguments, C++-only compile
17//! arguments, and link arguments. The C/C++ argv spaces stay
18//! separate all the way to the planner.
19
20use 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/// Manifest-shape build-flag declaration. One per `[profile]` /
31/// `[target.'cfg(...)'.profile]` / `[profile.<name>]` table.
32///
33/// Every field is optional so omission means "no contribution at
34/// this layer". The TOML parser rejects unknown fields explicitly
35/// so a future field cannot silently slip through.
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ProfileFlags {
38    /// Preprocessor macro definitions, one per entry. Each value
39    /// is either `"NAME"` (defines without a value) or
40    /// `"NAME=value"` (defines with an explicit value). Names are
41    /// validated at parse time; the planner emits `-DNAME` /
42    /// `-DNAME=value` directly.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub defines: Vec<String>,
45    /// Additional include directories. Paths are validated at
46    /// parse time: absolute paths and any path containing a `..`
47    /// component are rejected so include-search can never escape
48    /// a published source archive.
49    #[serde(
50        default,
51        rename = "include-dirs",
52        skip_serializing_if = "Vec::is_empty"
53    )]
54    pub include_dirs: Vec<Utf8PathBuf>,
55    /// Escape-hatch list of arguments appended verbatim to every
56    /// **C** compile command this layer applies to. Use this for
57    /// flags that are valid only when compiling C translation
58    /// units (e.g. `-std=c99`). Empty by default.
59    #[serde(default, rename = "cflags", skip_serializing_if = "Vec::is_empty")]
60    pub cflags: Vec<String>,
61    /// Escape-hatch list of arguments appended verbatim to every
62    /// **C++** compile command this layer applies to. Use this
63    /// for flags that are valid only when compiling C++
64    /// translation units (e.g. `-fno-rtti`, `-std=c++20`). Empty
65    /// by default.
66    #[serde(default, rename = "cxxflags", skip_serializing_if = "Vec::is_empty")]
67    pub cxxflags: Vec<String>,
68    /// Escape-hatch list of arguments appended verbatim to every
69    /// link command this layer applies to.
70    #[serde(default, rename = "ldflags", skip_serializing_if = "Vec::is_empty")]
71    pub ldflags: Vec<String>,
72    /// System libraries this target's objects require, as bare
73    /// library names (e.g. `"pthread"`, `"dl"`, `"m"`). Unlike
74    /// `ldflags` — which are raw, unvalidated, and applied only to
75    /// the declaring package's own link — `link_libs` are validated
76    /// safe library names that **propagate** to the final link of
77    /// every executable that depends on this target (transitively),
78    /// emitted as `-l<name>` after the archives so GNU `ld`'s
79    /// left-to-right resolution finds them. Because they are
80    /// validated (no leading `-`, no path separators, no spaces)
81    /// they cannot inject linker flags, so they are kept even for
82    /// untrusted (registry) packages.
83    #[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    /// Run the validation rules that apply at manifest parse time.
98    ///
99    /// - Defines must be non-empty and must not start with `=`.
100    /// - Include directories must be relative and must not contain
101    ///   any `..` component.
102    /// - Link libraries must be safe bare library names (see
103    ///   [`is_safe_link_lib`]).
104    ///
105    /// # Errors
106    /// Returns [`BuildFlagsValidationError::EmptyDefine`] for an empty define,
107    /// [`BuildFlagsValidationError::DefineMissingName`] for a define starting
108    /// with `=`, [`BuildFlagsValidationError::InvalidLinkLib`] for a malformed
109    /// link-library name, and propagates any error from validating an include
110    /// directory (a non-relative directory or one containing a `..` component).
111    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
134/// Whether `name` is a safe bare library name for a `link-libs`
135/// entry. The grammar is deliberately strict because `link_libs`
136/// propagate to consumers' link lines and are kept even for
137/// untrusted dependencies: a value that began with `-` or carried
138/// a path / whitespace could smuggle a linker flag (`-Wl,...`,
139/// `-fuse-ld=...`) or an arbitrary object path onto the link
140/// command. The accepted set — an alphanumeric/underscore first
141/// character followed by alphanumerics and `_`, `.`, `+`, `-` —
142/// covers real library names like `pthread`, `dl`, `m`, `stdc++`,
143/// and `c++` while rejecting everything that could be a flag or a
144/// path.
145pub 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/// Conditional `[target.'cfg(...)'.profile]` block. Same shape as
157/// [`ProfileFlags`] but tagged with the predicate that gates it.
158#[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/// Per-package build-flags settings. Holds the unconditional
166/// `[profile]` table plus any `[target.'cfg(...)'.profile]`
167/// overrides declared in the same manifest.
168#[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/// Final, deterministic build-flag set fed to the planner.
183///
184/// `defines` is sorted-and-deduplicated (defines are commutative
185/// for our purposes); include and argv lists keep user-visible
186/// order, with first-occurrence dedup for include dirs to mirror
187/// the existing planner behavior.
188#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
189pub struct ResolvedProfileFlags {
190    pub defines: Vec<String>,
191    pub include_dirs: Vec<Utf8PathBuf>,
192    /// Include directories the compile commands mark as *system*
193    /// search paths (`-isystem` in the GCC/Clang dialect), so
194    /// diagnostics inside their headers are suppressed. Populated
195    /// from third-party contributions the user does not control —
196    /// today the `pkg-config` probe of `system = true`
197    /// dependencies — never from the package's own manifest
198    /// declarations, which stay in [`Self::include_dirs`].
199    pub system_include_dirs: Vec<Utf8PathBuf>,
200    /// Language-neutral compile-time escape-hatch arguments.
201    /// Applied to every compile command, both C/C++.
202    pub extra_compile_args: Vec<String>,
203    /// C-only compile-time escape-hatch arguments. Applied only
204    /// when the compile command produces an object from a `.c`
205    /// translation unit.
206    pub cflags: Vec<String>,
207    /// C++-only compile-time escape-hatch arguments. Applied only
208    /// when the compile command produces an object from a C++
209    /// translation unit (`.cc` / `.cpp` / `.cxx` / `.c++` /
210    /// `.C`).
211    pub cxxflags: Vec<String>,
212    pub ldflags: Vec<String>,
213    /// Validated bare system-library names that propagate to the
214    /// link of every executable depending on this package. The
215    /// build planner walks the dependency closure, collects these,
216    /// and emits `-l<name>` (after the archives) on the consumer's
217    /// link command.
218    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    /// Compact JSON view used by `cabin metadata`.
234    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
256/// Resolve build flags by merging the per-package and
257/// per-profile layers, in order.
258///
259/// `package` is the package's own `[profile]` /
260/// `[target.'cfg(...)'.profile]` settings. `profile` is the
261/// **already-merged-across-inherits-chain** per-profile
262/// `ProfileFlags` produced by
263/// [`crate::profile::resolve_profile`] — *not* the lone overlay
264/// from the selected profile's `[profile.<name>]` table. The
265/// inherits-chain merge has already happened upstream via
266/// `ProfileFlags::append_layer`, so this layer simply lands
267/// on top of `package.general` and the matching conditional
268/// flags. `host_platform` is what the conditional layer
269/// evaluates against — passing the same `TargetPlatform` Cabin
270/// uses elsewhere keeps the cfg semantics consistent with
271/// target dependencies.
272///
273/// `ctx` is the platform / feature / detected-compiler context the
274/// conditional layer evaluates against — passing the same
275/// [`crate::ConditionContext`] inputs Cabin uses elsewhere keeps
276/// the cfg semantics consistent with target dependencies.
277///
278/// `package_trusted` says whether `package` comes from code the
279/// user controls — the workspace root, a member, or a `path`
280/// dependency. When it is `false` (a registry / downloaded
281/// dependency) the `cflags` / `cxxflags` / `ldflags` arrays that
282/// `package` declares for its own sources are dropped before the
283/// trusted `profile` layer is applied: those arrays are
284/// unvalidated and a `-fplugin=` / `-B<dir>` / `-specs=` /
285/// `-Xclang -load` / `-fuse-ld=<path>` entry would make the
286/// compiler or linker execute attacker-supplied code at build
287/// time. `defines` / `include_dirs` are validated at parse time
288/// (see [`ProfileFlags::validate`]) and are kept regardless of
289/// trust, and the `profile` layer — the trusted, root-derived
290/// flags — always applies so an untrusted dependency still builds
291/// with the user's selected profile.
292pub 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        // Untrusted (registry) dependency: discard the compiler /
308        // linker flag arrays it declared for its own sources. These
309        // are unvalidated, so a `-fplugin=` / `-B<dir>` / `-specs=`
310        // / `-Xclang -load` entry would run attacker code inside the
311        // compiler or linker during `cabin build`. `defines` /
312        // `include_dirs` / `link_libs` are validated elsewhere
313        // (see `ProfileFlags::validate` and `is_safe_link_lib`) and
314        // stay — a `link_libs` entry cannot be a flag or a path, so
315        // it cannot inject; the trusted `profile` layer below still
316        // applies.
317        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
329/// Append every field of a [`ProfileFlags`] layer into a target
330/// whose fields are structurally identical to `ProfileFlags` —
331/// either a [`ProfileFlags`] accumulator (used by the
332/// inherits-chain merge in
333/// [`crate::profile::resolve_profile`]) or a
334/// [`ResolvedProfileFlags`] accumulator (used by
335/// [`resolve_build_flags`]'s package / conditional / profile
336/// layer chain).
337///
338/// One canonical field list lives here so a future array field
339/// added to [`ProfileFlags`] needs exactly one update site.
340/// Both [`ProfileFlags::append_layer`] and [`apply_layer`]
341/// delegate to this macro; do not duplicate the per-field walk
342/// elsewhere.
343macro_rules! append_profile_flag_layer {
344    ($target:expr, $layer:expr) => {{
345        let target = $target;
346        let layer = $layer;
347        // `defines` are appended verbatim here. `resolve_build_flags`'s
348        // `finalize` step sort-and-dedups them once at the end, so a
349        // second normalization path inside the per-layer append would
350        // be a double-pass on the resolved side and break the
351        // semantics expected by the inherits-chain merge accumulator,
352        // which is itself a layer for that same finalize step.
353        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        // Link libraries dedup by first occurrence and keep order:
363        // `-l` resolution is order-sensitive, and a duplicate `-lm`
364        // is noise, so we mirror the include-dir treatment rather
365        // than the append-verbatim used for the raw flag arrays.
366        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    /// Append every field of `layer` into `self`, using the
376    /// same per-field semantics as the package / conditional /
377    /// profile layer chain in [`resolve_build_flags`].
378    ///
379    /// The merged accumulator is what
380    /// [`crate::profile::resolve_profile`] builds when it walks
381    /// a custom profile's `inherits` chain root → selected.
382    /// Consumers downstream feed the resulting `ProfileFlags`
383    /// to [`resolve_build_flags`] as the `profile` parameter,
384    /// where it lands on top of the per-package general /
385    /// conditional layers via the same macro.
386    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    // Defines are commutative: `-DA -DB` and `-DB -DA` produce the
397    // same preprocessor state, so a stable sort + dedup gives us a
398    // deterministic shape that does not depend on declaration
399    // order across layers.
400    let dedup: BTreeSet<String> = target.defines.drain(..).collect();
401    target.defines = dedup.into_iter().collect();
402    // Include dirs already deduplicated by `apply_layer` while
403    // preserving first-seen order; nothing more to do here.
404    // Argument lists preserve user order; no sorting.
405}
406
407/// Errors produced while validating a manifest-side build-flags
408/// declaration.
409#[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        // A matching conditional layer must not be able to sneak flags past
615        // the drop either.
616        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        // Validated, non-injection fields survive so dependencies can still
647        // declare their own defines / include search paths.
648        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        // The very same settings are kept verbatim for a trusted package.
655        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        // The dependency's own flag is dropped, but the trusted root profile
691        // layer is still applied so the dependency builds with the user's
692        // selected flags.
693        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        // `[target.'cfg(feature = "single-threaded")'.profile]
700        //  defines = ["SQLITE_THREADSAFE=0"]` applies iff the feature
701        // is enabled — the sqlite threadsafe-toggle wiring.
702        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        // No detection at all (fail-soft commands): the layer stays off.
765        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        // Unlike ldflags, validated link_libs are kept for untrusted
799        // (registry) packages because they cannot smuggle a flag.
800        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        // System include dirs (e.g. a pkg-config contribution) must
892        // count as a non-empty flag set, or consumers keyed on
893        // `is_empty` would drop them from metadata views.
894        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}