Skip to main content

ic_build/cargo/environment/
mod.rs

1//! Read-only Cargo configuration facts; callers retain environment and admission policy.
2
3#[cfg(test)]
4mod tests;
5
6use ic_host_artifacts::artifact::Sha256Digest;
7use std::{
8    borrow::Cow,
9    collections::BTreeSet,
10    ffi::OsStr,
11    fmt, fs, io,
12    path::{Path, PathBuf},
13};
14use toml::Value;
15
16/// One observed configuration's location, raw-byte identity and compiler overrides.
17///
18/// Full configuration contents, which may contain credentials, are not retained.
19pub struct ConfigurationFile {
20    /// Original lookup path, preserving file/directory symlink replacement checks.
21    pub path: PathBuf,
22    /// Canonical containing directory plus the original file name, for stable labels.
23    pub location: PathBuf,
24    /// SHA-256 of the same UTF-8 bytes parsed during this observation.
25    pub sha256: [u8; 32],
26    /// Present build compiler/wrapper fields, in canonical field order.
27    pub compiler_overrides: Vec<&'static str>,
28}
29
30/// Configuration observation failure; diagnostics exclude configuration contents.
31#[derive(Debug)]
32pub enum ConfigurationError {
33    /// Original filesystem failure at its selected lookup path.
34    Io {
35        /// Failed read or location operation.
36        operation: &'static str,
37        /// Selected lookup path.
38        path: PathBuf,
39        /// Original filesystem error.
40        source: io::Error,
41    },
42    /// Invalid TOML or include structure, without embedding source text.
43    Invalid {
44        /// Selected configuration path.
45        path: PathBuf,
46        /// Structural reason, excluding configuration values.
47        message: String,
48    },
49}
50
51impl fmt::Display for ConfigurationError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Io {
55                operation,
56                path,
57                source,
58            } => write!(f, "{operation} {}: {source}", path.display()),
59            Self::Invalid { path, message } => write!(
60                f,
61                "invalid Cargo configuration {}: {message}",
62                path.display()
63            ),
64        }
65    }
66}
67
68impl std::error::Error for ConfigurationError {
69    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
70        match self {
71            Self::Io { source, .. } => Some(source),
72            Self::Invalid { .. } => None,
73        }
74    }
75}
76
77/// Enumerate invocation ancestors' `.cargo` directories and the caller-selected Cargo home.
78///
79/// Relative home values resolve against the absolute invocation directory. No ambient
80/// environment is read, files selected, or directories created.
81///
82/// # Errors
83/// Returns invalid input when the invocation directory is not absolute.
84pub fn configuration_directories(
85    invocation: &Path,
86    cargo_home: Option<&OsStr>,
87    default_home: Option<&OsStr>,
88) -> io::Result<Vec<PathBuf>> {
89    if !invocation.is_absolute() {
90        return Err(io::Error::new(
91            io::ErrorKind::InvalidInput,
92            "Cargo invocation directory must be absolute",
93        ));
94    }
95    let mut directories: Vec<_> = invocation
96        .ancestors()
97        .map(|path| path.join(".cargo"))
98        .collect();
99    let home = cargo_home
100        .map(PathBuf::from)
101        .or_else(|| default_home.map(|home| PathBuf::from(home).join(".cargo")));
102    if let Some(home) = home {
103        directories.push(if home.is_absolute() {
104            home
105        } else {
106            invocation.join(home)
107        });
108    }
109    Ok(directories)
110}
111
112/// Select Cargo's preferred existing file in one directory: `config` before `config.toml`.
113/// Callers needing conservative custody of both paths may supply both to observation.
114#[must_use]
115pub fn preferred_configuration(directory: &Path) -> Option<PathBuf> {
116    let extensionless = directory.join("config");
117    if extensionless.exists() {
118        return Some(extensionless);
119    }
120    let toml = directory.join("config.toml");
121    toml.exists().then_some(toml)
122}
123
124/// Observe selected roots and their include trees in order, deduplicating lookup paths.
125///
126/// Roots must be absolute. Required missing/invalid files fail; optional absent includes
127/// are skipped. Includes resolve beside the configured entry, not a symlink referent.
128/// Recorded lookup paths are not read or expanded again in the same call. Active
129/// recursion is suppressed without merging distinct lookup aliases.
130///
131/// Callers own source custody, root selection and re-observation before acceptance.
132/// This does not resolve tools, execute Cargo, admit compilers or grant cache/release validity.
133///
134/// # Errors
135/// Returns original filesystem errors or a structural refusal for relative roots,
136/// invalid TOML and malformed includes. Error text excludes configuration contents.
137pub fn observe_configurations(
138    roots: &[PathBuf],
139) -> Result<Vec<ConfigurationFile>, ConfigurationError> {
140    let mut files = Vec::new();
141    let mut active = BTreeSet::new();
142    for root in roots {
143        append_configuration(&mut files, root, &mut active, false)?;
144    }
145    Ok(files)
146}
147
148/// Whether a variable overrides the selected Rust compiler, Rustdoc or their wrappers.
149/// The caller decides whether to admit or refuse such an override.
150#[must_use]
151pub fn is_compiler_override(name: &str) -> bool {
152    matches!(
153        name,
154        "RUSTC"
155            | "RUSTC_WRAPPER"
156            | "RUSTC_WORKSPACE_WRAPPER"
157            | "RUSTDOC"
158            | "CARGO_BUILD_RUSTC"
159            | "CARGO_BUILD_RUSTC_WRAPPER"
160            | "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER"
161            | "CARGO_BUILD_RUSTDOC"
162    )
163}
164
165/// Whether a variable supplies Rust flags or Cargo build/profile/target settings.
166/// Output-only `CARGO_TARGET_DIR` is excluded; callers still select their identity policy.
167#[must_use]
168pub fn is_build_variable(name: &str) -> bool {
169    matches!(
170        name,
171        "RUSTFLAGS" | "RUSTDOCFLAGS" | "CARGO_ENCODED_RUSTFLAGS" | "CARGO_ENCODED_RUSTDOCFLAGS"
172    ) || name.starts_with("CARGO_BUILD_")
173        || name.starts_with("CARGO_PROFILE_")
174        || name.starts_with("CARGO_TARGET_") && name != "CARGO_TARGET_DIR"
175}
176
177fn append_configuration(
178    files: &mut Vec<ConfigurationFile>,
179    config: &Path,
180    active: &mut BTreeSet<PathBuf>,
181    optional: bool,
182) -> Result<(), ConfigurationError> {
183    if !config.is_absolute() {
184        return Err(invalid_configuration(
185            config,
186            "configuration path must be absolute",
187        ));
188    }
189    if files.iter().any(|file| file.path == config) {
190        return Ok(());
191    }
192    let contents = match fs::read_to_string(config) {
193        Ok(contents) => contents,
194        Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
195        Err(source) => {
196            return Err(ConfigurationError::Io {
197                operation: "read Cargo configuration",
198                path: config.to_owned(),
199                source,
200            });
201        }
202    };
203    let parent = config.parent().ok_or_else(|| {
204        invalid_configuration(config, "configuration path has no parent directory")
205    })?;
206    let location =
207        parent
208            .canonicalize()
209            .map_err(|source| ConfigurationError::Io {
210                operation: "resolve Cargo configuration directory",
211                path: parent.to_owned(),
212                source,
213            })?
214            .join(config.file_name().ok_or_else(|| {
215                invalid_configuration(config, "configuration path has no file name")
216            })?);
217    if !active.insert(location.clone()) {
218        return Ok(());
219    }
220    let configuration = toml::from_str::<Value>(&contents)
221        .map_err(|_| invalid_configuration(config, "invalid TOML"))?;
222    let compiler_overrides = [
223        "rustc",
224        "rustc-wrapper",
225        "rustc-workspace-wrapper",
226        "rustdoc",
227    ]
228    .into_iter()
229    .filter(|field| {
230        configuration
231            .get("build")
232            .and_then(|build| build.get(field))
233            .is_some()
234    })
235    .collect();
236    files.push(ConfigurationFile {
237        path: config.to_owned(),
238        location: location.clone(),
239        sha256: *Sha256Digest::compute(contents.as_bytes()).as_bytes(),
240        compiler_overrides,
241    });
242    if let Some(include) = configuration.get("include") {
243        for (included, optional) in configuration_includes(include, config)? {
244            let included = if included.is_absolute() {
245                Cow::Borrowed(included)
246            } else {
247                Cow::Owned(parent.join(included))
248            };
249            append_configuration(files, &included, active, optional)?;
250        }
251    }
252    active.remove(&location);
253    Ok(())
254}
255
256fn configuration_includes<'a>(
257    include: &'a Value,
258    config: &Path,
259) -> Result<Vec<(&'a Path, bool)>, ConfigurationError> {
260    let values = match include {
261        Value::Array(values) => values.as_slice(),
262        value => std::slice::from_ref(value),
263    };
264    values
265        .iter()
266        .map(|value| match value {
267            Value::String(path) => Ok((Path::new(path), false)),
268            Value::Table(table) => {
269                let path = table.get("path").and_then(Value::as_str).ok_or_else(|| {
270                    invalid_configuration(
271                        config,
272                        "Cargo configuration include table requires a string `path`",
273                    )
274                })?;
275                let optional = table
276                    .get("optional")
277                    .map(|value| {
278                        value.as_bool().ok_or_else(|| {
279                            invalid_configuration(
280                                config,
281                                "Cargo configuration include `optional` must be a boolean",
282                            )
283                        })
284                    })
285                    .transpose()?
286                    .unwrap_or(false);
287                Ok((Path::new(path), optional))
288            }
289            _ => Err(invalid_configuration(
290                config,
291                "Cargo configuration `include` must contain paths or include tables",
292            )),
293        })
294        .collect()
295}
296
297fn invalid_configuration(path: &Path, message: &str) -> ConfigurationError {
298    ConfigurationError::Invalid {
299        path: path.to_owned(),
300        message: message.to_owned(),
301    }
302}