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