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