use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::compiler::native_input_protocol::{
NativeMacroObservation, NativeMacroPathAccess, NativeMacroSpawn, NativeMacroUnobservable as Unobservable,
};
use crate::compiler::observation::{EnvironmentObservation, is_secret_name};
use crate::error::{RailError, RailResult};
use crate::source::ContentDigest;
use super::cargo_inputs::{CargoInputs, CargoQuery};
use super::{NATIVE_CAPTURE_LIMITS, NativeCaptureBudget, capture_guarded_file, native_relative_path, semantic_mode};
pub(super) const MAX_MACRO_PATH_INPUTS: usize = 4096;
const STATELESS_DEVICES: [&str; 4] = ["/dev/null", "/dev/random", "/dev/urandom", "/dev/zero"];
const MAX_LISTING_ENTRIES: usize = 16_384;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct MacroPathSelector {
pub(crate) path: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(crate) listing: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) cargo_query: Option<CargoQueryRoot>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(crate) above_root: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CargoQueryRoot {
Repository,
CargoHome,
}
impl MacroPathSelector {
fn validate(&self) -> RailResult<()> {
if self.cargo_query.is_some() && self.listing
|| self.above_root && (self.listing || self.cargo_query.is_some() || !is_single_name(&self.path))
|| self.path.len() > super::MAX_DYNAMIC_REPOSITORY_PATH_BYTES
|| !self.path.is_empty() && native_relative_path(Path::new(&self.path)).ok().as_deref() != Some(&self.path)
{
return Err(RailError::message(
"procedural-macro input path is not a normalized repository path",
));
}
Ok(())
}
}
fn is_single_name(name: &str) -> bool {
!name.contains(['/', '\\'])
&& matches!(
Path::new(name).components().collect::<Vec<_>>().as_slice(),
[Component::Normal(_)]
)
}
pub(crate) fn validate_selectors(selectors: &[MacroPathSelector]) -> RailResult<()> {
if selectors.len() > MAX_MACRO_PATH_INPUTS || selectors.windows(2).any(|pair| pair[0] >= pair[1]) {
return Err(RailError::message(
"procedural-macro inputs are not bounded, sorted, and unique",
));
}
selectors.iter().try_for_each(MacroPathSelector::validate)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ListedEntryKind {
File,
Directory,
Symlink,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum MacroPathState {
Absent,
File {
content_digest: String,
bytes: u64,
mode: u32,
},
Directory,
Listing {
listing_digest: String,
entries: u64,
},
CargoQuery {
inputs_digest: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct MacroPathInput {
path: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
listing: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
cargo_query: Option<CargoQueryRoot>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
above_root: bool,
state: MacroPathState,
}
impl MacroPathInput {
pub(super) fn selector(&self) -> MacroPathSelector {
MacroPathSelector {
path: self.path.clone(),
listing: self.listing,
cargo_query: self.cargo_query,
above_root: self.above_root,
}
}
}
const OUTSIDE: &str = "procedural_macro_read_outside_repository";
const SYMLINK: &str = "procedural_macro_read_through_symlink";
const PROCESS: &str = "procedural_macro_process_unmodeled";
const MAX_DETAIL_SUBJECTS: usize = 4;
#[derive(Debug, PartialEq, Eq)]
pub(super) struct MacroBypass {
pub(super) reason: &'static str,
pub(super) detail: String,
}
pub(super) fn select(
observation: &NativeMacroObservation,
workspace_root: &Path,
source_root_spelling: &Path,
captured: &[&Path],
) -> Result<Vec<MacroPathSelector>, MacroBypass> {
let selected = path_selectors(observation, workspace_root, source_root_spelling, captured);
let Some(reason) = bypass_reason(observation) else {
return selected;
};
let mut detail = bypass_detail(observation);
if let Err(paths) = selected {
detail = format!("{detail}; {}", paths.detail);
}
Err(MacroBypass { reason, detail })
}
fn bypass_reason(observation: &NativeMacroObservation) -> Option<&'static str> {
if let Some(reason) = observation.unobservable.first() {
return Some(match reason {
Unobservable::ObservationUnavailable => "procedural_macro_observation_unavailable",
Unobservable::ImportUnclassified => "procedural_macro_import_unclassified",
Unobservable::DynamicDependency => "procedural_macro_dynamic_dependency",
Unobservable::Initializer => "procedural_macro_initializer",
Unobservable::RawSystemCall => "procedural_macro_raw_system_call",
Unobservable::ProcessControl => "procedural_macro_process_control",
Unobservable::Network => "procedural_macro_network",
Unobservable::FileWrite => "procedural_macro_file_write",
Unobservable::EnvironmentWrite => "procedural_macro_environment_write",
Unobservable::EnvironmentEnumeration => "procedural_macro_environment_enumeration",
Unobservable::DynamicLoad => "procedural_macro_dynamic_load",
Unobservable::ExecutableMemory => "procedural_macro_executable_memory",
Unobservable::WorkingDirectory => "procedural_macro_working_directory",
Unobservable::HostState => "procedural_macro_host_state",
Unobservable::PathUnavailable => "procedural_macro_path_unavailable",
Unobservable::ObservationLimit => "procedural_macro_observation_limit",
});
}
if observation.environment.iter().any(|name| is_secret_name(name)) {
return Some("procedural_macro_secret_environment");
}
None
}
fn bypass_detail(observation: &NativeMacroObservation) -> String {
let mut sections = Vec::new();
if !observation.unobservable_imports.is_empty() {
sections.push(format!(
"procedural-macro imports without a classification: {}",
observation.unobservable_imports.join(", ")
));
}
if !observation.unobservable.is_empty() {
let effects = observation.unobservable.iter().map(|effect| {
serde_json::to_value(effect)
.ok()
.and_then(|name| name.as_str().map(str::to_owned))
.unwrap_or_else(|| format!("{effect:?}"))
});
sections.push(format!(
"procedural-macro effects: {}",
effects.collect::<Vec<_>>().join(", ")
));
}
let secret = observation
.environment
.iter()
.filter(|name| is_secret_name(name))
.map(String::as_str)
.collect::<Vec<_>>();
if !secret.is_empty() {
sections.push(format!(
"procedural macro read secret-named variables: {}",
secret.join(", ")
));
}
sections.join("; ")
}
pub(super) fn environment_reads(observation: &NativeMacroObservation) -> impl Iterator<Item = EnvironmentObservation> {
observation.environment.iter().map(|name| EnvironmentObservation {
name: name.clone(),
value_digest: std::env::var_os(name)
.map(|value| format!("sha256:{}", ContentDigest::sha256(value.as_encoded_bytes()))),
secret_capability: is_secret_name(name),
})
}
pub(super) fn path_selectors(
observation: &NativeMacroObservation,
workspace_root: &Path,
source_root_spelling: &Path,
captured: &[&Path],
) -> Result<Vec<MacroPathSelector>, MacroBypass> {
let canonical_root = crate::utils::canonicalize_existing(workspace_root).map_err(|_| MacroBypass {
reason: OUTSIDE,
detail: format!("the workspace root '{}' cannot be resolved", workspace_root.display()),
})?;
let mut above = None;
let mut selectors = BTreeSet::new();
let mut blocked = Blocked::default();
for spawn in &observation.spawns {
match cargo_query_selector(spawn, &canonical_root) {
Some(selector) => {
selectors.insert(selector);
}
None => blocked.add(PROCESS, process_name(spawn)),
}
}
for read in &observation.paths {
let spelled = Path::new(&read.path);
if STATELESS_DEVICES.contains(&read.path.as_str()) {
continue;
}
let resolved = resolved_location(spelled);
if resolved
.as_deref()
.is_some_and(|resolved| captured.iter().any(|root| resolved.starts_with(root)))
{
continue;
}
let Some(remainder) = [canonical_root.as_path(), workspace_root, source_root_spelling]
.into_iter()
.find_map(|root| spelled.strip_prefix(root).ok())
else {
let above =
above.get_or_insert_with(|| directories_above(&canonical_root, [workspace_root, source_root_spelling]));
match resolved
.as_deref()
.map(|resolved| above_root_read(resolved, read.access, above))
{
Some(AboveRoot::Directory) => {}
Some(AboveRoot::Absent(selector)) => {
selectors.insert(selector);
}
Some(AboveRoot::Unbound) | None => blocked.add(OUTSIDE, read.path.clone()),
}
continue;
};
let relative = match normalize_within(&canonical_root, remainder) {
Ok(relative) => relative,
Err(PathError::Outside) => {
blocked.add(OUTSIDE, read.path.clone());
continue;
}
Err(PathError::Symlink) => {
blocked.add(SYMLINK, read.path.clone());
continue;
}
};
let absolute = canonical_root.join(&relative);
if captured.iter().any(|root| absolute.starts_with(root)) {
continue;
}
selectors.insert(MacroPathSelector {
path: relative,
listing: read.access == NativeMacroPathAccess::Listing,
cargo_query: None,
above_root: false,
});
}
if let Some(bypass) = blocked.bypass() {
return Err(bypass);
}
if selectors.len() > MAX_MACRO_PATH_INPUTS {
return Err(MacroBypass {
reason: "procedural_macro_observation_limit",
detail: format!("procedural macro read more than {MAX_MACRO_PATH_INPUTS} paths"),
});
}
Ok(selectors.into_iter().collect())
}
#[derive(Default)]
struct Blocked {
sections: Vec<(&'static str, Vec<String>)>,
}
impl Blocked {
fn add(&mut self, reason: &'static str, subject: String) {
match self.sections.iter_mut().find(|(known, _)| *known == reason) {
Some((_, subjects)) => subjects.push(subject),
None => self.sections.push((reason, vec![subject])),
}
}
fn bypass(self) -> Option<MacroBypass> {
let reason = self.sections.first()?.0;
let detail = self
.sections
.iter()
.map(|(reason, subjects)| {
let label = match *reason {
PROCESS => "procedural-macro processes without a model",
SYMLINK => "procedural-macro reads through a symbolic link",
_ => "procedural-macro reads outside the repository",
};
let shown = subjects.iter().take(MAX_DETAIL_SUBJECTS).map(String::as_str);
let more = subjects.len().saturating_sub(MAX_DETAIL_SUBJECTS);
let more = if more == 0 {
String::new()
} else {
format!(", and {more} more")
};
format!(
"{label} ({}): {}{more}",
subjects.len(),
shown.collect::<Vec<_>>().join(", ")
)
})
.collect::<Vec<_>>()
.join("; ");
Some(MacroBypass { reason, detail })
}
}
fn process_name(spawn: &NativeMacroSpawn) -> String {
let program = Path::new(&spawn.program)
.file_name()
.map_or_else(|| spawn.program.clone(), |name| name.to_string_lossy().into_owned());
match spawn.arguments.get(1) {
Some(subcommand) if !subcommand.starts_with('-') => format!("{program} {subcommand}"),
_ => program,
}
}
fn directories_above<'a>(canonical_root: &Path, spellings: impl IntoIterator<Item = &'a Path>) -> BTreeSet<PathBuf> {
let mut directories = canonical_root
.ancestors()
.skip(1)
.map(Path::to_path_buf)
.collect::<BTreeSet<_>>();
for spelling in spellings {
for directory in spelling.ancestors().skip(1) {
if let Ok(directory) = crate::utils::canonicalize_existing(directory)
&& !directory.starts_with(canonical_root)
{
directories.insert(directory);
}
}
}
directories
}
enum AboveRoot {
Directory,
Absent(MacroPathSelector),
Unbound,
}
fn above_root_read(resolved: &Path, access: NativeMacroPathAccess, above: &BTreeSet<PathBuf>) -> AboveRoot {
if above.contains(resolved) {
return if access == NativeMacroPathAccess::Entry {
AboveRoot::Directory
} else {
AboveRoot::Unbound
};
}
let (Some(parent), Some(name)) = (resolved.parent(), resolved.file_name().and_then(OsStr::to_str)) else {
return AboveRoot::Unbound;
};
if access == NativeMacroPathAccess::Listing || !above.contains(parent) || !is_single_name(name) {
return AboveRoot::Unbound;
}
match fs::symlink_metadata(resolved) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => AboveRoot::Absent(MacroPathSelector {
path: name.to_string(),
listing: false,
cargo_query: None,
above_root: true,
}),
Ok(_) | Err(_) => AboveRoot::Unbound,
}
}
enum PathError {
Outside,
Symlink,
}
fn resolved_location(path: &Path) -> Option<PathBuf> {
let mut missing = Vec::new();
let mut current = path;
loop {
if let Ok(canonical) = crate::utils::canonicalize_existing(current) {
return Some(
missing
.iter()
.rev()
.fold(canonical, |resolved, name| resolved.join(name)),
);
}
let name = current.file_name()?;
if name == OsStr::new("..") || name == OsStr::new(".") {
return None;
}
missing.push(name.to_owned());
current = current.parent()?;
}
}
fn cargo_query_selector(spawn: &NativeMacroSpawn, workspace_root: &Path) -> Option<MacroPathSelector> {
let cargo = std::env::var_os("CARGO")?;
if Path::new(&spawn.program) != Path::new(&cargo) || !Path::new(&cargo).is_absolute() {
return None;
}
let mut arguments = spawn.arguments.iter().skip(1).map(String::as_str);
if arguments.next()? != "locate-project" {
return None;
}
let current_directory = std::env::current_dir().ok()?;
let mut manifest = None;
while let Some(argument) = arguments.next() {
match argument {
"--workspace" | "--offline" | "--frozen" | "--locked" | "--quiet" | "-q" => {}
"--message-format=plain" | "--message-format=json" => {}
"--message-format" => {
if !matches!(arguments.next()?, "plain" | "json") {
return None;
}
}
"--manifest-path" => manifest = Some(arguments.next()?),
_ => manifest = Some(argument.strip_prefix("--manifest-path=")?),
}
}
let directory = match manifest {
Some(manifest) => {
let manifest = current_directory.join(manifest);
if manifest.file_name() != Some(OsStr::new("Cargo.toml")) {
return None;
}
manifest.parent()?.to_path_buf()
}
None => current_directory.clone(),
};
let directory = crate::utils::canonicalize_existing(&directory).ok()?;
let cargo_home = crate::cargo::CargoConfigSnapshot::cargo_home(¤t_directory)
.ok()
.and_then(|home| crate::utils::canonicalize_existing(&home).ok());
let (root, relative) = if let Ok(relative) = directory.strip_prefix(workspace_root) {
(CargoQueryRoot::Repository, relative)
} else {
(
CargoQueryRoot::CargoHome,
directory.strip_prefix(cargo_home.as_deref()?).ok()?,
)
};
Some(MacroPathSelector {
path: native_relative_path(relative).ok()?,
listing: false,
cargo_query: Some(root),
above_root: false,
})
}
fn capture_cargo_query(
workspace_root: &Path,
root: CargoQueryRoot,
relative: &Path,
budget: &mut NativeCaptureBudget,
) -> RailResult<MacroPathState> {
cargo_query_state(&cargo_query_inputs(workspace_root, root, relative, budget)?)
}
fn cargo_query_state(inputs: &CargoInputs) -> RailResult<MacroPathState> {
Ok(MacroPathState::CargoQuery {
inputs_digest: format!("sha256:{}", ContentDigest::sha256(&serde_json::to_vec(inputs)?)),
})
}
fn cargo_query_inputs(
workspace_root: &Path,
root: CargoQueryRoot,
relative: &Path,
budget: &mut NativeCaptureBudget,
) -> RailResult<CargoInputs> {
let current_directory = std::env::current_dir()?;
let base = match root {
CargoQueryRoot::Repository => workspace_root.to_path_buf(),
CargoQueryRoot::CargoHome => {
crate::utils::canonicalize_existing(&crate::cargo::CargoConfigSnapshot::cargo_home(¤t_directory)?)?
}
};
let cargo = std::env::var_os("CARGO")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.ok_or_else(|| RailError::message("the compiler's cargo executable is not an absolute CARGO path"))?;
CargoInputs::capture(
&base.join(relative),
¤t_directory,
&cargo,
CargoQuery::LocateProject,
None,
budget,
)
}
fn normalize_within(root: &Path, remainder: &Path) -> Result<String, PathError> {
let mut components = Vec::<&OsStr>::new();
for component in remainder.components() {
match component {
Component::CurDir => {}
Component::Normal(name) => components.push(name),
Component::ParentDir => {
let current = components.iter().fold(root.to_path_buf(), |path, name| path.join(name));
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.is_dir() && !crate::utils::is_symlink_or_reparse(&metadata) => {}
Ok(_) => return Err(PathError::Symlink),
Err(_) => return Err(PathError::Symlink),
}
components.pop().ok_or(PathError::Outside)?;
}
Component::RootDir | Component::Prefix(_) => return Err(PathError::Outside),
}
}
let relative = components.iter().collect::<PathBuf>();
if relative.as_os_str().is_empty() {
return Ok(String::new());
}
reject_symlink_components(root, &relative).map_err(|_| PathError::Symlink)?;
native_relative_path(&relative).map_err(|_| PathError::Outside)
}
fn reject_symlink_components(root: &Path, relative: &Path) -> RailResult<()> {
let mut current = root.to_path_buf();
let count = relative.components().count();
for (index, component) in relative.components().enumerate() {
current.push(component);
match fs::symlink_metadata(¤t) {
Ok(metadata) if crate::utils::is_symlink_or_reparse(&metadata) => {
return Err(RailError::message("procedural-macro input crosses a symbolic link"));
}
Ok(metadata) if index + 1 < count && !metadata.is_dir() => {
return Ok(());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
}
}
Ok(())
}
pub(super) fn capture(
workspace_root: &Path,
source_root_spelling: &Path,
selectors: &[MacroPathSelector],
started: Instant,
budget: &mut NativeCaptureBudget,
) -> RailResult<Vec<MacroPathInput>> {
validate_selectors(selectors)?;
let canonical_root = crate::utils::canonicalize_existing(workspace_root)?;
let above = selectors
.iter()
.any(|selector| selector.above_root)
.then(|| directories_above(&canonical_root, [workspace_root, source_root_spelling]));
selectors
.iter()
.map(|selector| {
budget.account_entry(&selector.path)?;
let state = match &above {
Some(above) if selector.above_root => capture_above_root(above, &selector.path, started, budget)?,
_ => capture_state(&canonical_root, selector, started, budget)?,
};
Ok(MacroPathInput {
path: selector.path.clone(),
listing: selector.listing,
cargo_query: selector.cargo_query,
above_root: selector.above_root,
state,
})
})
.collect()
}
fn capture_above_root(
above: &BTreeSet<PathBuf>,
name: &str,
started: Instant,
budget: &mut NativeCaptureBudget,
) -> RailResult<MacroPathState> {
for directory in above {
let path = directory.join(name);
match fs::symlink_metadata(&path) {
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Ok(_) => {
return Err(RailError::message(format!(
"procedural-macro input '{}' exists above the workspace root",
path.display()
)));
}
Err(error) => return Err(error.into()),
}
budget.check(0, started.elapsed())?;
}
Ok(MacroPathState::Absent)
}
fn capture_state(
root: &Path,
selector: &MacroPathSelector,
started: Instant,
budget: &mut NativeCaptureBudget,
) -> RailResult<MacroPathState> {
let relative = Path::new(&selector.path);
if let Some(query_root) = selector.cargo_query {
return capture_cargo_query(root, query_root, relative, budget);
}
reject_symlink_components(root, relative)?;
let path = root.join(relative);
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(MacroPathState::Absent);
}
Err(error) => return Err(error.into()),
};
if metadata.is_file() {
let (content_digest, _, bytes) = capture_guarded_file(&path, started, budget)?;
return Ok(MacroPathState::File {
content_digest,
bytes,
mode: semantic_mode(&metadata),
});
}
if !metadata.is_dir() {
return Err(RailError::message(
"procedural-macro input is neither a regular file nor a directory",
));
}
if !selector.listing {
return Ok(MacroPathState::Directory);
}
let mut entries = BTreeMap::new();
for entry in fs::read_dir(&path)? {
let entry = entry?;
let name = entry
.file_name()
.into_string()
.map_err(|_| RailError::message("procedural-macro listing contains a non-UTF-8 name"))?;
let file_type = entry.file_type()?;
let kind = if file_type.is_symlink() {
ListedEntryKind::Symlink
} else if file_type.is_dir() {
ListedEntryKind::Directory
} else if file_type.is_file() {
ListedEntryKind::File
} else {
ListedEntryKind::Other
};
entries.insert(name, kind);
if entries.len() > MAX_LISTING_ENTRIES {
return Err(RailError::message("procedural-macro listing exceeds its entry bound"));
}
budget.check(0, started.elapsed())?;
}
let encoded = serde_json::to_vec(&entries)?;
Ok(MacroPathState::Listing {
listing_digest: format!("sha256:{}", ContentDigest::sha256(&encoded)),
entries: entries.len() as u64,
})
}
pub(super) fn post_execution_paths(
workspace_root: &Path,
inputs: &[MacroPathInput],
) -> RailResult<Vec<super::PostExecutionPath>> {
let canonical_root = crate::utils::canonicalize_existing(workspace_root)?;
let mut paths = Vec::new();
for input in inputs.iter().filter(|input| !input.above_root) {
let relative = Path::new(&input.path);
match (&input.state, input.cargo_query) {
(MacroPathState::CargoQuery { .. }, Some(root)) => {
let mut budget = NativeCaptureBudget::new(NATIVE_CAPTURE_LIMITS);
let query = cargo_query_inputs(&canonical_root, root, relative, &mut budget)?;
if cargo_query_state(&query)? != input.state {
return Err(RailError::message(
"a procedural macro's Cargo query inputs changed after the capture",
));
}
paths.extend(query.post_execution_paths());
}
(MacroPathState::Absent, _) => paths.push(super::PostExecutionPath::Absent(canonical_root.join(relative))),
_ => paths.push(super::PostExecutionPath::Present(canonical_root.join(relative))),
}
}
Ok(paths)
}
pub(super) fn revalidate(
workspace_root: &Path,
source_root_spelling: &Path,
inputs: &[MacroPathInput],
) -> RailResult<()> {
let selectors = inputs.iter().map(MacroPathInput::selector).collect::<Vec<_>>();
let current = capture(
workspace_root,
source_root_spelling,
&selectors,
Instant::now(),
&mut NativeCaptureBudget::new(NATIVE_CAPTURE_LIMITS),
)?;
if current != inputs {
return Err(RailError::message(
"a procedural-macro input changed before the restore commit",
));
}
Ok(())
}
pub(crate) fn validate_inputs(inputs: &[MacroPathInput]) -> RailResult<()> {
let selectors = inputs.iter().map(MacroPathInput::selector).collect::<Vec<_>>();
validate_selectors(&selectors)?;
for input in inputs {
let valid = match &input.state {
MacroPathState::Absent => true,
_ if input.above_root => false,
MacroPathState::Directory => true,
MacroPathState::File { content_digest, .. } => super::validate_sha256(content_digest).is_ok(),
MacroPathState::Listing { listing_digest, .. } => {
input.listing && super::validate_sha256(listing_digest).is_ok()
}
MacroPathState::CargoQuery { inputs_digest } => {
input.cargo_query.is_some() && super::validate_sha256(inputs_digest).is_ok()
}
};
if !valid {
return Err(RailError::message("procedural-macro input state is invalid"));
}
}
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::compiler::native_input_protocol::NativeMacroPathRead;
const PROBE: &str = ".cargo-rail-above-root-probe";
fn observation(root: &Path, reads: &[(&str, NativeMacroPathAccess)]) -> NativeMacroObservation {
let mut paths = reads
.iter()
.map(|(path, access)| NativeMacroPathRead {
path: format!("{}/{path}", root.display()),
access: *access,
})
.collect::<Vec<_>>();
paths.sort_unstable();
NativeMacroObservation {
paths,
..NativeMacroObservation::default()
}
}
fn selector(path: &str, listing: bool) -> MacroPathSelector {
MacroPathSelector {
path: path.into(),
listing,
cargo_query: None,
above_root: false,
}
}
fn above_root(name: &str) -> MacroPathSelector {
MacroPathSelector {
above_root: true,
..selector(name, false)
}
}
fn read(path: &Path, access: NativeMacroPathAccess) -> NativeMacroPathRead {
NativeMacroPathRead {
path: path.to_str().expect("UTF-8 test path").into(),
access,
}
}
fn reasons(selected: Result<Vec<MacroPathSelector>, MacroBypass>) -> Result<Vec<MacroPathSelector>, &'static str> {
selected.map_err(|bypass| bypass.reason)
}
#[test]
fn bypass_names_unobservable_effects_before_processes_and_secret_variables() {
let mut observed = NativeMacroObservation::default();
assert_eq!(bypass_reason(&observed), None);
observed.environment = vec!["SERVICE_API_TOKEN".into()];
assert_eq!(bypass_reason(&observed), Some("procedural_macro_secret_environment"));
observed.unobservable = vec![Unobservable::Network, Unobservable::FileWrite];
assert_eq!(bypass_reason(&observed), Some("procedural_macro_network"));
}
#[test]
fn path_selectors_resolve_parent_components_and_skip_captured_namespaces() {
let workspace = tempfile::tempdir().unwrap();
let root = crate::utils::canonicalize_existing(workspace.path()).unwrap();
fs::create_dir_all(root.join("crates/a/src")).unwrap();
fs::create_dir_all(root.join("shared")).unwrap();
fs::write(root.join("Cargo.toml"), "").unwrap();
let observed = observation(
&root,
&[
("crates/a/../../Cargo.toml", NativeMacroPathAccess::Contents),
("shared", NativeMacroPathAccess::Listing),
("shared/./absent.txt", NativeMacroPathAccess::Entry),
("crates/a/src/lib.rs", NativeMacroPathAccess::Contents),
("", NativeMacroPathAccess::Entry),
],
);
let mut observed = observed;
observed
.paths
.push(crate::compiler::native_input_protocol::NativeMacroPathRead {
path: "/dev/null".into(),
access: NativeMacroPathAccess::Contents,
});
let captured = root.join("crates/a/src");
assert_eq!(
reasons(path_selectors(&observed, &root, &root, &[captured.as_path()])),
Ok(vec![
selector("", false),
selector("Cargo.toml", false),
selector("shared", true),
selector("shared/absent.txt", false),
])
);
}
#[test]
fn path_selectors_accept_the_cargo_root_spelling_and_reject_escapes_and_symlinks() {
let temporary = tempfile::tempdir().unwrap();
let root = crate::utils::canonicalize_existing(temporary.path())
.unwrap()
.join("workspace");
fs::create_dir_all(root.join("real")).unwrap();
let spelling = root.with_file_name("spelling");
std::os::unix::fs::symlink(&root, &spelling).unwrap();
let through_spelling = observation(&spelling, &[("real", NativeMacroPathAccess::Entry)]);
assert_eq!(
reasons(path_selectors(&through_spelling, &root, &spelling, &[])),
Ok(vec![selector("real", false)])
);
let outside = observation(&root, &[("../elsewhere", NativeMacroPathAccess::Contents)]);
assert_eq!(
reasons(path_selectors(&outside, &root, &root, &[])),
Err("procedural_macro_read_outside_repository")
);
let unrelated = observation(Path::new("/etc"), &[("hosts", NativeMacroPathAccess::Contents)]);
assert_eq!(
reasons(path_selectors(&unrelated, &root, &root, &[])),
Err("procedural_macro_read_outside_repository")
);
std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
for read in ["link/file", "link/../real"] {
let through_link = observation(&root, &[(read, NativeMacroPathAccess::Contents)]);
assert_eq!(
reasons(path_selectors(&through_link, &root, &root, &[])),
Err("procedural_macro_read_through_symlink"),
"{read}"
);
}
}
#[test]
fn captured_states_bind_contents_absence_directories_and_listings() {
let workspace = tempfile::tempdir().unwrap();
let root = crate::utils::canonicalize_existing(workspace.path()).unwrap();
fs::create_dir_all(root.join("templates")).unwrap();
fs::write(root.join("setting.txt"), "one").unwrap();
fs::write(root.join("templates/first"), "").unwrap();
let selectors = vec![
selector("missing/below", false),
selector("setting.txt", false),
selector("templates", false),
selector("templates", true),
];
let capture_now = || {
capture(
&root,
&root,
&selectors,
Instant::now(),
&mut NativeCaptureBudget::new(NATIVE_CAPTURE_LIMITS),
)
.unwrap()
};
let initial = capture_now();
let states = initial.iter().map(|input| input.state.clone()).collect::<Vec<_>>();
assert!(matches!(states[0], MacroPathState::Absent));
assert!(matches!(&states[1], MacroPathState::File { bytes: 3, .. }));
assert!(matches!(states[2], MacroPathState::Directory));
assert!(matches!(states[3], MacroPathState::Listing { entries: 1, .. }));
validate_inputs(&initial).unwrap();
let encoded = serde_json::to_vec(&initial).unwrap();
assert_eq!(
serde_json::from_slice::<Vec<MacroPathInput>>(&encoded).unwrap(),
initial
);
revalidate(&root, &root, &initial).unwrap();
for change in [
|root: &Path| fs::write(root.join("setting.txt"), "two").unwrap(),
|root: &Path| fs::write(root.join("templates/second"), "").unwrap(),
|root: &Path| fs::create_dir_all(root.join("missing/below")).unwrap(),
] {
let before = capture_now();
change(&root);
assert!(revalidate(&root, &root, &before).is_err());
assert_ne!(capture_now(), before);
}
}
#[test]
fn selectors_must_be_sorted_unique_and_normalized() {
assert!(validate_selectors(&[selector("b", false), selector("a", false)]).is_err());
assert!(validate_selectors(&[selector("a", false), selector("a", false)]).is_err());
assert!(validate_selectors(&[selector("a/../b", false)]).is_err());
assert!(validate_selectors(&[selector("/a", false)]).is_err());
validate_selectors(&[selector("", true), selector("a", false), selector("a", true)]).unwrap();
}
#[test]
fn a_bypass_keeps_the_first_reason_and_names_every_blocking_condition() {
let workspace = tempfile::tempdir().unwrap();
let root = crate::utils::canonicalize_existing(workspace.path()).unwrap();
fs::create_dir_all(root.join("real")).unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
let metadata = NativeMacroSpawn {
program: "/elsewhere/bin/cargo".into(),
arguments: [
"cargo",
"metadata",
"--format-version=1",
"--no-deps",
"--manifest-path=/secret",
]
.map(String::from)
.to_vec(),
};
let mut observed = observation(&root, &[("link/file", NativeMacroPathAccess::Contents)]);
observed.spawns = vec![metadata];
observed.paths.extend(
[
"/etc/one",
"/etc/two",
"/etc/three",
"/etc/four",
"/etc/five",
"/etc/six",
]
.map(|path| read(Path::new(path), NativeMacroPathAccess::Contents)),
);
observed.paths.sort_unstable();
let bypass = select(&observed, &root, &root, &[]).unwrap_err();
assert_eq!(bypass.reason, "procedural_macro_process_unmodeled");
assert_eq!(
bypass.detail,
format!(
"procedural-macro processes without a model (1): cargo metadata; \
procedural-macro reads outside the repository (6): /etc/five, /etc/four, /etc/one, /etc/six, and 2 more; \
procedural-macro reads through a symbolic link (1): {}",
root.join("link/file").display()
)
);
assert!(!bypass.detail.contains("/secret"), "{}", bypass.detail);
observed.unobservable = vec![Unobservable::ProcessControl, Unobservable::EnvironmentEnumeration];
observed.environment = vec!["SERVICE_API_TOKEN".into()];
let bypass = select(&observed, &root, &root, &[]).unwrap_err();
assert_eq!(bypass.reason, "procedural_macro_process_control");
assert!(
bypass.detail.starts_with(
"procedural-macro effects: process_control, environment_enumeration; \
procedural macro read secret-named variables: SERVICE_API_TOKEN; \
procedural-macro processes without a model (1): cargo metadata; \
procedural-macro reads outside the repository (6)"
),
"{}",
bypass.detail
);
let clean = observation(&root, &[("real", NativeMacroPathAccess::Entry)]);
assert_eq!(
reasons(select(&clean, &root, &root, &[])),
Ok(vec![selector("real", false)])
);
}
#[test]
fn a_missing_name_above_the_root_binds_while_it_stays_absent_everywhere_above() {
let temporary = tempfile::tempdir().unwrap();
let above = crate::utils::canonicalize_existing(temporary.path()).unwrap();
let root = above.join("workspace");
fs::create_dir_all(root.join("member")).unwrap();
let mut observed = observation(
&root,
&[
("member", NativeMacroPathAccess::Entry),
(&format!("member/{PROBE}"), NativeMacroPathAccess::Contents),
("", NativeMacroPathAccess::Entry),
(PROBE, NativeMacroPathAccess::Contents),
],
);
for directory in above.ancestors() {
observed.paths.push(read(directory, NativeMacroPathAccess::Entry));
observed
.paths
.push(read(&directory.join(PROBE), NativeMacroPathAccess::Contents));
}
observed.paths.sort_unstable();
let expected = vec![
selector("", false),
selector(PROBE, false),
above_root(PROBE),
selector("member", false),
selector(&format!("member/{PROBE}"), false),
];
assert_eq!(
reasons(path_selectors(&observed, &root, &root, &[])),
Ok(expected.clone())
);
let capture_now = |spelling: &Path| {
capture(
&root,
spelling,
&expected,
Instant::now(),
&mut NativeCaptureBudget::new(NATIVE_CAPTURE_LIMITS),
)
};
let initial = capture_now(&root).unwrap();
validate_inputs(&initial).unwrap();
let encoded = serde_json::to_vec(&initial).unwrap();
assert_eq!(
serde_json::from_slice::<Vec<MacroPathInput>>(&encoded).unwrap(),
initial
);
revalidate(&root, &root, &initial).unwrap();
let checked = post_execution_paths(&root, &initial).unwrap();
assert!(
checked.iter().all(|path| match path {
super::super::PostExecutionPath::Absent(path) | super::super::PostExecutionPath::Present(path) =>
path.starts_with(&root),
}),
"{checked:?}"
);
fs::write(above.join(PROBE), "SETTING=1\n").unwrap();
assert!(revalidate(&root, &root, &initial).is_err());
capture_now(&root).expect_err("an existing name above the root stops the capture");
let bypass = path_selectors(&observed, &root, &root, &[]).unwrap_err();
assert_eq!(bypass.reason, "procedural_macro_read_outside_repository");
assert!(
bypass.detail.contains(&above.join(PROBE).display().to_string()),
"{}",
bypass.detail
);
fs::remove_file(above.join(PROBE)).unwrap();
revalidate(&root, &root, &initial).unwrap();
fs::create_dir(above.join("sibling")).unwrap();
for (path, access) in [
(above.clone(), NativeMacroPathAccess::Listing),
(above.clone(), NativeMacroPathAccess::Contents),
(above.join(PROBE), NativeMacroPathAccess::Listing),
(above.join("other").join(PROBE), NativeMacroPathAccess::Contents),
(above.join("sibling"), NativeMacroPathAccess::Entry),
] {
let mut unbound = observation(&root, &[]);
unbound.paths.push(read(&path, access));
assert_eq!(
reasons(path_selectors(&unbound, &root, &root, &[])),
Err("procedural_macro_read_outside_repository"),
"{} {access:?}",
path.display()
);
}
}
#[test]
fn a_missing_name_above_the_root_binds_every_directory_that_the_root_spelling_reaches() {
let (real, other) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let root = crate::utils::canonicalize_existing(real.path())
.unwrap()
.join("workspace");
fs::create_dir_all(&root).unwrap();
let other = crate::utils::canonicalize_existing(other.path()).unwrap();
let spelling = other.join("link");
std::os::unix::fs::symlink(&root, &spelling).unwrap();
let mut observed = observation(&root, &[]);
observed
.paths
.push(read(&other.join(PROBE), NativeMacroPathAccess::Contents));
assert_eq!(
reasons(path_selectors(&observed, &root, &spelling, &[])),
Ok(vec![above_root(PROBE)])
);
assert_eq!(
reasons(path_selectors(&observed, &root, &root, &[])),
Err("procedural_macro_read_outside_repository"),
"a directory that no root spelling reaches does not bind"
);
let capture_with = |spelling: &Path| {
capture(
&root,
spelling,
&[above_root(PROBE)],
Instant::now(),
&mut NativeCaptureBudget::new(NATIVE_CAPTURE_LIMITS),
)
};
let initial = capture_with(&spelling).unwrap();
fs::write(other.join(PROBE), "").unwrap();
capture_with(&spelling).expect_err("the spelling reaches the existing name");
assert!(revalidate(&root, &spelling, &initial).is_err());
assert_eq!(
capture_with(&root).unwrap(),
initial,
"the canonical root does not reach the spelling's parent"
);
}
#[test]
fn a_name_above_the_root_is_one_normal_component_with_an_absent_state() {
for invalid in [
MacroPathSelector {
listing: true,
..above_root(PROBE)
},
MacroPathSelector {
cargo_query: Some(CargoQueryRoot::Repository),
..above_root(PROBE)
},
above_root("nested/name"),
above_root(".."),
above_root("."),
above_root(""),
] {
assert!(
validate_selectors(std::slice::from_ref(&invalid)).is_err(),
"{invalid:?}"
);
}
validate_selectors(&[selector(PROBE, false), above_root(PROBE)]).unwrap();
let stored: MacroPathSelector = serde_json::from_str(r#"{"path":".env"}"#).unwrap();
assert!(!stored.above_root);
let present = MacroPathInput {
path: PROBE.into(),
listing: false,
cargo_query: None,
above_root: true,
state: MacroPathState::Directory,
};
assert!(validate_inputs(&[present]).is_err());
}
}