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    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/// Recorded lookup paths are not read or expanded again in the same call. Active
128/// recursion is suppressed without merging distinct lookup aliases.
129///
130/// Callers own source custody, root selection and re-observation before acceptance.
131/// This does not resolve tools, execute Cargo, admit compilers or grant cache/release validity.
132///
133/// # Errors
134/// Returns original filesystem errors or a structural refusal for relative roots,
135/// invalid TOML and malformed includes. Error text excludes configuration contents.
136pub fn observe_configurations(
137    roots: &[PathBuf],
138) -> Result<Vec<ConfigurationFile>, ConfigurationError> {
139    let mut files = Vec::new();
140    let mut active = BTreeSet::new();
141    for root in roots {
142        append_configuration(&mut files, root, &mut active, false)?;
143    }
144    Ok(files)
145}
146
147/// Whether a variable overrides the selected Rust compiler, Rustdoc or their wrappers.
148/// The caller decides whether to admit or refuse such an override.
149#[must_use]
150pub fn is_compiler_override(name: &str) -> bool {
151    matches!(
152        name,
153        "RUSTC"
154            | "RUSTC_WRAPPER"
155            | "RUSTC_WORKSPACE_WRAPPER"
156            | "RUSTDOC"
157            | "CARGO_BUILD_RUSTC"
158            | "CARGO_BUILD_RUSTC_WRAPPER"
159            | "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER"
160            | "CARGO_BUILD_RUSTDOC"
161    )
162}
163
164/// Whether a variable supplies Rust flags or Cargo build/profile/target settings.
165/// Output-only `CARGO_TARGET_DIR` is excluded; callers still select their identity policy.
166#[must_use]
167pub fn is_build_variable(name: &str) -> bool {
168    matches!(
169        name,
170        "RUSTFLAGS" | "RUSTDOCFLAGS" | "CARGO_ENCODED_RUSTFLAGS" | "CARGO_ENCODED_RUSTDOCFLAGS"
171    ) || name.starts_with("CARGO_BUILD_")
172        || name.starts_with("CARGO_PROFILE_")
173        || name.starts_with("CARGO_TARGET_") && name != "CARGO_TARGET_DIR"
174}
175
176fn append_configuration(
177    files: &mut Vec<ConfigurationFile>,
178    config: &Path,
179    active: &mut BTreeSet<PathBuf>,
180    optional: bool,
181) -> Result<(), ConfigurationError> {
182    if !config.is_absolute() {
183        return Err(invalid_configuration(
184            config,
185            "configuration path must be absolute",
186        ));
187    }
188    if files.iter().any(|file| file.path == config) {
189        return Ok(());
190    }
191    let contents = match fs::read_to_string(config) {
192        Ok(contents) => contents,
193        Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
194        Err(source) => {
195            return Err(ConfigurationError::Io {
196                operation: "read Cargo configuration",
197                path: config.to_owned(),
198                source,
199            });
200        }
201    };
202    let parent = config.parent().ok_or_else(|| {
203        invalid_configuration(config, "configuration path has no parent directory")
204    })?;
205    let location =
206        parent
207            .canonicalize()
208            .map_err(|source| ConfigurationError::Io {
209                operation: "resolve Cargo configuration directory",
210                path: parent.to_owned(),
211                source,
212            })?
213            .join(config.file_name().ok_or_else(|| {
214                invalid_configuration(config, "configuration path has no file name")
215            })?);
216    if !active.insert(location.clone()) {
217        return Ok(());
218    }
219    let configuration = toml::from_str::<Value>(&contents)
220        .map_err(|_| invalid_configuration(config, "invalid TOML"))?;
221    let compiler_overrides = [
222        "rustc",
223        "rustc-wrapper",
224        "rustc-workspace-wrapper",
225        "rustdoc",
226    ]
227    .into_iter()
228    .filter(|field| {
229        configuration
230            .get("build")
231            .and_then(|build| build.get(field))
232            .is_some()
233    })
234    .collect();
235    files.push(ConfigurationFile {
236        path: config.to_owned(),
237        location: location.clone(),
238        sha256: *Sha256Digest::compute(contents.as_bytes()).as_bytes(),
239        compiler_overrides,
240    });
241    if let Some(include) = configuration.get("include") {
242        for (included, optional) in configuration_includes(include, config)? {
243            let included = if included.is_absolute() {
244                included
245            } else {
246                parent.join(included)
247            };
248            append_configuration(files, &included, active, optional)?;
249        }
250    }
251    active.remove(&location);
252    Ok(())
253}
254
255fn configuration_includes(
256    include: &Value,
257    config: &Path,
258) -> Result<Vec<(PathBuf, bool)>, ConfigurationError> {
259    let values = match include {
260        Value::Array(values) => values.as_slice(),
261        value => std::slice::from_ref(value),
262    };
263    values
264        .iter()
265        .map(|value| match value {
266            Value::String(path) => Ok((PathBuf::from(path), false)),
267            Value::Table(table) => {
268                let path = table.get("path").and_then(Value::as_str).ok_or_else(|| {
269                    invalid_configuration(
270                        config,
271                        "Cargo configuration include table requires a string `path`",
272                    )
273                })?;
274                let optional = table
275                    .get("optional")
276                    .map(|value| {
277                        value.as_bool().ok_or_else(|| {
278                            invalid_configuration(
279                                config,
280                                "Cargo configuration include `optional` must be a boolean",
281                            )
282                        })
283                    })
284                    .transpose()?
285                    .unwrap_or(false);
286                Ok((PathBuf::from(path), optional))
287            }
288            _ => Err(invalid_configuration(
289                config,
290                "Cargo configuration `include` must contain paths or include tables",
291            )),
292        })
293        .collect()
294}
295
296fn invalid_configuration(path: &Path, message: &str) -> ConfigurationError {
297    ConfigurationError::Invalid {
298        path: path.to_owned(),
299        message: message.to_owned(),
300    }
301}