use std::{collections::BTreeMap, ffi::OsString};
const INPUT_PREFIX: &str = "INPUT_";
const LEGACY_FALLBACKS: &[(&str, &str)] =
&[("INPUT_TOKEN", "GITHUB_TOKEN"), ("INPUT_OWNER", "OWNER"), ("INPUT_REPO-NAME", "REPO_NAME")];
#[derive(Debug, Clone, PartialEq, Eq)]
enum EnvAction {
Remove,
CopyFrom(&'static str),
}
pub unsafe fn normalize_inputs<I>(args: I) -> Vec<OsString>
where
I: IntoIterator<Item = OsString>,
{
let environment = collect_environment();
for (key, action) in environment_plan(&environment) {
match action {
EnvAction::Remove => {
unsafe { std::env::remove_var(&key) };
}
EnvAction::CopyFrom(source) => {
if let Some(value) = std::env::var_os(source) {
unsafe { std::env::set_var(&key, value) };
}
}
}
}
sanitize_args(args)
}
fn collect_environment() -> BTreeMap<String, String> {
std::env::vars_os()
.filter_map(|(key, value)| {
Some((key.into_string().ok()?, value.to_string_lossy().into_owned()))
})
.collect()
}
fn environment_plan(environment: &BTreeMap<String, String>) -> BTreeMap<String, EnvAction> {
let mut actions = BTreeMap::new();
for (key, value) in environment {
if key.starts_with(INPUT_PREFIX) && value.trim().is_empty() {
actions.insert(key.clone(), EnvAction::Remove);
}
}
for (input_key, legacy_key) in LEGACY_FALLBACKS {
if is_provided(environment.get(*input_key)) {
continue;
}
if is_provided(environment.get(*legacy_key)) {
actions.insert((*input_key).to_owned(), EnvAction::CopyFrom(legacy_key));
}
}
actions
}
fn is_provided(value: Option<&String>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn sanitize_args<I>(args: I) -> Vec<OsString>
where
I: IntoIterator<Item = OsString>,
{
let mut sanitized = Vec::new();
let mut remaining = args.into_iter().peekable();
while let Some(arg) = remaining.next() {
let text = arg.to_string_lossy().into_owned();
if text == "--" {
sanitized.push(arg);
sanitized.extend(remaining);
break;
}
let Some(flag) = text.strip_prefix("--").filter(|flag| !flag.is_empty()) else {
sanitized.push(arg);
continue;
};
if let Some((_, value)) = flag.split_once('=') {
if !value.trim().is_empty() {
sanitized.push(arg);
}
continue;
}
if remaining.peek().is_some_and(is_blank) {
remaining.next();
continue;
}
sanitized.push(arg);
}
sanitized
}
fn is_blank(value: &OsString) -> bool {
value.to_string_lossy().trim().is_empty()
}
#[cfg(test)]
#[allow(clippy::significant_drop_tightening)]
pub mod testing {
use std::{
collections::BTreeMap,
ffi::OsString,
sync::{Mutex, MutexGuard, PoisonError},
};
use super::{INPUT_PREFIX, LEGACY_FALLBACKS};
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
snapshot: BTreeMap<String, OsString>,
_lock: MutexGuard<'static, ()>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for key in managed_keys() {
unsafe { std::env::remove_var(&key) };
}
for (key, value) in &self.snapshot {
unsafe { std::env::set_var(key, value) };
}
}
}
fn managed_keys() -> Vec<String> {
let mut keys: Vec<String> = std::env::vars_os()
.filter_map(|(key, _)| key.into_string().ok())
.filter(|key| key.starts_with(INPUT_PREFIX))
.collect();
keys.extend(LEGACY_FALLBACKS.iter().map(|(_, legacy)| (*legacy).to_owned()));
keys.sort();
keys.dedup();
keys
}
pub fn with_env<T>(overrides: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
let lock = ENV_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
let snapshot = managed_keys()
.into_iter()
.filter_map(|key| std::env::var_os(&key).map(|value| (key, value)))
.collect();
let guard = EnvGuard { snapshot, _lock: lock };
for key in managed_keys() {
unsafe { std::env::remove_var(&key) };
}
for (key, value) in overrides {
unsafe { std::env::set_var(key, value) };
}
let result = body();
drop(guard);
result
}
}
#[cfg(test)]
#[allow(clippy::significant_drop_tightening)]
mod tests {
use std::ffi::OsString;
use super::{EnvAction, environment_plan, sanitize_args};
fn environment(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
pairs.iter().map(|(key, value)| ((*key).to_owned(), (*value).to_owned())).collect()
}
fn args(values: &[&str]) -> Vec<String> {
sanitize_args(values.iter().map(OsString::from))
.into_iter()
.map(|value| value.to_string_lossy().into_owned())
.collect()
}
#[test]
fn empty_input_vars_are_removed() {
let plan = environment_plan(&environment(&[("INPUT_BASE-BRANCH", "")]));
assert_eq!(plan.get("INPUT_BASE-BRANCH"), Some(&EnvAction::Remove));
}
#[test]
fn whitespace_only_input_vars_are_removed() {
let plan = environment_plan(&environment(&[("INPUT_LABELS", " \t ")]));
assert_eq!(plan.get("INPUT_LABELS"), Some(&EnvAction::Remove));
}
#[test]
fn non_empty_input_vars_are_left_alone() {
let plan = environment_plan(&environment(&[("INPUT_TAG-PREFIX", "release-")]));
assert!(plan.is_empty());
}
#[test]
fn non_input_vars_are_never_removed() {
let plan = environment_plan(&environment(&[("PATH", ""), ("GITHUB_TOKEN", "")]));
assert!(plan.is_empty());
}
#[test]
fn legacy_env_seeds_a_missing_input_var() {
let plan = environment_plan(&environment(&[
("GITHUB_TOKEN", "ghp_legacy"),
("OWNER", "threatflux"),
("REPO_NAME", "github_actions"),
]));
assert_eq!(plan.get("INPUT_TOKEN"), Some(&EnvAction::CopyFrom("GITHUB_TOKEN")));
assert_eq!(plan.get("INPUT_OWNER"), Some(&EnvAction::CopyFrom("OWNER")));
assert_eq!(plan.get("INPUT_REPO-NAME"), Some(&EnvAction::CopyFrom("REPO_NAME")));
}
#[test]
fn input_var_wins_over_legacy_env() {
let plan = environment_plan(&environment(&[
("INPUT_TOKEN", "ghp_input"),
("GITHUB_TOKEN", "ghp_legacy"),
]));
assert!(plan.is_empty());
}
#[test]
fn empty_input_var_falls_back_to_legacy_env() {
let plan = environment_plan(&environment(&[
("INPUT_REPO-NAME", " "),
("REPO_NAME", "github_actions"),
]));
assert_eq!(plan.get("INPUT_REPO-NAME"), Some(&EnvAction::CopyFrom("REPO_NAME")));
}
#[test]
fn blank_legacy_env_never_seeds_an_input_var() {
let plan = environment_plan(&environment(&[("INPUT_OWNER", ""), ("OWNER", " ")]));
assert_eq!(plan.get("INPUT_OWNER"), Some(&EnvAction::Remove));
}
#[test]
fn empty_flag_values_are_dropped_from_args() {
assert_eq!(
args(&["bin", "update", "--token", "", "--owner", "threatflux"]),
["bin", "update", "--owner", "threatflux"]
);
}
#[test]
fn whitespace_only_flag_values_are_dropped_from_args() {
assert_eq!(args(&["bin", "release", "--base-branch", " "]), ["bin", "release"]);
}
#[test]
fn inline_empty_flag_values_are_dropped_from_args() {
assert_eq!(
args(&["bin", "status", "--repo-name=", "--owner=threatflux"]),
["bin", "status", "--owner=threatflux"]
);
}
#[test]
fn non_empty_flag_values_are_preserved_in_args() {
let line = ["bin", "pin", "--repo", ".", "--dry-run", "true"];
assert_eq!(args(&line), line);
}
#[test]
fn args_after_a_double_dash_are_preserved() {
let line = ["bin", "update", "--", "--title", ""];
assert_eq!(args(&line), line);
}
}