use crate::session::{self, STAGING_ENV};
use eyre::{Result, WrapErr as _, bail};
use mbx_cache_core::{
ActionDiagnostic, AgentRequest, AgentResponse, CacheDigest, CacheFileNode, RestoreStats,
canonical_json,
};
use mbx_cache_rustc::PathMapping;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::ffi::OsStr;
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::process::{ExitCode, ExitStatus};
pub(crate) struct CachedCompilation {
pub(crate) action: CacheDigest,
pub(crate) stdout: Vec<u8>,
pub(crate) stderr: Vec<u8>,
pub(crate) outputs: Vec<CachedOutput>,
pub(crate) restore: RestoreStats,
}
pub(crate) struct CachedOutput {
pub(crate) path: PathBuf,
pub(crate) digest: CacheDigest,
pub(crate) executable: bool,
pub(crate) mode: u32,
}
pub(crate) struct StagedOutputs {
pub(crate) directory: tempfile::TempDir,
pub(crate) files: Vec<(tempfile::TempPath, PathBuf)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Materialization {
Reflink,
Copy,
}
pub(crate) fn find_blobs(digests: &[CacheDigest]) -> Result<Vec<PathBuf>> {
let responses = session::request_agent(&[AgentRequest::FindBlobs {
digests: digests.to_vec(),
}])?;
let Some(response) = responses.into_iter().next() else {
bail!("cache agent did not return a blob lookup response");
};
match response {
AgentResponse::Blobs { paths } if paths.len() == digests.len() => paths
.into_iter()
.zip(digests)
.map(|(path, digest)| match path {
Some(path) => Ok(path),
None => bail!("cached action is missing blob {}", digest.hash),
})
.collect(),
AgentResponse::Blobs { .. } => {
bail!("cache agent returned an incomplete blob lookup response")
}
AgentResponse::Blob { path: Some(path) } if digests.len() == 1 => Ok(vec![path]),
AgentResponse::Blob { path: None } if digests.len() == 1 => {
let digest = &digests[0];
bail!("cached action is missing blob {}", digest.hash)
}
AgentResponse::Error { message } => bail!(message),
_ => bail!("cache agent returned an unexpected blob lookup response"),
}
}
pub(crate) fn read_canonical_blob<T>(
path: &Path,
digest: &CacheDigest,
description: &str,
) -> Result<T>
where
T: DeserializeOwned + Serialize,
{
let bytes = read_verified_blob(path, digest, description)?;
let value = serde_json::from_slice(&bytes)
.wrap_err_with(|| format!("cached {description} is not valid JSON"))?;
if canonical_json(&value)? != bytes {
bail!("cached {description} is not canonical JSON");
}
Ok(value)
}
pub(crate) fn read_verified_blob(
path: &Path,
digest: &CacheDigest,
description: &str,
) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
std::fs::File::open(path)?.read_to_end(&mut bytes)?;
if !digest.matches_bytes(&bytes)? {
bail!("cached {description} failed digest verification");
}
Ok(bytes)
}
pub(crate) fn stage_verified_cached_output(
directory: &Path,
index: usize,
source: &Path,
node: &CacheFileNode,
) -> Result<(tempfile::TempPath, Materialization)> {
let temporary = directory.join(format!("output-{index}"));
let copied_bytes = reflink_copy::reflink_or_copy(source, &temporary)
.wrap_err_with(|| format!("failed to materialize cached output {}", node.name))?;
let materialization = match copied_bytes {
None => Materialization::Reflink,
Some(written) if written == node.digest.size => Materialization::Copy,
Some(_) => bail!(
"materialized cached output has the wrong size: {}",
node.name
),
};
let temporary = tempfile::TempPath::try_from_path(temporary)?;
make_owner_writable(&temporary)?;
if std::fs::metadata(&temporary)?.len() != node.digest.size {
bail!(
"materialized cached output has the wrong size: {}",
node.name
);
}
apply_file_mode(&temporary, node.mode, node.executable)?;
std::fs::OpenOptions::new()
.write(true)
.open(&temporary)?
.set_times(std::fs::FileTimes::new().set_modified(std::time::SystemTime::now()))?;
Ok((temporary, materialization))
}
pub(crate) fn persist_outputs(staged: StagedOutputs) -> Result<()> {
let StagedOutputs {
directory: _directory,
files,
} = staged;
let destinations = files
.iter()
.map(|(_, destination)| destination.clone())
.collect::<Vec<_>>();
for (temporary, destination) in files {
let persisted = temporary
.persist(&destination)
.map_err(|error| error.error)
.wrap_err_with(|| format!("failed to atomically restore {}", destination.display()));
if let Err(error) = persisted {
for destination in &destinations {
match std::fs::remove_file(destination) {
Ok(()) => {}
Err(remove_error) if remove_error.kind() == std::io::ErrorKind::NotFound => {}
Err(remove_error) => session::report_shim_warning(&format!(
"failed to roll back {}: {remove_error}",
destination.display()
)),
}
}
return Err(error);
}
}
Ok(())
}
pub(crate) fn replay_bytes(stdout_bytes: &[u8], stderr_bytes: &[u8]) -> Result<()> {
let mut stdout = std::io::stdout().lock();
stdout.write_all(stdout_bytes)?;
stdout.flush()?;
let mut stderr = std::io::stderr().lock();
stderr.write_all(stderr_bytes)?;
stderr.flush()?;
Ok(())
}
pub(crate) fn staging_directory() -> Result<tempfile::TempDir> {
let root = match std::env::var_os(STAGING_ENV).filter(|root| !root.is_empty()) {
Some(root) => PathBuf::from(root),
None => crate::config::Config::load()?
.store_dir()
.join("standalone-staging"),
};
std::fs::create_dir_all(&root)?;
Ok(tempfile::tempdir_in(root)?)
}
pub(crate) fn resolve_executable(executable: &OsStr) -> Result<PathBuf> {
let executable = PathBuf::from(executable);
if executable.is_absolute() {
return Ok(executable);
}
which::which(&executable).wrap_err_with(|| {
format!(
"failed to resolve compiler executable {}",
executable.display()
)
})
}
pub(crate) fn record_action_hit(action: &CacheDigest, restore: RestoreStats, crate_name: &str) {
record_action_hit_with_diagnostic(action, restore, crate_name, None);
}
pub(crate) fn record_action_hit_with_diagnostic(
action: &CacheDigest,
restore: RestoreStats,
crate_name: &str,
diagnostic: Option<ActionDiagnostic>,
) {
let mut requests = Vec::new();
if let Some(diagnostic) = diagnostic
&& let Some(request) =
session::action_diagnostic_request("hit", Some(crate_name), diagnostic)
{
requests.push(request);
}
requests.extend(session::unit_outcome_requests("hit", Some(crate_name)));
requests.push(AgentRequest::RecordActionHit {
action: action.clone(),
restore,
crate_name: Some(crate_name.to_string()),
});
let responses = session::request_agent(&requests);
match responses.map(|mut responses| responses.pop()) {
Ok(Some(AgentResponse::ActionHitRecorded)) => {}
Ok(Some(AgentResponse::Error { message })) => {
session::report_shim_warning(&format!("hit was not recorded: {message}"));
}
Ok(_) => session::report_shim_warning("hit was not recorded"),
Err(error) => {
session::report_shim_warning(&format!("hit was not recorded: {error:#}"));
}
}
}
pub(crate) fn verification_warning(
adapter: &str,
unit: &str,
action: &CacheDigest,
divergence: &str,
) -> String {
format!(
"shadow verification diverged from cached output: {adapter} action {} ({}): {divergence}",
action.hash,
unit.escape_default()
)
}
pub(crate) fn stream_divergence(name: &str, cached: &[u8], compiled: &[u8]) -> Option<String> {
if cached == compiled {
return None;
}
let offset = cached
.iter()
.zip(compiled)
.position(|(a, b)| a != b)
.unwrap_or(cached.len().min(compiled.len()));
let line = cached[..offset].iter().filter(|&&b| b == b'\n').count() + 1;
let excerpt = |bytes: &[u8]| {
let start = offset.saturating_sub(24);
let end = bytes.len().min(offset.saturating_add(48));
format!(
"{}{}{}",
if start > 0 { "..." } else { "" },
bytes[start..end].escape_ascii(),
if end < bytes.len() { "..." } else { "" }
)
};
Some(format!(
"{name} differs at byte {offset} (line {line}): cached=\"{}\", compiled=\"{}\"",
excerpt(cached),
excerpt(compiled)
))
}
pub(crate) fn record_verification(matched: bool, restore: RestoreStats) {
let responses =
session::request_agent(&[AgentRequest::RecordActionVerification { matched, restore }]);
match responses.map(|responses| responses.into_iter().next()) {
Ok(Some(AgentResponse::ActionVerificationRecorded)) => {}
Ok(Some(AgentResponse::Error { message })) => {
session::report_shim_warning(&format!("verification was not recorded: {message}"));
}
Ok(_) => session::report_shim_warning("verification was not recorded"),
Err(error) => {
session::report_shim_warning(&format!("verification was not recorded: {error:#}"));
}
}
}
#[cfg(unix)]
pub(crate) fn file_mode(metadata: &std::fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt as _;
metadata.permissions().mode() & 0o644
}
#[cfg(windows)]
pub(crate) fn file_mode(_metadata: &std::fs::Metadata) -> u32 {
0
}
#[cfg(unix)]
pub(crate) fn validate_file_mode(node: &CacheFileNode, executable: bool) -> Result<()> {
if node.executable != executable
|| node.mode & !0o777 != 0
|| node.mode & 0o111 != 0
|| node.mode & 0o022 != 0
{
bail!("cached output has an unsafe file mode: {}", node.name);
}
Ok(())
}
#[cfg(windows)]
pub(crate) fn validate_file_mode(node: &CacheFileNode, executable: bool) -> Result<()> {
if node.executable != executable || node.mode != 0 {
bail!("cached output has an unsafe file mode: {}", node.name);
}
Ok(())
}
#[cfg(unix)]
pub(crate) fn apply_file_mode(temporary: &Path, mode: u32, executable: bool) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let executable_mode = if executable { 0o111 } else { 0 };
std::fs::set_permissions(
temporary,
std::fs::Permissions::from_mode(mode | executable_mode),
)?;
Ok(())
}
#[cfg(windows)]
pub(crate) fn apply_file_mode(_temporary: &Path, _mode: u32, _executable: bool) -> Result<()> {
Ok(())
}
#[cfg(unix)]
pub(crate) fn executable_mode_matches(metadata: &std::fs::Metadata, executable: bool) -> bool {
use std::os::unix::fs::PermissionsExt as _;
(metadata.permissions().mode() & 0o111 != 0) == executable
}
#[cfg(windows)]
pub(crate) fn executable_mode_matches(_metadata: &std::fs::Metadata, _executable: bool) -> bool {
true
}
#[cfg(unix)]
pub(crate) fn make_owner_writable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let mut permissions = std::fs::metadata(path)?.permissions();
permissions.set_mode(permissions.mode() | 0o200);
std::fs::set_permissions(path, permissions)?;
Ok(())
}
#[cfg(windows)]
pub(crate) fn make_owner_writable(path: &Path) -> Result<()> {
let mut permissions = std::fs::metadata(path)?.permissions();
permissions.set_readonly(false);
std::fs::set_permissions(path, permissions)?;
Ok(())
}
pub(crate) fn normalize_output_text(bytes: &[u8], mappings: &[PathMapping]) -> Vec<u8> {
let mut normalized = bytes.to_vec();
for (root, placeholder) in root_spellings(mappings) {
normalized = replace_bytes(&normalized, root.as_bytes(), placeholder.as_bytes());
}
normalized
}
pub(crate) fn denormalize_output_text(bytes: &[u8], mappings: &[PathMapping]) -> Vec<u8> {
let mut text = bytes.to_vec();
for (root, placeholder) in root_spellings(mappings) {
text = replace_bytes(&text, placeholder.as_bytes(), root.as_bytes());
}
text
}
fn root_spellings(mappings: &[PathMapping]) -> Vec<(String, String)> {
let mut spellings = Vec::new();
for mapping in PathMapping::ordered(mappings) {
let Some(root) = mapping.root.to_str() else {
continue;
};
let root = root.trim_end_matches(['/', '\\']);
if root.is_empty() {
continue;
}
for (spelling, suffix) in [
(root.to_string(), ""),
(root.replace('\\', "\\\\"), ":escaped"),
(root.replace('\\', "/"), ":slash"),
] {
if spellings.iter().any(|(existing, _)| existing == &spelling) {
continue;
}
spellings.push((spelling, format!("${{{}{suffix}}}", mapping.placeholder)));
}
}
spellings
}
fn ends_at_boundary(haystack: &[u8], end: usize) -> bool {
match haystack.get(end) {
None => true,
Some(byte) => !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')),
}
}
fn replace_bytes(haystack: &[u8], needle: &[u8], replacement: &[u8]) -> Vec<u8> {
if needle.is_empty() || haystack.len() < needle.len() {
return haystack.to_vec();
}
let mut out = Vec::with_capacity(haystack.len());
let mut index = 0;
while index <= haystack.len() - needle.len() {
if &haystack[index..index + needle.len()] == needle
&& ends_at_boundary(haystack, index + needle.len())
{
out.extend_from_slice(replacement);
index += needle.len();
} else {
out.push(haystack[index]);
index += 1;
}
}
out.extend_from_slice(&haystack[index..]);
out
}
#[cfg(unix)]
pub(crate) fn exit_code(status: ExitStatus) -> ExitCode {
use std::os::unix::process::ExitStatusExt as _;
ExitCode::from(
status
.code()
.unwrap_or_else(|| 128 + status.signal().unwrap_or(1)) as u8,
)
}
#[cfg(windows)]
pub(crate) fn exit_code(status: ExitStatus) -> ExitCode {
crate::phase_timing::finish();
unsafe { windows_sys::Win32::System::Threading::ExitProcess(status.code().unwrap_or(1) as u32) }
}
#[cfg(test)]
mod verification_tests {
use super::*;
#[test]
fn stream_comparison_handles_eof_and_reports_the_first_differing_line() {
assert_eq!(stream_divergence("stderr", b"same", b"same"), None);
let difference = stream_divergence("stderr", b"same\nold", b"same\nnew").unwrap();
assert!(difference.contains("byte 5 (line 2)"), "{difference}");
assert!(difference.contains(r"same\nold"), "{difference}");
for (cached, compiled) in [
(b"".as_slice(), b"extra".as_slice()),
(b"extra".as_slice(), b"".as_slice()),
] {
let difference = stream_divergence("stdout", cached, compiled).unwrap();
assert!(difference.contains("byte 0 (line 1)"), "{difference}");
}
}
#[test]
fn excerpts_are_bounded_and_escape_binary_and_terminal_bytes() {
let cached = vec![b'a'; 10_000];
let mut compiled = cached.clone();
compiled.extend_from_slice(b"\x1b\0\xff\n");
let difference = stream_divergence("stderr", &cached, &compiled).unwrap();
assert!(difference.contains("byte 10000"), "{difference}");
assert!(difference.contains(r"\x1b\x00\xff\n"), "{difference}");
assert!(!difference.contains(['\n', '\0', '\x1b']));
assert!(difference.len() < 512, "{difference}");
}
#[test]
fn verification_unit_names_escape_terminal_controls() {
let warning = verification_warning(
"cc",
"cc:bad\x1b[2J\n\0.c",
&CacheDigest::blake3(b"action"),
"standard error differs",
);
assert!(!warning.chars().any(char::is_control), "{warning:?}");
assert!(warning.contains(r"cc:bad\u{1b}[2J\n\u{0}.c"), "{warning}");
assert!(warning.ends_with("standard error differs"));
}
#[test]
fn different_actions_of_one_unit_keep_distinct_warning_identities() {
let first = verification_warning(
"rustc",
"example",
&CacheDigest::blake3(b"lib"),
"standard error differs",
);
let second = verification_warning(
"rustc",
"example",
&CacheDigest::blake3(b"test"),
"standard error differs",
);
assert_ne!(
first, second,
"session deduplication must preserve both units"
);
assert!(first.contains("rustc action"));
assert!(first.contains("(example)"));
}
}