use std::path::PathBuf;
pub use tui_common::util::parse_bool;
pub fn write_atomic(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let name = path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"write_atomic: path has no file name",
)
})?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut tmp_name = name.to_owned();
tmp_name.push(format!(".tmp.{}.{}", std::process::id(), nanos));
let tmp = path.with_file_name(tmp_name);
let write = || -> std::io::Result<()> {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tmp)?;
f.write_all(contents.as_bytes())
};
if let Err(e) = write() {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
pub fn config_dir() -> PathBuf {
test_or_home(".config/ebman")
}
fn test_or_home(suffix: &str) -> PathBuf {
#[cfg(test)]
{
let mut p = std::env::temp_dir();
p.push(format!(
"ebman-test-{}-{}",
std::process::id(),
suffix.replace('/', "-")
));
let _ = std::fs::create_dir_all(&p);
p
}
#[cfg(not(test))]
{
match std::env::var_os("HOME") {
Some(home) => {
let mut p = PathBuf::from(home);
p.push(suffix);
p
}
None => PathBuf::from("."),
}
}
}
pub fn cache_dir() -> PathBuf {
test_or_home(".cache/ebman")
}
pub fn config_file(name: &str) -> PathBuf {
config_dir().join(name)
}
pub fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
use std::fmt::Write;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out
}
pub fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
out.push_str(&json_escape(s));
out.push('"');
out
}
pub fn redact_option_value(namespace: &str, name: &str, value: &str, redact: bool) -> String {
if !redact {
return value.to_string();
}
if namespace == "aws:elasticbeanstalk:application:environment"
|| name.eq_ignore_ascii_case("DBPassword")
{
return "(redacted)".to_string();
}
value.to_string()
}
pub fn open_append_secure(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let f = opts.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(f)
}
pub fn write_secure(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
f.write_all(contents)
}
#[cfg(test)]
mod tests {
use super::{json_escape, json_string};
#[cfg(unix)]
#[test]
fn every_file_ebman_writes_is_operator_only() {
use std::os::unix::fs::PermissionsExt;
let dir = super::cache_dir().join("perm-check");
let _ = std::fs::create_dir_all(&dir);
let atomic = dir.join("config.toml");
super::write_atomic(
&atomic,
"notify_webhook = \"https://hooks.example/secret\"\n",
)
.expect("write");
let mode = std::fs::metadata(&atomic)
.expect("stat")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "write_atomic left {mode:o}");
std::fs::set_permissions(&atomic, std::fs::Permissions::from_mode(0o644)).expect("chmod");
super::write_atomic(&atomic, "x = 1\n").expect("rewrite");
let mode = std::fs::metadata(&atomic)
.expect("stat")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "a rewrite left {mode:o}");
let appended = dir.join("audit.log");
drop(super::open_append_secure(&appended).expect("append"));
let mode = std::fs::metadata(&appended)
.expect("stat")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "open_append_secure left {mode:o}");
let written = dir.join("explain-cache.json");
super::write_secure(&written, b"{}").expect("write_secure");
let mode = std::fs::metadata(&written)
.expect("stat")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "write_secure left {mode:o}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_atomic_temp_file_is_never_world_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = super::cache_dir().join("temp-perm-check");
let _ = std::fs::create_dir_all(&dir);
let target = dir.join("config.toml");
let body = "k = \"v\"\n".repeat(50_000);
let watch = dir.clone();
let seen = std::thread::spawn(move || {
let mut worst = 0o600u32;
for _ in 0..2_000 {
if let Ok(entries) = std::fs::read_dir(&watch) {
for e in entries.flatten() {
let name = e.file_name();
if name.to_string_lossy().contains(".tmp.") {
if let Ok(md) = e.metadata() {
worst |= md.permissions().mode() & 0o777;
}
}
}
}
}
worst
});
super::write_atomic(&target, &body).expect("write");
let worst = seen.join().expect("watcher");
assert_eq!(
worst & 0o077,
0,
"a temp file was visible to group/other at {worst:o}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn json_escape_escapes_quotes_backslashes_newlines_tabs() {
assert_eq!(json_escape(""), "");
assert_eq!(json_escape("hello"), "hello");
assert_eq!(json_escape("with \"quotes\""), "with \\\"quotes\\\"");
assert_eq!(json_escape("a\\b"), "a\\\\b");
assert_eq!(json_escape("a\nb"), "a\\nb");
assert_eq!(json_escape("a\tb"), "a\\tb");
assert_eq!(json_escape("a\rb"), "a\\rb");
assert_eq!(json_escape("\x01"), "\\u0001");
assert_eq!(json_escape("\x07"), "\\u0007");
}
#[test]
fn json_string_wraps_in_quotes() {
assert_eq!(json_string(""), "\"\"");
assert_eq!(json_string("hello"), "\"hello\"");
assert_eq!(json_string("with \"quotes\""), "\"with \\\"quotes\\\"\"");
}
#[test]
fn json_string_round_trips_via_yaml_parser() {
let inputs = [
"",
"plain",
"with \"quotes\" and \\ backslashes",
"line1\nline2\twith tab",
"control \x01\x02 chars",
];
for input in inputs {
let escaped = json_string(input);
let parsed: String = serde_yml::from_str(&escaped)
.unwrap_or_else(|e| panic!("json_string({input:?}) = {escaped} failed: {e}"));
assert_eq!(parsed, input);
}
}
}
pub fn split_csv(value: &str) -> Vec<String> {
value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod split_csv_tests {
use super::split_csv;
#[test]
fn trims_entries_and_drops_empties() {
assert_eq!(
split_csv("subnet-a,subnet-b, subnet-c, ,subnet-d"),
vec!["subnet-a", "subnet-b", "subnet-c", "subnet-d"]
);
}
#[test]
fn empty_and_separator_only_input_yields_nothing() {
assert!(split_csv("").is_empty());
assert!(split_csv(",,,").is_empty());
assert!(split_csv(" , ").is_empty());
}
#[test]
fn a_single_entry_needs_no_separator() {
assert_eq!(split_csv(" solo "), vec!["solo"]);
}
#[test]
fn interior_whitespace_is_preserved() {
assert_eq!(split_csv("a b, c d"), vec!["a b", "c d"]);
}
}
pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
fn split(s: &str) -> (&str, Option<&str>) {
match s.split_once('-') {
Some((core, pre)) => (core, Some(pre)),
None => (s, None),
}
}
let (a_core, a_pre) = split(a);
let (b_core, b_pre) = split(b);
let parse = |s: &str| {
s.split('.')
.map(|p| p.parse::<u64>().ok())
.collect::<Vec<_>>()
};
let av = parse(a_core);
let bv = parse(b_core);
for i in 0..av.len().max(bv.len()) {
let aa = av.get(i).and_then(|x| *x);
let bb = bv.get(i).and_then(|x| *x);
match (aa, bb) {
(Some(x), Some(y)) => match x.cmp(&y) {
Ordering::Equal => continue,
o => return o,
},
(Some(_), None) => return Ordering::Greater,
(None, Some(_)) => return Ordering::Less,
(None, None) => break,
}
}
match compare_prerelease(a_pre, b_pre) {
Ordering::Equal => a.cmp(b),
o => o,
}
}
fn compare_prerelease(a: Option<&str>, b: Option<&str>) -> std::cmp::Ordering {
use std::cmp::Ordering;
let (a, b) = match (a, b) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Greater,
(Some(_), None) => return Ordering::Less,
(Some(a), Some(b)) => (a, b),
};
let mut ai = a.split('.');
let mut bi = b.split('.');
loop {
match (ai.next(), bi.next()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(x), Some(y)) => {
let o = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(nx), Ok(ny)) => nx.cmp(&ny),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => x.cmp(y),
};
if o != Ordering::Equal {
return o;
}
}
}
}
}
#[cfg(test)]
mod compare_versions_tests {
use super::compare_versions;
#[test]
fn compare_versions_ranks_a_prerelease_below_its_release() {
use std::cmp::Ordering;
assert_eq!(compare_versions("1.0.0-rc1", "1.0.0"), Ordering::Less);
assert_eq!(compare_versions("1.0.0", "1.0.0-rc1"), Ordering::Greater);
}
#[test]
fn compare_versions_orders_prereleases_among_themselves() {
use std::cmp::Ordering;
assert_eq!(compare_versions("1.0.0-rc1", "1.0.0-rc2"), Ordering::Less);
assert_eq!(
compare_versions("1.0.0-alpha", "1.0.0-beta"),
Ordering::Less
);
assert_eq!(
compare_versions("1.0.0-rc.2", "1.0.0-rc.10"),
Ordering::Less,
"numeric identifiers compare as numbers, not strings"
);
assert_eq!(compare_versions("1.0.0-1", "1.0.0-alpha"), Ordering::Less);
assert_eq!(compare_versions("1.0.0-rc", "1.0.0-rc.1"), Ordering::Less);
assert_eq!(compare_versions("1.0.0-rc1", "1.0.0-rc1"), Ordering::Equal);
}
#[test]
fn compare_versions_still_orders_release_cores() {
use std::cmp::Ordering;
assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater);
assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
assert_eq!(compare_versions("1.10.0", "1.9.0"), Ordering::Greater);
assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Less);
assert_eq!(compare_versions("4.0.1", "4.0.1"), Ordering::Equal);
}
#[test]
fn platform_picker_sorts_the_release_above_its_rc() {
let mut versions = vec!["1.0.0", "1.0.0-rc1", "1.0.1", "1.0.0-rc2"];
versions.sort_by(|a, b| compare_versions(b, a));
assert_eq!(versions, vec!["1.0.1", "1.0.0", "1.0.0-rc2", "1.0.0-rc1"]);
}
}
pub struct Partition {
pub arn: &'static str,
prefixes: &'static [&'static str],
pub global_region: &'static str,
pub console_host: Option<&'static str>,
}
pub const PARTITIONS: &[Partition] = &[
Partition {
arn: "aws-us-gov",
prefixes: &["us-gov-"],
global_region: "us-gov-west-1",
console_host: Some("{region}.console.amazonaws-us-gov.com"),
},
Partition {
arn: "aws-cn",
prefixes: &["cn-"],
global_region: "cn-north-1",
console_host: Some("{region}.console.amazonaws.cn"),
},
Partition {
arn: "aws-iso-b",
prefixes: &["us-isob-"],
global_region: "us-isob-east-1",
console_host: None,
},
Partition {
arn: "aws-iso-f",
prefixes: &["us-isof-"],
global_region: "us-isof-south-1",
console_host: None,
},
Partition {
arn: "aws-iso-e",
prefixes: &["eu-isoe-"],
global_region: "eu-isoe-west-1",
console_host: None,
},
Partition {
arn: "aws-iso",
prefixes: &["us-iso-"],
global_region: "us-iso-east-1",
console_host: None,
},
Partition {
arn: "aws-eusc",
prefixes: &["eusc-"],
global_region: "eusc-de-east-1",
console_host: None,
},
Partition {
arn: "aws",
prefixes: &[],
global_region: "us-east-1",
console_host: Some("{region}.console.aws.amazon.com"),
},
];
fn commercial() -> &'static Partition {
PARTITIONS
.iter()
.find(|p| p.arn == "aws")
.expect("commercial partition present in PARTITIONS")
}
impl Partition {
#[cfg(test)]
fn prefixes(&self) -> &'static [&'static str] {
self.prefixes
}
}
pub fn partition_for_region(region: &str) -> &'static Partition {
PARTITIONS
.iter()
.find(|p| p.prefixes.iter().any(|pre| region.starts_with(pre)))
.unwrap_or_else(commercial)
}
pub fn arn_partition(arn: &str) -> Option<&str> {
let rest = arn.strip_prefix("arn:")?;
let seg = rest.split(':').next()?;
(!seg.is_empty()).then_some(seg)
}
pub fn arn_prefixes() -> impl Iterator<Item = String> {
PARTITIONS.iter().map(|p| format!("arn:{}:", p.arn))
}
pub fn console_base_url(region: &str) -> Option<String> {
let host = partition_for_region(region).console_host?;
Some(format!("https://{}", host.replace("{region}", region)))
}
#[cfg(test)]
mod partition_tests {
use super::*;
#[test]
fn regions_map_to_their_partition() {
assert_eq!(partition_for_region("eu-west-2").arn, "aws");
assert_eq!(partition_for_region("us-gov-east-1").arn, "aws-us-gov");
assert_eq!(partition_for_region("cn-northwest-1").arn, "aws-cn");
assert_eq!(partition_for_region("us-iso-east-1").arn, "aws-iso");
assert_eq!(partition_for_region("us-isob-east-1").arn, "aws-iso-b");
assert_eq!(partition_for_region("us-isof-south-1").arn, "aws-iso-f");
assert_eq!(partition_for_region("eu-isoe-west-1").arn, "aws-iso-e");
assert_eq!(partition_for_region("eusc-de-east-1").arn, "aws-eusc");
assert_eq!(partition_for_region("mars-central-1").arn, "aws");
assert_eq!(partition_for_region("").arn, "aws");
}
#[test]
fn arn_partition_reads_the_segment() {
assert_eq!(arn_partition("arn:aws:iam::1:role/r"), Some("aws"));
assert_eq!(
arn_partition("arn:aws-us-gov:sts::1:assumed-role/R/S"),
Some("aws-us-gov")
);
assert_eq!(arn_partition("arn:aws-cn:s3:::bucket"), Some("aws-cn"));
assert_eq!(arn_partition("not-an-arn"), None);
assert_eq!(arn_partition("arn:"), None);
}
#[test]
fn console_urls_follow_the_partition() {
assert_eq!(
console_base_url("eu-west-2").as_deref(),
Some("https://eu-west-2.console.aws.amazon.com")
);
assert_eq!(
console_base_url("us-gov-west-1").as_deref(),
Some("https://us-gov-west-1.console.amazonaws-us-gov.com")
);
assert_eq!(
console_base_url("cn-north-1").as_deref(),
Some("https://cn-north-1.console.amazonaws.cn")
);
assert!(console_base_url("us-iso-east-1").is_none());
}
#[test]
fn arn_prefixes_covers_every_partition() {
let prefixes: Vec<String> = arn_prefixes().collect();
assert_eq!(prefixes.len(), PARTITIONS.len());
assert!(prefixes.contains(&"arn:aws:".to_string()));
assert!(prefixes.contains(&"arn:aws-us-gov:".to_string()));
assert!(prefixes.contains(&"arn:aws-iso-b:".to_string()));
assert!(
prefixes.contains(&"arn:aws-eusc:".to_string()),
"report_bug scrubs ARNs from this list — a missing partition \
leaks account IDs into a public issue"
);
}
}
#[cfg(test)]
mod partition_ordering_tests {
use super::PARTITIONS;
#[test]
fn no_prefix_shadows_another() {
for (i, a) in PARTITIONS.iter().enumerate() {
for pa in a.prefixes() {
for (j, b) in PARTITIONS.iter().enumerate() {
if i >= j {
continue;
}
for pb in b.prefixes() {
assert!(
!pb.starts_with(pa),
"{} (entry {i}, prefix {pa:?}) shadows {} (entry {j}, prefix {pb:?}) \
— reorder so the more specific prefix comes first",
a.arn,
b.arn
);
}
}
}
}
}
}
#[cfg(test)]
mod dir_redirect_tests {
use super::{cache_dir, config_dir};
#[test]
fn test_runs_never_resolve_a_path_under_home() {
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
for dir in [config_dir(), cache_dir()] {
if let Some(home) = home.as_ref() {
assert!(
!dir.starts_with(home),
"{dir:?} resolves under $HOME during tests"
);
}
assert!(
dir.starts_with(std::env::temp_dir()),
"{dir:?} should be under the temp dir during tests"
);
assert!(dir.is_dir(), "{dir:?} must exist — writers don't create it");
}
}
#[test]
fn the_two_directories_are_distinct() {
assert_ne!(config_dir(), cache_dir());
}
}