mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
## What changed Add `RemotePluginMeasurementCache` to prepare trusted measurement declarations for a single installed `GLOBAL` plugin without loading capabilities or updating the shared plugin catalog. - Recheck installation authorization on every preparation and scope cached references by authentication identity, endpoint, plugin identity, and version. - Keep up to four entries, share concurrent downloads, and retain isolated bundle files while commands hold references. Reuse cached historical versions after release updates; download only the current release on a cache miss. - Respect Windows and POSIX case rules, reject ambiguous matches, and omit authenticated URLs and response bodies from preparation errors. ## Testing Add bundle preparation tests covering authorization, version reuse, concurrent downloads, cancellation and retry, eviction with live references, case matching, error privacy, and temporary-store cleanup. GitOrigin-RevId: a68f42b90ff78b7943da676a7b43f1ea76b31582
701 lines
25 KiB
Rust
701 lines
25 KiB
Rust
use crate::OPENAI_API_CURATED_MARKETPLACE_NAME;
|
|
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
|
use crate::OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME;
|
|
use crate::PluginLoadOutcome;
|
|
use crate::loader::curated_plugin_cache_version;
|
|
use crate::marketplace::MarketplacePluginSource;
|
|
use crate::marketplace::find_marketplace_plugin;
|
|
use crate::marketplace_policy::primary_runtime_marketplace_root;
|
|
use crate::plugin_metrics::PluginMetricsOperation;
|
|
use crate::plugin_metrics::ResolvedPluginMetricsOperation;
|
|
use crate::plugin_metrics::load_plugin_metrics_operations;
|
|
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
|
use crate::startup_sync::curated_plugins_api_marketplace_path;
|
|
use crate::startup_sync::curated_plugins_repo_path;
|
|
use crate::startup_sync::read_curated_plugins_sha;
|
|
use crate::store::DEFAULT_PLUGIN_VERSION;
|
|
use crate::store::PluginStore;
|
|
use crate::store::plugin_version_for_source;
|
|
use crate::store::validate_plugin_version_segment;
|
|
use codex_exec_server::ExecutorFileSystem;
|
|
use codex_exec_server::GetMetadataOptions;
|
|
use codex_exec_server::ReadFileOptions;
|
|
use codex_plugin::PluginId;
|
|
use codex_protocol::items::is_safe_plugin_relative_path;
|
|
use codex_shell_command::bash::extract_bash_command;
|
|
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
|
use codex_shell_command::parse_command::is_pathish;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathConvention;
|
|
use codex_utils_path_uri::PathUri;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashSet;
|
|
use std::path::Component;
|
|
use std::path::Path;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct TrustedPluginRoot {
|
|
plugin_id: PluginId,
|
|
version: String,
|
|
root: AbsolutePathBuf,
|
|
metrics_operations_by_path: BTreeMap<String, PluginMetricsOperation>,
|
|
}
|
|
|
|
/// Trusted plugin command attribution safe to carry into command analytics.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PluginCommandAttribution {
|
|
pub plugin_id: PluginId,
|
|
pub normalized_relative_path: String,
|
|
}
|
|
|
|
impl PluginCommandAttribution {
|
|
/// Returns the paired fields used at command protocol boundaries.
|
|
pub fn serialized_fields(&self) -> (String, String) {
|
|
(
|
|
self.plugin_id.as_key(),
|
|
self.normalized_relative_path.clone(),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Active first-party roots eligible for command attribution.
|
|
/// Trusted means OpenAI-shipped synced or bundled runtime code, or a
|
|
/// server-installed global remote plugin cache entry, not a local override.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct TrustedPluginRoots {
|
|
roots: Vec<TrustedPluginRoot>,
|
|
}
|
|
|
|
/// An untrusted hint from a canonical executor script path, never authorization.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PluginMeasurementTarget {
|
|
pub(crate) plugin_id: PluginId,
|
|
pub(crate) version: String,
|
|
pub(crate) path_convention: PathConvention,
|
|
}
|
|
|
|
impl PluginMeasurementTarget {
|
|
/// Selects one remote plugin using the same command parser as attribution.
|
|
/// Skip paths outside the remote plugin cache before consulting the executor.
|
|
pub async fn from_command(
|
|
command: &[String],
|
|
cwd: &PathUri,
|
|
file_system: &dyn ExecutorFileSystem,
|
|
) -> Option<Self> {
|
|
let command = single_plain_command(command)?;
|
|
let invocation = script_invocation(&command)?;
|
|
let script = cwd.join(invocation.script).ok()?;
|
|
Self::from_script_path(&script)?;
|
|
let script = file_system
|
|
.canonicalize(&script, /*sandbox*/ None)
|
|
.await
|
|
.ok()?;
|
|
Self::from_script_path(&script)
|
|
}
|
|
|
|
fn from_script_path(script: &PathUri) -> Option<Self> {
|
|
let path_convention = script.infer_path_convention()?;
|
|
let native_path = script.inferred_native_path_string();
|
|
// Normalize only the hint so equivalent Windows paths share preparation.
|
|
// Store identities must retain the authenticated catalog's spelling.
|
|
let native_path = match path_convention {
|
|
PathConvention::Windows => native_path.to_ascii_lowercase(),
|
|
PathConvention::Posix => native_path,
|
|
};
|
|
let components = path_convention
|
|
.path_segments(&native_path)
|
|
.collect::<Vec<_>>();
|
|
let mut candidates = components.windows(6).filter_map(|parts| {
|
|
if parts[..3] != ["plugins", "cache", REMOTE_GLOBAL_MARKETPLACE_NAME]
|
|
|| parts[4] == DEFAULT_PLUGIN_VERSION
|
|
{
|
|
return None;
|
|
}
|
|
validate_plugin_version_segment(parts[4]).ok()?;
|
|
Some(Self {
|
|
plugin_id: PluginId::new(parts[3].to_owned(), parts[2].to_owned()).ok()?,
|
|
version: parts[4].to_owned(),
|
|
path_convention,
|
|
})
|
|
});
|
|
let target = candidates.next()?;
|
|
candidates.next().is_none().then_some(target)
|
|
}
|
|
}
|
|
|
|
impl TrustedPluginRoots {
|
|
/// Called only after the authenticated remote bundle installer has prepared
|
|
/// an isolated store. Read declarations without loading plugin capabilities.
|
|
pub(crate) fn from_measurement_reference(
|
|
codex_home: &Path,
|
|
plugin_id: &PluginId,
|
|
version: &str,
|
|
remote_id: &str,
|
|
) -> Option<Self> {
|
|
let store = PluginStore::try_new(codex_home.to_owned()).ok()?;
|
|
if store.active_plugin_version(plugin_id).as_deref() != Some(version)
|
|
|| store.remote_plugin_id(plugin_id).ok()?.as_deref() != Some(remote_id)
|
|
{
|
|
return None;
|
|
}
|
|
let root = store.plugin_root(plugin_id, version).canonicalize().ok()?;
|
|
let metrics_operations_by_path = load_plugin_metrics_operations(&root).unwrap_or_default();
|
|
Some(Self {
|
|
roots: vec![TrustedPluginRoot {
|
|
plugin_id: plugin_id.clone(),
|
|
version: version.to_owned(),
|
|
root,
|
|
metrics_operations_by_path,
|
|
}],
|
|
})
|
|
}
|
|
|
|
/// Adds already-trusted roots, preserving the existing root for each plugin and version.
|
|
/// Distinct versions remain eligible for exact executor-version matching.
|
|
pub fn extend(&mut self, other: &Self) {
|
|
let mut seen = self
|
|
.roots
|
|
.iter()
|
|
.map(|root| (root.plugin_id.as_key(), root.version.clone()))
|
|
.collect::<HashSet<_>>();
|
|
self.roots.extend(
|
|
other
|
|
.roots
|
|
.iter()
|
|
.filter(|root| seen.insert((root.plugin_id.as_key(), root.version.clone())))
|
|
.cloned(),
|
|
);
|
|
}
|
|
|
|
pub fn from_plugin_load_outcome(loaded_plugins: &PluginLoadOutcome, codex_home: &Path) -> Self {
|
|
let primary_runtime_marketplace_root = primary_runtime_marketplace_root();
|
|
let Ok(store) = PluginStore::try_new(codex_home.to_path_buf()) else {
|
|
return Self::default();
|
|
};
|
|
let mut seen = HashSet::new();
|
|
let roots = loaded_plugins
|
|
.plugins()
|
|
.iter()
|
|
.filter(|plugin| plugin.is_active())
|
|
.filter_map(|plugin| {
|
|
let plugin_id = PluginId::parse(&plugin.config_name).ok()?;
|
|
let expected_root = Self::expected_plugin_root(
|
|
&store,
|
|
codex_home,
|
|
&plugin_id,
|
|
primary_runtime_marketplace_root.as_deref(),
|
|
)?;
|
|
if plugin.root != expected_root || !expected_root.as_path().is_dir() {
|
|
return None;
|
|
}
|
|
let version = expected_root.as_path().file_name()?.to_str()?.to_owned();
|
|
let root = expected_root.canonicalize().ok()?;
|
|
root.as_path().is_dir().then(|| TrustedPluginRoot {
|
|
plugin_id,
|
|
version,
|
|
metrics_operations_by_path: load_plugin_metrics_operations(&root)
|
|
.unwrap_or_default(),
|
|
root,
|
|
})
|
|
})
|
|
.filter(|root| seen.insert((root.plugin_id.as_key(), root.root.clone())))
|
|
.collect();
|
|
Self { roots }
|
|
}
|
|
|
|
fn expected_plugin_root(
|
|
store: &PluginStore,
|
|
codex_home: &Path,
|
|
plugin_id: &PluginId,
|
|
primary_runtime_marketplace_root: Option<&Path>,
|
|
) -> Option<AbsolutePathBuf> {
|
|
match plugin_id.marketplace_name.as_str() {
|
|
REMOTE_GLOBAL_MARKETPLACE_NAME => {
|
|
let active_version = store.active_plugin_version(plugin_id)?;
|
|
if active_version == DEFAULT_PLUGIN_VERSION
|
|
|| store.remote_plugin_id(plugin_id).ok().flatten().is_none()
|
|
{
|
|
return None;
|
|
}
|
|
Some(store.plugin_root(plugin_id, &active_version))
|
|
}
|
|
OPENAI_CURATED_MARKETPLACE_NAME | OPENAI_API_CURATED_MARKETPLACE_NAME => {
|
|
let curated_sha = read_curated_plugins_sha(codex_home)?;
|
|
let expected_root =
|
|
store.plugin_root(plugin_id, &curated_plugin_cache_version(&curated_sha));
|
|
let marketplace_path = match plugin_id.marketplace_name.as_str() {
|
|
OPENAI_CURATED_MARKETPLACE_NAME => curated_plugins_repo_path(codex_home)
|
|
.join(".agents/plugins/marketplace.json"),
|
|
OPENAI_API_CURATED_MARKETPLACE_NAME => {
|
|
curated_plugins_api_marketplace_path(codex_home)
|
|
}
|
|
_ => return None,
|
|
};
|
|
Self::marketplace_plugin(marketplace_path, plugin_id)?;
|
|
Some(expected_root)
|
|
}
|
|
OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME => {
|
|
let marketplace_root = primary_runtime_marketplace_root?;
|
|
let expected_source_root = AbsolutePathBuf::from_absolute_path_checked(
|
|
marketplace_root
|
|
.join("plugins")
|
|
.join(&plugin_id.plugin_name),
|
|
)
|
|
.ok()?;
|
|
let marketplace_plugin = Self::marketplace_plugin(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
plugin_id,
|
|
)?;
|
|
let MarketplacePluginSource::Local { path: source_root } =
|
|
marketplace_plugin.source
|
|
else {
|
|
return None;
|
|
};
|
|
if source_root != expected_source_root {
|
|
return None;
|
|
}
|
|
let plugin_version = plugin_version_for_source(source_root.as_path()).ok()?;
|
|
Some(store.plugin_root(plugin_id, &plugin_version))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn marketplace_plugin(
|
|
marketplace_path: std::path::PathBuf,
|
|
plugin_id: &PluginId,
|
|
) -> Option<crate::marketplace::ResolvedMarketplacePlugin> {
|
|
let marketplace_path =
|
|
AbsolutePathBuf::from_absolute_path_checked(marketplace_path).ok()?;
|
|
let plugin = find_marketplace_plugin(&marketplace_path, &plugin_id.plugin_name).ok()?;
|
|
(plugin.plugin_id == *plugin_id).then_some(plugin)
|
|
}
|
|
|
|
/// Resolves one exact command to one trusted plugin script.
|
|
///
|
|
/// Complex shell syntax, missing files, symlink escapes, and overlapping
|
|
/// matches are all unattributed by design.
|
|
pub fn resolve_attribution(
|
|
&self,
|
|
command: &[String],
|
|
cwd: &AbsolutePathBuf,
|
|
) -> Option<PluginCommandAttribution> {
|
|
self.local_candidate(command, cwd)
|
|
.map(|candidate| candidate.attribution())
|
|
}
|
|
|
|
/// Resolves one exact command to one trusted manifest-declared operation.
|
|
pub fn resolve_metrics_operation(
|
|
&self,
|
|
command: &[String],
|
|
cwd: &AbsolutePathBuf,
|
|
) -> Option<ResolvedPluginMetricsOperation> {
|
|
self.local_candidate(command, cwd)?.metrics_operation()
|
|
}
|
|
|
|
fn local_candidate(
|
|
&self,
|
|
command: &[String],
|
|
cwd: &AbsolutePathBuf,
|
|
) -> Option<MatchedPluginScript<'_>> {
|
|
let command = single_plain_command(command)?;
|
|
let invocation = script_invocation(command.as_slice())?;
|
|
let script = if Path::new(invocation.script).is_absolute() {
|
|
AbsolutePathBuf::from_absolute_path_checked(invocation.script).ok()?
|
|
} else {
|
|
cwd.join(invocation.script)
|
|
}
|
|
.canonicalize()
|
|
.ok()?;
|
|
if !script.as_path().is_file() {
|
|
return None;
|
|
}
|
|
|
|
let mut matches = self.roots.iter().filter_map(|root| {
|
|
let relative_path = script
|
|
.as_path()
|
|
.strip_prefix(root.root.as_path())
|
|
.ok()
|
|
.filter(|relative_path| !relative_path.as_os_str().is_empty())?;
|
|
Some(MatchedPluginScript {
|
|
root,
|
|
normalized_relative_path: normalized_relative_script_path(relative_path)?,
|
|
})
|
|
});
|
|
let attribution = matches.next()?;
|
|
matches.next().is_none().then_some(attribution)
|
|
}
|
|
|
|
/// Resolves a trusted script on the selected executor filesystem.
|
|
///
|
|
/// Remote commands can use a path convention that the app-server host cannot
|
|
/// canonicalize. Resolve aliases on that executor first, then match the
|
|
/// canonical target to a trusted local plugin script with the same contents.
|
|
/// Generic attribution accepts matching script bytes across plugin versions.
|
|
pub async fn resolve_executor_attribution(
|
|
&self,
|
|
command: &[String],
|
|
cwd: &PathUri,
|
|
file_system: &dyn ExecutorFileSystem,
|
|
) -> Option<PluginCommandAttribution> {
|
|
self.executor_candidate(command, cwd, file_system, PluginVersionMatch::Any)
|
|
.await
|
|
.map(|candidate| candidate.matched.attribution())
|
|
}
|
|
|
|
/// Resolves one trusted executor script to one manifest-declared operation,
|
|
/// requiring the executor's version to match the trusted declaration's version.
|
|
pub async fn resolve_metrics_operation_in_filesystem(
|
|
&self,
|
|
command: &[String],
|
|
cwd: &PathUri,
|
|
file_system: &dyn ExecutorFileSystem,
|
|
) -> Option<ResolvedPluginMetricsOperation> {
|
|
self.executor_candidate(command, cwd, file_system, PluginVersionMatch::Exact)
|
|
.await?
|
|
.matched
|
|
.metrics_operation()
|
|
}
|
|
|
|
async fn executor_candidate(
|
|
&self,
|
|
command: &[String],
|
|
cwd: &PathUri,
|
|
file_system: &dyn ExecutorFileSystem,
|
|
version_match: PluginVersionMatch,
|
|
) -> Option<ExecutorAttributionCandidate<'_>> {
|
|
if self.roots.is_empty() {
|
|
return None;
|
|
}
|
|
let command = single_plain_command(command)?;
|
|
let invocation = script_invocation(command.as_slice())?;
|
|
let script = cwd.join(invocation.script).ok()?;
|
|
// Preserve known script suffixes (including directory aliases), but avoid
|
|
// an executor round trip for unrelated workspace scripts.
|
|
let suffixes = normalized_script_suffixes(&script);
|
|
if !self.roots.iter().any(|root| {
|
|
suffixes.iter().any(|suffix| {
|
|
executor_plugin_root_matches(&script, root, suffix, PluginVersionMatch::Any)
|
|
|| root.root.join(suffix).as_path().is_file()
|
|
})
|
|
}) {
|
|
return None;
|
|
}
|
|
let script = file_system
|
|
.canonicalize(&script, /*sandbox*/ None)
|
|
.await
|
|
.ok()?;
|
|
let candidates = self.local_candidates_for_executor_script(&script, version_match);
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
let metadata = file_system
|
|
.get_metadata(
|
|
&script,
|
|
GetMetadataOptions::default(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await
|
|
.ok()?;
|
|
if !metadata.is_file
|
|
|| !candidates
|
|
.iter()
|
|
.any(|candidate| metadata.size == candidate.contents.len() as u64)
|
|
{
|
|
return None;
|
|
}
|
|
let contents = file_system
|
|
.read_file(&script, ReadFileOptions::default(), /*sandbox*/ None)
|
|
.await
|
|
.ok()?;
|
|
let mut matches = candidates
|
|
.into_iter()
|
|
.filter(|candidate| contents == candidate.contents);
|
|
let candidate = matches.next()?;
|
|
match version_match {
|
|
PluginVersionMatch::Any => matches
|
|
.all(|other| other.matched.attribution() == candidate.matched.attribution())
|
|
.then_some(candidate),
|
|
PluginVersionMatch::Exact => matches.next().is_none().then_some(candidate),
|
|
}
|
|
}
|
|
|
|
fn local_candidates_for_executor_script(
|
|
&self,
|
|
script: &PathUri,
|
|
version_match: PluginVersionMatch,
|
|
) -> Vec<ExecutorAttributionCandidate<'_>> {
|
|
let suffixes = normalized_script_suffixes(script);
|
|
self.roots
|
|
.iter()
|
|
.filter_map(|root| {
|
|
let (script, normalized_relative_path) = suffixes.iter().find_map(|suffix| {
|
|
if !executor_plugin_root_matches(script, root, suffix, version_match) {
|
|
return None;
|
|
}
|
|
let script = root.root.join(suffix).canonicalize().ok()?;
|
|
let relative_path = script.as_path().strip_prefix(root.root.as_path()).ok()?;
|
|
if !script.as_path().is_file() {
|
|
return None;
|
|
}
|
|
let normalized_relative_path = normalized_relative_script_path(relative_path)?;
|
|
Some((script, normalized_relative_path))
|
|
})?;
|
|
Some(ExecutorAttributionCandidate {
|
|
matched: MatchedPluginScript {
|
|
root,
|
|
normalized_relative_path,
|
|
},
|
|
contents: std::fs::read(script.as_path()).ok()?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
struct MatchedPluginScript<'a> {
|
|
root: &'a TrustedPluginRoot,
|
|
normalized_relative_path: String,
|
|
}
|
|
|
|
impl MatchedPluginScript<'_> {
|
|
fn attribution(&self) -> PluginCommandAttribution {
|
|
PluginCommandAttribution {
|
|
plugin_id: self.root.plugin_id.clone(),
|
|
normalized_relative_path: self.normalized_relative_path.clone(),
|
|
}
|
|
}
|
|
|
|
fn metrics_operation(&self) -> Option<ResolvedPluginMetricsOperation> {
|
|
Some(ResolvedPluginMetricsOperation {
|
|
plugin_id: self.root.plugin_id.clone(),
|
|
operation: self
|
|
.root
|
|
.metrics_operations_by_path
|
|
.get(&self.normalized_relative_path)?
|
|
.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
struct ExecutorAttributionCandidate<'a> {
|
|
matched: MatchedPluginScript<'a>,
|
|
contents: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum PluginVersionMatch {
|
|
Any,
|
|
Exact,
|
|
}
|
|
|
|
fn normalized_script_suffixes(script: &PathUri) -> Vec<String> {
|
|
let path = script.inferred_native_path_string().replace('\\', "/");
|
|
let components = path
|
|
.split('/')
|
|
.filter(|component| !component.is_empty())
|
|
.collect::<Vec<_>>();
|
|
(0..components.len())
|
|
.filter_map(|start| {
|
|
let suffix = components[start..].join("/");
|
|
is_safe_plugin_relative_path(&suffix).then_some(suffix)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn executor_plugin_root_matches(
|
|
script: &PathUri,
|
|
trusted: &TrustedPluginRoot,
|
|
relative_path: &str,
|
|
version_match: PluginVersionMatch,
|
|
) -> bool {
|
|
let relative_depth = relative_path.split('/').count();
|
|
let Some(root) = script.ancestors().nth(relative_depth) else {
|
|
return false;
|
|
};
|
|
let Some(cache_parent) = root.ancestors().nth(5) else {
|
|
return false;
|
|
};
|
|
let Ok(plugin_root) = cache_parent.join(&format!(
|
|
"plugins/cache/{}/{}",
|
|
trusted.plugin_id.marketplace_name, trusted.plugin_id.plugin_name
|
|
)) else {
|
|
return false;
|
|
};
|
|
match version_match {
|
|
PluginVersionMatch::Any => root.parent().as_ref() == Some(&plugin_root),
|
|
PluginVersionMatch::Exact => plugin_root
|
|
.join(&trusted.version)
|
|
.is_ok_and(|expected| expected == root),
|
|
}
|
|
}
|
|
|
|
/// Returns the structurally parsed arguments following a single script command.
|
|
/// Callers must keep the values in-process and must not log or serialize them.
|
|
pub fn command_script_arguments(command: &[String]) -> Option<Vec<String>> {
|
|
let command = single_plain_command(command)?;
|
|
Some(script_invocation(command.as_slice())?.arguments.to_vec())
|
|
}
|
|
|
|
/// Converts a path already proven to be below a trusted plugin root into the
|
|
/// only path shape that may leave the resolver: non-empty, relative, and
|
|
/// slash-separated with no traversal or platform-specific prefixes.
|
|
pub(crate) fn normalized_relative_script_path(relative_path: &Path) -> Option<String> {
|
|
let normalized = relative_path
|
|
.components()
|
|
.map(|component| {
|
|
let Component::Normal(component) = component else {
|
|
return None;
|
|
};
|
|
component.to_str()
|
|
})
|
|
.collect::<Option<Vec<_>>>()?
|
|
.join("/");
|
|
|
|
is_safe_plugin_relative_path(&normalized).then_some(normalized)
|
|
}
|
|
|
|
fn single_plain_command(command: &[String]) -> Option<Vec<String>> {
|
|
if let Some(commands) = parse_shell_lc_plain_commands(command) {
|
|
let [command] = commands.as_slice() else {
|
|
return None;
|
|
};
|
|
return single_plain_command(command);
|
|
}
|
|
if let Some(script) = windows_shell_script(command) {
|
|
let wrapper = ["sh".to_string(), "-lc".to_string(), script.to_string()];
|
|
return single_plain_command(&wrapper);
|
|
}
|
|
if extract_bash_command(command).is_some() {
|
|
return None;
|
|
}
|
|
Some(command.to_vec())
|
|
}
|
|
|
|
struct ScriptInvocation<'a> {
|
|
script: &'a str,
|
|
arguments: &'a [String],
|
|
}
|
|
|
|
fn script_invocation(command: &[String]) -> Option<ScriptInvocation<'_>> {
|
|
let [program, args @ ..] = command else {
|
|
return None;
|
|
};
|
|
if let Some(interpreter) = interpreter_name(program) {
|
|
return interpreter_script_invocation(&interpreter, args);
|
|
}
|
|
is_pathish(program).then_some(ScriptInvocation {
|
|
script: program,
|
|
arguments: args,
|
|
})
|
|
}
|
|
|
|
fn interpreter_name(program: &str) -> Option<String> {
|
|
let basename = executable_basename(program)?;
|
|
let basename = basename.to_ascii_lowercase();
|
|
let basename = basename.strip_suffix(".exe").unwrap_or(&basename);
|
|
matches!(
|
|
basename,
|
|
"bash"
|
|
| "node"
|
|
| "nodejs"
|
|
| "perl"
|
|
| "php"
|
|
| "powershell"
|
|
| "pwsh"
|
|
| "python"
|
|
| "python3"
|
|
| "ruby"
|
|
| "sh"
|
|
| "zsh"
|
|
)
|
|
.then(|| basename.to_string())
|
|
}
|
|
|
|
fn interpreter_script_invocation<'a>(
|
|
interpreter: &str,
|
|
args: &'a [String],
|
|
) -> Option<ScriptInvocation<'a>> {
|
|
if matches!(interpreter, "powershell" | "pwsh") {
|
|
let [file_flag, script, arguments @ ..] = args else {
|
|
return None;
|
|
};
|
|
return (file_flag.eq_ignore_ascii_case("-file") && !script.starts_with('-'))
|
|
.then_some(ScriptInvocation { script, arguments });
|
|
}
|
|
|
|
let mut args = args;
|
|
loop {
|
|
match args {
|
|
[separator, script, arguments @ ..]
|
|
if separator == "--" && !script.starts_with('-') =>
|
|
{
|
|
return Some(ScriptInvocation { script, arguments });
|
|
}
|
|
[flag, remaining @ ..] if safe_interpreter_flag(interpreter, flag) => {
|
|
args = remaining;
|
|
}
|
|
[script, arguments @ ..] if !script.starts_with('-') => {
|
|
return Some(ScriptInvocation { script, arguments });
|
|
}
|
|
_ => return None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn safe_interpreter_flag(interpreter: &str, flag: &str) -> bool {
|
|
matches!(
|
|
(interpreter, flag),
|
|
("python" | "python3", "-u") | ("bash" | "sh" | "zsh", "-e")
|
|
)
|
|
}
|
|
|
|
fn executable_basename(program: &str) -> Option<&str> {
|
|
program
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.filter(|basename| !basename.is_empty())
|
|
}
|
|
|
|
fn windows_shell_script(command: &[String]) -> Option<&str> {
|
|
let [program, args @ ..] = command else {
|
|
return None;
|
|
};
|
|
let basename = executable_basename(program)?.to_ascii_lowercase();
|
|
if matches!(basename.as_str(), "cmd" | "cmd.exe") {
|
|
let [flag, script] = args else {
|
|
return None;
|
|
};
|
|
return flag.eq_ignore_ascii_case("/c").then_some(script);
|
|
}
|
|
if !matches!(
|
|
basename.as_str(),
|
|
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
let [flags @ .., command_flag, script] = args else {
|
|
return None;
|
|
};
|
|
if !matches!(
|
|
command_flag.to_ascii_lowercase().as_str(),
|
|
"-command" | "-c"
|
|
) {
|
|
return None;
|
|
}
|
|
flags
|
|
.iter()
|
|
.all(|flag| {
|
|
matches!(
|
|
flag.to_ascii_lowercase().as_str(),
|
|
"-nologo" | "-noprofile" | "-noninteractive"
|
|
)
|
|
})
|
|
.then_some(script)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "script_attribution_tests.rs"]
|
|
mod tests;
|