use std::collections::hash_map::RandomState;
use std::fs::OpenOptions;
use std::hash::{BuildHasher, Hasher};
use std::io::Write;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{Error, Result};
fn random_hex() -> String {
static SEED: OnceLock<RandomState> = OnceLock::new();
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seed = SEED.get_or_init(RandomState::new);
let mut hasher = seed.build_hasher();
hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
hasher.write_u64(u64::from(std::process::id()));
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64);
hasher.write_u64(nanos);
format!("{:016x}", hasher.finish())
}
fn delimiter() -> String {
format!("ghadelimiter_{}", random_hex())
}
pub(crate) fn random_token() -> String {
format!("stopcommands_{}", random_hex())
}
fn key_value_message_with(key: &str, value: &str, delim: &str) -> Result<String> {
if key.contains(['\r', '\n']) {
return Err(Error::InvalidName {
name: key.to_owned(),
reason: "key contains a carriage return or line feed",
});
}
if key.contains(delim) || value.contains(delim) {
return Err(Error::DelimiterCollision);
}
Ok(format!("{key}<<{delim}\n{value}\n{delim}"))
}
pub(crate) fn key_value_message(key: &str, value: &str) -> Result<String> {
key_value_message_with(key, value, &delimiter())
}
pub(crate) fn issue_file_command(var: &'static str, line: &str) -> Result<bool> {
let Some(path) = std::env::var_os(var) else {
return Ok(false);
};
let path = PathBuf::from(path);
if !path.exists() {
return Err(Error::MissingEnvFile { var, path });
}
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heredoc_shape() {
let msg = key_value_message_with("NAME", "multi\nline", "D").unwrap();
assert_eq!(msg, "NAME<<D\nmulti\nline\nD");
}
#[test]
fn key_with_line_break_is_rejected() {
for bad in ["a\nb", "a\rb", "x\r\ny"] {
let err = key_value_message_with(bad, "v", "D").unwrap_err();
assert!(
matches!(err, Error::InvalidName { .. }),
"{bad:?} should be rejected"
);
}
assert!(key_value_message_with("K", "line1\nline2", "D").is_ok());
}
#[test]
fn collision_in_value_errors() {
let err = key_value_message_with("k", "has D inside", "D").unwrap_err();
assert!(matches!(err, Error::DelimiterCollision));
}
#[test]
fn collision_in_key_errors() {
let err = key_value_message_with("kD", "v", "D").unwrap_err();
assert!(matches!(err, Error::DelimiterCollision));
}
#[test]
fn generated_delimiter_is_prefixed_and_unique() {
let a = delimiter();
let b = delimiter();
assert!(a.starts_with("ghadelimiter_"));
assert_ne!(a, b, "counter must vary the delimiter per call");
}
#[test]
fn unset_var_signals_fallback() {
let ok = issue_file_command("GHACTIONS_TEST_DEFINITELY_UNSET", "x").unwrap();
assert!(!ok);
}
}