#![deny(missing_docs)]
use mbx_cache_core::{CacheDigest, canonical_json};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
mod dep_info;
pub use dep_info::{DepInfoCommand, DiscoveredInputs, RustcDepInfo};
pub const ACTION_SCHEMA_VERSION: u8 = 1;
pub const ADAPTER_VERSION: u8 = 2;
impl BypassReason {
pub fn kind(&self) -> &'static str {
self.into()
}
}
const SUPPORTED_CODEGEN_OPTIONS: &[&str] = &[
"codegen-units",
"control-flow-guard",
"debug-assertions",
"debuginfo",
"default-linker-libraries",
"embed-bitcode",
"extra-filename",
"force-frame-pointers",
"force-unwind-tables",
"instrument-coverage",
"link-dead-code",
"link-self-contained",
"lto",
"metadata",
"no-prepopulate-passes",
"opt-level",
"overflow-checks",
"panic",
"prefer-dynamic",
"relocation-model",
"rpath",
"save-temps",
"soft-float",
"split-debuginfo",
"split-dwarf-kind",
"strip",
"symbol-mangling-version",
"target-cpu",
"target-feature",
"tls-model",
];
const NATIVE_DIRECTORY_PREDICTION_PREFIX: &str = "@native-directory:";
const MAX_PREDICTED_INPUTS: usize = 16 * 1024;
const MAX_NATIVE_INPUT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const COMPILER_BUNDLED_WASM_TARGETS: &[&str] = &[
"wasm32-unknown-unknown",
"wasm32-wasip1",
"wasm32-wasip1-threads",
"wasm32-wasip2",
"wasm32v1-none",
"wasm64-unknown-unknown",
];
#[derive(Debug, Clone, PartialEq, Eq, Error, strum::IntoStaticStr)]
#[strum(serialize_all = "kebab-case")]
#[non_exhaustive]
pub enum BypassReason {
#[error("rustc argument {index} is not valid UTF-8")]
NonUtf8Argument {
index: usize,
},
#[error("could not model rustc response file: {0}")]
ResponseFile(String),
#[error("rustc flag is not modeled by the cache adapter: {0}")]
UnknownFlag(String),
#[error("rustc codegen option is not modeled by the cache adapter: {0}")]
UnknownCodegenOption(String),
#[error("rustc flag requires a value: {0}")]
MissingValue(String),
#[error("rustc invocation is a compiler query, not a compilation")]
CompilerQuery,
#[error("rustc invocation reads source from standard input")]
StandardInput,
#[error("rustc invocation has no source input")]
MissingInput,
#[error("rustc invocation has multiple source inputs")]
MultipleInputs,
#[error("incremental compilation cannot be combined with action caching")]
Incremental,
#[error("rustc crate type is not cacheable yet: {0}")]
UnsupportedCrateType(String),
#[error("rustc output type is not cacheable yet: {0}")]
UnsupportedEmit(String),
#[error("rustc invocation does not emit a cacheable artifact")]
NoCacheableOutput,
#[error("rustc invocation does not emit dependency information")]
NoDepInfo,
#[error("rustc output paths do not share one directory")]
SplitOutputDirectories,
#[error("rustc output path has no file name: {0}")]
InvalidOutputPath(PathBuf),
#[error("rustc -o with an emit that has no explicit path is not modeled: {0}")]
ImplicitEmitWithOutputFile(PathBuf),
#[error("native library lookup is not cacheable yet")]
NativeLibrary,
#[error("rustc output name does not distinguish a program from a library: {0}")]
AmbiguousOutputName(PathBuf),
#[error("native link is not reproducible across checkouts: {0}")]
UnportableNativeLink(String),
#[error("rustc search path kind is not cacheable yet: {0}")]
UnsupportedSearchPath(String),
#[error("rustc extern does not identify an input artifact: {0}")]
UnresolvedExtern(String),
#[error("absolute path has no stable cache mapping: {0}")]
UnmappedAbsolutePath(PathBuf),
#[error("cache key paths must be valid UTF-8: {0}")]
NonUtf8Path(PathBuf),
#[error("cache action working directory must be absolute: {0}")]
RelativeWorkingDirectory(PathBuf),
#[error("cache path mapping must use an absolute root: {0}")]
RelativePathMapping(PathBuf),
#[error("cache path mapping placeholder is invalid: {0}")]
InvalidPathPlaceholder(String),
#[error("required compiler input was not provided: {0}")]
MissingRequiredInput(String),
#[error("compiler input has an invalid digest: {0}")]
InvalidInputDigest(String),
#[error("compiler input appears more than once with different content: {0}")]
ConflictingInput(String),
#[error("rustc dep-info is malformed: {0}")]
MalformedDepInfo(String),
#[error("failed to read rustc dep-info {path}: {message}")]
DepInfoRead {
path: PathBuf,
message: String,
},
#[error("rustc dep-info output path must be absolute: {0}")]
RelativeDepInfoPath(PathBuf),
#[error("rustc dep-info output path cannot contain a comma: {0}")]
UnsafeDepInfoPath(PathBuf),
#[error("failed to read compiler input {path}: {message}")]
InputRead {
path: PathBuf,
message: String,
},
#[error("compiler input changed after discovery: {0}")]
InputChanged(PathBuf),
#[error("compiler input was modified during compilation: {0}")]
InputModifiedDuringCompilation(PathBuf),
#[error("discovered inputs were collected from a different working directory")]
DiscoveryWorkingDirectory,
#[error("compiler environment input has conflicting values: {0}")]
ConflictingEnvironment(String),
#[error("failed to serialize the rustc action: {0}")]
Serialization(String),
#[error("rustc action prediction is unsupported")]
UnsupportedPrediction,
#[error("rustc action prediction contains an invalid input path: {0}")]
InvalidPredictedInput(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Argument {
Plain(String),
Path { flag: String, path: PathBuf },
SearchPath { kind: String, path: PathBuf },
Extern { name: String, path: Option<PathBuf> },
Emit(Vec<Emit>),
RemapPath { from: PathBuf, to: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Emit {
kind: String,
path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RustcInvocation {
arguments: Vec<Argument>,
source: PathBuf,
required_inputs: Vec<PathBuf>,
crate_name: String,
extra_filename: String,
out_dir: Option<PathBuf>,
explicit_output: Option<PathBuf>,
emits: Vec<Emit>,
target: Option<String>,
link_output: LinkOutput,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinkOutput {
Library,
WasmExecutable,
NativeExecutable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct ParseOptions {
pub cache_native_links: bool,
}
impl ParseOptions {
pub fn caching_native_links(enabled: bool) -> Self {
Self {
cache_native_links: enabled,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RustcOutputs {
pub directory: PathBuf,
pub files: Vec<PathBuf>,
pub dep_info: PathBuf,
}
impl RustcInvocation {
pub fn parse(arguments: &[OsString]) -> Result<Self, BypassReason> {
Self::parse_with(arguments, ParseOptions::default())
}
pub fn parse_with(arguments: &[OsString], options: ParseOptions) -> Result<Self, BypassReason> {
let expanded = expand_response_files(arguments)?;
Parser::new(&expanded.arguments, options).parse()
}
pub fn links_natively(&self) -> bool {
self.link_output == LinkOutput::NativeExecutable
}
fn native_search_is_inert(&self) -> bool {
self.link_output == LinkOutput::Library
}
pub fn source(&self) -> &Path {
&self.source
}
pub fn target(&self) -> Option<&str> {
self.target.as_deref()
}
pub fn crate_name(&self) -> &str {
&self.crate_name
}
pub fn source_fingerprint(&self, discovered: &DiscoveredInputs) -> CacheDigest {
let linked = self
.arguments
.iter()
.filter_map(|argument| match argument {
Argument::Extern {
path: Some(path), ..
} => Some(path.as_path()),
_ => None,
})
.collect::<BTreeSet<_>>();
let owned = discovered
.inputs
.iter()
.filter(|input| !linked.contains(input.path.as_path()))
.map(|input| (input.path.as_path(), &input.digest))
.collect::<BTreeMap<_, _>>();
let mut bytes = Vec::new();
for (path, digest) in owned {
bytes.extend_from_slice(path.as_os_str().as_encoded_bytes());
bytes.push(0);
bytes.extend_from_slice(digest.key().as_bytes());
bytes.push(0);
}
CacheDigest::blake3(&bytes)
}
pub fn outputs(&self, working_dir: &Path) -> Result<RustcOutputs, BypassReason> {
if !working_dir.is_absolute() {
return Err(BypassReason::RelativeWorkingDirectory(
working_dir.to_path_buf(),
));
}
let explicit_output = self
.explicit_output
.as_deref()
.map(|path| absolute_path(path, working_dir));
let output_directory = explicit_output
.as_deref()
.and_then(Path::parent)
.map(Path::to_path_buf)
.or_else(|| {
self.out_dir
.as_deref()
.map(|path| absolute_path(path, working_dir))
})
.unwrap_or_else(|| normalize_components(working_dir));
if let Some(output) = &explicit_output
&& self.emits.iter().any(|emit| {
emit.path.is_none()
&& matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata")
})
{
return Err(BypassReason::ImplicitEmitWithOutputFile(output.clone()));
}
let mut files = BTreeSet::new();
let mut dep_info = None;
for emit in &self.emits {
if emit.kind == "dep-info" {
let path = emit.path.as_ref().map_or_else(
|| {
explicit_output.clone().map_or_else(
|| {
output_directory
.join(format!("{}{}.d", self.crate_name, self.extra_filename))
},
|path| path.with_extension("d"),
)
},
|path| absolute_path(path, working_dir),
);
if path.file_name().is_none() {
return Err(BypassReason::InvalidOutputPath(path));
}
dep_info = Some(path);
continue;
}
let (prefix, extension) = match emit.kind.as_str() {
"link" => match self.link_output {
LinkOutput::Library => ("lib", "rlib"),
LinkOutput::WasmExecutable => ("", "wasm"),
LinkOutput::NativeExecutable => ("", ""),
},
"metadata" => ("lib", "rmeta"),
_ => continue,
};
let path = if let Some(path) = &emit.path {
absolute_path(path, working_dir)
} else {
let name = format!("{prefix}{}{}", self.crate_name, self.extra_filename);
output_directory.join(if extension.is_empty() {
name
} else {
format!("{name}.{extension}")
})
};
if path.file_name().is_none() {
return Err(BypassReason::InvalidOutputPath(path));
}
if path.parent() != Some(output_directory.as_path()) {
return Err(BypassReason::SplitOutputDirectories);
}
if emit.kind == "link"
&& !matches!(self.link_output, LinkOutput::Library)
&& matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rlib" | "rmeta")
)
{
return Err(BypassReason::AmbiguousOutputName(path));
}
files.insert(path);
}
let dep_info = dep_info.ok_or(BypassReason::NoDepInfo)?;
if dep_info.parent() != Some(output_directory.as_path()) {
return Err(BypassReason::SplitOutputDirectories);
}
Ok(RustcOutputs {
directory: output_directory,
files: files.into_iter().collect(),
dep_info,
})
}
pub fn action(&self, context: ActionContext) -> Result<RustcAction, BypassReason> {
self.action_linked_by(context, None)
}
pub fn action_linked_by(
&self,
context: ActionContext,
linker: Option<LinkerIdentity>,
) -> Result<RustcAction, BypassReason> {
ActionBuilder::new(self, context).linked_by(linker).build()
}
pub fn invocation_digest(&self, context: &ActionContext) -> Result<CacheDigest, BypassReason> {
let descriptor = ActionBuilder::new(self, context.clone()).invocation_descriptor()?;
let bytes = canonical_json(&descriptor)
.map_err(|error| BypassReason::Serialization(error.to_string()))?;
Ok(CacheDigest::blake3(&bytes))
}
pub fn prediction(
&self,
context: &ActionContext,
discovered: &DiscoveredInputs,
) -> Result<RustcInputPrediction, BypassReason> {
let builder = ActionBuilder::new(self, context.clone());
builder.validate_mappings()?;
let mut inputs = discovered
.inputs
.iter()
.map(|input| builder.normalize_path(&input.path))
.collect::<Result<BTreeSet<_>, _>>()?;
let mut has_native_directory = false;
for argument in &self.arguments {
if let Argument::SearchPath { kind, path } = argument
&& kind == "native"
{
match builder.normalize_path(path) {
Ok(normalized) => {
has_native_directory = true;
inputs.insert(format!("{NATIVE_DIRECTORY_PREDICTION_PREFIX}{normalized}"));
}
Err(BypassReason::UnmappedAbsolutePath(_)) if self.native_search_is_inert() => {
}
Err(error) => return Err(error),
}
}
}
Ok(RustcInputPrediction {
version: if has_native_directory { 3 } else { 1 },
inputs: inputs.into_iter().collect(),
environment: discovered.environment.keys().cloned().collect(),
compiler_duration_ns: 0,
crate_name: String::new(),
})
}
}
impl RustcOutputs {
pub fn is_executable(&self, path: &Path) -> bool {
self.files.iter().any(|output| output == path)
&& !matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rlib" | "rmeta")
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathMapping {
pub root: PathBuf,
pub placeholder: String,
}
impl PathMapping {
pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
Self {
root: root.into(),
placeholder: placeholder.into(),
}
}
pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
let mut ordered = mappings.to_vec();
ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
ordered
}
}
pub fn normalize_mapped_path(
path: &Path,
working_dir: &Path,
mappings: &[PathMapping],
) -> Result<String, BypassReason> {
let mappings = mappings
.iter()
.map(|mapping| PathMapping {
root: resolve_mapping_root(&mapping.root),
placeholder: mapping.placeholder.clone(),
})
.collect::<Vec<_>>();
normalize_resolved_mapped_path(path, working_dir, &mappings)
}
fn normalize_resolved_mapped_path(
path: &Path,
working_dir: &Path,
mappings: &[PathMapping],
) -> Result<String, BypassReason> {
let absolute = if path.is_absolute() {
normalize_components(path)
} else {
normalize_components(&working_dir.join(path))
};
let resolved = if absolute.is_absolute() {
resolve_path_aliases(&absolute)
} else {
absolute.clone()
};
for mapping in mappings {
if let Ok(relative) = resolved.strip_prefix(&mapping.root) {
let suffix = slash_path(relative)?;
return Ok(if suffix.is_empty() {
format!("${{{}}}", mapping.placeholder)
} else {
format!("${{{}}}/{suffix}", mapping.placeholder)
});
}
}
Err(BypassReason::UnmappedAbsolutePath(absolute))
}
#[cfg(unix)]
fn resolve_path_aliases(path: &Path) -> PathBuf {
let mut existing = path;
let mut missing = Vec::new();
loop {
match std::fs::canonicalize(existing) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return normalize_components(&resolved);
}
Err(_) => {
let Some(name) = existing.file_name() else {
return path.to_path_buf();
};
missing.push(name.to_os_string());
let Some(parent) = existing.parent() else {
return path.to_path_buf();
};
existing = parent;
}
}
}
}
#[cfg(not(unix))]
fn resolve_path_aliases(path: &Path) -> PathBuf {
path.to_path_buf()
}
fn resolve_mapping_root(root: &Path) -> PathBuf {
let root = normalize_components(root);
if root.is_absolute() {
resolve_path_aliases(&root)
} else {
root
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompilerIdentity {
pub toolchain: String,
pub rustc_version: String,
pub host: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionInput {
pub path: PathBuf,
pub digest: CacheDigest,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionContext {
pub compiler: CompilerIdentity,
pub working_dir: PathBuf,
pub path_mappings: Vec<PathMapping>,
pub environment: BTreeMap<String, Option<String>>,
pub portable_environment: BTreeSet<String>,
pub inputs: Vec<ActionInput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinkerIdentity {
pub driver: String,
pub driver_version: String,
pub linker_version: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub crt_objects: BTreeMap<String, CacheDigest>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sdk: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub deployment_target: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RustcAction {
pub digest: CacheDigest,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustcInputPrediction {
pub version: u8,
pub inputs: Vec<String>,
pub environment: Vec<String>,
#[serde(default, skip_serializing_if = "is_zero")]
pub compiler_duration_ns: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub crate_name: String,
}
fn is_zero(value: &u64) -> bool {
*value == 0
}
impl RustcInputPrediction {
pub fn discover(
&self,
working_dir: &Path,
path_mappings: &[PathMapping],
) -> Result<DiscoveredInputs, BypassReason> {
if !matches!(self.version, 1..=3) {
return Err(BypassReason::UnsupportedPrediction);
}
if self.inputs.len() > MAX_PREDICTED_INPUTS || self.environment.len() > 4 * 1024 {
return Err(BypassReason::UnsupportedPrediction);
}
let mut paths = BTreeSet::new();
let admitted_roots = dep_info::native_input_roots(working_dir, path_mappings);
let mut native_bytes = 0_u64;
for path in &self.inputs {
if self.version >= 3
&& let Some(path) = path.strip_prefix(NATIVE_DIRECTORY_PREDICTION_PREFIX)
{
let directory = denormalize_path(path, path_mappings)?;
dep_info::collect_native_directory(
&directory,
&admitted_roots,
&mut paths,
&mut native_bytes,
)?;
} else {
paths.insert(denormalize_path(path, path_mappings)?);
}
}
let environment = self
.environment
.iter()
.map(|name| {
if name.is_empty() || name.contains(['=', '\0']) {
return Err(BypassReason::UnsupportedPrediction);
}
let value = std::env::var_os(name)
.map(|value| {
value
.into_string()
.map_err(|_| BypassReason::UnsupportedPrediction)
})
.transpose()?;
Ok((name.clone(), value))
})
.collect::<Result<BTreeMap<_, _>, _>>()?;
DiscoveredInputs::from_paths(working_dir, paths, environment)
}
}
#[derive(Serialize)]
struct ActionDescriptor {
version: u8,
kind: &'static str,
adapter_version: u8,
compiler: CompilerDescriptor,
arguments: Vec<String>,
environment: BTreeMap<String, Option<String>>,
inputs: Vec<InputDescriptor>,
#[serde(skip_serializing_if = "Option::is_none")]
linker: Option<LinkerIdentity>,
}
#[derive(Serialize)]
struct InvocationDescriptor {
version: u8,
kind: &'static str,
adapter_version: u8,
compiler: CompilerDescriptor,
arguments: Vec<String>,
required_inputs: Vec<String>,
}
#[derive(Serialize)]
struct CompilerDescriptor {
toolchain: String,
rustc_version: String,
host: String,
}
#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
struct InputDescriptor {
path: String,
digest: CacheDigest,
}
struct Parser<'a> {
arguments: &'a [OsString],
index: usize,
parsed: Vec<Argument>,
source: Option<PathBuf>,
crate_types: Vec<String>,
emits: Vec<Emit>,
required_inputs: Vec<PathBuf>,
test: bool,
crate_name: Option<String>,
extra_filename: String,
out_dir: Option<PathBuf>,
explicit_output: Option<PathBuf>,
target: Option<String>,
options: ParseOptions,
}
struct ExpandedArguments {
arguments: Vec<OsString>,
}
#[derive(Default)]
struct ResponseExpander {
shell_argfiles: bool,
next_is_unstable_option: bool,
arguments: Vec<OsString>,
}
impl ResponseExpander {
fn push(&mut self, argument: String) {
if self.next_is_unstable_option {
self.shell_argfiles |= argument == "shell-argfiles";
self.next_is_unstable_option = false;
} else if let Some(option) = argument.strip_prefix("-Z") {
if option.is_empty() {
self.next_is_unstable_option = true;
} else {
self.shell_argfiles |= option == "shell-argfiles";
}
}
self.arguments.push(argument.into());
}
}
fn expand_response_files(arguments: &[OsString]) -> Result<ExpandedArguments, BypassReason> {
let mut expanded = ResponseExpander::default();
for (index, argument) in arguments.iter().enumerate() {
let argument = argument
.to_str()
.ok_or(BypassReason::NonUtf8Argument { index })?;
let Some(argfile) = argument.strip_prefix('@') else {
expanded.push(argument.to_string());
continue;
};
let (path, shell) = match argfile.split_once(':') {
Some(("shell", path)) if expanded.shell_argfiles => (path, true),
_ => (argfile, false),
};
let contents = std::fs::read_to_string(path).map_err(|error| {
BypassReason::ResponseFile(format!("{}: {error}", Path::new(path).display()))
})?;
if shell {
let arguments = shlex::split(&contents).ok_or_else(|| {
BypassReason::ResponseFile(format!(
"invalid shell-style arguments in {}",
Path::new(path).display()
))
})?;
for argument in arguments {
expanded.push(argument);
}
} else {
for argument in contents.lines() {
expanded.push(argument.to_string());
}
}
}
Ok(ExpandedArguments {
arguments: expanded.arguments,
})
}
impl<'a> Parser<'a> {
fn new(arguments: &'a [OsString], options: ParseOptions) -> Self {
Self {
arguments,
options,
index: 0,
parsed: Vec::new(),
source: None,
crate_types: Vec::new(),
emits: Vec::new(),
required_inputs: Vec::new(),
test: false,
crate_name: None,
extra_filename: String::new(),
out_dir: None,
explicit_output: None,
target: None,
}
}
fn parse(mut self) -> Result<RustcInvocation, BypassReason> {
while self.index < self.arguments.len() {
let value = self.current()?.to_string();
self.index += 1;
if let Some(long) = value.strip_prefix("--") {
self.parse_long(long)?;
} else if value.starts_with('-') && value != "-" {
self.parse_short(&value)?;
} else {
self.parse_input(&value)?;
}
}
let source = self.source.clone().ok_or(BypassReason::MissingInput)?;
let link_output = self.classify()?;
let crate_name = self.crate_name.clone().map_or_else(
|| {
source
.file_stem()
.and_then(|name| name.to_str())
.map(|name| name.replace('-', "_"))
.ok_or_else(|| BypassReason::NonUtf8Path(source.clone()))
},
Ok,
)?;
self.required_inputs.push(source.clone());
Ok(RustcInvocation {
arguments: self.parsed,
source,
required_inputs: self.required_inputs,
crate_name,
extra_filename: self.extra_filename,
out_dir: self.out_dir,
explicit_output: self.explicit_output,
emits: self.emits,
target: self.target,
link_output,
})
}
fn current(&self) -> Result<&str, BypassReason> {
self.arguments[self.index]
.to_str()
.ok_or(BypassReason::NonUtf8Argument { index: self.index })
}
fn take_value(&mut self, flag: &str, inline: Option<&str>) -> Result<String, BypassReason> {
if let Some(value) = inline {
if value.is_empty() {
return Err(BypassReason::MissingValue(flag.into()));
}
return Ok(value.into());
}
if self.index >= self.arguments.len() {
return Err(BypassReason::MissingValue(flag.into()));
}
let value = self.current()?.to_string();
self.index += 1;
Ok(value)
}
fn parse_long(&mut self, value: &str) -> Result<(), BypassReason> {
let (flag, inline) = value
.split_once('=')
.map_or((value, None), |(flag, value)| (flag, Some(value)));
let rendered_flag = format!("--{flag}");
match flag {
"help" | "version" | "explain" | "print" => Err(BypassReason::CompilerQuery),
"test" => {
self.test = true;
self.parsed.push(Argument::Plain(rendered_flag));
Ok(())
}
"verbose" => {
self.parsed.push(Argument::Plain(rendered_flag));
Ok(())
}
"crate-name" => {
let value = self.take_value(&rendered_flag, inline)?;
self.crate_name = Some(value.clone());
self.parsed
.push(Argument::Plain(format!("{rendered_flag}={value}")));
Ok(())
}
"cfg" | "check-cfg" | "edition" | "error-format" | "json" | "color"
| "diagnostic-width" | "remap-path-scope" | "allow" | "warn" | "force-warn"
| "deny" | "forbid" | "cap-lints" => {
let value = self.take_value(&rendered_flag, inline)?;
self.parsed
.push(Argument::Plain(format!("{rendered_flag}={value}")));
Ok(())
}
"target" => {
let value = self.take_value(&rendered_flag, inline)?;
self.target = Some(value.clone());
if value.ends_with(".json") || value.contains(['/', '\\']) {
let path = PathBuf::from(value);
self.required_inputs.push(path.clone());
self.parsed.push(Argument::Path {
flag: rendered_flag,
path,
});
} else {
self.target = Some(value.clone());
self.parsed
.push(Argument::Plain(format!("{rendered_flag}={value}")));
}
Ok(())
}
"crate-type" => {
let value = self.take_value(&rendered_flag, inline)?;
self.crate_types
.extend(value.split(',').map(ToOwned::to_owned));
self.parsed
.push(Argument::Plain(format!("{rendered_flag}={value}")));
Ok(())
}
"emit" => {
let value = self.take_value(&rendered_flag, inline)?;
let emits = parse_emits(&value);
self.emits.extend(emits.clone());
self.parsed.push(Argument::Emit(emits));
Ok(())
}
"out-dir" => {
let path = PathBuf::from(self.take_value(&rendered_flag, inline)?);
self.out_dir = Some(path.clone());
self.parsed.push(Argument::Path {
flag: rendered_flag,
path,
});
Ok(())
}
"sysroot" => {
let path = self.take_value(&rendered_flag, inline)?;
self.parsed.push(Argument::Path {
flag: rendered_flag,
path: path.into(),
});
Ok(())
}
"extern" => {
let value = self.take_value(&rendered_flag, inline)?;
let (name, path) = value
.split_once('=')
.map_or((value.as_str(), None), |(name, path)| {
(name, Some(PathBuf::from(path)))
});
if let Some(path) = &path {
self.required_inputs.push(path.clone());
}
self.parsed.push(Argument::Extern {
name: name.into(),
path,
});
Ok(())
}
"remap-path-prefix" => {
let value = self.take_value(&rendered_flag, inline)?;
let Some((from, to)) = value.split_once('=') else {
return Err(BypassReason::MissingValue(rendered_flag));
};
self.parsed.push(Argument::RemapPath {
from: from.into(),
to: to.into(),
});
Ok(())
}
"codegen" => {
let value = self.take_value(&rendered_flag, inline)?;
self.parse_codegen(&value)
}
_ => Err(BypassReason::UnknownFlag(rendered_flag)),
}
}
fn parse_short(&mut self, value: &str) -> Result<(), BypassReason> {
if let Some(attached) = value.strip_prefix("-Z") {
let option = self.take_value("-Z", (!attached.is_empty()).then_some(attached))?;
if option == "shell-argfiles" {
self.parsed.push(Argument::Plain("-Zshell-argfiles".into()));
return Ok(());
}
return Err(BypassReason::UnknownFlag(format!("-Z{option}")));
}
match value {
"-h" | "-V" | "-vV" => return Err(BypassReason::CompilerQuery),
"-g" | "-O" | "-v" => {
self.parsed.push(Argument::Plain(value.into()));
return Ok(());
}
_ => {}
}
for (short, long) in [
("-A", "--allow"),
("-W", "--warn"),
("-D", "--deny"),
("-F", "--forbid"),
] {
if let Some(attached) = value.strip_prefix(short) {
let lint = self.take_value(short, (!attached.is_empty()).then_some(attached))?;
self.parsed.push(Argument::Plain(format!("{long}={lint}")));
return Ok(());
}
}
if let Some(attached) = value.strip_prefix("-C") {
let option = self.take_value("-C", (!attached.is_empty()).then_some(attached))?;
return self.parse_codegen(&option);
}
if let Some(attached) = value.strip_prefix("-L") {
let search = self.take_value("-L", (!attached.is_empty()).then_some(attached))?;
let (kind, path) = search
.split_once('=')
.map_or(("all", search.as_str()), |(kind, path)| (kind, path));
if !matches!(kind, "dependency" | "native") {
return Err(BypassReason::UnsupportedSearchPath(kind.into()));
}
self.parsed.push(Argument::SearchPath {
kind: kind.into(),
path: path.into(),
});
return Ok(());
}
if value == "-l" || value.starts_with("-l") {
return Err(BypassReason::NativeLibrary);
}
if let Some(attached) = value.strip_prefix("-o") {
let path = self.take_value("-o", (!attached.is_empty()).then_some(attached))?;
self.explicit_output = Some(path.clone().into());
self.parsed.push(Argument::Path {
flag: "-o".into(),
path: path.into(),
});
return Ok(());
}
Err(BypassReason::UnknownFlag(value.into()))
}
fn parse_codegen(&mut self, value: &str) -> Result<(), BypassReason> {
let name = value.split_once('=').map_or(value, |(name, _)| name);
if name == "incremental" {
return Err(BypassReason::Incremental);
}
if SUPPORTED_CODEGEN_OPTIONS.binary_search(&name).is_err() {
return Err(BypassReason::UnknownCodegenOption(name.into()));
}
self.parsed
.push(Argument::Plain(format!("--codegen={value}")));
if name == "extra-filename" {
self.extra_filename = value
.split_once('=')
.map_or(String::new(), |(_, value)| value.to_string());
}
Ok(())
}
fn parse_input(&mut self, value: &str) -> Result<(), BypassReason> {
if value == "-" {
return Err(BypassReason::StandardInput);
}
if self.source.replace(value.into()).is_some() {
return Err(BypassReason::MultipleInputs);
}
Ok(())
}
fn classify(&self) -> Result<LinkOutput, BypassReason> {
let link_output = if !self.test
&& !self.crate_types.is_empty()
&& self
.crate_types
.iter()
.all(|crate_type| matches!(crate_type.as_str(), "lib" | "rlib"))
{
LinkOutput::Library
} else if self
.target
.as_deref()
.is_some_and(compiler_bundled_wasm_target)
&& ((self.test && self.crate_types.is_empty())
|| matches!(self.crate_types.as_slice(), [kind] if kind == "bin" || kind == "cdylib"))
{
if self.parsed.iter().any(|argument| match argument {
Argument::Plain(value) if value == "--codegen=link-self-contained" => false,
Argument::Plain(value) if value.starts_with("--codegen=link-self-contained=") => {
!matches!(
value.rsplit_once('=').map(|(_, value)| value),
Some("y" | "yes" | "on" | "true")
)
}
_ => false,
}) {
return Err(BypassReason::UnknownCodegenOption(
"link-self-contained".into(),
));
}
if self.target.as_deref().is_some_and(|target| target.contains("wasi"))
&& self.parsed.iter().any(|argument| {
matches!(argument, Argument::Plain(value) if value.strip_prefix("--codegen=target-feature=").is_some_and(|features| features.split(',').any(|feature| feature == "-crt-static")))
})
{
return Err(BypassReason::UnknownCodegenOption(
"target-feature=-crt-static".into(),
));
}
LinkOutput::WasmExecutable
} else if self.options.cache_native_links && self.links_a_native_program() {
self.check_native_link_is_portable()?;
LinkOutput::NativeExecutable
} else if self.test {
return Err(BypassReason::UnsupportedCrateType("test".into()));
} else {
return Err(BypassReason::UnsupportedCrateType(
self.crate_types
.iter()
.find(|crate_type| !matches!(crate_type.as_str(), "lib" | "rlib"))
.cloned()
.unwrap_or_else(|| "bin".into()),
));
};
if let Some(name) = self.parsed.iter().find_map(|argument| match argument {
Argument::Extern { name, path: None } if name != "proc_macro" => Some(name),
_ => None,
}) {
return Err(BypassReason::UnresolvedExtern(name.clone()));
}
if let Some(emit) = self
.emits
.iter()
.find(|emit| !matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata"))
{
return Err(BypassReason::UnsupportedEmit(emit.kind.clone()));
}
if !self
.emits
.iter()
.any(|emit| matches!(emit.kind.as_str(), "link" | "metadata"))
{
return Err(BypassReason::NoCacheableOutput);
}
Ok(link_output)
}
}
impl Parser<'_> {
fn links_a_native_program(&self) -> bool {
self.target.is_none()
&& self.emits.iter().any(|emit| emit.kind == "link")
&& ((self.test && self.crate_types.is_empty())
|| matches!(self.crate_types.as_slice(), [kind] if kind == "bin"))
}
fn check_native_link_is_portable(&self) -> Result<(), BypassReason> {
for argument in &self.parsed {
let Argument::Plain(value) = argument else {
continue;
};
let (name, value) = if value == "-g" {
("debuginfo", Some("2"))
} else if let Some(option) = value.strip_prefix("--codegen=") {
match option.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (option, None),
}
} else {
continue;
};
let unportable = match name {
"split-debuginfo" => value != Some("off"),
"debuginfo" if cfg!(target_os = "macos") => !matches!(value, Some("0" | "none")),
"rpath" | "prefer-dynamic" => is_enabled(value),
"link-self-contained" => true,
_ => false,
};
if unportable {
return Err(BypassReason::UnportableNativeLink(match value {
Some(value) => format!("{name}={value}"),
None => name.to_owned(),
}));
}
}
Ok(())
}
}
fn is_enabled(value: Option<&str>) -> bool {
matches!(value, None | Some("y" | "yes" | "on" | "true"))
}
fn compiler_bundled_wasm_target(target: &str) -> bool {
COMPILER_BUNDLED_WASM_TARGETS.binary_search(&target).is_ok()
}
fn parse_emits(value: &str) -> Vec<Emit> {
value
.split(',')
.map(|emit| {
let (kind, path) = emit
.split_once('=')
.map_or((emit, None), |(kind, path)| (kind, Some(path.into())));
Emit {
kind: kind.into(),
path,
}
})
.collect()
}
struct ActionBuilder<'a> {
invocation: &'a RustcInvocation,
context: ActionContext,
mappings: Vec<PathMapping>,
linker: Option<LinkerIdentity>,
}
impl<'a> ActionBuilder<'a> {
fn new(invocation: &'a RustcInvocation, mut context: ActionContext) -> Self {
context.path_mappings = PathMapping::ordered(&context.path_mappings);
let mappings = context
.path_mappings
.iter()
.map(|mapping| PathMapping {
root: resolve_mapping_root(&mapping.root),
placeholder: mapping.placeholder.clone(),
})
.collect();
Self {
linker: None,
invocation,
mappings,
context,
}
}
fn linked_by(mut self, linker: Option<LinkerIdentity>) -> Self {
self.linker = linker;
self
}
fn build(self) -> Result<RustcAction, BypassReason> {
self.validate_mappings()?;
let invocation = self.invocation_descriptor()?;
let environment = self.environment_descriptor()?;
let mut inputs = BTreeMap::<String, CacheDigest>::new();
for input in &self.context.inputs {
input
.digest
.validate()
.map_err(|_| BypassReason::InvalidInputDigest(input.path.display().to_string()))?;
let path = self.normalize_path(&input.path)?;
if inputs
.insert(path.clone(), input.digest.clone())
.is_some_and(|existing| existing != input.digest)
{
return Err(BypassReason::ConflictingInput(path));
}
}
let required = self
.invocation
.required_inputs
.iter()
.map(|path| self.normalize_path(path))
.collect::<Result<BTreeSet<_>, _>>()?;
if let Some(missing) = required.iter().find(|path| !inputs.contains_key(*path)) {
return Err(BypassReason::MissingRequiredInput(missing.clone()));
}
let inputs = inputs
.into_iter()
.map(|(path, digest)| InputDescriptor { path, digest })
.collect();
if self.invocation.links_natively() && self.linker.is_none() {
return Err(BypassReason::UnportableNativeLink(
"linker identity is unknown".into(),
));
}
let descriptor = ActionDescriptor {
version: ACTION_SCHEMA_VERSION,
kind: "rustc",
adapter_version: ADAPTER_VERSION,
compiler: invocation.compiler,
arguments: invocation.arguments,
environment,
inputs,
linker: self.linker.clone(),
};
let bytes = canonical_json(&descriptor)
.map_err(|error| BypassReason::Serialization(error.to_string()))?;
let digest = CacheDigest::blake3(&bytes);
Ok(RustcAction { digest, bytes })
}
fn invocation_descriptor(&self) -> Result<InvocationDescriptor, BypassReason> {
self.validate_mappings()?;
let arguments = self
.invocation
.arguments
.iter()
.map(|argument| self.normalize_argument(argument))
.collect::<Result<Vec<_>, _>>()?;
let required_inputs = self
.invocation
.required_inputs
.iter()
.map(|path| self.normalize_path(path))
.collect::<Result<BTreeSet<_>, _>>()?
.into_iter()
.collect();
Ok(InvocationDescriptor {
version: ACTION_SCHEMA_VERSION,
kind: "rustc",
adapter_version: ADAPTER_VERSION,
compiler: CompilerDescriptor {
toolchain: self.context.compiler.toolchain.clone(),
rustc_version: self.context.compiler.rustc_version.clone(),
host: self.context.compiler.host.clone(),
},
arguments,
required_inputs,
})
}
fn validate_mappings(&self) -> Result<(), BypassReason> {
if !self.context.working_dir.is_absolute() {
return Err(BypassReason::RelativeWorkingDirectory(
self.context.working_dir.clone(),
));
}
let mut roots = BTreeSet::new();
let mut placeholders = BTreeSet::new();
for mapping in &self.mappings {
if !mapping.root.is_absolute() {
return Err(BypassReason::RelativePathMapping(mapping.root.clone()));
}
if mapping.placeholder.is_empty()
|| !mapping
.placeholder
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|| !roots.insert(normalize_components(&mapping.root))
|| !placeholders.insert(&mapping.placeholder)
{
return Err(BypassReason::InvalidPathPlaceholder(
mapping.placeholder.clone(),
));
}
}
Ok(())
}
fn normalize_argument(&self, argument: &Argument) -> Result<String, BypassReason> {
match argument {
Argument::Plain(value) => Ok(value.clone()),
Argument::Path { flag, path } => Ok(format!("{flag}={}", self.normalize_path(path)?)),
Argument::SearchPath { kind, path } => {
let text = match self.normalize_path(path) {
Ok(text) => text,
Err(BypassReason::UnmappedAbsolutePath(absolute))
if kind == "native" && self.invocation.native_search_is_inert() =>
{
absolute
.to_str()
.ok_or(BypassReason::NonUtf8Path(absolute.clone()))?
.to_string()
}
Err(error) => return Err(error),
};
Ok(format!("-L{kind}={text}"))
}
Argument::Extern { name, path } => match path {
Some(path) => Ok(format!("--extern={name}={}", self.normalize_path(path)?)),
None => Ok(format!("--extern={name}")),
},
Argument::Emit(emits) => Ok(format!(
"--emit={}",
emits
.iter()
.map(|emit| match &emit.path {
Some(path) => self
.normalize_path(path)
.map(|path| format!("{}={path}", emit.kind)),
None => Ok(emit.kind.clone()),
})
.collect::<Result<Vec<_>, _>>()?
.join(",")
)),
Argument::RemapPath { from, to } => Ok(format!(
"--remap-path-prefix={}={}",
self.normalize_path(from)?,
to
)),
}
}
fn environment_descriptor(&self) -> Result<BTreeMap<String, Option<String>>, BypassReason> {
self.context
.environment
.iter()
.map(|(name, value)| {
let value = match value {
Some(value) if self.context.portable_environment.contains(name) => {
Some(self.normalize_path(Path::new(value))?)
}
value => value.clone(),
};
Ok((name.clone(), value))
})
.collect()
}
fn normalize_path(&self, path: &Path) -> Result<String, BypassReason> {
normalize_resolved_mapped_path(path, &self.context.working_dir, &self.mappings)
}
}
fn denormalize_path(value: &str, mappings: &[PathMapping]) -> Result<PathBuf, BypassReason> {
for mapping in mappings {
let prefix = format!("${{{}}}", mapping.placeholder);
let suffix = if value == prefix {
""
} else if let Some(suffix) = value.strip_prefix(&format!("{prefix}/")) {
suffix
} else {
continue;
};
if !mapping.root.is_absolute()
|| (!suffix.is_empty()
&& suffix.split('/').any(|component| {
component.is_empty()
|| matches!(component, "." | "..")
|| component.contains('\\')
}))
{
return Err(BypassReason::InvalidPredictedInput(value.into()));
}
let mut path = normalize_components(&mapping.root);
path.extend(suffix.split('/').filter(|component| !component.is_empty()));
return Ok(path);
}
Err(BypassReason::InvalidPredictedInput(value.into()))
}
fn normalize_components(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
component => normalized.push(component.as_os_str()),
}
}
normalized
}
fn absolute_path(path: &Path, working_dir: &Path) -> PathBuf {
if path.is_absolute() {
normalize_components(path)
} else {
normalize_components(&working_dir.join(path))
}
}
fn slash_path(path: &Path) -> Result<String, BypassReason> {
path.components()
.filter_map(|component| match component {
Component::Normal(value) => Some(
value
.to_str()
.map(ToOwned::to_owned)
.ok_or_else(|| BypassReason::NonUtf8Path(path.to_path_buf())),
),
_ => None,
})
.collect::<Result<Vec<_>, _>>()
.map(|components| components.join("/"))
}
#[cfg(test)]
#[path = "rustc_cache_tests.rs"]
mod tests;