use std::sync::Mutex;
use super::registry::*;
use super::vars::{VarAccess, VarDef};
use crate::runtime::log::ERL_ERROR;
use crate::server::access_security::{
AccessLevel, AccessSecurityConfig, as_check_client_ip, parse_acf, set_as_check_client_ip,
};
#[derive(Default)]
struct AsState {
filename: Option<String>,
substitutions: Option<String>,
as_init_ran: bool,
}
fn as_state() -> &'static Mutex<AsState> {
static STATE: std::sync::OnceLock<Mutex<AsState>> = std::sync::OnceLock::new();
STATE.get_or_init(|| Mutex::new(AsState::default()))
}
#[cfg(test)]
pub(crate) fn as_state_test_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(test)]
pub(crate) fn reset_as_state_for_test() {
*as_state().lock().unwrap() = AsState::default();
}
pub(crate) fn register(registry: &mut CommandRegistry) {
super::vars::register_variable(as_check_client_ip_var());
registry.register(cmd_as_set_filename());
registry.register(cmd_as_set_substitutions());
registry.register(cmd_as_init());
registry.register(cmd_asdbdump());
registry.register(cmd_aspuag());
registry.register(cmd_asphag());
registry.register(cmd_asprules());
registry.register(cmd_aspmem());
registry.register(cmd_astac());
registry.register(cmd_ascar());
registry.register(cmd_as_dump_hash());
}
fn as_check_client_ip_var() -> VarDef {
VarDef {
name: "asCheckClientIP",
access: VarAccess::Int {
get: || i64::from(as_check_client_ip()),
set: |v| set_as_check_client_ip(v != 0),
},
}
}
fn cmd_as_set_filename() -> CommandDef {
CommandDef::new(
"asSetFilename",
vec![ArgDesc {
name: "ascf",
arg_type: ArgType::Path,
}],
"asSetFilename <ascf> — Set path+file of ACF file. Run asInit to (re)load.",
|args: &[ArgValue], ctx: &CommandContext| {
let ArgValue::String(path) = &args[0] else {
as_state().lock().unwrap().filename = None;
return Ok(CommandOutcome::Continue);
};
let path = path.clone();
if !path.starts_with('/') && !path.contains(':') {
ctx.println("asSetFilename: Warning - relative paths won't usually work");
}
as_state().lock().unwrap().filename = Some(path);
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_as_set_substitutions() -> CommandDef {
CommandDef::new(
"asSetSubstitutions",
vec![ArgDesc {
name: "substitutions",
arg_type: ArgType::String,
}],
"asSetSubstitutions <subs> — Set substitutions used when reading the ACF file.",
|args: &[ArgValue], _ctx: &CommandContext| {
let ArgValue::String(subs) = &args[0] else {
as_state().lock().unwrap().substitutions = None;
return Ok(CommandOutcome::Continue);
};
as_state().lock().unwrap().substitutions = Some(subs.clone());
Ok(CommandOutcome::Continue)
},
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AsInitOutcome {
Loaded,
NeverEnabled,
NotActive,
BadConfig,
LoadFailed,
}
impl AsInitOutcome {
pub(crate) fn failed(self) -> bool {
!matches!(self, Self::Loaded | Self::NeverEnabled)
}
pub(crate) fn message(self) -> Option<&'static str> {
match self {
Self::NotActive => Some(
"Access security is NOT enabled. \
Was asSetFilename specified before iocInit?",
),
Self::Loaded | Self::NeverEnabled | Self::BadConfig | Self::LoadFailed => None,
}
}
}
pub(crate) fn as_init(acf: &crate::server::access_security::AcfCell) -> AsInitOutcome {
let (was_first_time, filename, substitutions) = {
let mut st = as_state().lock().unwrap();
let was_first_time = !st.as_init_ran;
st.as_init_ran = true;
(
was_first_time,
st.filename.clone(),
st.substitutions.clone(),
)
};
if !was_first_time && acf.load().is_none() {
return AsInitOutcome::NotActive;
}
let Some(filename) = filename else {
return if was_first_time {
AsInitOutcome::NeverEnabled
} else {
AsInitOutcome::BadConfig
};
};
let Some(config) = as_init_file(&filename, substitutions.as_deref()) else {
return AsInitOutcome::LoadFailed;
};
acf.store(Some(std::sync::Arc::new(config)));
AsInitOutcome::Loaded
}
fn as_init_file(filename: &str, substitutions: Option<&str>) -> Option<AccessSecurityConfig> {
let raw = match std::fs::read_to_string(filename) {
Ok(raw) => raw,
Err(_) => {
eprintln!("{}", cant_open_file(filename));
return None;
}
};
let content = match substitutions {
Some(subs) if !subs.is_empty() => {
let macros = super::commands::parse_macro_string(subs);
crate::server::db_loader::substitute_macros_per_line(&raw, ¯os)
}
_ => raw,
};
match parse_acf(&content) {
Ok(config) => Some(config),
Err(e) => {
eprintln!("{ERL_ERROR} {e}");
None
}
}
}
fn cant_open_file(filename: &str) -> String {
format!("{ERL_ERROR} asInitFile: Can't open file '{filename}'")
}
fn cmd_as_init() -> CommandDef {
CommandDef::new(
"asInit",
vec![],
"asInit — (Re)load the ACF file set by asSetFilename.",
|_args: &[ArgValue], ctx: &CommandContext| {
let outcome = as_init(ctx.acf());
if let Some(message) = outcome.message() {
ctx.println(message);
}
Ok(if outcome.failed() {
CommandOutcome::Failed
} else {
CommandOutcome::Continue
})
},
)
}
fn with_config<F: FnOnce(&AccessSecurityConfig)>(ctx: &CommandContext, f: F) -> CommandResult {
match &*ctx.acf().load() {
Some(cfg) => {
f(cfg);
Ok(CommandOutcome::Continue)
}
None => {
ctx.println("Access security not loaded — run asInit.");
Ok(CommandOutcome::Continue)
}
}
}
fn cmd_asdbdump() -> CommandDef {
CommandDef::new(
"asdbdump",
vec![],
"asdbdump — Dump the processed ACF file (as read).",
|_args: &[ArgValue], ctx: &CommandContext| {
with_config(ctx, |cfg| {
for line in cfg.dump_report().lines() {
ctx.println(line);
}
})
},
)
}
fn print_asg(ctx: &CommandContext, cfg: &AccessSecurityConfig, name: &str) {
let mut buf = String::new();
cfg.fmt_asg(name, &mut buf);
for line in buf.lines() {
ctx.println(line);
}
}
fn cmd_aspuag() -> CommandDef {
CommandDef::new(
"aspuag",
vec![ArgDesc {
name: "uagname",
arg_type: ArgType::String,
}],
"aspuag [uagname] — Show members of a User Access Group (all if omitted).",
|args: &[ArgValue], ctx: &CommandContext| {
let filter = match &args[0] {
ArgValue::String(s) => Some(s.as_str()),
_ => None,
};
with_config(ctx, |cfg| {
let mut names: Vec<_> = cfg.uag.keys().collect();
names.sort();
for name in names {
if filter.is_some_and(|f| f != name) {
continue;
}
ctx.println(&format!("UAG({name})"));
for m in &cfg.uag[name] {
ctx.println(&format!("\t{m}"));
}
}
})
},
)
}
fn cmd_asphag() -> CommandDef {
CommandDef::new(
"asphag",
vec![ArgDesc {
name: "hagname",
arg_type: ArgType::String,
}],
"asphag [hagname] — Show members of a Host Access Group (all if omitted).",
|args: &[ArgValue], ctx: &CommandContext| {
let filter = match &args[0] {
ArgValue::String(s) => Some(s.as_str()),
_ => None,
};
with_config(ctx, |cfg| {
let mut names: Vec<_> = cfg.hag.keys().collect();
names.sort();
for name in names {
if filter.is_some_and(|f| f != name) {
continue;
}
ctx.println(&format!("HAG({name})"));
for h in &cfg.hag[name] {
ctx.println(&format!("\t{h}"));
}
}
})
},
)
}
fn cmd_asprules() -> CommandDef {
CommandDef::new(
"asprules",
vec![ArgDesc {
name: "asgname",
arg_type: ArgType::String,
}],
"asprules [asgname] — List rules of an Access Security Group (all if omitted).",
|args: &[ArgValue], ctx: &CommandContext| {
let filter = match &args[0] {
ArgValue::String(s) => Some(s.as_str()),
_ => None,
};
with_config(ctx, |cfg| {
let mut names: Vec<_> = cfg.asg.keys().collect();
names.sort();
for name in names {
if filter.is_some_and(|f| f != name) {
continue;
}
ctx.println(&format!("ASG({name})"));
print_asg(ctx, cfg, name);
}
})
},
)
}
fn cmd_aspmem() -> CommandDef {
CommandDef::new(
"aspmem",
vec![
ArgDesc {
name: "asgname",
arg_type: ArgType::String,
},
ArgDesc {
name: "clients",
arg_type: ArgType::Int,
},
],
"aspmem [asgname] [clients] — List records that are members of an ASG.",
|args: &[ArgValue], ctx: &CommandContext| {
let filter = match &args[0] {
ArgValue::String(s) => Some(s.clone()),
_ => None,
};
if matches!(&args[1], ArgValue::Int(n) if *n != 0) {
ctx.println("aspmem: per-member CA-client listing is not available in this IOC");
}
let names = ctx.block_on(ctx.db().all_record_names());
let mut by_asg: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for rec_name in &names {
if let Some(rec) = ctx.db().get_record(rec_name) {
let inst = rec.read();
by_asg
.entry(inst.common.access_group().to_string())
.or_default()
.push(rec_name.clone());
}
}
for (asg, members) in &by_asg {
if filter.as_deref().is_some_and(|f| f != asg) {
continue;
}
ctx.println(&format!("ASG({asg})"));
for m in members {
ctx.println(&format!("\t{m}"));
}
}
Ok(CommandOutcome::Continue)
},
)
}
fn cmd_astac() -> CommandDef {
CommandDef::new(
"astac",
vec![
ArgDesc {
name: "recordname",
arg_type: ArgType::Record,
},
ArgDesc {
name: "user",
arg_type: ArgType::String,
},
ArgDesc {
name: "host",
arg_type: ArgType::String,
},
],
"astac <record> <user> <host> — Show the access user:host would have on a PV.",
|args: &[ArgValue], ctx: &CommandContext| {
let (record, user, host) = match (&args[0], &args[1], &args[2]) {
(ArgValue::String(r), ArgValue::String(u), ArgValue::String(h)) => {
(r.clone(), u.clone(), h.clone())
}
_ => {
ctx.println("Usage: astac \"record name\", \"user\", \"host\"");
return Ok(CommandOutcome::Continue);
}
};
let (asg, asl) = match ctx.db().get_record(&record) {
Some(rec) => {
let inst = rec.read();
(inst.common.access_group().to_string(), inst.common.asl)
}
None => {
crate::runtime::log::errlog_printf(&format!(
"Record Not Found filename=\"{}\" line number={} dbNameToAddr error\n",
file!(),
line!()
));
return Ok(CommandOutcome::Continue);
}
};
with_config(ctx, |cfg| {
let level = cfg.check_access_asl(&asg, &host, &user, asl);
let perm = match level {
AccessLevel::NoAccess => "NoAccess",
AccessLevel::Read => "Read",
AccessLevel::ReadWrite => "ReadWrite",
};
ctx.println(&format!(
"{record} ASG({asg}) ASL={asl} {user}@{host}: {perm}"
));
})
},
)
}
fn cmd_ascar() -> CommandDef {
CommandDef::new(
"ascar",
vec![ArgDesc {
name: "level",
arg_type: ArgType::Int,
}],
"ascar [level] — Report status of PVs used in INP*() Access Security rules.",
|_args: &[ArgValue], ctx: &CommandContext| {
with_config(ctx, |cfg| {
let mut total = 0usize;
let mut asgs: Vec<_> = cfg.asg.keys().collect();
asgs.sort();
for name in asgs {
for inp in &cfg.asg[name].inp {
let letter = (b'A' + inp.index) as char;
ctx.println(&format!(
"ASG({name}) INP{letter} \"{}\" — not connected",
inp.link
));
total += 1;
}
}
ctx.println(&format!(
"ascar: {total} INP link(s) declared; \
CALC-rule channels are not opened by this IOC"
));
})
},
)
}
fn cmd_as_dump_hash() -> CommandDef {
CommandDef::new(
"asDumpHash",
vec![],
"asDumpHash — Show the contents of the hash table used to locate UAGs and HAGs.",
|_args: &[ArgValue], ctx: &CommandContext| {
let Some(cfg) = ctx.acf().load().as_ref().cloned() else {
return Ok(CommandOutcome::Continue);
};
for line in dump_as_hash(&cfg) {
ctx.println(&line);
}
Ok(CommandOutcome::Continue)
},
)
}
const AS_HASH_BUCKETS: usize = 256;
const AS_HASH_SEED_UAG: u32 = 0x7561_6700; const AS_HASH_SEED_HAG: u32 = 0x6861_6700;
fn dump_as_hash(cfg: &AccessSecurityConfig) -> Vec<String> {
let mut buckets: Vec<Vec<(String, String)>> = vec![Vec::new(); AS_HASH_BUCKETS];
let mask = (AS_HASH_BUCKETS - 1) as u32;
let mut push = |member: &str, owner: String, seed: u32| {
let h = (crate::runtime::stdlib::epics_str_hash(member, seed) & mask) as usize;
buckets[h].push((member.to_string(), owner));
};
let mut uags: Vec<&String> = cfg.uag.keys().collect();
uags.sort();
for name in uags {
for user in &cfg.uag[name] {
push(user, format!("UAG({name})"), AS_HASH_SEED_UAG);
}
}
let mut hags: Vec<&String> = cfg.hag.keys().collect();
hags.sort();
for name in hags {
for host in &cfg.hag[name] {
push(host, format!("HAG({name})"), AS_HASH_SEED_HAG);
}
}
let mut out = vec![format!("Hash table has {AS_HASH_BUCKETS} buckets")];
let mut empty = 0usize;
for (h, entries) in buckets.iter().enumerate() {
if entries.is_empty() {
empty += 1;
continue;
}
let mut line = format!(" [{h:3}] {:3} ", entries.len());
for (i, (member, owner)) in entries.iter().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(line);
line = " ".to_string();
}
line.push_str(&format!(" {member} {owner}"));
}
out.push(line);
}
out.push(format!("{empty} buckets empty."));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::database::PvDatabase;
use std::io::Write;
use std::sync::Arc;
fn make_ctx() -> (Arc<PvDatabase>, CommandContext) {
let rt = tokio::runtime::Runtime::new().unwrap();
let db = Arc::new(PvDatabase::new());
let bridge = {
let _guard = rt.enter();
crate::runtime::task::BlockingBridge::capture()
};
let ctx = CommandContext::new(db.clone(), bridge);
std::mem::forget(rt);
(db, ctx)
}
#[test]
fn as_init_without_filename_is_noop_success() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register(&mut reg);
let cmd = reg.get("asInit").unwrap();
let args = parse_args(&[], &cmd.args).unwrap();
let outcome = cmd.handler.call(&args, &ctx);
assert!(
matches!(outcome, Ok(CommandOutcome::Continue)),
"asInit with no filename must be a Continue no-op, not an error"
);
assert!(
ctx.acf().load().is_none(),
"no filename must leave access security disabled (no config)"
);
}
#[test]
fn as_set_filename_then_init_loads_acf() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register(&mut reg);
let tmp = tempfile::Builder::new().suffix(".acf").tempfile().unwrap();
writeln!(
tmp.as_file(),
"UAG(ops) {{ alice }}\nASG(DEFAULT) {{ RULE(1, WRITE) {{ UAG(ops) }} }}"
)
.unwrap();
let set = reg.get("asSetFilename").unwrap();
let a = parse_args(&[tmp.path().to_string_lossy().into()], &set.args).unwrap();
assert!(set.handler.call(&a, &ctx).is_ok());
let init = reg.get("asInit").unwrap();
let a = parse_args(&[], &init.args).unwrap();
assert!(init.handler.call(&a, &ctx).is_ok());
assert!(ctx.acf().load().is_some());
}
fn run_as(ctx: &CommandContext, name: &str, argv: &[String]) -> (CommandResult, String) {
let mut reg = CommandRegistry::new();
register(&mut reg);
let cmd = reg.get(name).unwrap();
let args = parse_args(argv, &cmd.args).unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut outcome = None;
ctx.with_output(std::fs::File::create(&path).unwrap(), || {
outcome = Some(cmd.handler.call(&args, ctx));
});
(outcome.unwrap(), std::fs::read_to_string(&path).unwrap())
}
#[test]
fn as_set_filename_prints_c_s_relative_path_warning_and_nothing_else() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
const WARNING: &str = "asSetFilename: Warning - relative paths won't usually work\n";
let (out, printed) = run_as(&ctx, "asSetFilename", &["/etc/acf/site.acf".into()]);
assert!(out.is_ok());
assert_eq!(printed, "", "a rooted path is silent");
let (out, printed) = run_as(&ctx, "asSetFilename", &["host:/acf/site.acf".into()]);
assert!(out.is_ok());
assert_eq!(printed, "", "C spares any path holding a colon");
let (out, printed) = run_as(&ctx, "asSetFilename", &["site.acf".into()]);
assert!(out.is_ok());
assert_eq!(printed, WARNING);
}
#[test]
fn a_second_bare_as_init_is_c_s_refusal_and_the_first_is_silent() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let (out, printed) = run_as(&ctx, "asInit", &[]);
assert!(matches!(out, Ok(CommandOutcome::Continue)));
assert_eq!(printed, "", "C `:127` returns 0 without a word");
let (out, printed) = run_as(&ctx, "asInit", &[]);
assert!(
matches!(out, Ok(CommandOutcome::Failed)),
"C returns S_asLib_asNotActive into iocshSetError"
);
assert_eq!(
printed,
"Access security is NOT enabled. Was asSetFilename specified before iocInit?\n"
);
}
#[test]
fn a_successful_as_init_prints_nothing() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let tmp = tempfile::Builder::new().suffix(".acf").tempfile().unwrap();
writeln!(
tmp.as_file(),
"UAG(ops) {{ alice }}\nASG(DEFAULT) {{ RULE(1, WRITE) {{ UAG(ops) }} }}"
)
.unwrap();
let (out, printed) = run_as(
&ctx,
"asSetFilename",
&[tmp.path().to_string_lossy().into()],
);
assert!(out.is_ok());
assert_eq!(printed, "");
let (out, printed) = run_as(&ctx, "asInit", &[]);
assert!(matches!(out, Ok(CommandOutcome::Continue)));
assert_eq!(printed, "");
assert!(ctx.acf().load().is_some());
}
fn run_as_dump_hash(ctx: &CommandContext) -> String {
let mut reg = CommandRegistry::new();
register(&mut reg);
let cmd = reg.get("asDumpHash").unwrap();
let args = parse_args(&[], &cmd.args).unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
ctx.with_output(std::fs::File::create(&path).unwrap(), || {
cmd.handler.call(&args, ctx).unwrap();
});
std::fs::read_to_string(&path).unwrap()
}
fn load_acf(ctx: &CommandContext, acf: &str) -> tempfile::NamedTempFile {
let mut reg = CommandRegistry::new();
register(&mut reg);
let tmp = tempfile::Builder::new().suffix(".acf").tempfile().unwrap();
write!(tmp.as_file(), "{acf}").unwrap();
let set = reg.get("asSetFilename").unwrap();
let a = parse_args(&[tmp.path().to_string_lossy().into()], &set.args).unwrap();
set.handler.call(&a, ctx).unwrap();
let init = reg.get("asInit").unwrap();
let a = parse_args(&[], &init.args).unwrap();
init.handler.call(&a, ctx).unwrap();
tmp
}
#[test]
fn as_dump_hash_prints_nothing_when_access_security_is_inactive() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
assert_eq!(
run_as_dump_hash(&ctx),
"",
"C returns before any output when asActive is false"
);
}
#[test]
fn as_dump_hash_lists_every_uag_user_and_hag_host_against_its_group() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let _tmp = load_acf(
&ctx,
"UAG(ops) { alice, bob }\n\
HAG(consoles) { host.example.org }\n\
ASG(DEFAULT) { RULE(1, WRITE) { UAG(ops) HAG(consoles) } }\n",
);
let out = run_as_dump_hash(&ctx);
assert!(
out.starts_with(&format!("Hash table has {AS_HASH_BUCKETS} buckets\n")),
"gpHashLib.c:214 prints the table size first: {out}"
);
for (member, owner) in [
("alice", "UAG(ops)"),
("bob", "UAG(ops)"),
("host.example.org", "HAG(consoles)"),
] {
assert!(
out.contains(&format!(" {member} {owner}")),
"{member} must appear against {owner}: {out}"
);
}
let empty: usize = out
.lines()
.last()
.unwrap()
.split_whitespace()
.next()
.unwrap()
.parse()
.unwrap();
let used = out
.lines()
.filter(|l| l.trim_start().starts_with('['))
.count();
assert_eq!(
used + empty,
AS_HASH_BUCKETS,
"every bucket is either listed or counted empty: {out}"
);
}
#[test]
fn a_name_used_as_both_a_user_and_a_host_is_two_entries() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let _tmp = load_acf(
&ctx,
"UAG(u) { same }\nHAG(h) { same }\n\
ASG(DEFAULT) { RULE(1, WRITE) { UAG(u) HAG(h) } }\n",
);
let out = run_as_dump_hash(&ctx);
assert!(out.contains(" same UAG(u)"), "the user node: {out}");
assert!(out.contains(" same HAG(h)"), "the host node: {out}");
let entries: usize = out
.lines()
.filter(|l| l.trim_start().starts_with('['))
.map(|l| {
l.split(']')
.nth(1)
.unwrap()
.split_whitespace()
.next()
.unwrap()
.parse::<usize>()
.unwrap()
})
.sum();
assert_eq!(entries, 2, "two nodes, not one deduplicated one: {out}");
}
#[test]
fn a_bucket_with_four_entries_wraps_after_the_third() {
let mask = (AS_HASH_BUCKETS - 1) as u32;
let mut by_bucket: std::collections::HashMap<u32, Vec<String>> =
std::collections::HashMap::new();
let mut collide: Option<Vec<String>> = None;
for i in 0..20_000u32 {
let name = format!("u{i}");
let h = crate::runtime::stdlib::epics_str_hash(&name, AS_HASH_SEED_UAG) & mask;
let v = by_bucket.entry(h).or_default();
v.push(name);
if v.len() == 4 {
collide = Some(v.clone());
break;
}
}
let names = collide.expect("four names must collide within 20000 candidates");
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let _tmp = load_acf(
&ctx,
&format!(
"UAG(g) {{ {} }}\nASG(DEFAULT) {{ RULE(1, WRITE) {{ UAG(g) }} }}\n",
names.join(", ")
),
);
let out = run_as_dump_hash(&ctx);
let bucket_lines: Vec<&str> = out
.lines()
.filter(|l| names.iter().any(|n| l.contains(&format!(" {n} UAG(g)"))))
.collect();
assert_eq!(
bucket_lines.len(),
2,
"four entries print on two lines: {out}"
);
assert!(
bucket_lines[0].trim_start().starts_with("[") && bucket_lines[0].contains(" 4 "),
"the first line carries the bucket index and its count of 4: {out}"
);
assert!(
bucket_lines[1].starts_with(" "),
"the continuation line is indented past the count column: {out}"
);
}
#[test]
fn as_init_expands_substitutions_after_a_comment_apostrophe() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register(&mut reg);
let tmp = tempfile::Builder::new().suffix(".acf").tempfile().unwrap();
writeln!(
tmp.as_file(),
"# the operators' group\nUAG($(G)) {{ alice }}\n\
ASG(DEFAULT) {{ RULE(1, WRITE) {{ UAG($(G)) }} }}"
)
.unwrap();
let set = reg.get("asSetFilename").unwrap();
let a = parse_args(&[tmp.path().to_string_lossy().into()], &set.args).unwrap();
assert!(set.handler.call(&a, &ctx).is_ok());
let subs = reg.get("asSetSubstitutions").unwrap();
let a = parse_args(&["G=ops".into()], &subs.args).unwrap();
assert!(subs.handler.call(&a, &ctx).is_ok());
let init = reg.get("asInit").unwrap();
let a = parse_args(&[], &init.args).unwrap();
assert!(
init.handler.call(&a, &ctx).is_ok(),
"the comment's apostrophe must not leave $(G) unexpanded"
);
let config = ctx.acf().load();
let config = config.as_ref().expect("config must be active");
assert!(
config.uag.contains_key("ops"),
"UAG($(G)) must have expanded to UAG(ops)"
);
}
#[test]
fn as_init_bad_acf_errors() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register(&mut reg);
let tmp = tempfile::Builder::new().suffix(".acf").tempfile().unwrap();
writeln!(tmp.as_file(), "ASG(X) {{ RULE(-1, READ) }}").unwrap();
let set = reg.get("asSetFilename").unwrap();
let a = parse_args(&[tmp.path().to_string_lossy().into()], &set.args).unwrap();
set.handler.call(&a, &ctx).unwrap();
let init = reg.get("asInit").unwrap();
let a = parse_args(&[], &init.args).unwrap();
assert!(
matches!(init.handler.call(&a, &ctx), Ok(CommandOutcome::Failed)),
"negative RULE level must fail asInit, silently"
);
}
#[test]
fn the_unreadable_acf_line_is_c_s() {
assert_eq!(
cant_open_file("/no/such.acf"),
"\u{1b}[31;1mERROR\u{1b}[0m asInitFile: Can't open file '/no/such.acf'"
);
assert_eq!(
cant_open_file("x"),
format!("{ERL_ERROR} asInitFile: Can't open file 'x'")
);
}
#[test]
fn an_unreadable_acf_fails_as_init_without_a_message() {
let _guard = as_state_test_guard();
reset_as_state_for_test();
let (_db, ctx) = make_ctx();
let mut reg = CommandRegistry::new();
register(&mut reg);
let set = reg.get("asSetFilename").unwrap();
let a = parse_args(&["/no/such/directory/nope.acf".into()], &set.args).unwrap();
set.handler.call(&a, &ctx).unwrap();
let outcome = as_init(ctx.acf());
assert_eq!(outcome, AsInitOutcome::LoadFailed);
assert!(outcome.failed());
assert_eq!(outcome.message(), None);
assert!(
ctx.acf().load().is_none(),
"a refused file must not activate access security"
);
}
}