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    /// Language-neutral compile-time escape-hatch arguments.
193    /// Applied to every compile command, both C/C++.
194    pub extra_compile_args: Vec<String>,
195    /// C-only compile-time escape-hatch arguments. Applied only
196    /// when the compile command produces an object from a `.c`
197    /// translation unit.
198    pub cflags: Vec<String>,
199    /// C++-only compile-time escape-hatch arguments. Applied only
200    /// when the compile command produces an object from a C++
201    /// translation unit (`.cc` / `.cpp` / `.cxx` / `.c++` /
202    /// `.C`).
203    pub cxxflags: Vec<String>,
204    pub ldflags: Vec<String>,
205    /// Validated bare system-library names that propagate to the
206    /// link of every executable depending on this package. The
207    /// build planner walks the dependency closure, collects these,
208    /// and emits `-l<name>` (after the archives) on the consumer's
209    /// link command.
210    pub link_libs: Vec<String>,
211}
212
213impl ResolvedProfileFlags {
214    pub fn is_empty(&self) -> bool {
215        self.defines.is_empty()
216            && self.include_dirs.is_empty()
217            && self.extra_compile_args.is_empty()
218            && self.cflags.is_empty()
219            && self.cxxflags.is_empty()
220            && self.ldflags.is_empty()
221            && self.link_libs.is_empty()
222    }
223
224    /// Compact JSON view used by `cabin metadata`.
225    pub fn as_json(&self) -> serde_json::Value {
226        serde_json::json!({
227            "defines": self.defines,
228            "include_dirs": self
229                .include_dirs
230                .iter()
231                .map(|p| p.as_str().to_owned())
232                .collect::<Vec<_>>(),
233            "extra_compile_args": self.extra_compile_args,
234            "cflags": self.cflags,
235            "cxxflags": self.cxxflags,
236            "ldflags": self.ldflags,
237            "link_libs": self.link_libs,
238        })
239    }
240}
241
242/// Resolve build flags by merging the per-package and
243/// per-profile layers, in order.
244///
245/// `package` is the package's own `[profile]` /
246/// `[target.'cfg(...)'.profile]` settings. `profile` is the
247/// **already-merged-across-inherits-chain** per-profile
248/// `ProfileFlags` produced by
249/// [`crate::profile::resolve_profile`] — *not* the lone overlay
250/// from the selected profile's `[profile.<name>]` table. The
251/// inherits-chain merge has already happened upstream via
252/// `ProfileFlags::append_layer`, so this layer simply lands
253/// on top of `package.general` and the matching conditional
254/// flags. `host_platform` is what the conditional layer
255/// evaluates against — passing the same `TargetPlatform` Cabin
256/// uses elsewhere keeps the cfg semantics consistent with
257/// target dependencies.
258///
259/// `package_trusted` says whether `package` comes from code the
260/// user controls — the workspace root, a member, or a `path`
261/// dependency. When it is `false` (a registry / downloaded
262/// dependency) the `cflags` / `cxxflags` / `ldflags` arrays that
263/// `package` declares for its own sources are dropped before the
264/// trusted `profile` layer is applied: those arrays are
265/// unvalidated and a `-fplugin=` / `-B<dir>` / `-specs=` /
266/// `-Xclang -load` / `-fuse-ld=<path>` entry would make the
267/// compiler or linker execute attacker-supplied code at build
268/// time. `defines` / `include_dirs` are validated at parse time
269/// (see [`ProfileFlags::validate`]) and are kept regardless of
270/// trust, and the `profile` layer — the trusted, root-derived
271/// flags — always applies so an untrusted dependency still builds
272/// with the user's selected profile.
273pub fn resolve_build_flags(
274    package: &ProfileSettings,
275    profile: Option<&ProfileFlags>,
276    host_platform: &crate::condition::TargetPlatform,
277    enabled_features: &BTreeSet<String>,
278    package_trusted: bool,
279) -> ResolvedProfileFlags {
280    let mut out = ResolvedProfileFlags::default();
281
282    apply_layer(&mut out, &package.general);
283    for conditional in &package.conditional {
284        if conditional
285            .condition
286            .evaluate(host_platform, enabled_features)
287        {
288            apply_layer(&mut out, &conditional.flags);
289        }
290    }
291    if !package_trusted {
292        // Untrusted (registry) dependency: discard the compiler /
293        // linker flag arrays it declared for its own sources. These
294        // are unvalidated, so a `-fplugin=` / `-B<dir>` / `-specs=`
295        // / `-Xclang -load` entry would run attacker code inside the
296        // compiler or linker during `cabin build`. `defines` /
297        // `include_dirs` / `link_libs` are validated elsewhere
298        // (see `ProfileFlags::validate` and `is_safe_link_lib`) and
299        // stay — a `link_libs` entry cannot be a flag or a path, so
300        // it cannot inject; the trusted `profile` layer below still
301        // applies.
302        out.cflags.clear();
303        out.cxxflags.clear();
304        out.ldflags.clear();
305    }
306    if let Some(prof) = profile {
307        apply_layer(&mut out, prof);
308    }
309
310    finalize(&mut out);
311    out
312}
313
314/// Append every field of a [`ProfileFlags`] layer into a target
315/// whose fields are structurally identical to `ProfileFlags` —
316/// either a [`ProfileFlags`] accumulator (used by the
317/// inherits-chain merge in
318/// [`crate::profile::resolve_profile`]) or a
319/// [`ResolvedProfileFlags`] accumulator (used by
320/// [`resolve_build_flags`]'s package / conditional / profile
321/// layer chain).
322///
323/// One canonical field list lives here so a future array field
324/// added to [`ProfileFlags`] needs exactly one update site.
325/// Both [`ProfileFlags::append_layer`] and [`apply_layer`]
326/// delegate to this macro; do not duplicate the per-field walk
327/// elsewhere.
328macro_rules! append_profile_flag_layer {
329    ($target:expr, $layer:expr) => {{
330        let target = $target;
331        let layer = $layer;
332        // `defines` are appended verbatim here. `resolve_build_flags`'s
333        // `finalize` step sort-and-dedups them once at the end, so a
334        // second normalization path inside the per-layer append would
335        // be a double-pass on the resolved side and break the
336        // semantics expected by the inherits-chain merge accumulator,
337        // which is itself a layer for that same finalize step.
338        target.defines.extend(layer.defines.iter().cloned());
339        for inc in &layer.include_dirs {
340            if !target.include_dirs.iter().any(|existing| existing == inc) {
341                target.include_dirs.push(inc.clone());
342            }
343        }
344        target.cflags.extend(layer.cflags.iter().cloned());
345        target.cxxflags.extend(layer.cxxflags.iter().cloned());
346        target.ldflags.extend(layer.ldflags.iter().cloned());
347        // Link libraries dedup by first occurrence and keep order:
348        // `-l` resolution is order-sensitive, and a duplicate `-lm`
349        // is noise, so we mirror the include-dir treatment rather
350        // than the append-verbatim used for the raw flag arrays.
351        for lib in &layer.link_libs {
352            if !target.link_libs.iter().any(|existing| existing == lib) {
353                target.link_libs.push(lib.clone());
354            }
355        }
356    }};
357}
358
359impl ProfileFlags {
360    /// Append every field of `layer` into `self`, using the
361    /// same per-field semantics as the package / conditional /
362    /// profile layer chain in [`resolve_build_flags`].
363    ///
364    /// The merged accumulator is what
365    /// [`crate::profile::resolve_profile`] builds when it walks
366    /// a custom profile's `inherits` chain root → selected.
367    /// Consumers downstream feed the resulting `ProfileFlags`
368    /// to [`resolve_build_flags`] as the `profile` parameter,
369    /// where it lands on top of the per-package general /
370    /// conditional layers via the same macro.
371    pub(crate) fn append_layer(&mut self, layer: &ProfileFlags) {
372        append_profile_flag_layer!(self, layer);
373    }
374}
375
376fn apply_layer(target: &mut ResolvedProfileFlags, layer: &ProfileFlags) {
377    append_profile_flag_layer!(target, layer);
378}
379
380fn finalize(target: &mut ResolvedProfileFlags) {
381    // Defines are commutative: `-DA -DB` and `-DB -DA` produce the
382    // same preprocessor state, so a stable sort + dedup gives us a
383    // deterministic shape that does not depend on declaration
384    // order across layers.
385    let dedup: BTreeSet<String> = target.defines.drain(..).collect();
386    target.defines = dedup.into_iter().collect();
387    // Include dirs already deduplicated by `apply_layer` while
388    // preserving first-seen order; nothing more to do here.
389    // Argument lists preserve user order; no sorting.
390}
391
392/// Errors produced while validating a manifest-side build-flags
393/// declaration.
394#[derive(Debug, Error, Clone, PartialEq, Eq)]
395pub enum BuildFlagsValidationError {
396    #[error("[profile] declares an empty define entry")]
397    EmptyDefine,
398    #[error("[profile] define entry {raw:?} is missing a name")]
399    DefineMissingName { raw: String },
400    #[error(
401        "[profile] link library {raw:?} is not a valid library name; use a bare name like \"pthread\" (no leading `-`, path separators, or whitespace)"
402    )]
403    InvalidLinkLib { raw: String },
404    #[error(
405        "[profile] include directory {path:?} must be a relative path; absolute paths are not allowed"
406    )]
407    AbsoluteIncludeDir { path: String },
408    #[error(
409        "[profile] include directory {path:?} must not contain `..`; include search paths cannot escape the package root"
410    )]
411    IncludeDirHasParent { path: String },
412    #[error("[profile] include directory {path:?} contains a non-UTF-8 component")]
413    NonUtf8IncludeDir { path: String },
414}
415
416fn validate_include_dir(dir: &Path) -> Result<(), BuildFlagsValidationError> {
417    if dir.is_absolute() {
418        return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
419            path: display_path(dir),
420        });
421    }
422    for component in dir.components() {
423        match component {
424            std::path::Component::ParentDir => {
425                return Err(BuildFlagsValidationError::IncludeDirHasParent {
426                    path: display_path(dir),
427                });
428            }
429            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
430                return Err(BuildFlagsValidationError::AbsoluteIncludeDir {
431                    path: display_path(dir),
432                });
433            }
434            std::path::Component::Normal(part) => {
435                if part.to_str().is_none() {
436                    return Err(BuildFlagsValidationError::NonUtf8IncludeDir {
437                        path: display_path(dir),
438                    });
439                }
440            }
441            std::path::Component::CurDir => {}
442        }
443    }
444    Ok(())
445}
446
447fn display_path(dir: &Path) -> String {
448    dir.display().to_string()
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::condition::{ConditionKey, TargetPlatform};
455
456    fn host_for(os: &str) -> TargetPlatform {
457        let mut p = TargetPlatform::current();
458        p.os = os.to_owned();
459        p
460    }
461
462    #[test]
463    fn empty_settings_resolve_to_empty_flags() {
464        let p = ProfileSettings::default();
465        let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
466        assert!(r.is_empty());
467    }
468
469    #[test]
470    fn defines_merge_dedup_and_sort() {
471        let mut p = ProfileSettings::default();
472        p.general.defines = vec!["B".into(), "A".into(), "B".into()];
473        let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
474        assert_eq!(r.defines, vec!["A".to_owned(), "B".to_owned()]);
475    }
476
477    #[test]
478    fn include_dirs_keep_first_occurrence_order() {
479        let mut p = ProfileSettings::default();
480        p.general.include_dirs = vec![
481            Utf8PathBuf::from("include"),
482            Utf8PathBuf::from("third_party/include"),
483            Utf8PathBuf::from("include"),
484        ];
485        let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
486        assert_eq!(
487            r.include_dirs,
488            vec![
489                Utf8PathBuf::from("include"),
490                Utf8PathBuf::from("third_party/include"),
491            ]
492        );
493    }
494
495    #[test]
496    fn matching_conditional_layer_is_applied() {
497        let mut p = ProfileSettings::default();
498        p.general.defines = vec!["BASE".into()];
499        p.conditional.push(ConditionalProfileFlags {
500            condition: Condition::KeyValue {
501                key: ConditionKey::Os,
502                value: "linux".into(),
503            },
504            flags: ProfileFlags {
505                defines: vec!["LINUX_ONLY".into()],
506                ..Default::default()
507            },
508        });
509        let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
510        assert_eq!(r.defines, vec!["BASE".to_owned(), "LINUX_ONLY".to_owned()]);
511    }
512
513    #[test]
514    fn non_matching_conditional_layer_is_skipped() {
515        let mut p = ProfileSettings::default();
516        p.general.defines = vec!["BASE".into()];
517        p.conditional.push(ConditionalProfileFlags {
518            condition: Condition::KeyValue {
519                key: ConditionKey::Os,
520                value: "macos".into(),
521            },
522            flags: ProfileFlags {
523                defines: vec!["MAC_ONLY".into()],
524                ..Default::default()
525            },
526        });
527        let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
528        assert_eq!(r.defines, vec!["BASE".to_owned()]);
529    }
530
531    #[test]
532    fn profile_layer_appends_after_target_conditional() {
533        let mut p = ProfileSettings::default();
534        p.general.cxxflags = vec!["-fPIC".into()];
535        p.conditional.push(ConditionalProfileFlags {
536            condition: Condition::KeyValue {
537                key: ConditionKey::Os,
538                value: "linux".into(),
539            },
540            flags: ProfileFlags {
541                cxxflags: vec!["-flto=thin".into()],
542                ..Default::default()
543            },
544        });
545        let prof = ProfileFlags {
546            cxxflags: vec!["-Wall".into()],
547            ..Default::default()
548        };
549        let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), &BTreeSet::new(), true);
550        assert_eq!(
551            r.cxxflags,
552            vec![
553                "-fPIC".to_owned(),
554                "-flto=thin".to_owned(),
555                "-Wall".to_owned(),
556            ]
557        );
558    }
559
560    #[test]
561    fn untrusted_package_drops_command_flags_but_keeps_defines_and_includes() {
562        let mut p = ProfileSettings::default();
563        p.general.defines = vec!["DEP_DEFINE".into()];
564        p.general.include_dirs = vec![Utf8PathBuf::from("dep/include")];
565        p.general.cflags = vec!["-fplugin=evil.so".into()];
566        p.general.cxxflags = vec!["-Xclang".into(), "-load".into()];
567        p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
568        // A matching conditional layer must not be able to sneak flags past
569        // the drop either.
570        p.conditional.push(ConditionalProfileFlags {
571            condition: Condition::KeyValue {
572                key: ConditionKey::Os,
573                value: "linux".into(),
574            },
575            flags: ProfileFlags {
576                cxxflags: vec!["-B.".into()],
577                ldflags: vec!["-specs=evil.specs".into()],
578                ..Default::default()
579            },
580        });
581
582        let untrusted = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), false);
583        assert!(
584            untrusted.cflags.is_empty(),
585            "untrusted cflags must be dropped"
586        );
587        assert!(
588            untrusted.cxxflags.is_empty(),
589            "untrusted cxxflags must be dropped"
590        );
591        assert!(
592            untrusted.ldflags.is_empty(),
593            "untrusted ldflags must be dropped"
594        );
595        // Validated, non-injection fields survive so dependencies can still
596        // declare their own defines / include search paths.
597        assert_eq!(untrusted.defines, vec!["DEP_DEFINE".to_owned()]);
598        assert_eq!(
599            untrusted.include_dirs,
600            vec![Utf8PathBuf::from("dep/include")]
601        );
602
603        // The very same settings are kept verbatim for a trusted package.
604        let trusted = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
605        assert_eq!(trusted.cflags, vec!["-fplugin=evil.so".to_owned()]);
606        assert_eq!(
607            trusted.cxxflags,
608            vec!["-Xclang".to_owned(), "-load".to_owned(), "-B.".to_owned()]
609        );
610        assert_eq!(
611            trusted.ldflags,
612            vec![
613                "-fuse-ld=/tmp/evil".to_owned(),
614                "-specs=evil.specs".to_owned()
615            ]
616        );
617    }
618
619    #[test]
620    fn untrusted_package_still_receives_trusted_profile_layer() {
621        let mut p = ProfileSettings::default();
622        p.general.cxxflags = vec!["-fplugin=evil.so".into()];
623        let prof = ProfileFlags {
624            cxxflags: vec!["-O2".into()],
625            ldflags: vec!["-s".into()],
626            ..Default::default()
627        };
628        let r = resolve_build_flags(&p, Some(&prof), &host_for("linux"), &BTreeSet::new(), false);
629        // The dependency's own flag is dropped, but the trusted root profile
630        // layer is still applied so the dependency builds with the user's
631        // selected flags.
632        assert_eq!(r.cxxflags, vec!["-O2".to_owned()]);
633        assert_eq!(r.ldflags, vec!["-s".to_owned()]);
634    }
635
636    #[test]
637    fn feature_conditional_layer_gated_by_enabled_features() {
638        // `[target.'cfg(feature = "single-threaded")'.profile]
639        //  defines = ["SQLITE_THREADSAFE=0"]` applies iff the feature
640        // is enabled — the sqlite threadsafe-toggle wiring.
641        let mut p = ProfileSettings::default();
642        p.conditional.push(ConditionalProfileFlags {
643            condition: Condition::Feature("single-threaded".into()),
644            flags: ProfileFlags {
645                defines: vec!["SQLITE_THREADSAFE=0".into()],
646                ..Default::default()
647            },
648        });
649        let enabled: BTreeSet<String> = BTreeSet::from(["single-threaded".to_owned()]);
650        let on = resolve_build_flags(&p, None, &host_for("linux"), &enabled, true);
651        assert_eq!(on.defines, vec!["SQLITE_THREADSAFE=0".to_owned()]);
652        let off = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), true);
653        assert!(
654            off.defines.is_empty(),
655            "feature-off must not apply the layer: {:?}",
656            off.defines
657        );
658    }
659
660    #[test]
661    fn link_libs_merge_dedup_preserving_order() {
662        let mut p = ProfileSettings::default();
663        p.general.link_libs = vec!["pthread".into(), "m".into()];
664        p.conditional.push(ConditionalProfileFlags {
665            condition: Condition::KeyValue {
666                key: ConditionKey::Family,
667                value: "unix".into(),
668            },
669            flags: ProfileFlags {
670                link_libs: vec!["dl".into(), "m".into()],
671                ..Default::default()
672            },
673        });
674        let mut host = host_for("linux");
675        host.family = "unix".into();
676        let r = resolve_build_flags(&p, None, &host, &BTreeSet::new(), true);
677        assert_eq!(
678            r.link_libs,
679            vec!["pthread".to_owned(), "m".to_owned(), "dl".to_owned()]
680        );
681    }
682
683    #[test]
684    fn link_libs_survive_untrusted_packages() {
685        // Unlike ldflags, validated link_libs are kept for untrusted
686        // (registry) packages because they cannot smuggle a flag.
687        let mut p = ProfileSettings::default();
688        p.general.link_libs = vec!["pthread".into()];
689        p.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
690        let r = resolve_build_flags(&p, None, &host_for("linux"), &BTreeSet::new(), false);
691        assert_eq!(r.link_libs, vec!["pthread".to_owned()]);
692        assert!(r.ldflags.is_empty(), "untrusted ldflags must be dropped");
693    }
694
695    #[test]
696    fn validate_rejects_flag_like_link_lib() {
697        for bad in ["-lm", "-Wl,--foo", "../escape", "a/b", "has space", ""] {
698            let decl = ProfileFlags {
699                link_libs: vec![bad.into()],
700                ..Default::default()
701            };
702            assert!(
703                matches!(
704                    decl.validate(),
705                    Err(BuildFlagsValidationError::InvalidLinkLib { .. })
706                ),
707                "expected {bad:?} to be rejected"
708            );
709        }
710    }
711
712    #[test]
713    fn validate_accepts_real_link_lib_names() {
714        let decl = ProfileFlags {
715            link_libs: vec!["pthread".into(), "dl".into(), "m".into(), "stdc++".into()],
716            ..Default::default()
717        };
718        assert!(decl.validate().is_ok());
719    }
720
721    #[test]
722    fn validate_rejects_absolute_include_dir() {
723        let decl = ProfileFlags {
724            include_dirs: vec![Utf8PathBuf::from("/etc/include")],
725            ..Default::default()
726        };
727        let err = decl.validate().unwrap_err();
728        assert!(matches!(
729            err,
730            BuildFlagsValidationError::AbsoluteIncludeDir { .. }
731        ));
732    }
733
734    #[test]
735    fn validate_rejects_parent_traversal_include_dir() {
736        let decl = ProfileFlags {
737            include_dirs: vec![Utf8PathBuf::from("../sneaky")],
738            ..Default::default()
739        };
740        let err = decl.validate().unwrap_err();
741        assert!(matches!(
742            err,
743            BuildFlagsValidationError::IncludeDirHasParent { .. }
744        ));
745    }
746
747    #[test]
748    fn validate_rejects_empty_define() {
749        let decl = ProfileFlags {
750            defines: vec![String::new()],
751            ..Default::default()
752        };
753        assert!(matches!(
754            decl.validate().unwrap_err(),
755            BuildFlagsValidationError::EmptyDefine
756        ));
757    }
758
759    #[test]
760    fn validate_rejects_define_missing_name() {
761        let decl = ProfileFlags {
762            defines: vec!["=oops".into()],
763            ..Default::default()
764        };
765        assert!(matches!(
766            decl.validate().unwrap_err(),
767            BuildFlagsValidationError::DefineMissingName { .. }
768        ));
769    }
770}