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::{ArtifactError, Sha256Digest};
7use std::{
8    borrow::Cow,
9    collections::BTreeSet,
10    ffi::OsStr,
11    io,
12    path::{Path, PathBuf},
13};
14use toml::Value;
15
16/// Caller-selected bounds for one complete configuration observation.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct ConfigurationLimits {
19    /// Maximum bytes read from any one file.
20    pub file_bytes: usize,
21    /// Maximum bytes read across all files and aliases.
22    pub total_bytes: usize,
23    /// Maximum number of files read, including aliases.
24    pub files: usize,
25    /// Maximum include depth; roots have depth zero.
26    pub depth: usize,
27}
28
29/// One observed configuration's location, raw-byte identity and compiler overrides.
30///
31/// Full configuration contents, which may contain credentials, are not retained.
32pub struct ConfigurationFile {
33    /// Original lookup path, preserving file/directory symlink replacement checks.
34    pub path: PathBuf,
35    /// Canonical containing directory plus the original file name, for stable labels.
36    pub location: PathBuf,
37    /// SHA-256 of the same UTF-8 bytes parsed during this observation.
38    pub sha256: Sha256Digest,
39    /// Digest of build/target/profile/unstable settings and recognized build/compiler
40    /// environment entries, excluding output directories and unrelated credentials.
41    /// `None` means the configuration contains no comparison-relevant settings.
42    pub recipe_sha256: Option<Sha256Digest>,
43    /// Present build compiler/wrapper fields, in canonical field order.
44    pub compiler_overrides: Vec<&'static str>,
45}
46
47/// Configuration observation failure; diagnostics exclude configuration contents.
48#[derive(Debug, thiserror::Error)]
49pub enum ConfigurationError {
50    /// Bounded regular-file admission failed.
51    #[error("read Cargo configuration {}: {source}", path.display())]
52    Read {
53        /// Selected lookup path.
54        path: PathBuf,
55        /// Original bounded-read error.
56        source: ArtifactError,
57    },
58    /// Original filesystem failure at its selected lookup path.
59    #[error("{operation} {}: {source}", path.display())]
60    Io {
61        /// Failed read or location operation.
62        operation: &'static str,
63        /// Selected lookup path.
64        path: PathBuf,
65        /// Original filesystem error.
66        source: io::Error,
67    },
68    /// Invalid TOML or include structure, without embedding source text.
69    #[error("invalid Cargo configuration {}: {message}", path.display())]
70    Invalid {
71        /// Selected configuration path.
72        path: PathBuf,
73        /// Structural reason, excluding configuration values.
74        message: String,
75    },
76}
77
78/// Enumerate invocation ancestors' `.cargo` directories and the caller-selected Cargo home.
79///
80/// Relative home values resolve against the absolute invocation directory. No ambient
81/// environment is read, files selected, or directories created.
82///
83/// # Errors
84/// Returns invalid input when the invocation directory is not absolute.
85pub fn configuration_directories(
86    invocation: &Path,
87    cargo_home: Option<&OsStr>,
88    default_home: Option<&OsStr>,
89) -> io::Result<Vec<PathBuf>> {
90    if !invocation.is_absolute() {
91        return Err(io::Error::new(
92            io::ErrorKind::InvalidInput,
93            "Cargo invocation directory must be absolute",
94        ));
95    }
96    let mut directories: Vec<_> = invocation
97        .ancestors()
98        .map(|path| path.join(".cargo"))
99        .collect();
100    let home = cargo_home
101        .map(PathBuf::from)
102        .or_else(|| default_home.map(|home| PathBuf::from(home).join(".cargo")));
103    if let Some(home) = home {
104        directories.push(if home.is_absolute() {
105            home
106        } else {
107            invocation.join(home)
108        });
109    }
110    Ok(directories)
111}
112
113/// Select Cargo's preferred existing file in one directory: `config` before `config.toml`.
114/// Callers needing conservative custody of both paths may supply both to observation.
115#[must_use]
116pub fn preferred_configuration(directory: &Path) -> Option<PathBuf> {
117    let extensionless = directory.join("config");
118    if extensionless.exists() {
119        return Some(extensionless);
120    }
121    let toml = directory.join("config.toml");
122    toml.exists().then_some(toml)
123}
124
125/// Observe selected roots and their include trees in order, deduplicating lookup paths.
126///
127/// Roots must be absolute. Required missing/invalid files fail; optional absent includes
128/// are skipped while budget remains. Includes resolve beside the configured entry, not a symlink referent.
129/// Recorded lookup paths are not read or expanded again in the same call. Active
130/// recursion is suppressed without merging distinct lookup aliases.
131///
132/// Callers own source custody, root selection and re-observation before acceptance.
133/// This does not resolve tools, execute Cargo, admit compilers or grant cache/release validity.
134///
135/// # Errors
136/// Returns original filesystem errors or a structural refusal for relative roots,
137/// invalid TOML, malformed includes and exhausted traversal limits. Bounded reads
138/// refuse non-regular files and excessive bytes. Error text excludes configuration contents.
139pub fn observe_configurations(
140    roots: &[PathBuf],
141    mut limits: ConfigurationLimits,
142) -> Result<Vec<ConfigurationFile>, ConfigurationError> {
143    let mut files = Vec::new();
144    let mut active = BTreeSet::new();
145    for root in roots {
146        append_configuration(&mut files, root, &mut active, false, &mut limits, 0)?;
147    }
148    Ok(files)
149}
150
151/// Whether a variable overrides the selected Rust compiler, Rustdoc or their wrappers.
152/// The caller decides whether to admit or refuse such an override.
153#[must_use]
154pub fn is_compiler_override(name: &str) -> bool {
155    matches!(
156        name,
157        "RUSTC"
158            | "RUSTC_WRAPPER"
159            | "RUSTC_WORKSPACE_WRAPPER"
160            | "RUSTDOC"
161            | "CARGO_BUILD_RUSTC"
162            | "CARGO_BUILD_RUSTC_WRAPPER"
163            | "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER"
164            | "CARGO_BUILD_RUSTDOC"
165    )
166}
167
168/// Whether a variable supplies Rust flags or Cargo build/profile/target settings.
169/// Output-only `CARGO_TARGET_DIR` is excluded; callers still select their identity policy.
170#[must_use]
171pub fn is_build_variable(name: &str) -> bool {
172    matches!(
173        name,
174        "RUSTFLAGS" | "RUSTDOCFLAGS" | "CARGO_ENCODED_RUSTFLAGS" | "CARGO_ENCODED_RUSTDOCFLAGS"
175    ) || name.starts_with("CARGO_BUILD_")
176        || name.starts_with("CARGO_PROFILE_")
177        || name.starts_with("CARGO_TARGET_") && name != "CARGO_TARGET_DIR"
178}
179
180pub(crate) fn is_recipe_variable(name: &str) -> bool {
181    !matches!(name, "CARGO_BUILD_TARGET_DIR" | "CARGO_BUILD_BUILD_DIR")
182        && (is_build_variable(name)
183            || is_compiler_override(name)
184            || matches!(name, "CARGO_INCREMENTAL" | "RUSTC_BOOTSTRAP"))
185}
186
187fn append_configuration(
188    files: &mut Vec<ConfigurationFile>,
189    config: &Path,
190    active: &mut BTreeSet<PathBuf>,
191    optional: bool,
192    limits: &mut ConfigurationLimits,
193    depth: usize,
194) -> Result<(), ConfigurationError> {
195    if !config.is_absolute() {
196        return Err(invalid_configuration(
197            config,
198            "configuration path must be absolute",
199        ));
200    }
201    if files.iter().any(|file| file.path == config) {
202        return Ok(());
203    }
204    if depth > limits.depth {
205        return Err(invalid_configuration(
206            config,
207            "include depth limit exceeded",
208        ));
209    }
210    if limits.files == 0 {
211        return Err(invalid_configuration(
212            config,
213            "configuration file count limit exceeded",
214        ));
215    }
216    let contents =
217        match ic_host_fs::read::read_file(config, limits.file_bytes.min(limits.total_bytes)) {
218            Ok(contents) => contents,
219            Err(ArtifactError::Io(error))
220                if optional && error.kind() == io::ErrorKind::NotFound =>
221            {
222                return Ok(());
223            }
224            Err(ArtifactError::Io(source)) => {
225                return Err(ConfigurationError::Io {
226                    operation: "read Cargo configuration",
227                    path: config.to_owned(),
228                    source,
229                });
230            }
231            Err(source) => {
232                return Err(ConfigurationError::Read {
233                    path: config.to_owned(),
234                    source,
235                });
236            }
237        };
238    limits.files -= 1;
239    limits.total_bytes -= contents.len();
240    let contents = String::from_utf8(contents)
241        .map_err(|_| invalid_configuration(config, "configuration is not UTF-8"))?;
242    let parent = config.parent().ok_or_else(|| {
243        invalid_configuration(config, "configuration path has no parent directory")
244    })?;
245    let location =
246        parent
247            .canonicalize()
248            .map_err(|source| ConfigurationError::Io {
249                operation: "resolve Cargo configuration directory",
250                path: parent.to_owned(),
251                source,
252            })?
253            .join(config.file_name().ok_or_else(|| {
254                invalid_configuration(config, "configuration path has no file name")
255            })?);
256    if !active.insert(location.clone()) {
257        return Ok(());
258    }
259    let configuration = toml::from_str::<Value>(&contents)
260        .map_err(|_| invalid_configuration(config, "invalid TOML"))?;
261    let compiler_overrides = [
262        "rustc",
263        "rustc-wrapper",
264        "rustc-workspace-wrapper",
265        "rustdoc",
266    ]
267    .into_iter()
268    .filter(|field| {
269        configuration
270            .get("build")
271            .and_then(|build| build.get(field))
272            .is_some()
273    })
274    .collect();
275    files.push(ConfigurationFile {
276        path: config.to_owned(),
277        location: location.clone(),
278        sha256: Sha256Digest::compute(contents.as_bytes()),
279        compiler_overrides,
280        recipe_sha256: configuration_recipe(&configuration),
281    });
282    if let Some(include) = configuration.get("include") {
283        for (included, optional) in configuration_includes(include, config)? {
284            let included = if included.is_absolute() {
285                Cow::Borrowed(included)
286            } else {
287                Cow::Owned(parent.join(included))
288            };
289            append_configuration(files, &included, active, optional, limits, depth + 1)?;
290        }
291    }
292    active.remove(&location);
293    Ok(())
294}
295
296fn configuration_includes<'a>(
297    include: &'a Value,
298    config: &Path,
299) -> Result<Vec<(&'a Path, bool)>, ConfigurationError> {
300    let values = match include {
301        Value::Array(values) => values.as_slice(),
302        value => std::slice::from_ref(value),
303    };
304    values
305        .iter()
306        .map(|value| match value {
307            Value::String(path) => Ok((Path::new(path), false)),
308            Value::Table(table) => {
309                let path = table.get("path").and_then(Value::as_str).ok_or_else(|| {
310                    invalid_configuration(
311                        config,
312                        "Cargo configuration include table requires a string `path`",
313                    )
314                })?;
315                let optional = table
316                    .get("optional")
317                    .map(|value| {
318                        value.as_bool().ok_or_else(|| {
319                            invalid_configuration(
320                                config,
321                                "Cargo configuration include `optional` must be a boolean",
322                            )
323                        })
324                    })
325                    .transpose()?
326                    .unwrap_or(false);
327                Ok((Path::new(path), optional))
328            }
329            _ => Err(invalid_configuration(
330                config,
331                "Cargo configuration `include` must contain paths or include tables",
332            )),
333        })
334        .collect()
335}
336
337fn invalid_configuration(path: &Path, message: &str) -> ConfigurationError {
338    ConfigurationError::Invalid {
339        path: path.to_owned(),
340        message: message.to_owned(),
341    }
342}
343
344fn configuration_recipe(configuration: &Value) -> Option<Sha256Digest> {
345    let mut selected = toml::map::Map::new();
346    for key in ["build", "target", "profile", "unstable", "include"] {
347        if let Some(value) = configuration.get(key) {
348            let mut value = value.clone();
349            if key == "build"
350                && let Some(table) = value.as_table_mut()
351            {
352                table.remove("target-dir");
353                table.remove("build-dir");
354            }
355            if !value.as_table().is_some_and(toml::map::Map::is_empty) {
356                selected.insert(key.to_owned(), value);
357            }
358        }
359    }
360    if let Some(environment) = configuration.get("env").and_then(Value::as_table) {
361        let environment: toml::map::Map<_, _> = environment
362            .iter()
363            .filter(|(name, _)| is_recipe_variable(name))
364            .map(|(name, value)| (name.clone(), value.clone()))
365            .collect();
366        if !environment.is_empty() {
367            selected.insert("env".to_owned(), Value::Table(environment));
368        }
369    }
370    (!selected.is_empty())
371        .then(|| Sha256Digest::compute(Value::Table(selected).to_string().as_bytes()))
372}