Skip to main content

epics_base_rs/server/
access_security.rs

1use std::collections::HashMap;
2
3use crate::error::{CaError, CaResult};
4
5/// Access level for a channel.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum AccessLevel {
8    NoAccess,
9    Read,
10    ReadWrite,
11}
12
13/// Opaque proof that an access check has been performed.
14///
15/// Type-state ACF gate: every `ChannelSource` op that
16/// touches a PV by name now demands an `AccessChecked` instead of
17/// raw `(name, ctx)`. The struct has only one public constructor —
18/// [`AccessGate::check`] — so it is impossible to call a gated op
19/// without first running the check. This is the structural fix for
20/// the missed-path pattern that surfaced as ACF coverage grew
21/// (ACF was first added on three ops, then later review
22/// uncovered four more wire paths that skipped the check).
23///
24/// The private `_seal` field blocks external struct-literal
25/// construction; the constructor is reachable only through
26/// `AccessGate::check`.
27#[derive(Debug, Clone)]
28pub struct AccessChecked {
29    pv_name: String,
30    level: AccessLevel,
31    /// Write-trap mask of the rule that resolved `level`. C
32    /// `asComputePvt` stores this as `pasgclient->trapMask`
33    /// (`asLibRoutines.c:1048`); put-logging listeners consult it
34    /// to honour `TRAPWRITE` / `NOTRAPWRITE`.
35    rule_was_trap: bool,
36    // Private nominal type; external crates cannot construct
37    // `AccessSeal` and therefore cannot fabricate `AccessChecked`
38    // via struct literal.
39    _seal: AccessSeal,
40}
41
42#[derive(Debug, Clone)]
43struct AccessSeal;
44
45impl AccessChecked {
46    /// The PV name the check was performed against.
47    pub fn pv_name(&self) -> &str {
48        &self.pv_name
49    }
50
51    /// Resolved access level for `(peer, asg, asl)`.
52    pub fn level(&self) -> AccessLevel {
53        self.level
54    }
55
56    /// True iff the level grants at least READ.
57    pub fn allows_read(&self) -> bool {
58        !matches!(self.level, AccessLevel::NoAccess)
59    }
60
61    /// True iff the level grants WRITE.
62    pub fn allows_write(&self) -> bool {
63        matches!(self.level, AccessLevel::ReadWrite)
64    }
65
66    /// True iff the ACF rule that resolved this access level carried
67    /// the `TRAPWRITE` option. Mirrors C `pasgclient->trapMask`
68    /// (`asLibRoutines.c:1048`) — `false` for `NOTRAPWRITE`, for a
69    /// rule with no trap option, and for a denied (`NoAccess`)
70    /// resolution. CA put-logging dispatch sets
71    /// [`TrapWriteMessage::rule_was_trap`] from this value.
72    pub fn rule_was_trap(&self) -> bool {
73        self.rule_was_trap
74    }
75}
76
77/// Per-source access policy holder. Wraps an optional
78/// [`AccessSecurityConfig`] cell plus the PV → ASG/ASL resolution
79/// hooks the source provides. The wire dispatcher (tcp.rs) asks
80/// the source for its `AccessGate`, calls
81/// [`AccessGate::check`] once per op, and threads the resulting
82/// [`AccessChecked`] into the source's typed op methods.
83///
84/// Two variants:
85///
86/// * `Required` — an ACF cell is attached. The check evaluates it
87///   under the read lock; absent ACF still produces a permissive
88///   token (matching the earlier behaviour for sources whose ACF
89///   cell is `None`).
90/// * `Open` — the source explicitly opts out of ACF entirely
91///   (e.g. test fixtures, in-process sources that never touch the
92///   network). All checks return a `ReadWrite` token.
93#[derive(Clone)]
94pub struct AccessGate {
95    inner: AccessGateInner,
96    /// Generation counter bumped whenever the
97    /// gate's underlying ACF policy changes (reload / clear / hot
98    /// swap). Long-lived consumers (PVA monitor tasks spawned at
99    /// SUBSCRIBE time, gateway bridge tasks) capture the value at
100    /// spawn and compare on each event; a mismatch forces a fresh
101    /// `check()` so a peer that was allowed at subscribe time but
102    /// is now `NoAccess` under the new policy sees its subscription
103    /// torn down on the next event (matching the CA-side
104    /// `reeval_access_rights` semantics).
105    ///
106    /// Two backing shapes —
107    /// * `Atomic`: owned `AtomicU64` for terminal gates
108    ///   (`Required`, `Open`). `bump_acl_version` `fetch_add`s.
109    /// * `Aggregator`: a closure that returns a derived version
110    ///   from sub-gates. `CompositeSource` uses this to expose a
111    ///   gate whose `acl_version()` is the `wrapping_sum` of its
112    ///   inner sources' versions (NOT `max`: max produced
113    ///   false negatives when an
114    ///   inner bumped to a value still under the existing peak),
115    ///   so a bump on any inner (e.g. a
116    ///   `GatewayChannelSource::set_acf` on a child) is visible at
117    ///   the composite's top-level gate. Note: this gate is only a
118    ///   **change signal** — the allow/deny authority remains the
119    ///   matched inner source's gate; see
120    ///   `ChannelSource::revalidate_read` for the owner path the
121    ///   monitor reload loop uses. Pre-fix the composite
122    ///   inherited the default `Open` gate (version=0 forever) and
123    ///   tcp.rs's monitor loop compared against that stale value,
124    ///   missing every inner reload.
125    acl_version: AclVersionSource,
126    /// optional `INP*`-link value resolver. When present,
127    /// [`Self::check`] evaluates CALC-gated rules against live values;
128    /// when absent, CALC rules fail closed (deny). Installed by the
129    /// owning server via [`Self::with_inp_resolver`].
130    inp_resolver: Option<InpResolver>,
131    /// C `asAddClient` computes a client's access once per channel and
132    /// every operation is a bit test; the Rust op layer called
133    /// [`Self::check_with_roles`] — resolver + full rule walk — on every
134    /// EXEC. The walk result is deterministic in (policy snapshot,
135    /// pv name, credential identity) whenever no [`InpResolver`] is
136    /// installed (CALC rules then fail closed, reading no live values),
137    /// so those checks are cached here, shared across gate clones.
138    /// An entry is valid only while all three of its stamps hold: the
139    /// `acl_version` generation, the ASG-field-change generation
140    /// ([`asg_change_generation`] — the resolver reads `record.ASG`),
141    /// and the identity of the ACF config `Arc` it was computed against
142    /// (so a cell swap that forgot to bump the version still misses).
143    /// `Open` gates and unattached cells bypass the cache: their checks
144    /// are already walk-free, and an unattached-cell result would
145    /// otherwise survive the ACF being attached.
146    check_cache: std::sync::Arc<parking_lot::RwLock<HashMap<CheckKey, CachedCheck>>>,
147}
148
149/// Full identity a cached [`AccessGate`] check depends on. `roles` is
150/// part of the key — a re-auth that only changes role claims must miss.
151#[derive(Clone, PartialEq, Eq, Hash)]
152struct CheckKey {
153    pv_name: String,
154    host: String,
155    user: String,
156    method: String,
157    authority: String,
158    roles: Vec<String>,
159}
160
161#[derive(Clone, Copy)]
162struct CachedCheck {
163    acl_version: u64,
164    asg_generation: u64,
165    /// `Arc::as_ptr` of the ACF config the entry was computed against.
166    cfg_ident: usize,
167    level: AccessLevel,
168    rule_was_trap: bool,
169}
170
171/// Bound on distinct (pv × credential) entries; overflow flushes the
172/// map (a re-walk per entry is exactly the pre-cache behaviour).
173const CHECK_CACHE_CAP: usize = 4096;
174
175#[derive(Clone)]
176enum AclVersionSource {
177    Atomic(std::sync::Arc<std::sync::atomic::AtomicU64>),
178    Aggregator(std::sync::Arc<dyn Fn() -> u64 + Send + Sync>),
179}
180
181/// Asynchronous closure that resolves `pv_name → (ASG, ASL)` for a
182/// source. Sources install one when constructing an
183/// [`AccessGate::required`].
184pub type AsgAslResolver = std::sync::Arc<
185    dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, u8)> + Send>>
186        + Send
187        + Sync,
188>;
189
190/// resolves an ASG `INP*` link string (typically a
191/// `record.field` PV name) to its current numeric value, or `None` when
192/// the input is unresolvable / disconnected (bad input → the CALC-gated
193/// rule denies). Installed on an [`AccessGate`] by the owning server so
194/// `check` can evaluate `RULE(...) { CALC(...) }` against live values.
195/// Async because the value typically lives behind the server's async
196/// database lock; [`AccessGate::check_with_roles`] resolves the ASG's
197/// links up front, then evaluates the (sync) expression.
198pub type InpResolver = std::sync::Arc<
199    dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<f64>> + Send>>
200        + Send
201        + Sync,
202>;
203
204/// The shared Access Security policy cell — one per server, cloned into every
205/// [`AccessGate`] built from it.
206///
207/// Lock-free by construction: a reader takes an `Arc` snapshot of the policy
208/// and an operator reload publishes a whole new one. This is the ACF cell
209/// `epics-pva-rs` and `epics-ca-rs` share. It used to be a
210/// `tokio::sync::RwLock` whose read guard was held across the *whole* check —
211/// including the async ASG resolve and every CALC `INP*` resolve — so a
212/// preempted low-priority `CAS-client` / `PVAS-conn` thread could hold an
213/// operator reload, and any higher-priority checker behind it, off for an
214/// unbounded, kernel-invisible time.
215///
216/// The observable check semantics are unchanged: an in-flight check still
217/// completes against the policy it started with, because it holds that `Arc`
218/// for its whole body. Only the writer changes — it publishes instead of
219/// waiting for in-flight readers.
220///
221/// A newtype (not an alias) so every post-construction swap goes through
222/// [`AcfCell::store`], which fires [`notify_asg_field_changed`] — the one
223/// change signal policy-derived caches ([`AccessGate`]'s check cache, the
224/// QSRV grant cache) and the CA server's `reeval_access_rights` path key
225/// on. A raw `ArcSwapOption` swap would update enforcement (checks load
226/// per-op) but leave those caches and the wire ACCESS_RIGHTS stale.
227#[derive(Clone)]
228pub struct AcfCell(std::sync::Arc<arc_swap::ArcSwapOption<AccessSecurityConfig>>);
229
230impl AcfCell {
231    /// Snapshot the current policy (lock-free guard).
232    pub fn load(&self) -> arc_swap::Guard<Option<std::sync::Arc<AccessSecurityConfig>>> {
233        self.0.load()
234    }
235
236    /// Snapshot the current policy as an owned `Option<Arc<..>>`.
237    pub fn load_full(&self) -> Option<std::sync::Arc<AccessSecurityConfig>> {
238        self.0.load_full()
239    }
240
241    /// Publish a new policy (or `None` to clear it) and fire the
242    /// process-wide access-policy change notification so live
243    /// connections re-evaluate their rights and derived caches drop
244    /// their entries.
245    pub fn store(&self, value: Option<std::sync::Arc<AccessSecurityConfig>>) {
246        self.0.store(value);
247        notify_asg_field_changed();
248    }
249}
250
251/// Build a shared [`AcfCell`] holding `initial`. The single construction
252/// point, so no caller has to name `arc_swap` or get the `Arc` nesting right.
253pub fn new_acf_cell(initial: Option<AccessSecurityConfig>) -> AcfCell {
254    AcfCell(std::sync::Arc::new(arc_swap::ArcSwapOption::new(
255        initial.map(std::sync::Arc::new),
256    )))
257}
258
259/// Build a shared [`AcfCell`] that serves `db`, with its ASG `INP*` watcher
260/// already running (C `asCa.c`, see `spawn_asg_inp_watcher`).
261///
262/// The constructor every server that enforces a policy over a record database
263/// must use. Access levels are cached per channel, so a policy cell without
264/// the watcher silently keeps a `CALC`-gated grant alive after the gate
265/// closes; welding the watcher to construction is what stops the next serving
266/// entry point from re-opening that hole. [`new_acf_cell`] stays for the cells
267/// that gate no database — the gateways' proxied namespace, fixtures.
268///
269/// Must be called from within the runtime: it spawns.
270pub fn new_acf_cell_watching(
271    initial: Option<AccessSecurityConfig>,
272    db: &std::sync::Arc<crate::server::database::PvDatabase>,
273) -> AcfCell {
274    let cell = new_acf_cell(initial);
275    spawn_asg_inp_watcher(db, &cell);
276    cell
277}
278
279/// Start the housekeeping an [`AcfCell`] that gates `db` must have: the ASG
280/// `INP*` watcher (C `asCaStart`, `asCa.c:180-205`) and the HAG DNS
281/// refresher.
282///
283/// Split from [`new_acf_cell_watching`] for one reason. Both tasks run on the
284/// process-global callback pool, and building that pool is `iocInit`'s job in
285/// C — `callbackInit` sits inside `iocBuild_1` (`iocInit.c:152`) — because the
286/// pool reads `callbackSetQueueSize` / `callbackParallelThreads` once, when it
287/// is constructed, and both commands refuse afterwards (`callback.c:106-109`,
288/// `:162-165`). Anything that spawns on the pool before the startup script
289/// runs therefore silently disarms those two commands for the whole script.
290/// So an IOC with a lifecycle creates its cell with [`new_acf_cell`] before
291/// the script and calls this from the build.
292///
293/// That leaves the cell unwatched for the length of the script, which cannot
294/// re-open the cached-grant hole [`new_acf_cell_watching`] exists to close:
295/// nothing is serving the database yet, so no channel is holding an access
296/// level to go stale. A caller with no such lifecycle — a bare
297/// `CaServerBuilder`/`PvaServerBuilder`, already past `iocInit` by
298/// construction — keeps using [`new_acf_cell_watching`].
299///
300/// Must be called from within the runtime: it spawns.
301pub fn start_acf_watchers(
302    db: &std::sync::Arc<crate::server::database::PvDatabase>,
303    cell: &AcfCell,
304) {
305    spawn_asg_inp_watcher(db, cell);
306    spawn_hag_refresh(cell);
307}
308
309/// HAG DNS re-resolution cadence — the same 60 s the CA client's
310/// `refresh_dns` interval uses for its half of epics-base#863.
311const HAG_DNS_REFRESH: std::time::Duration = std::time::Duration::from_secs(60);
312
313/// Spawn the periodic HAG re-resolution task for `cell` (UI-107 /
314/// epics-base#863, access-security half). Every `HAG_DNS_REFRESH`,
315/// when `asCheckClientIP` is on and a policy is loaded, re-resolves the
316/// raw HAG spellings through [`AccessSecurityConfig::with_refreshed_hags`]
317/// and republishes a changed config via [`AcfCell::store`] — the same
318/// notification path `asInit` uses, so live clients re-evaluate their
319/// rights automatically. C recovers stale HAG IPs only on a manual
320/// `asInit`; this is the sibling of the CA-side `refresh_dns` deviation.
321///
322/// Resolution runs inline in the task (the established `refresh_dns`
323/// pattern): a wedged resolver delays this refresher, nothing else. The
324/// task holds only a `Weak` to the cell and ends when the owning IOC
325/// drops it.
326fn spawn_hag_refresh(cell: &AcfCell) {
327    let weak = std::sync::Arc::downgrade(&cell.0);
328    // Middle band: an IOC-wide HAG re-resolution owns no record and so has no
329    // PRIO to read. It is one of the two entries that make `callbackQueueShow`
330    // report a cbMedium high-water of 2 where C reports 0 — C has no HAG
331    // refresher at all — and that difference is intended, not a leak.
332    crate::runtime::task::spawn_background(
333        crate::runtime::task::CallbackPriority::Medium,
334        async move {
335            loop {
336                crate::runtime::task::sleep_background(HAG_DNS_REFRESH).await;
337                let Some(inner) = weak.upgrade() else { break };
338                if !as_check_client_ip() {
339                    continue;
340                }
341                let Some(config) = inner.load_full() else {
342                    continue;
343                };
344                if let Some(refreshed) = config.with_refreshed_hags() {
345                    tracing::info!(
346                        target: "epics_base_rs::access_security",
347                        "HAG DNS refresh: re-resolved members changed; republishing policy"
348                    );
349                    AcfCell(inner).store(Some(std::sync::Arc::new(refreshed)));
350                }
351            }
352        },
353    );
354}
355
356/// Retry cadence for an ASG `INP*` link whose record is not in the database
357/// yet. C reaches the same place through CA: `asCaStart` creates a channel per
358/// link (`asCa.c:180-205`) and the search retries until the record appears, so
359/// an input declared before its record loads is still monitored afterwards.
360const ASG_INP_RETRY: std::time::Duration = std::time::Duration::from_secs(10);
361
362/// Spawn the ASG `INP*` value watcher for `cell` over `db` — C `asCa.c`.
363///
364/// C monitors every ASG input link and each update runs
365/// `pasg->inpChanged |= (1<<idx); if(!caInitializing) asComputeAsg(pasg);`
366/// (`asCa.c:148-161`), reaching `asComputePvt` (`asLibRoutines.c:1049-1051`)
367/// which fires `asClientCOAR` for every client whose level moved. The port had
368/// no such monitor: a level was recomputed only on an ACF reload or a write to
369/// a record's `ASG` field, so shutting a `CALC`-gated interlock left every
370/// already-connected client holding the WRITE grant it was given when the gate
371/// was open, and a client that connected while it was shut stayed read-only
372/// after it opened.
373///
374/// This is that monitor, in-process: one `EventMask::VALUE` subscription per
375/// distinct link target ([`AccessSecurityConfig::inp_link_targets`]), and
376/// [`notify_asg_field_changed`] on any post. That is the signal
377/// [`AcfCell::store`] already raises, so the CA server's `reeval_access_rights`
378/// and the QSRV grant cache need no new plumbing. Like C's `asComputeAsg` this
379/// re-evaluates every client rather than only the ASGs reading the changed
380/// link; the downstream `oldaccess != access` gate keeps the wire cost at zero
381/// when no level moved.
382///
383/// The task holds only `Weak`s to the cell and the database, and ends when the
384/// owning IOC drops either.
385fn spawn_asg_inp_watcher(db: &std::sync::Arc<crate::server::database::PvDatabase>, cell: &AcfCell) {
386    let weak_cell = std::sync::Arc::downgrade(&cell.0);
387    let weak_db = std::sync::Arc::downgrade(db);
388    let mut acf_rx = subscribe_asg_changes();
389    // Middle band: the watcher spans every ASG INP link in the IOC, so no one
390    // record's PRIO applies. It is the other entry behind the cbMedium
391    // high-water of 2: C does this work on `asCaTask`, a CA client thread that
392    // is not in the callback pool, so its own `callbackQueueShow` reports 0.
393    crate::runtime::task::spawn_background(
394        crate::runtime::task::CallbackPriority::Medium,
395        async move {
396            // C `asCaTask` registers with the watchdog as its first act
397            // (`asCa.c:171`) and removes on the way out (`:232`) — but the
398            // thread itself only exists between `asCaStart` and `asCaStop`,
399            // which nothing but `asInitCommon` calls (`asDbLib.c:147`, `:136`).
400            // A C IOC with no access-security file therefore lists no
401            // `asCaTask` at all. Here the watcher is welded to the cell
402            // instead, so it is the entry that carries C's condition: held
403            // exactly while there is a policy to serve, and released the
404            // moment there is not. Unbounded, as C's is: the loop parks on its
405            // ASG INP readers, and access inputs that never change are not a
406            // fault.
407            let mut watched: Option<crate::runtime::taskwd::TaskwdEntry> = None;
408            enum Wake {
409                /// A watched link posted a new value.
410                Values,
411                /// Re-derive the watch set (policy may have been replaced, or a
412                /// link's record may have loaded since the last attempt).
413                Rebuild,
414                Stop,
415            }
416            let mut readers: Vec<crate::server::event_queue::EventReader> = Vec::new();
417            // Targets not yet attached because their record is not loaded.
418            let mut pending: Vec<(String, String)> = Vec::new();
419            // Identity of the policy `readers` was built from. A notification this
420            // task raises itself does not move it, so the watcher cannot re-enter
421            // its own rebuild.
422            let mut built_from: usize = 0;
423
424            loop {
425                let (Some(inner), Some(db)) = (weak_cell.upgrade(), weak_db.upgrade()) else {
426                    break;
427                };
428                let config = inner.load_full();
429                drop(inner);
430                match (config.is_some(), watched.is_some()) {
431                    (true, false) => {
432                        watched = Some(crate::runtime::taskwd::taskwd_insert(
433                            "asCaTask",
434                            crate::runtime::taskwd::CheckIn::Unbounded,
435                            None,
436                        ))
437                    }
438                    (false, true) => watched = None,
439                    _ => {}
440                }
441                let id = config
442                    .as_ref()
443                    .map_or(0, |c| std::sync::Arc::as_ptr(c) as usize);
444                if id != built_from {
445                    built_from = id;
446                    readers.clear();
447                    pending = config.map(|c| c.inp_link_targets()).unwrap_or_default();
448                }
449                pending.retain(|(record, field)| !attach_asg_inp(&db, record, field, &mut readers));
450                drop(db);
451
452                let wake = tokio::select! {
453                    r = acf_rx.recv() => match r {
454                        Err(tokio::sync::broadcast::error::RecvError::Closed) => Wake::Stop,
455                        _ => Wake::Rebuild,
456                    },
457                    () = drain_any_asg_inp(&mut readers) => Wake::Values,
458                    () = crate::runtime::task::sleep_background(ASG_INP_RETRY) => Wake::Rebuild,
459                };
460                match wake {
461                    Wake::Stop => break,
462                    Wake::Rebuild => {}
463                    Wake::Values => notify_asg_field_changed(),
464                }
465            }
466        },
467    );
468}
469
470/// Subscribe one `INP*` target to its record's value events. `false` = not
471/// attached, retry later; the record is not in the database yet, or its
472/// subscriber cap is full (which frees again as clients disconnect, and losing
473/// a security monitor is worth the retry's log noise).
474fn attach_asg_inp(
475    db: &crate::server::database::PvDatabase,
476    record: &str,
477    field: &str,
478    readers: &mut Vec<crate::server::event_queue::EventReader>,
479) -> bool {
480    let Some(rec) = db.get_record(record) else {
481        return false;
482    };
483    let reader = rec.write().add_subscriber(
484        field,
485        0,
486        crate::types::DbFieldType::Double,
487        crate::server::recgbl::EventMask::VALUE.bits(),
488    );
489    match reader {
490        Some(reader) => {
491            readers.push(reader);
492            true
493        }
494        None => false,
495    }
496}
497
498/// Resolve once any watched link has posted, having drained every queued post
499/// so one re-evaluation covers a burst. C coalesces the same way — many
500/// `asComputeAsg` calls, one `asClientCOAR` per actual level change.
501async fn drain_any_asg_inp(readers: &mut [crate::server::event_queue::EventReader]) {
502    std::future::poll_fn(|cx| {
503        let mut fired = false;
504        for reader in readers.iter_mut() {
505            while let std::task::Poll::Ready(Some(_)) = reader.poll_recv(cx) {
506                fired = true;
507            }
508        }
509        if fired {
510            std::task::Poll::Ready(())
511        } else {
512            std::task::Poll::Pending
513        }
514    })
515    .await
516}
517
518#[derive(Clone)]
519enum AccessGateInner {
520    /// ACF cell + resolver. The cell may hold `None` for "no
521    /// policy attached" — the gate then issues permissive tokens
522    /// (level = `ReadWrite`) so legacy behaviour is preserved when
523    /// the operator hasn't loaded an ACF file.
524    Required {
525        acf: AcfCell,
526        resolver: AsgAslResolver,
527    },
528    /// Always-permissive. Used by sources that have no security
529    /// boundary by design (composite test fixtures, ControlSource
530    /// for gateway diagnostic PVs, etc.).
531    Open,
532}
533
534impl AccessGate {
535    /// Build a gate that consults an ACF cell + a per-name
536    /// `(ASG, ASL)` resolver. Allocates a fresh `acl_version`
537    /// counter; use [`Self::required_with_version`] to share the
538    /// counter with the owning server (so its `reload_acf_from`
539    /// can signal the same generation bump this gate observes).
540    pub fn required(acf: AcfCell, resolver: AsgAslResolver) -> Self {
541        Self::required_with_version(
542            acf,
543            resolver,
544            std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
545        )
546    }
547
548    /// Build a gate with an externally-supplied `acl_version`
549    /// counter. The owning server (e.g. `PvaServer`) keeps the
550    /// same `Arc` and `fetch_add`s on every `reload_acf_from` /
551    /// `clear_acf` so monitor tasks holding the gate observe a
552    /// version bump on their next event.
553    pub fn required_with_version(
554        acf: AcfCell,
555        resolver: AsgAslResolver,
556        acl_version: std::sync::Arc<std::sync::atomic::AtomicU64>,
557    ) -> Self {
558        Self {
559            inner: AccessGateInner::Required { acf, resolver },
560            acl_version: AclVersionSource::Atomic(acl_version),
561            inp_resolver: None,
562            check_cache: std::sync::Arc::new(parking_lot::RwLock::new(HashMap::new())),
563        }
564    }
565
566    /// attach an `INP*`-link value resolver so CALC-gated ACF
567    /// rules are evaluated against live values instead of failing
568    /// closed. The owning server installs one backed by its PV value
569    /// registry.
570    pub fn with_inp_resolver(mut self, resolver: InpResolver) -> Self {
571        self.inp_resolver = Some(resolver);
572        self
573    }
574
575    /// Build a gate that grants `ReadWrite` to everyone. Used for
576    /// sources that have no ACF semantics — composite test
577    /// fixtures, in-process diagnostic sources, etc.
578    pub fn open() -> Self {
579        Self {
580            inner: AccessGateInner::Open,
581            acl_version: AclVersionSource::Atomic(std::sync::Arc::new(
582                std::sync::atomic::AtomicU64::new(0),
583            )),
584            inp_resolver: None,
585            check_cache: std::sync::Arc::new(parking_lot::RwLock::new(HashMap::new())),
586        }
587    }
588
589    /// Build a permissive gate whose `acl_version()` is derived
590    /// from a caller-supplied closure. Used by `CompositeSource`
591    /// to aggregate inner sub-gates' versions — the closure
592    /// returns `wrapping_sum(inner.access_gate().acl_version())`
593    /// so a bump on any sub-source moves the aggregate (every
594    /// per-inner version is monotonic via `fetch_add`, so the sum
595    /// changes iff some inner moved). NOT `max(...)` — that shape
596    /// produced false negatives when a smaller inner bumped under
597    /// the existing peak. This gate is only
598    /// a **change signal** for the monitor reload loop; the
599    /// allow/deny authority is the matched inner source's gate,
600    /// reached via `ChannelSource::revalidate_read`.
601    ///
602    /// `bump_acl_version()` on an `Aggregator` gate is a no-op:
603    /// the version is derived, not owned. The aggregator's
604    /// underlying gates own their own counters.
605    pub fn open_with_aggregator(f: std::sync::Arc<dyn Fn() -> u64 + Send + Sync>) -> Self {
606        Self {
607            inner: AccessGateInner::Open,
608            acl_version: AclVersionSource::Aggregator(f),
609            inp_resolver: None,
610            check_cache: std::sync::Arc::new(parking_lot::RwLock::new(HashMap::new())),
611        }
612    }
613
614    /// Current ACL generation. Monitor / subscription tasks capture
615    /// this at spawn time and compare on each event. A bump (via
616    /// [`Self::bump_acl_version`]) signals "the underlying ACF
617    /// changed — re-check before forwarding the next event".
618    pub fn acl_version(&self) -> u64 {
619        match &self.acl_version {
620            AclVersionSource::Atomic(a) => a.load(std::sync::atomic::Ordering::Acquire),
621            AclVersionSource::Aggregator(f) => f(),
622        }
623    }
624
625    /// Bump the ACL generation. Called by the owning server after
626    /// swapping the ACF policy. Long-lived consumers detect the
627    /// change on their next event and re-check.
628    ///
629    /// On an `Aggregator`-backed gate this is a no-op — the
630    /// version is read-through to the underlying gates, which own
631    /// their own counters.
632    pub fn bump_acl_version(&self) {
633        if let AclVersionSource::Atomic(a) = &self.acl_version {
634            a.fetch_add(1, std::sync::atomic::Ordering::Release);
635        }
636    }
637
638    /// Perform the access check for `pv_name` under the connecting
639    /// peer's `(host, user, method, authority)`. Returns the only
640    /// kind of value the source's op methods will accept.
641    pub async fn check(
642        &self,
643        pv_name: impl Into<String>,
644        host: &str,
645        user: &str,
646        method: &str,
647        authority: &str,
648    ) -> AccessChecked {
649        self.check_with_roles(pv_name, host, user, &[], method, authority)
650            .await
651    }
652
653    /// Like [`Self::check`] but with the client's
654    /// `roles` (QSRV local-group-derived credentials) so a `role/<name>`
655    /// UAG member can match, and with CALC-gated rules evaluated against
656    /// the installed [`InpResolver`] (fail closed when none is set).
657    pub async fn check_with_roles(
658        &self,
659        pv_name: impl Into<String>,
660        host: &str,
661        user: &str,
662        roles: &[String],
663        method: &str,
664        authority: &str,
665    ) -> AccessChecked {
666        let pv_name = pv_name.into();
667        // An `Open` gate and an unattached ACF cell both grant
668        // `ReadWrite`; neither resolved through an ACF rule, so the
669        // trap mask is `false` (no `TRAPWRITE` rule applied).
670        let (level, rule_was_trap) = match &self.inner {
671            AccessGateInner::Open => (AccessLevel::ReadWrite, false),
672            AccessGateInner::Required { acf, resolver } => {
673                match acf.load_full() {
674                    None => (AccessLevel::ReadWrite, false),
675                    Some(cfg) => {
676                        // See the [`Self::check_cache`] field doc: cache
677                        // only walk results that read no live values, and
678                        // snapshot every stamp BEFORE the compute so a
679                        // change racing it invalidates the entry instead
680                        // of being lost.
681                        let acl_version = self.acl_version();
682                        let asg_generation = asg_change_generation();
683                        let cfg_ident = std::sync::Arc::as_ptr(&cfg) as usize;
684                        let key = self.inp_resolver.is_none().then(|| CheckKey {
685                            pv_name: pv_name.clone(),
686                            host: host.to_string(),
687                            user: user.to_string(),
688                            method: method.to_string(),
689                            authority: authority.to_string(),
690                            roles: roles.to_vec(),
691                        });
692                        if let Some(ref key) = key
693                            && let Some(hit) = self.check_cache.read().get(key)
694                            && hit.acl_version == acl_version
695                            && hit.asg_generation == asg_generation
696                            && hit.cfg_ident == cfg_ident
697                        {
698                            return AccessChecked {
699                                pv_name,
700                                level: hit.level,
701                                rule_was_trap: hit.rule_was_trap,
702                                _seal: AccessSeal,
703                            };
704                        }
705                        let (asg, asl) = resolver(pv_name.clone()).await;
706                        // pre-resolve the ASG's INP* links up
707                        // front — the resolver is async (it reads the
708                        // server DB). `Some(inputs)` when every declared
709                        // link resolved; `None` when there is no resolver
710                        // or any input is bad/disconnected → CALC fails
711                        // closed. Each rule's expression is then evaluated
712                        // synchronously in `compute_rules`.
713                        let inp_values: Option<AsgInputs> = match self.inp_resolver {
714                            None => None,
715                            Some(ref res) => {
716                                let mut inputs = AsgInputs::default();
717                                if let Some(group) =
718                                    cfg.asg.get(&asg).or_else(|| cfg.asg.get("DEFAULT"))
719                                {
720                                    for inp in &group.inp {
721                                        inputs.record(inp.index, res(inp.link.clone()).await);
722                                    }
723                                }
724                                Some(inputs)
725                            }
726                        };
727                        let (level, rule_was_trap) = cfg.compute_for_name(
728                            &asg,
729                            host,
730                            user,
731                            roles,
732                            asl,
733                            method,
734                            authority,
735                            inp_values.as_ref(),
736                        );
737                        if let Some(key) = key {
738                            let mut cache = self.check_cache.write();
739                            if cache.len() >= CHECK_CACHE_CAP {
740                                cache.clear();
741                            }
742                            cache.insert(
743                                key,
744                                CachedCheck {
745                                    acl_version,
746                                    asg_generation,
747                                    cfg_ident,
748                                    level,
749                                    rule_was_trap,
750                                },
751                            );
752                        }
753                        (level, rule_was_trap)
754                    }
755                }
756            }
757        };
758        AccessChecked {
759            pv_name,
760            level,
761            rule_was_trap,
762            _seal: AccessSeal,
763        }
764    }
765}
766
767#[cfg(test)]
768mod access_checked_tests {
769    use super::*;
770    use std::sync::Arc;
771
772    #[epics_macros_rs::epics_test]
773    async fn open_gate_grants_read_write() {
774        let gate = AccessGate::open();
775        let checked = gate.check("any:pv", "h", "u", "anonymous", "").await;
776        assert_eq!(checked.level(), AccessLevel::ReadWrite);
777        assert!(checked.allows_read());
778        assert!(checked.allows_write());
779        assert_eq!(checked.pv_name(), "any:pv");
780    }
781
782    #[epics_macros_rs::epics_test]
783    async fn required_gate_with_no_acf_attached_is_permissive() {
784        let cell = crate::server::access_security::new_acf_cell(None);
785        let resolver: AsgAslResolver =
786            Arc::new(|_pv| Box::pin(async { ("DEFAULT".to_string(), 0u8) }));
787        let gate = AccessGate::required(cell, resolver);
788        let checked = gate.check("any:pv", "h", "u", "anonymous", "").await;
789        assert_eq!(checked.level(), AccessLevel::ReadWrite);
790    }
791
792    #[epics_macros_rs::epics_test]
793    async fn required_gate_with_acf_denies_unprivileged_peer() {
794        let cfg = parse_acf(
795            r#"
796UAG(ops) { alice }
797ASG(DEFAULT) {
798    RULE(0, READ) { UAG(ops) }
799}
800"#,
801        )
802        .unwrap();
803        let cell = crate::server::access_security::new_acf_cell(Some(cfg));
804        let resolver: AsgAslResolver =
805            Arc::new(|_pv| Box::pin(async { ("DEFAULT".to_string(), 0u8) }));
806        let gate = AccessGate::required(cell, resolver);
807
808        let allowed = gate.check("x", "h", "alice", "anonymous", "").await;
809        assert!(allowed.allows_read());
810        assert!(!allowed.allows_write());
811
812        let denied = gate.check("x", "h", "intruder", "anonymous", "").await;
813        assert_eq!(denied.level(), AccessLevel::NoAccess);
814        assert!(!denied.allows_read());
815    }
816
817    /// The gate's check cache must not outlive its policy: a cell swap
818    /// (ACF reload) has to miss even when the caller forgets the
819    /// `bump_acl_version` convention — the config-`Arc` identity stamp
820    /// is what closes that path. The pre-swap repeat exercises the hit
821    /// path against the same policy.
822    #[epics_macros_rs::epics_test]
823    async fn check_cache_misses_on_acf_swap_without_version_bump() {
824        let cfg_deny = parse_acf(
825            r#"
826ASG(DEFAULT) {
827}
828"#,
829        )
830        .unwrap();
831        let cfg_allow = parse_acf(
832            r#"
833ASG(DEFAULT) {
834    RULE(1, WRITE)
835}
836"#,
837        )
838        .unwrap();
839        let cell = crate::server::access_security::new_acf_cell(Some(cfg_deny));
840        let resolver: AsgAslResolver =
841            Arc::new(|_pv| Box::pin(async { ("DEFAULT".to_string(), 0u8) }));
842        let gate = AccessGate::required(cell.clone(), resolver);
843
844        assert!(
845            !gate
846                .check("x", "h", "u", "anonymous", "")
847                .await
848                .allows_write()
849        );
850        // Cache-hit path, same policy.
851        assert!(
852            !gate
853                .check("x", "h", "u", "anonymous", "")
854                .await
855                .allows_write()
856        );
857
858        // Swap the policy WITHOUT bumping acl_version.
859        cell.store(Some(Arc::new(cfg_allow)));
860        assert!(
861            gate.check("x", "h", "u", "anonymous", "")
862                .await
863                .allows_write()
864        );
865    }
866}
867
868/// Access granted by a matching `RULE`. Mirrors the C three-way
869/// `asAccessRights` enum (`asNOACCESS` / `asREAD` / `asWRITE`) used by
870/// `rule_head_mandatory` in `asLib.y:253-269`. The Rust port previously
871/// collapsed this to a `write: bool`, which turned `RULE(0, NONE)` —
872/// and any misspelled keyword — into a READ-granting rule.
873#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
874pub enum RuleAccess {
875    /// `RULE(N, NONE)` — grants `asNOACCESS`.
876    #[default]
877    None,
878    /// `RULE(N, READ)` — grants `asREAD`.
879    Read,
880    /// `RULE(N, WRITE)` — grants `asWRITE`.
881    Write,
882}
883
884/// A single access rule within an ASG.
885#[derive(Debug, Clone, Default)]
886pub struct AccessRule {
887    pub level: u8,
888    /// Three-way access this rule grants when it matches. C
889    /// `asLib.y:259-267` distinguishes `NONE`/`READ`/`WRITE`.
890    pub access: RuleAccess,
891    pub uag: Vec<String>,
892    pub hag: Vec<String>,
893    /// Authentication method scope (epics-base PR #563). When set,
894    /// the rule only applies when the requesting client authenticated
895    /// via one of the listed methods. Common values: `"anonymous"`,
896    /// `"ca"`, `"x509"`, `"cap-token"`. Empty vector means "any method".
897    pub method: Vec<String>,
898    /// Cert authority / issuer scope (epics-base PR #563 + #618).
899    /// When set, the rule only applies when the client's authenticator
900    /// was vouched by one of the listed authorities — e.g. an
901    /// X.509 issuer DN, or the cap-token issuer ID. Empty means "any
902    /// authority".
903    pub authority: Vec<String>,
904    /// Write-trap mask (epics-base `asLib.y:272-283` `rule_log_option`,
905    /// `AS_TRAP_WRITE`). `true` when the RULE header carried the
906    /// `TRAPWRITE` option, `false` for `NOTRAPWRITE` or no option.
907    /// The mask is what arms the put-log bracket: a matching rule hands
908    /// it out with the access it grants, [`trap_write_armed`] folds it
909    /// with C's `asActive` test (`asLib.h:57-60`
910    /// `asTrapWriteWithData`), and [`TrapWriteGuard`] fires the
911    /// Before/After pair that C's `asTrapWriteBeforeWithData`
912    /// (`asTrapWrite.c:114`) and `asTrapWriteAfterWrite` fire. What is
913    /// per-server is only the LISTENER, registered through
914    /// [`register_trap_write_listener`] — C keeps the same split
915    /// (`asTrapWrite.c:122-123` returns 0 on an empty `listenerList`).
916    pub trap: bool,
917    /// CALC condition expression (epics-base `asLib.y:294-299`,
918    /// `RULE(...) { CALC("A=1") }`). `None` means an unconditional
919    /// rule. When `Some`, the rule only grants access while the
920    /// expression evaluates to 1 against the ASG's `INP*` link values.
921    pub calc: Option<String>,
922    /// The expression compiled at ACF parse — C compiles a RULE's CALC
923    /// once at load (`asAsgRuleCalc` runs `postfix()`), then every
924    /// `asComputePvt` evaluates the stored RPN. `parse_acf` upholds
925    /// `calc.is_some() ⟹ calc_compiled.is_some()`; a hand-built rule
926    /// that carries `calc` text without the compiled form fails closed
927    /// in `compute_rules`.
928    pub calc_compiled: Option<crate::calc::CompiledExpr>,
929    /// The arguments this rule's CALC expression READS, as a bitmap over
930    /// A..U — C's `pasgrule->inpUsed`, computed once at load by
931    /// `calcArgUsage` (`asLibRoutines.c:1416`). `asComputePvt` (`:1048`)
932    /// intersects it with the ASG's `inpBad` so an unresolvable link disables
933    /// only the rules that actually read it. `0` for a rule with no CALC.
934    pub inp_used: u32,
935    /// True when the rule must be treated as inert by `asComputePvt`.
936    /// C `asAsgRuleDisable` (`asLib.y:300-306`) sets `pasgrule->ignore`
937    /// for a RULE that contains an unsupported keyword. This port also
938    /// sets it for a `CALC` clause that cannot be evaluated here (no
939    /// `INP*` link resolution), so an un-evaluable conditional rule
940    /// fails CLOSED instead of becoming unconditional.
941    pub ignore: bool,
942}
943
944/// The access a matching rule grants, with the `ignore` flag folded in
945/// — an ignored rule is inert (`None`). Helper for `asComputePvt`.
946fn rule_access(rule: &AccessRule) -> AccessLevel {
947    if rule.ignore {
948        return AccessLevel::NoAccess;
949    }
950    match rule.access {
951        RuleAccess::None => AccessLevel::NoAccess,
952        RuleAccess::Read => AccessLevel::Read,
953        RuleAccess::Write => AccessLevel::ReadWrite,
954    }
955}
956
957/// C's truth test for a `RULE(...) { CALC(...) }` result
958/// (`asLibRoutines.c:972`):
959///
960/// ```c
961/// pasgrule->result = ((result>.99) && (result<1.01)) ? 1 : 0;
962/// ```
963///
964/// consumed at `:1048` as `pasgrule->result==1`. The open interval is a
965/// deliberate tolerance for float error around 1, NOT a shorthand for
966/// "non-zero": C refuses a rule whose CALC returns 2, -1, 0.5 or 3. Testing
967/// `result != 0.0` instead — which both of this port's former evaluators did
968/// — grants WRITE on every truthy non-unity result.
969fn calc_result_is_true(result: f64) -> bool {
970    result > 0.99 && result < 1.01
971}
972
973/// Monotonic ordering of access levels used by `asComputePvt`'s
974/// `access >= pasgrule->access` short-circuit.
975fn rule_rank(level: AccessLevel) -> u8 {
976    match level {
977        AccessLevel::NoAccess => 0,
978        AccessLevel::Read => 1,
979        AccessLevel::ReadWrite => 2,
980    }
981}
982
983/// Access Security Group.
984#[derive(Debug, Clone, Default)]
985pub struct AccessSecurityGroup {
986    pub rules: Vec<AccessRule>,
987    /// `INP(A..U)` database link declarations (epics-base
988    /// `asLib.y:234-243`). Index 0 = `INPA`, .. 20 = `INPU`. Each
989    /// entry is the link string. Stored for `asdbdump` / `ascar`
990    /// inspection and to feed `CALC` rule evaluation; the link
991    /// values are not resolved by this crate (see `AccessRule::calc`).
992    pub inp: Vec<AsgInp>,
993}
994
995/// The live state of an ASG's `INP(A..U)` links: the resolved values C keeps
996/// in `pasg->pavalue[]` and the `inpBad` bitmap it keeps alongside them.
997///
998/// C sets a bit per *input* (`asCa.c connectCallback:91-105`, on a channel
999/// that is not connected) and `asComputePvt` (`asLibRoutines.c:1048`) tests it
1000/// against the *rule's* own `inpUsed`:
1001///
1002/// ```c
1003/// if(!pasgrule->calc
1004/// || (!(pasg->inpBad & pasgrule->inpUsed) && (pasgrule->result==1)))
1005/// ```
1006///
1007/// so a bad input disables only the rules that read it. Both of this port's
1008/// resolvers used to abort their link walk on the first unresolvable link and
1009/// hand `None` to the evaluator, which failed EVERY CALC rule in the group —
1010/// one typo in an `INPB` no rule mentions took writes away from the whole ASG.
1011#[derive(Debug, Clone, Default)]
1012pub struct AsgInputs {
1013    /// Resolved values, indexed A..U.
1014    pub values: crate::calc::NumericInputs,
1015    /// Bit `i` set ⟹ `INP(i)` is declared but could not be resolved.
1016    pub bad: u32,
1017}
1018
1019impl AsgInputs {
1020    /// Record one declared link's resolution. `None` — no such record, no such
1021    /// field, a non-numeric value, a disconnected CA link — sets the input's
1022    /// `bad` bit and leaves its value at 0, which is what C holds for a
1023    /// channel that never connected.
1024    ///
1025    /// This is the single owner of "what an unresolvable INP link means";
1026    /// every resolver drives it rather than deciding for itself.
1027    pub fn record(&mut self, index: u8, value: Option<f64>) {
1028        let idx = index as usize;
1029        if idx >= crate::calc::CALC_NARGS {
1030            return;
1031        }
1032        match value {
1033            Some(v) => self.values.vars[idx] = v,
1034            None => self.bad |= 1u32 << idx,
1035        }
1036    }
1037}
1038
1039/// A single `INP(A..U)` link declaration within an ASG.
1040#[derive(Debug, Clone)]
1041pub struct AsgInp {
1042    /// Letter index: 0 = `A`, .. 20 = `U`.
1043    pub index: u8,
1044    /// The link string (typically a record.field PV name).
1045    pub link: String,
1046}
1047
1048/// Split an ASG `INP*` link into the `(record, field)` it names — C's
1049/// `dbNameToAddr` on the link string, with the `VAL` default a bare record
1050/// name carries.
1051///
1052/// The single owner of that split. The resolvers that READ a link and the
1053/// watcher that SUBSCRIBES to it must name the same field, or a value change
1054/// fires no re-evaluation.
1055pub fn inp_link_target(link: &str) -> (&str, &str) {
1056    let (record, field) = crate::server::database::parse_pv_name(link);
1057    (record, if field.is_empty() { "VAL" } else { field })
1058}
1059
1060/// Access Security Configuration parsed from an ACF file.
1061#[derive(Debug, Clone)]
1062pub struct AccessSecurityConfig {
1063    pub uag: HashMap<String, Vec<String>>,
1064    pub hag: HashMap<String, Vec<String>>,
1065    /// The HAG members exactly as spelled in the ACF, keyed like `hag`.
1066    /// `hag` stores `hag_members` resolution *output* (dotted quads
1067    /// under `asCheckClientIP`), which cannot be re-resolved after a
1068    /// DNS change; [`Self::with_refreshed_hags`] re-runs the resolution
1069    /// from these raw spellings (epics-base#863 / UI-107).
1070    pub hag_raw: HashMap<String, Vec<String>>,
1071    pub asg: HashMap<String, AccessSecurityGroup>,
1072    pub unknown_access: AccessLevel,
1073}
1074
1075impl AccessSecurityConfig {
1076    /// Re-run `hag_members` — the single resolution owner — over the
1077    /// raw HAG spellings and return the refreshed config when any
1078    /// stored member changed, `None` when resolution is unchanged.
1079    ///
1080    /// Only meaningful under `asCheckClientIP` (the default string
1081    /// mode stores lowercased literals that no DNS change can move);
1082    /// callers gate on [`as_check_client_ip`] before paying for
1083    /// resolution. C freezes HAG IPs at ACF load until a manual
1084    /// `asInit` (epics-base#863; its PR #862 moves upstream toward
1085    /// refresh) — this is the sibling of the CA-side `refresh_dns`
1086    /// deviation that closed the client half of that issue.
1087    pub fn with_refreshed_hags(&self) -> Option<Self> {
1088        let refreshed: HashMap<String, Vec<String>> = self
1089            .hag_raw
1090            .iter()
1091            .map(|(name, raw)| (name.clone(), hag_members(raw)))
1092            .collect();
1093        if refreshed == self.hag {
1094            return None;
1095        }
1096        let mut new = self.clone();
1097        new.hag = refreshed;
1098        Some(new)
1099    }
1100
1101    /// Render the parsed ACF (UAG/HAG/ASG with their `INP*` links and
1102    /// RULEs) in C `asDumpFP` shape, as a `String`.
1103    ///
1104    /// This is the single owner of the dump format: the `asdbdump` iocsh
1105    /// command and the CA gateway's R3 access-security report both render
1106    /// through here, so the two cannot drift. UAG, HAG, and ASG names are
1107    /// emitted in sorted order so the dump is stable across `HashMap`
1108    /// iteration order.
1109    ///
1110    /// The verbose member/client listing of C's
1111    /// `asDumpFP(fp, NULL, NULL, verbose=TRUE)` is intentionally *not*
1112    /// included: this crate models no live AS-member/client registry (see
1113    /// the `aspmem` iocsh command, which derives membership by scanning
1114    /// records rather than from an `asgMemberList`). The dump therefore
1115    /// covers the parsed configuration structures only.
1116    pub fn dump_report(&self) -> String {
1117        let mut out = String::new();
1118        let mut uags: Vec<_> = self.uag.keys().collect();
1119        uags.sort();
1120        for name in uags {
1121            out.push_str(&format!("UAG({name})\n"));
1122            for m in &self.uag[name] {
1123                out.push('\t');
1124                dump_quoted(&mut out, m);
1125                out.push('\n');
1126            }
1127        }
1128        let mut hags: Vec<_> = self.hag.keys().collect();
1129        hags.sort();
1130        for name in hags {
1131            out.push_str(&format!("HAG({name})\n"));
1132            for h in &self.hag[name] {
1133                out.push('\t');
1134                dump_quoted(&mut out, h);
1135                out.push('\n');
1136            }
1137        }
1138        let mut asgs: Vec<_> = self.asg.keys().collect();
1139        asgs.sort();
1140        for name in asgs {
1141            out.push_str(&format!("ASG({name})\n"));
1142            self.fmt_asg(name, &mut out);
1143        }
1144        out
1145    }
1146
1147    /// Append one ASG's `INP*` links and RULEs to `out`, in C `asDumpFP`
1148    /// shape. Shared by [`Self::dump_report`] and the `asprules` iocsh
1149    /// command's per-ASG renderer so the rule format has one owner.
1150    pub fn fmt_asg(&self, name: &str, out: &mut String) {
1151        let Some(asg) = self.asg.get(name) else {
1152            return;
1153        };
1154        for inp in &asg.inp {
1155            let letter = (b'A' + inp.index) as char;
1156            out.push_str(&format!("\tINP{letter}(\"{}\")\n", inp.link));
1157        }
1158        for rule in &asg.rules {
1159            let access = match rule.access {
1160                RuleAccess::None => "NONE",
1161                RuleAccess::Read => "READ",
1162                RuleAccess::Write => "WRITE",
1163            };
1164            let disabled = if rule.ignore { " [DISABLED]" } else { "" };
1165            out.push_str(&format!("\tRULE({},{access}){disabled}\n", rule.level));
1166            for u in &rule.uag {
1167                out.push_str(&format!("\t\tUAG({u})\n"));
1168            }
1169            for h in &rule.hag {
1170                out.push_str(&format!("\t\tHAG({h})\n"));
1171            }
1172            for m in &rule.method {
1173                out.push_str(&format!("\t\tMETHOD(\"{m}\")\n"));
1174            }
1175            for a in &rule.authority {
1176                out.push_str(&format!("\t\tAUTHORITY(\"{a}\")\n"));
1177            }
1178            if let Some(calc) = &rule.calc {
1179                out.push_str(&format!("\t\tCALC(\"{calc}\")\n"));
1180            }
1181        }
1182    }
1183
1184    /// Check access for a given ASG, hostname, and username.
1185    ///
1186    /// Convenience that omits the ASL gate (treats every rule as
1187    /// applicable). Equivalent to `check_access_asl(..., 0)` with
1188    /// rules typically declared at level 0/1. New code should call
1189    /// [`Self::check_access_asl`] so a per-record ASL can correctly
1190    /// disable a rule whose level is below the record's ASL.
1191    pub fn check_access(&self, asg_name: &str, host: &str, user: &str) -> AccessLevel {
1192        self.check_access_asl(asg_name, host, user, 0)
1193    }
1194
1195    /// Method/authority-aware access check. Mirrors epics-base PR
1196    /// #563 (METHOD/AUTHORITY) and PR #618 (cert-based ACF). When
1197    /// `method` and `authority` are provided, rules with non-empty
1198    /// `method`/`authority` lists are gated on a literal match.
1199    /// Rules with empty `method`/`authority` ignore those scopes
1200    /// (legacy behaviour preserved).
1201    pub fn check_access_method(
1202        &self,
1203        asg_name: &str,
1204        host: &str,
1205        user: &str,
1206        record_asl: u8,
1207        method: &str,
1208        authority: &str,
1209    ) -> AccessLevel {
1210        self.check_access_method_trap(asg_name, host, user, record_asl, method, authority)
1211            .0
1212    }
1213
1214    /// Method/authority-aware access check that also returns the
1215    /// write-trap mask of the rule that resolved the access level.
1216    ///
1217    /// Mirrors C `asComputePvt` (`asLibRoutines.c:983-1048`): the
1218    /// function tracks `trapMask` alongside `access`, and on every
1219    /// rule that *raises* the access level it copies that rule's
1220    /// `trapMask` (`asLibRoutines.c:1041-1042`). The final
1221    /// `pasgclient->trapMask` (`:1048`) is therefore the trap flag of
1222    /// the last rule that set the granted access — exactly the value
1223    /// `asTrapWriteWithData` (`rsrv/camessage.c:799-802`) consults to
1224    /// decide whether to invoke put-logging listeners.
1225    ///
1226    /// Returns `(level, rule_was_trap)`. `rule_was_trap` is `false`
1227    /// when access stays `NoAccess` (no rule matched), when the
1228    /// matching rule carried `NOTRAPWRITE`, and when it carried no
1229    /// trap option at all.
1230    /// Resolve `asg_name` (falling back to `DEFAULT`) and evaluate its
1231    /// rules with the given `roles` and the ASG's resolved `INP*` values.
1232    /// The single entry every CALC-aware caller uses — the CA server and
1233    /// [`AccessGate::check_with_roles`] alike.
1234    #[allow(clippy::too_many_arguments)]
1235    pub fn compute_for_name(
1236        &self,
1237        asg_name: &str,
1238        host: &str,
1239        user: &str,
1240        roles: &[String],
1241        record_asl: u8,
1242        method: &str,
1243        authority: &str,
1244        inputs: Option<&AsgInputs>,
1245    ) -> (AccessLevel, bool) {
1246        let asg = match self.asg.get(asg_name) {
1247            Some(a) => a,
1248            None => match self.asg.get("DEFAULT") {
1249                Some(a) => a,
1250                None => return (AccessLevel::NoAccess, false),
1251            },
1252        };
1253        self.compute_rules(
1254            asg, host, user, roles, record_asl, method, authority, inputs,
1255        )
1256    }
1257
1258    pub fn check_access_method_trap(
1259        &self,
1260        asg_name: &str,
1261        host: &str,
1262        user: &str,
1263        record_asl: u8,
1264        method: &str,
1265        authority: &str,
1266    ) -> (AccessLevel, bool) {
1267        // C `asAddMemberPvt` (asLibRoutines.c:893-928): a member whose
1268        // ASG name is not present in the parsed config is silently
1269        // reassigned to `DEFAULT`. `asInitialize` (asLibRoutines.c:107)
1270        // *always* synthesises a `DEFAULT` ASG before parsing, so this
1271        // lookup never legitimately misses — `parse_acf` reproduces
1272        // that by always inserting an (empty) `DEFAULT`. A missing
1273        // `DEFAULT` here would mean the config was built by hand
1274        // bypassing `parse_acf`; fail CLOSED rather than open.
1275        let asg = match self.asg.get(asg_name) {
1276            Some(a) => a,
1277            None => match self.asg.get("DEFAULT") {
1278                Some(a) => a,
1279                // Never grant ReadWrite on an ASG-lookup
1280                // miss. C resolves every miss to the always-present
1281                // empty `DEFAULT` ⇒ `asNOACCESS`.
1282                None => return (AccessLevel::NoAccess, false),
1283            },
1284        };
1285        // C `asComputePvt` (asLibRoutines.c:983) initialises
1286        // `access = asNOACCESS` and only ever *raises* it on a matching
1287        // RULE. An ASG with no RULE statements (`ASG(LOCKED) { }`)
1288        // therefore denies every client. Never short-circuit
1289        // an empty rule list to ReadWrite.
1290        //
1291        // An empty/unknown user or host cannot match a UAG/HAG-scoped
1292        // rule, but a rule with empty `uag`/`hag` lists still applies
1293        // (C `asComputePvt` only checks the UAG list when
1294        // `ellCount(&pasgrule->uagList) > 0`). So the loop below is run
1295        // unconditionally — it naturally denies a `("", "")` peer for
1296        // any UAG/HAG-scoped rule while still honouring an
1297        // unconditional `RULE(0, READ)`.
1298        // C `asComputePvt` initialises `trapMask = 0` and copies the
1299        // matching rule's `trapMask` only on the lines that also raise
1300        // `access` (`asLibRoutines.c:986`, `:1042`). A `NoAccess`
1301        // outcome therefore always carries `trap = false`.
1302        // No INP* resolution on this sync path, so a CALC-gated rule has no
1303        // values to evaluate against and fails CLOSED — see `compute_rules`.
1304        self.compute_rules(asg, host, user, &[], record_asl, method, authority, None)
1305    }
1306
1307    /// The single rule-matching loop — C `asComputePvt`
1308    /// (`asLibRoutines.c:992-1062`) — parameterised by the client's `roles`
1309    /// (for `role/<name>` UAG members, QSRV `documentation/ioc.rst:181-188`)
1310    /// and by the ASG's resolved `INP*` values.
1311    ///
1312    /// CALC evaluation lives HERE and nowhere else. C has one owner too:
1313    /// `asComputeAsgPvt` (`asLibRoutines.c:953-990`) computes
1314    /// `pasgrule->result` and `asComputePvt` (`:1048`) consumes it. The port
1315    /// used to take a `calc_ok` closure instead, which every caller wrote for
1316    /// itself — and both callers wrote the same wrong truth test.
1317    ///
1318    /// `inputs` is `None` on the sync path
1319    /// ([`Self::check_access_method_trap`]), which resolves no links; a
1320    /// CALC-gated rule then fails CLOSED.
1321    #[allow(clippy::too_many_arguments)]
1322    pub(crate) fn compute_rules(
1323        &self,
1324        asg: &AccessSecurityGroup,
1325        host: &str,
1326        user: &str,
1327        roles: &[String],
1328        record_asl: u8,
1329        method: &str,
1330        authority: &str,
1331        inputs: Option<&AsgInputs>,
1332    ) -> (AccessLevel, bool) {
1333        let mut access = AccessLevel::NoAccess;
1334        let mut trap = false;
1335        for rule in &asg.rules {
1336            // C `asComputePvt`: a rule disabled by `asAsgRuleDisable`
1337            // (unsupported keyword) is skipped. A CALC clause no longer
1338            // forces `ignore`; it is gated by `calc_ok` below.
1339            if rule.ignore {
1340                continue;
1341            }
1342            // Monotonic raise: once WRITE is reached nothing can lower
1343            // it, and a rule whose access is not stronger than the
1344            // current level cannot change the outcome.
1345            if access == AccessLevel::ReadWrite {
1346                break;
1347            }
1348            if rule_rank(rule_access(rule)) <= rule_rank(access) {
1349                continue;
1350            }
1351            if record_asl > rule.level {
1352                continue;
1353            }
1354            // UAG: only consulted when the rule scopes one. An empty
1355            // UAG list means "any user" — including an empty username.
1356            // a `role/<name>` member matches when the client
1357            // holds that role (QSRV local-group-derived credentials);
1358            // a plain member matches the account string.
1359            let user_match = rule.uag.is_empty()
1360                || rule.uag.iter().any(|g| {
1361                    self.uag
1362                        .get(g)
1363                        .map(|members| {
1364                            members.iter().any(|m| {
1365                                // a member matches the account
1366                                // string exactly (this also covers a
1367                                // caller that pre-expands roles into
1368                                // synthesised `role/<name>` credential
1369                                // strings and passes them as `user`), OR
1370                                // a `role/<name>` member matches when the
1371                                // client's `roles` slice carries that role.
1372                                m == user
1373                                    || matches!(
1374                                        m.strip_prefix("role/"),
1375                                        Some(role) if roles.iter().any(|r| r == role)
1376                                    )
1377                            })
1378                        })
1379                        .unwrap_or(false)
1380                });
1381            if !user_match {
1382                continue;
1383            }
1384            // HAG: host comparison is case-insensitive. C stores
1385            // every HAG host lowercased (`asHagAddHost`) and lowercases
1386            // the connecting client's host before `asComputePvt`.
1387            let host_lc = host.to_ascii_lowercase();
1388            let host_match = rule.hag.is_empty()
1389                || rule.hag.iter().any(|g| {
1390                    self.hag
1391                        .get(g)
1392                        .map(|members| members.iter().any(|m| m.eq_ignore_ascii_case(&host_lc)))
1393                        .unwrap_or(false)
1394                });
1395            if !host_match {
1396                continue;
1397            }
1398            let method_match = rule.method.is_empty()
1399                || rule.method.iter().any(|m| m.eq_ignore_ascii_case(method));
1400            if !method_match {
1401                continue;
1402            }
1403            let authority_match = rule.authority.is_empty()
1404                || rule
1405                    .authority
1406                    .iter()
1407                    .any(|a| a.eq_ignore_ascii_case(authority));
1408            if !authority_match {
1409                continue;
1410            }
1411            // A CALC-gated rule grants only while its expression evaluates
1412            // true against the resolved INP* link values. The program was
1413            // compiled once at ACF parse; a rule holding `calc` text with no
1414            // compiled form (hand-built, bypassing `parse_acf`) fails closed
1415            // here, as does one with no resolved inputs at all.
1416            if rule.calc.is_some() {
1417                let Some(compiled) = rule.calc_compiled.as_ref() else {
1418                    continue;
1419                };
1420                let Some(inputs) = inputs else {
1421                    continue;
1422                };
1423                // C `asLibRoutines.c:1048`: `!(pasg->inpBad & pasgrule->inpUsed)`.
1424                // A bad input the rule READS disables it; a bad input elsewhere
1425                // in the group is none of this rule's business.
1426                if inputs.bad & rule.inp_used != 0 {
1427                    continue;
1428                }
1429                match crate::calc::eval(compiled, &mut inputs.values.clone()) {
1430                    Ok(result) if calc_result_is_true(result) => {}
1431                    _ => continue,
1432                }
1433            }
1434            // C `asLibRoutines.c:1041-1042`: a matching rule sets
1435            // both `access` and `trapMask` together. The trap mask of
1436            // the last access-raising rule is the one the put-logging
1437            // hook consults.
1438            access = rule_access(rule);
1439            trap = rule.trap;
1440        }
1441        (access, trap)
1442    }
1443
1444    /// Walk `asg_name`'s declared `INP(A..U)` links (falling back to
1445    /// `DEFAULT`, as every other lookup here does) and resolve each with
1446    /// `resolve`, returning C's per-ASG input state. This is `asCa.c`'s job
1447    /// done on demand: the port has no standing CA monitor per link, so the
1448    /// values are read when the rules are evaluated.
1449    ///
1450    /// An unknown ASG with no `DEFAULT` yields empty inputs — no links, so no
1451    /// bad bits, and a CALC rule then evaluates against zeros exactly as C
1452    /// does for an ASG that declares none.
1453    pub fn resolve_asg_inputs(
1454        &self,
1455        asg_name: &str,
1456        resolve: &dyn Fn(&str) -> Option<f64>,
1457    ) -> AsgInputs {
1458        let mut inputs = AsgInputs::default();
1459        let Some(group) = self.asg.get(asg_name).or_else(|| self.asg.get("DEFAULT")) else {
1460            return inputs;
1461        };
1462        for inp in &group.inp {
1463            inputs.record(inp.index, resolve(&inp.link));
1464        }
1465        inputs
1466    }
1467
1468    /// Every distinct `(record, field)` an `INP*` link in this policy names,
1469    /// across all ASGs — the set a re-evaluation trigger must watch. C builds
1470    /// the same set one CA channel at a time in `asCaStart`.
1471    pub fn inp_link_targets(&self) -> Vec<(String, String)> {
1472        let mut targets = std::collections::BTreeSet::new();
1473        for group in self.asg.values() {
1474            for inp in &group.inp {
1475                let (record, field) = inp_link_target(&inp.link);
1476                targets.insert((record.to_string(), field.to_string()));
1477            }
1478        }
1479        targets.into_iter().collect()
1480    }
1481
1482    /// Check access taking the per-record ASL into account.
1483    ///
1484    /// Per epics-base `asLibRoutines.c::asCompute`: a rule with
1485    /// `RULE(N, …)` only applies when the record's ASL ≤ N. The
1486    /// canonical example is `RULE(0, READ) RULE(1, WRITE)` — every
1487    /// record is readable, but only records with ASL ≥ 1 are
1488    /// writable. Without this gate, a low-ASL record's protection
1489    /// is silently equivalent to ASL 0.
1490    pub fn check_access_asl(
1491        &self,
1492        asg_name: &str,
1493        host: &str,
1494        user: &str,
1495        record_asl: u8,
1496    ) -> AccessLevel {
1497        // Forward to the method-aware path with default scopes
1498        // (any method, any authority). Mirrors epics-base PR #563:
1499        // legacy ACF rules without `METHOD`/`AUTHORITY` clauses match
1500        // every authentication method and authority. New code should
1501        // call `check_access_method` directly when method/authority
1502        // negotiation is observable.
1503        self.check_access_method(asg_name, host, user, record_asl, "", "")
1504    }
1505}
1506
1507/// TRAPWRITE listener subsystem.
1508///
1509/// C `libcom/src/as/asLib.h:57-62` defines `asTrapWriteWithData` which
1510/// is invoked unconditionally around every `dbChannel_put` in
1511/// `rsrv/camessage.c:768-779`. Listeners registered via
1512/// `asTrapWriteRegisterListener` receive the put event — this is the
1513/// hook `caPutLog` and site put-loggers attach to. Pre-fix Rust
1514/// parsed the `TRAPWRITE`/`NOTRAPWRITE` keyword into
1515/// `AccessRule::trap` but had no listener subsystem, so the field
1516/// was a no-op and every put-logging tool migrating from rsrv saw
1517/// silent regression.
1518///
1519/// Rust API: registrations live in a process-wide RwLock-protected
1520/// `Vec<TrapWriteListener>`. The CA TCP dispatcher
1521/// (`crates/epics-ca-rs/src/server/tcp.rs`) calls
1522/// [`dispatch_trap_write`] before each `dbChannel_put`-equivalent
1523/// (op = `BeforeWrite`) and after the put completes (op = `AfterWrite`
1524/// with the post-write status). Listeners that need ACF-rule
1525/// trap-mask filtering can consult [`AccessChecked::rule_was_trap`]
1526/// via the message's `rule_was_trap` field — when `false`, libca-
1527/// faithful loggers should skip the event.
1528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1529pub enum TrapWriteOp {
1530    BeforeWrite,
1531    AfterWrite,
1532}
1533
1534/// Read-only message handed to a [`TrapWriteListener`]. Held by
1535/// reference so the listener does not own any of the strings —
1536/// matches the C `asTrapWriteMessage` lifetime semantics
1537/// (`libcom/src/as/asLib.h:51-56`).
1538///
1539/// the message now carries the wire-level `dbr_type` and
1540/// `no_elements` that C's `asTrapWriteMessage` exposes
1541/// (`asLib.h:34-56`), plus a monotonic `event_id` that pairs the
1542/// `BeforeWrite` and `AfterWrite` for one put. libca passes
1543/// `userPvt` to the listener for per-event state (`asLib.h:45-51`),
1544/// returned-then-restored across the pair; Rust's listener takes a
1545/// `&TrapWriteMessage`, so listeners that need per-event state
1546/// maintain a private `event_id → state` map.
1547#[derive(Debug, Clone, Copy)]
1548pub struct TrapWriteMessage<'a> {
1549    pub op: TrapWriteOp,
1550    pub pv_name: &'a str,
1551    pub user: &'a str,
1552    pub host: &'a str,
1553    pub peer: &'a str,
1554    /// Pre-rendered value string. Empty when the listener subsystem
1555    /// is being notified at audit-off cost (caller may pass `""` to
1556    /// avoid stringifying large arrays just for trap dispatch).
1557    pub value_str: &'a str,
1558    /// wire DBR type the put came in as (`DBR_*` constant
1559    /// from `db_access.h`). Listeners that want to log or filter
1560    /// by type read it here instead of reaching back through
1561    /// `serverSpecific`.
1562    pub dbr_type: u16,
1563    /// element count from the put header
1564    /// (`asTrapWriteMessage::no_elements`). 1 for scalar, N for
1565    /// waveform.
1566    pub no_elements: u32,
1567    /// monotonic id that pairs the `BeforeWrite` and the
1568    /// matching `AfterWrite` for a single put. The C `userPvt`
1569    /// continuation slot is not a fit for `&` message — listeners
1570    /// that need per-event state should index a private map by
1571    /// this id and clear the entry in `AfterWrite`.
1572    pub event_id: u64,
1573    /// `Some("ok"|"fail"|EPICS error code) once `op == AfterWrite`;
1574    /// always `None` for `BeforeWrite`.
1575    pub status: Option<&'a str>,
1576    /// True iff the matched ACF `RULE(...)` had the `TRAPWRITE`
1577    /// option set. Loggers that want libca-faithful filtering should
1578    /// skip events with this `false` (mirrors C `pclient->trapMask`
1579    /// gate inside `asTrapWriteWithData`).
1580    pub rule_was_trap: bool,
1581}
1582
1583/// monotonic id allocator for `TrapWriteMessage::event_id`.
1584/// Wraps at u64::MAX (~10^19 events; ~580 years at 1 Mput/s).
1585static TRAP_WRITE_EVENT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1586
1587/// Allocate the next trap-write event id. Call once at the start of
1588/// a put dispatch, thread the value through `BeforeWrite` and the
1589/// matching `AfterWrite`.
1590pub fn next_trap_write_event_id() -> u64 {
1591    TRAP_WRITE_EVENT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1592}
1593
1594/// Listener closure. Must be `Send + Sync` because the CA TCP
1595/// dispatcher invokes it from arbitrary tokio worker tasks. No
1596/// `async` — listeners that need to await must spawn their own task
1597/// off the closure (matches C's synchronous-callback contract; long
1598/// work in a listener blocks the wire path).
1599pub type TrapWriteListener = std::sync::Arc<dyn Fn(&TrapWriteMessage<'_>) + Send + Sync>;
1600
1601/// Opaque handle returned by [`register_trap_write_listener`].
1602/// Drop the handle to unregister the listener (equivalent to C
1603/// `asTrapWriteUnregisterListener`).
1604pub struct TrapWriteListenerHandle {
1605    id: u64,
1606}
1607
1608impl Drop for TrapWriteListenerHandle {
1609    fn drop(&mut self) {
1610        if let Some(reg) = TRAP_WRITE_REGISTRY.get() {
1611            let mut guard = reg.write().expect("trap-write registry poisoned");
1612            guard.retain(|(id, _)| *id != self.id);
1613        }
1614    }
1615}
1616
1617static TRAP_WRITE_REGISTRY: std::sync::OnceLock<std::sync::RwLock<Vec<(u64, TrapWriteListener)>>> =
1618    std::sync::OnceLock::new();
1619static TRAP_WRITE_NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1620
1621fn trap_write_registry() -> &'static std::sync::RwLock<Vec<(u64, TrapWriteListener)>> {
1622    TRAP_WRITE_REGISTRY.get_or_init(|| std::sync::RwLock::new(Vec::new()))
1623}
1624
1625/// Register a TRAPWRITE listener. The returned handle unregisters
1626/// the listener when dropped — keep it alive for as long as you
1627/// want events.
1628pub fn register_trap_write_listener(listener: TrapWriteListener) -> TrapWriteListenerHandle {
1629    let id = TRAP_WRITE_NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1630    let mut guard = trap_write_registry()
1631        .write()
1632        .expect("trap-write registry poisoned");
1633    guard.push((id, listener));
1634    TrapWriteListenerHandle { id }
1635}
1636
1637/// Cheap probe: returns `true` if any TRAPWRITE listener is
1638/// currently registered. Lets the CA TCP dispatcher skip rendering
1639/// the per-write value string when nothing would consume it.
1640/// O(1) — `RwLock::read` + `is_empty`.
1641pub fn has_trap_write_listeners() -> bool {
1642    let Some(reg) = TRAP_WRITE_REGISTRY.get() else {
1643        return false;
1644    };
1645    let guard = reg.read().expect("trap-write registry poisoned");
1646    !guard.is_empty()
1647}
1648
1649/// Dispatch a trap-write event to every registered listener.
1650/// Fast path when no listeners: an `RwLock::read` and a length
1651/// check, no allocation. Called by the CA TCP dispatcher before and
1652/// after every `dbChannel_put`-equivalent.
1653///
1654/// the listener list is *snapshotted* under the read
1655/// lock (a `Vec<Arc<...>>` clone — cheap, all `Arc`-bumps), then
1656/// the lock is released before any listener runs. This means a
1657/// listener may register or drop another listener handle mid-
1658/// callback without deadlocking on the registry's
1659/// `std::sync::RwLock` (which is not re-entrant on POSIX); a
1660/// `TrapWriteListenerHandle::drop` racing dispatch on a tokio
1661/// worker thread does not block the worker for the unbounded
1662/// listener-call duration; the writer waits at most for the Vec
1663/// clone.
1664///
1665/// each listener call is wrapped in `catch_unwind` so a
1666/// panicking listener does not unwind into the CA per-circuit task.
1667/// The listener `Fn` type does NOT carry an `UnwindSafe` bound;
1668/// `AssertUnwindSafe` is sound here because the dispatch shares no
1669/// mutable state with the listener (the snapshot is consumed in
1670/// loop order; the message is `Copy`).
1671pub fn dispatch_trap_write(msg: &TrapWriteMessage<'_>) {
1672    let Some(reg) = TRAP_WRITE_REGISTRY.get() else {
1673        return;
1674    };
1675    let snapshot: Vec<TrapWriteListener> = {
1676        let guard = reg.read().expect("trap-write registry poisoned");
1677        if guard.is_empty() {
1678            return;
1679        }
1680        guard.iter().map(|(_, l)| l.clone()).collect()
1681    };
1682    for listener in snapshot {
1683        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1684            listener(msg);
1685        }));
1686        if let Err(payload) = result {
1687            let descr = if let Some(s) = payload.downcast_ref::<&'static str>() {
1688                (*s).to_string()
1689            } else if let Some(s) = payload.downcast_ref::<String>() {
1690                s.clone()
1691            } else {
1692                "(non-string panic payload)".to_string()
1693            };
1694            tracing::error!(
1695                target: "epics_base_rs::server::access_security",
1696                pv = msg.pv_name,
1697                event_id = msg.event_id,
1698                op = ?msg.op,
1699                panic = %descr,
1700                "TRAPWRITE listener panicked — isolating; remaining listeners will still run. \
1701                 C asTrapWriteWithData has no unwind concept; this is a Rust-only safety net \
1702                 to keep the per-circuit task alive."
1703            );
1704        }
1705    }
1706}
1707
1708/// Owned trap-write identity used to construct a [`TrapWriteGuard`].
1709///
1710/// Unlike [`TrapWriteMessage`] (borrowed and `Copy`), these fields are
1711/// owned so the guard can outlive the call frame that created it —
1712/// survive a move into a spawned put-completion task and live across
1713/// `.await` points until the put really finishes, is superseded, or the
1714/// connection tears down.
1715pub struct TrapWriteFields {
1716    pub pv_name: String,
1717    pub user: String,
1718    pub host: String,
1719    pub peer: String,
1720    pub value_str: String,
1721    pub dbr_type: u16,
1722    pub no_elements: u32,
1723    pub event_id: u64,
1724    pub rule_was_trap: bool,
1725    /// AfterWrite `status` dispatched when the guard is dropped without
1726    /// a preceding [`TrapWriteGuard::complete`] — i.e. the put was
1727    /// cancelled / superseded / torn down before its real status was
1728    /// known. C `asTrapWriteAfter` carries no status; this Rust-only
1729    /// field lets listeners distinguish a cancelled tail from a clean
1730    /// completion.
1731    pub cancel_status: String,
1732}
1733
1734impl TrapWriteFields {
1735    fn message<'a>(&'a self, op: TrapWriteOp, status: Option<&'a str>) -> TrapWriteMessage<'a> {
1736        TrapWriteMessage {
1737            op,
1738            pv_name: &self.pv_name,
1739            user: &self.user,
1740            host: &self.host,
1741            peer: &self.peer,
1742            value_str: &self.value_str,
1743            dbr_type: self.dbr_type,
1744            no_elements: self.no_elements,
1745            event_id: self.event_id,
1746            status,
1747            rule_was_trap: self.rule_was_trap,
1748        }
1749    }
1750}
1751
1752/// RAII guard that pairs one `asTrapWrite` BeforeWrite/AfterWrite
1753/// bracket so the AfterWrite fires on *every* exit path of a record
1754/// put — normal completion, early return, async cancellation (the
1755/// future that owns the guard is dropped mid-`.await`), or task abort
1756/// (a superseding WRITE_NOTIFY or a client teardown aborting the
1757/// completion task).
1758///
1759/// This makes the C invariant hold *by construction*: every
1760/// `asTrapWriteWithData` (BeforeWrite) is matched by exactly one
1761/// `asTrapWriteAfter` (AfterWrite) on all rsrv exit paths — normal
1762/// completion (`rsrv/camessage.c:1400`), still-busy teardown
1763/// (`rsrvFreePutNotify`, `camessage.c:1621-1660`), and supersede-cancel
1764/// (`write_notify_action`, `camessage.c:1697-1700`) — and mirrors pvxs's
1765/// `SecurityLogger`, whose destructor calls `asTrapWriteAfterWrite`
1766/// (`ioc/securitylogger.h:23-59`). Before this guard the Rust emitters
1767/// dispatched AfterWrite from an explicit call that an
1768/// aborted/superseded/cancelled put skipped, leaving a BeforeWrite with
1769/// no matching AfterWrite in the put-log.
1770///
1771/// Lifecycle:
1772/// - [`TrapWriteGuard::begin`] fires BeforeWrite and arms the guard.
1773/// - [`TrapWriteGuard::complete`] fires AfterWrite *now* with the real
1774///   put status and disarms the guard (Drop becomes a no-op). Call it on
1775///   the normal path once the put status is known.
1776/// - If the guard is dropped while still armed (any cancel path), Drop
1777///   fires AfterWrite with [`TrapWriteFields::cancel_status`].
1778///
1779/// AfterWrite therefore fires exactly once: either from `complete` or
1780/// from Drop, never both, never neither.
1781pub struct TrapWriteGuard {
1782    /// `Some` while an AfterWrite is still owed. `begin` leaves it
1783    /// `None` when no listener is registered (the whole bracket is a
1784    /// no-op); `complete` takes it to fire-and-disarm.
1785    armed: Option<Box<TrapWriteFields>>,
1786}
1787
1788impl TrapWriteGuard {
1789    /// Fire BeforeWrite and arm the AfterWrite finalizer.
1790    ///
1791    /// When no trap-write listener is registered this returns a
1792    /// disarmed no-op guard (its `complete`/Drop dispatch nothing), so a
1793    /// caller may hold a guard unconditionally. Callers still gate on
1794    /// the ACF trap mask (`rule_was_trap`) before constructing one — a
1795    /// non-trapped put must never open a bracket (C `asActive &&
1796    /// trapMask`, `asLib.h:57`).
1797    pub fn begin(fields: TrapWriteFields) -> Self {
1798        if !has_trap_write_listeners() {
1799            return Self { armed: None };
1800        }
1801        dispatch_trap_write(&fields.message(TrapWriteOp::BeforeWrite, None));
1802        Self {
1803            armed: Some(Box::new(fields)),
1804        }
1805    }
1806
1807    /// Fire AfterWrite now with the real put `status` and disarm the
1808    /// guard so Drop does nothing. A no-op on an already-disarmed guard
1809    /// (no listener at `begin`, or `complete` already called), so it is
1810    /// safe to call on every normal-completion path.
1811    pub fn complete(&mut self, status: &str) {
1812        if let Some(fields) = self.armed.take() {
1813            dispatch_trap_write(&fields.message(TrapWriteOp::AfterWrite, Some(status)));
1814        }
1815    }
1816}
1817
1818impl Drop for TrapWriteGuard {
1819    fn drop(&mut self) {
1820        if let Some(fields) = self.armed.take() {
1821            dispatch_trap_write(
1822                &fields.message(TrapWriteOp::AfterWrite, Some(&fields.cancel_status)),
1823            );
1824        }
1825    }
1826}
1827
1828/// Per-write trap-log identity that does not depend on the value being
1829/// written. Borrows the caller's identity strings (matching the C
1830/// `asTrapWriteMessage` by-reference lifetime, `asLib.h:34-56`).
1831pub struct TrapWriteMeta<'a> {
1832    /// The channel (`record.FIELD`) being written — pvxs passes
1833    /// `dbChannelName(pChan)`.
1834    pub pv_name: &'a str,
1835    /// Authenticated account name (pvxs `cred->account`).
1836    pub user: &'a str,
1837    /// Client host (pvxs `cred->host`).
1838    pub host: &'a str,
1839    /// Client peer ("ip:port") when the caller has the socket address;
1840    /// callers whose identity block carries no separate peer pass the
1841    /// host again.
1842    pub peer: &'a str,
1843    /// Final field DBF type of the channel (pvxs
1844    /// `dbChannelFinalFieldType`).
1845    pub dbr_type: u16,
1846}
1847
1848/// The C `asActive && trapMask` gate (`asLib.h:57-60`), as one function.
1849///
1850/// `rule_was_trap` is the matched ACF/ASG rule's `TRAPWRITE` flag,
1851/// resolved once by the access layer ([`AccessChecked::rule_was_trap`]);
1852/// the listener probe is the `asActive` half. A caller that must pay for
1853/// something *before* opening the bracket — rendering a value, resolving
1854/// the channel's DBF type — asks here rather than re-spelling the
1855/// conjunction, so there is exactly one statement of when a put is
1856/// audited.
1857pub fn trap_write_armed(rule_was_trap: bool) -> bool {
1858    rule_was_trap && has_trap_write_listeners()
1859}
1860
1861/// Bracket one backing record PUT with the EPICS `asTrapWrite`
1862/// put-logging hook, then run and return the write's result.
1863///
1864/// The single write-owner shared by every server that writes a local
1865/// record on behalf of a remote client: the QSRV bridge, the native PVA
1866/// [`ChannelSource`](crate::server::database::PvDatabase) over a
1867/// `PvDatabase`, and any future source with the same job. pvxs keeps the
1868/// equivalent bracket in ONE place too — `IOCSource::doPreProcessing`
1869/// builds a `SecurityLogger` (`ioc/iocsource.cpp:363-374`,
1870/// `ioc/securitylogger.h:29-58`) that every IOC source's put runs
1871/// through (`ioc/singlesource.cpp:354-360`,
1872/// `ioc/groupsource.cpp:594-602`).
1873///
1874/// When the write is not trapped ([`trap_write_armed`] is false) the
1875/// write runs unbracketed and nothing is dispatched. On a trapped write
1876/// this emits exactly one `BeforeWrite` (before the put) and exactly one
1877/// `AfterWrite` (after the put completes, on every exit path — including
1878/// the future being dropped mid-write) carrying the same `event_id`,
1879/// value string and `ok`/`fail` status. The value is rendered once
1880/// (truncated to 64 elements, like the CA dispatcher) only when actually
1881/// emitting.
1882pub async fn put_with_trap<T, E, F, Fut>(
1883    rule_was_trap: bool,
1884    meta: TrapWriteMeta<'_>,
1885    value: crate::types::EpicsValue,
1886    write: F,
1887) -> Result<T, E>
1888where
1889    F: FnOnce(crate::types::EpicsValue) -> Fut,
1890    Fut: std::future::Future<Output = Result<T, E>>,
1891{
1892    if !trap_write_armed(rule_was_trap) {
1893        return write(value).await;
1894    }
1895
1896    let mut guard = TrapWriteGuard::begin(trap_fields(&meta, &value));
1897    let result = write(value).await;
1898    guard.complete(if result.is_ok() { "ok" } else { "fail" });
1899    result
1900}
1901
1902/// [`put_with_trap`]'s synchronous twin, for a write already holding the
1903/// record's advisory gate (the QSRV atomic group PUT, `already_locked`
1904/// entries). C's `SecurityLogger` bracket is plain synchronous C++ with
1905/// no `async` concept at all — this is that shape. There is no
1906/// cancellation-mid-write case; the same RAII guard still balances the
1907/// trap log on a panic unwinding through `write`.
1908pub fn put_with_trap_blocking<T, E, F>(
1909    rule_was_trap: bool,
1910    meta: TrapWriteMeta<'_>,
1911    value: crate::types::EpicsValue,
1912    write: F,
1913) -> Result<T, E>
1914where
1915    F: FnOnce(crate::types::EpicsValue) -> Result<T, E>,
1916{
1917    if !trap_write_armed(rule_was_trap) {
1918        return write(value);
1919    }
1920
1921    let mut guard = TrapWriteGuard::begin(trap_fields(&meta, &value));
1922    let result = write(value);
1923    guard.complete(if result.is_ok() { "ok" } else { "fail" });
1924    result
1925}
1926
1927fn trap_fields(meta: &TrapWriteMeta<'_>, value: &crate::types::EpicsValue) -> TrapWriteFields {
1928    TrapWriteFields {
1929        pv_name: meta.pv_name.to_string(),
1930        user: meta.user.to_string(),
1931        host: meta.host.to_string(),
1932        peer: meta.peer.to_string(),
1933        value_str: value.display_truncated(64),
1934        dbr_type: meta.dbr_type,
1935        no_elements: value.count(),
1936        event_id: next_trap_write_event_id(),
1937        rule_was_trap: true,
1938        cancel_status: "cancel".to_string(),
1939    }
1940}
1941
1942/// ASG-field change notifier.
1943///
1944/// C `database/src/ioc/as/asDbLib.c:107-110,144` registers
1945/// `asSpcAsCallback` as the per-record `ASG` field's special
1946/// callback; `dbPut record.ASG NEW_ASG` invokes `asChangeGroup` →
1947/// `asAddMemberPvt` → `asComputePvt` for every `ASGCLIENT` and
1948/// fires the COAR callback for each affected CA connection. Pre-fix
1949/// Rust mutated `instance.common.asg` directly with no notification
1950/// — the *next* CA op used live `compute_access` so enforcement was
1951/// correct, but the wire ACCESS_RIGHTS the client saw still
1952/// reflected the OLD ASG until something else (CLIENT_NAME / ACF
1953/// reload) triggered a re-eval. UIs gating put-button enable on the
1954/// cached level showed stale state.
1955///
1956/// Rust path: every record put that targets the `ASG` field calls
1957/// [`notify_asg_field_changed`]; the CA server (ca-rs
1958/// `server/tcp.rs`) subscribes via [`subscribe_asg_changes`] at
1959/// startup and routes the event into the same per-client
1960/// `reeval_access_rights` path the ACF reload uses. Coarser than
1961/// libca (we re-eval every connection on any ASG-field change, not
1962/// just the connections whose `ASGCLIENT` referenced the changed
1963/// record), but the wire shape (ACCESS_RIGHTS push only when level
1964/// actually changed) already keeps the cost bounded by the
1965/// `oldaccess != access` gate downstream.
1966static ASG_CHANGE_BROADCAST: std::sync::OnceLock<tokio::sync::broadcast::Sender<()>> =
1967    std::sync::OnceLock::new();
1968
1969fn asg_change_broadcast() -> &'static tokio::sync::broadcast::Sender<()> {
1970    ASG_CHANGE_BROADCAST.get_or_init(|| {
1971        let (tx, _rx) = tokio::sync::broadcast::channel(16);
1972        tx
1973    })
1974}
1975
1976/// Monotonic count of ASG-field changes, for pull-style consumers
1977/// (see [`asg_change_generation`]) that cannot hold a broadcast
1978/// receiver — e.g. a sync access-check cache that must know whether
1979/// a cached (channel → ASG) resolution is still current.
1980static ASG_CHANGE_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1981
1982/// Fire from the field-I/O layer when a record's `ASG` field is
1983/// successfully written. Idempotent: if no subscriber exists yet
1984/// the send is a no-op (lagged subscribers also tolerated — the
1985/// wire re-eval is coarse and one missed beat is recovered by the
1986/// downstream `oldaccess != access` filter).
1987pub fn notify_asg_field_changed() {
1988    ASG_CHANGE_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
1989    let _ = asg_change_broadcast().send(());
1990}
1991
1992/// Current ASG-field-change generation. A consumer that caches
1993/// anything derived from a record's `ASG` field snapshots this before
1994/// resolving and treats its entry as stale once the value moves —
1995/// the pull-side counterpart of [`subscribe_asg_changes`]. C's
1996/// equivalent invalidation is `asChangeGroup` re-running
1997/// `asComputePvt` for every `ASGCLIENT` on `dbPut record.ASG`.
1998pub fn asg_change_generation() -> u64 {
1999    ASG_CHANGE_GENERATION.load(std::sync::atomic::Ordering::Acquire)
2000}
2001
2002/// Subscribe to ASG-field-change notifications. Called once at
2003/// server start by the CA TCP dispatcher; events are folded into
2004/// the per-client `reeval_access_rights` path.
2005pub fn subscribe_asg_changes() -> tokio::sync::broadcast::Receiver<()> {
2006    asg_change_broadcast().subscribe()
2007}
2008
2009/// Parse an ACF (Access Control File).
2010/// C `asDumpQuoted` (asLibRoutines.c:660-666, epics-base #871): print a
2011/// UAG/HAG member as `"` + `epicsStrPrintEscaped` + `"`. C passes
2012/// `strlen(s)`, so the escape never runs past a NUL byte.
2013fn dump_quoted(out: &mut String, s: &str) {
2014    let bytes = s.as_bytes();
2015    let len = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
2016    out.push('"');
2017    out.push_str(&crate::runtime::epics_string::print_escaped(&bytes[..len]));
2018    out.push('"');
2019}
2020
2021/// The ACF reader, and the single owner of the line counter every rejection
2022/// out of this parser carries.
2023///
2024/// The counter is the reason this is a type rather than a bare
2025/// `Peekable<Chars>`: [`Self::next`] is the ONLY way the input advances, so
2026/// `line` cannot drift from the text — no caller can step over a newline
2027/// without the counter seeing it. C keeps the same number in
2028/// `asLib_lex.l`'s `line_num`; this port keeps its own, more specific
2029/// sentences and simply says where they happened, because an ACF that fails
2030/// on line 240 of 900 is otherwise unlocatable.
2031struct AcfScanner<'a> {
2032    src: &'a str,
2033    /// Byte offset of the next character. The text stays addressable behind
2034    /// the cursor so a diagnostic can quote what the operator wrote without
2035    /// consuming it — see [`Self::offending`].
2036    pos: usize,
2037    /// 1-based, counting every `\n` consumed.
2038    line: u32,
2039}
2040
2041impl<'a> AcfScanner<'a> {
2042    fn new(content: &'a str) -> Self {
2043        Self {
2044            src: content,
2045            pos: 0,
2046            line: 1,
2047        }
2048    }
2049
2050    fn peek(&mut self) -> Option<char> {
2051        self.src[self.pos..].chars().next()
2052    }
2053
2054    /// The one place the input advances, so the one place `line` moves.
2055    fn next(&mut self) -> Option<char> {
2056        let c = self.src[self.pos..].chars().next()?;
2057        self.pos += c.len_utf8();
2058        if c == '\n' {
2059            self.line += 1;
2060        }
2061        Some(c)
2062    }
2063
2064    /// Quote the token the operator actually typed, for a diagnostic that
2065    /// says `got '…'`.
2066    ///
2067    /// A "consume while it matches" loop stops BEFORE the character that
2068    /// broke it, so its buffer holds only what was ACCEPTED — on
2069    /// `RULE(abc, READ)` the digit loop accepts nothing and the buffer is
2070    /// empty, and quoting it reports `got ''`, a token that is nowhere in
2071    /// the file. `accepted` is that buffer; the rest is read forward from
2072    /// the cursor to the next delimiter, without consuming, so the same call
2073    /// is right whether the caller is about to unwind or to carry on.
2074    ///
2075    /// Capped so a file with a megabyte-long run cannot put a megabyte on
2076    /// the operator's console.
2077    fn offending(&self, accepted: &str) -> String {
2078        const CAP: usize = 32;
2079        let rest: String = self.src[self.pos..]
2080            .chars()
2081            .take_while(|c| !c.is_whitespace() && !matches!(c, '(' | ')' | '{' | '}' | ',' | '#'))
2082            .collect();
2083        let token: String = accepted.chars().chain(rest.chars()).collect();
2084        if token.chars().count() > CAP {
2085            let head: String = token.chars().take(CAP).collect();
2086            format!("{head}…")
2087        } else {
2088            token
2089        }
2090    }
2091
2092    /// Whitespace and `#` comments are not tokens; a comment runs to the end
2093    /// of its line and the newline that ends it still counts.
2094    fn skip_ws_comments(&mut self) {
2095        while let Some(c) = self.peek() {
2096            if c.is_whitespace() {
2097                self.next();
2098            } else if c == '#' {
2099                while let Some(c) = self.peek() {
2100                    self.next();
2101                    if c == '\n' {
2102                        break;
2103                    }
2104                }
2105            } else {
2106                break;
2107            }
2108        }
2109    }
2110
2111    /// Build this parser's rejection: the port's own sentence, prefixed with
2112    /// where in the file it happened. Every failure path goes through here,
2113    /// so none of them can be written without the location.
2114    ///
2115    /// Use this only for a complaint about the token under the cursor. A
2116    /// complaint about a construct that OPENED earlier — every
2117    /// `unterminated …`, and the CALC clause — belongs to [`Self::reject_at`]
2118    /// against the line the construct started on.
2119    fn reject(&self, what: impl std::fmt::Display) -> CaError {
2120        self.reject_at(self.line, what)
2121    }
2122
2123    /// Reject against a line the caller stamped when it entered the
2124    /// construct.
2125    ///
2126    /// The parser only discovers an unterminated `(`, `{` or `"` at EOF, so
2127    /// its own position is the END of the file. On the 900-line ACF this line
2128    /// number exists for, that number is worse than useless — it points every
2129    /// unterminated construct at the same place. The opening line is the one
2130    /// the operator has to edit.
2131    fn reject_at(&self, line: u32, what: impl std::fmt::Display) -> CaError {
2132        CaError::Protocol(format!("ACF line {line}: {what}"))
2133    }
2134}
2135
2136pub fn parse_acf(content: &str) -> CaResult<AccessSecurityConfig> {
2137    let mut config = AccessSecurityConfig {
2138        uag: HashMap::new(),
2139        hag: HashMap::new(),
2140        hag_raw: HashMap::new(),
2141        asg: HashMap::new(),
2142        unknown_access: AccessLevel::Read,
2143    };
2144
2145    // C `asInitialize` (asLibRoutines.c:107) calls `asAsgAdd(DEFAULT)`
2146    // *before* parsing the file, so a `DEFAULT` ASG always exists.
2147    // Synthesise it here unconditionally: any record whose
2148    // `ASG` field names an unknown group resolves to this empty
2149    // `DEFAULT`, which has no RULEs ⇒ `asNOACCESS` ⇒ access denied.
2150    // A `DEFAULT` block declared in the file simply overwrites this
2151    // placeholder below.
2152    config
2153        .asg
2154        .insert("DEFAULT".to_string(), AccessSecurityGroup::default());
2155
2156    let mut sc = AcfScanner::new(content);
2157    let mut buf = String::new();
2158
2159    while sc.peek().is_some() {
2160        sc.skip_ws_comments();
2161        buf.clear();
2162        read_word(&mut sc, &mut buf);
2163
2164        match buf.as_str() {
2165            "UAG" => {
2166                let name = read_paren_name(&mut sc)?;
2167                let members = read_brace_list(&mut sc)?;
2168                config.uag.insert(name, members);
2169            }
2170            "HAG" => {
2171                let name = read_paren_name(&mut sc)?;
2172                let members = read_brace_list(&mut sc)?;
2173                // C `asHagAddHost` reads `asCheckClientIP` at ACF-parse
2174                // time and stores names or resolved IPs accordingly.
2175                config.hag.insert(name.clone(), hag_members(&members));
2176                config.hag_raw.insert(name, members);
2177            }
2178            "ASG" => {
2179                let name = read_paren_name(&mut sc)?;
2180                let asg = parse_asg_body(&mut sc)?;
2181                config.asg.insert(name, asg);
2182            }
2183            "" => {
2184                // `read_word` only consumes `[A-Za-z0-9_]`, and
2185                // `skip_ws_comments` already ran above. So an empty
2186                // word means one of two things:
2187                //
2188                //   * genuine EOF / whitespace-only / comment-only
2189                //     input — `sc.peek()` is `None` ⇒ break, `Ok`
2190                //     (the pre-existing, deliberate empty-file
2191                //     divergence from C; see `empty_acf_denies_all_access`);
2192                //   * a stray top-level punctuation token where a
2193                //     block keyword is expected (`(`, `)`, `{`, `}`,
2194                //     `,`) — C's grammar has no production starting
2195                //     with bare punctuation at top level ⇒ `yyerror`.
2196                //     A file of only `(((` or only `}` is genuine
2197                //     garbage and must fail closed.
2198                match sc.peek() {
2199                    Some(c) if matches!(c, '(' | ')' | '{' | '}' | ',') => {
2200                        return Err(sc.reject(format!(
2201                            "unexpected '{c}' where a top-level block keyword is expected"
2202                        )));
2203                    }
2204                    // EOF, or any other stray character — preserve the
2205                    // pre-existing break-and-`Ok` behaviour; only the
2206                    // stray block-punctuation case is in scope here.
2207                    _ => break,
2208                }
2209            }
2210            other => {
2211                // C `asLib.y:88-103` (`generic_item`) treats an
2212                // unrecognised top-level *block* as a *warning*
2213                // (`yywarn "Ignoring unsupported TOP LEVEL block"`) and
2214                // parsing continues — forward-compat with future/vendor
2215                // ACF extensions.
2216                //
2217                // The leniency is bounded by the grammar: every
2218                // `generic_item` alternative is `tokenSTRING
2219                // generic_head [...]`, and `generic_head`
2220                // (asLib.y:105-108) is `'(' ... ')'` — a *mandatory*
2221                // balanced parenthesised head. There is no
2222                // `generic_item: tokenSTRING` alone. So C only warns
2223                // when the unknown keyword is immediately followed by
2224                // `(`; a bare keyword (followed by another word, or at
2225                // EOF) matches no rule ⇒ `yyerror` ⇒ `asInitialize`
2226                // fails. `skip_unknown_top_level_block` enforces exactly
2227                // that: it returns `Err` for genuine garbage and `Ok`
2228                // (after warning) for a well-formed unknown block.
2229                skip_unknown_top_level_block(other, &mut sc)?;
2230            }
2231        }
2232    }
2233
2234    Ok(config)
2235}
2236
2237/// Skip an unrecognised top-level block: a *mandatory* `(...)` head and
2238/// an optional `{...}` body. Mirrors C `asLib.y` `generic_item`
2239/// (asLib.y:88-103) + `generic_head` (asLib.y:105-108) — the only
2240/// recover-and-continue posture C allows for an unknown keyword.
2241///
2242/// C's grammar makes the parenthesised head mandatory: every
2243/// `generic_item` alternative is `tokenSTRING generic_head [...]`, and
2244/// `generic_head` is `'(' ')'` | `'(' generic_element ')'` |
2245/// `'(' generic_list ')'`. So:
2246///
2247/// * unknown keyword **followed by `(`** with a balanced head ⇒ warn
2248///   and continue (`yywarn "Ignoring unsupported TOP LEVEL block"`);
2249/// * unknown keyword **not** followed by `(` (another bare word, or
2250///   EOF) ⇒ no grammar rule matches ⇒ C `yyerror` ⇒ `asInitialize`
2251///   fails. Return `Err`;
2252/// * unbalanced parens/braces (depth never returns to 0 before EOF) ⇒
2253///   the C lexer/parser raises `yyerror` ⇒ return `Err`.
2254fn skip_unknown_top_level_block(keyword: &str, sc: &mut AcfScanner) -> CaResult<()> {
2255    // Every complaint below names `keyword`, so every one of them belongs to
2256    // the line the keyword is on — not to wherever the scan ran out.
2257    let at = sc.line;
2258    sc.skip_ws_comments();
2259    // C `generic_head` requires a `(` here. A bare keyword with another
2260    // word or EOF after it matches no production ⇒ hard parse error.
2261    if sc.peek() != Some('(') {
2262        return Err(sc.reject_at(
2263            at,
2264            format!(
2265                "unexpected token '{keyword}' — expected a top-level \
2266                 UAG/HAG/ASG block or an unknown keyword followed by '('"
2267            ),
2268        ));
2269    }
2270    // Consume the balanced `(...)` head. Unbalanced ⇒ error.
2271    let mut depth = 0;
2272    let mut closed = false;
2273    while let Some(c) = sc.peek() {
2274        sc.next();
2275        match c {
2276            '(' => depth += 1,
2277            ')' => {
2278                depth -= 1;
2279                if depth == 0 {
2280                    closed = true;
2281                    break;
2282                }
2283            }
2284            _ => {}
2285        }
2286    }
2287    if !closed {
2288        return Err(sc.reject_at(
2289            at,
2290            format!("unbalanced '(' in unsupported top-level block '{keyword}'"),
2291        ));
2292    }
2293    sc.skip_ws_comments();
2294    // The `{...}` body is optional (the `tokenSTRING generic_head` bare
2295    // form). If present it must be balanced.
2296    if sc.peek() == Some('{') {
2297        let mut depth = 0;
2298        let mut closed = false;
2299        while let Some(c) = sc.peek() {
2300            sc.next();
2301            match c {
2302                '{' => depth += 1,
2303                '}' => {
2304                    depth -= 1;
2305                    if depth == 0 {
2306                        closed = true;
2307                        break;
2308                    }
2309                }
2310                _ => {}
2311            }
2312        }
2313        if !closed {
2314            return Err(sc.reject_at(
2315                at,
2316                format!("unbalanced '{{' in unsupported top-level block '{keyword}'"),
2317            ));
2318        }
2319    }
2320    // Well-formed unknown block: warn and continue.
2321    tracing::warn!(
2322        target: "epics_base_rs::access_security",
2323        line = at,
2324        keyword = %keyword,
2325        "ACF: ignoring unsupported top-level block"
2326    );
2327    Ok(())
2328}
2329
2330/// C `asCheckClientIP` (`asLibRoutines.c:34`) — process-global, default
2331/// `0`/false, set from the shell before the ACF is loaded.
2332///
2333/// It is the **single owner of what a host identity means** across access
2334/// security, and it decides two things that must agree or nothing matches:
2335///
2336/// * how `HAG` members are stored ([`hag_members`]) — lowercased literal
2337///   names, or resolved dotted-quad IPs;
2338/// * what the CA server records as a client's host — the name the client
2339///   claims over `CA_PROTO_HOST_NAME`, or its peer IP
2340///   (`camessage.c:839-843`, `caservertask.c:1425-1439`).
2341///
2342/// C's default is `0`: rsrv stores the client-supplied hostname
2343/// unconditionally and HAGs match on names. The IP-checking mode is opt-in.
2344static AS_CHECK_CLIENT_IP: std::sync::atomic::AtomicBool =
2345    std::sync::atomic::AtomicBool::new(false);
2346
2347/// Read the `AS_CHECK_CLIENT_IP` mode.
2348pub fn as_check_client_ip() -> bool {
2349    AS_CHECK_CLIENT_IP.load(std::sync::atomic::Ordering::Relaxed)
2350}
2351
2352/// Set the `AS_CHECK_CLIENT_IP` mode. C exposes this as an iocsh
2353/// *variable* (`var asCheckClientIP 1`, registered in
2354/// `libComRegister.c:475-479`, `:518-520` at `R7.0.10`), and so does this port —
2355/// `var asCheckClientIP 1` reaches this setter through the iocsh
2356/// variable table.
2357///
2358/// Ordering is C's: `hag_members` reads the flag when the ACF is
2359/// *parsed*, so — exactly as in C — it must be set **before** `asInit`,
2360/// or the HAG entries are stored in the wrong form.
2361pub fn set_as_check_client_ip(on: bool) {
2362    AS_CHECK_CLIENT_IP.store(on, std::sync::atomic::Ordering::Relaxed);
2363}
2364
2365/// Store one HAG's members the way C `asHagAddHost`
2366/// (`asLibRoutines.c:1218-1256`) does, which depends on
2367/// [`as_check_client_ip`]:
2368///
2369/// * **default (`false`)** — each host is stored as a lowercased literal
2370///   name. The client identity it is matched against is the name the
2371///   client claimed over `CA_PROTO_HOST_NAME`, so no DNS is involved on
2372///   either side.
2373/// * **`true`** — each host is resolved to a dotted-quad IP at parse time;
2374///   an unresolvable entry is stored as `unresolved:<host>` (C's own
2375///   sentinel, which simply never matches) rather than aborting the load.
2376///   The client identity is then the peer IP.
2377///
2378/// The two halves are read from the same flag on purpose: a mixed
2379/// configuration (names on one side, IPs on the other) matches nothing,
2380/// which is precisely the R7-16 defect.
2381fn hag_members(members: &[String]) -> Vec<String> {
2382    if !as_check_client_ip() {
2383        return members.iter().map(|m| m.to_ascii_lowercase()).collect();
2384    }
2385
2386    use std::net::ToSocketAddrs;
2387    members
2388        .iter()
2389        .map(|m| match format!("{m}:0").to_socket_addrs() {
2390            // C `aToIPAddr` resolves via `AF_INET` and the CA server keys ACF on
2391            // an IPv4 peer address, so a HAG host must store its **IPv4** dotted
2392            // quad. Taking `iter.next()` blindly would store an IPv6 address on a
2393            // dual-stack host that resolves `::1` first (e.g. `localhost` on many
2394            // CI runners) — an entry no IPv4 CA peer could ever match. A host with
2395            // no IPv4 address is stored as the `unresolved:` sentinel, exactly as
2396            // C does when `aToIPAddr` yields nothing.
2397            Ok(iter) => match iter.filter(|sa| sa.is_ipv4()).map(|sa| sa.ip()).next() {
2398                Some(ip) => ip.to_string(),
2399                None => format!("unresolved:{m}"),
2400            },
2401            Err(e) => {
2402                tracing::warn!(
2403                    target: "epics_base_rs::access_security",
2404                    host = %m,
2405                    error = %e,
2406                    "ACF: Unable to resolve host (asCheckClientIP=1)"
2407                );
2408                format!("unresolved:{m}")
2409            }
2410        })
2411        .collect()
2412}
2413
2414fn read_word(sc: &mut AcfScanner, buf: &mut String) {
2415    while let Some(c) = sc.peek() {
2416        if c.is_alphanumeric() || c == '_' {
2417            buf.push(c);
2418            sc.next();
2419        } else {
2420            break;
2421        }
2422    }
2423}
2424
2425fn read_paren_name(sc: &mut AcfScanner) -> CaResult<String> {
2426    sc.skip_ws_comments();
2427    if sc.next() != Some('(') {
2428        return Err(sc.reject("expected '('"));
2429    }
2430    let opened = sc.line;
2431    sc.skip_ws_comments();
2432    // L-4: C's lexer requires a single `tokenSTRING` then `')'`.
2433    // Accept an optional double-quoted form; in the unquoted form
2434    // interior whitespace ends the name — a second non-space run
2435    // before `)` is a parse error rather than being silently merged
2436    // (`UAG(my group)` must NOT become `mygroup`). EOF before `)` is
2437    // also an error.
2438    let mut name = String::new();
2439    if sc.peek() == Some('"') {
2440        sc.next();
2441        let mut closed = false;
2442        while let Some(c) = sc.peek() {
2443            sc.next();
2444            if c == '"' {
2445                closed = true;
2446                break;
2447            }
2448            name.push(c);
2449        }
2450        if !closed {
2451            return Err(sc.reject_at(opened, "unterminated quoted name"));
2452        }
2453        sc.skip_ws_comments();
2454        if sc.next() != Some(')') {
2455            return Err(sc.reject("expected ')' after quoted name"));
2456        }
2457        return Ok(name);
2458    }
2459    loop {
2460        match sc.peek() {
2461            Some(')') => {
2462                sc.next();
2463                break;
2464            }
2465            Some(c) if c.is_whitespace() => {
2466                // Whitespace ends the name. Allow only trailing
2467                // whitespace before `)`; reject embedded whitespace.
2468                sc.skip_ws_comments();
2469                match sc.peek() {
2470                    Some(')') => {
2471                        sc.next();
2472                        break;
2473                    }
2474                    Some(_) => {
2475                        return Err(sc.reject("whitespace inside parenthesised name"));
2476                    }
2477                    None => {
2478                        return Err(sc.reject_at(opened, "unterminated '(' — missing ')'"));
2479                    }
2480                }
2481            }
2482            Some(c) => {
2483                name.push(c);
2484                sc.next();
2485            }
2486            None => {
2487                return Err(sc.reject_at(opened, "unterminated '(' — missing ')'"));
2488            }
2489        }
2490    }
2491    Ok(name)
2492}
2493
2494fn read_brace_list(sc: &mut AcfScanner) -> CaResult<Vec<String>> {
2495    sc.skip_ws_comments();
2496    if sc.next() != Some('{') {
2497        return Err(sc.reject("expected '{'"));
2498    }
2499    let opened = sc.line;
2500    let mut items = Vec::new();
2501    let mut current = String::new();
2502
2503    loop {
2504        sc.skip_ws_comments();
2505        match sc.peek() {
2506            Some('}') => {
2507                sc.next();
2508                break;
2509            }
2510            Some(',') => {
2511                sc.next();
2512                if !current.is_empty() {
2513                    items.push(current.clone());
2514                    current.clear();
2515                }
2516            }
2517            // Quoted string: asLib_lex.l `{doublequote}({stringchar}|{escape})*{doublequote}`
2518            // where stringchar is [^"\n\\]. Allows '/' so "role/groupname" entries work.
2519            // pvxs/documentation/ioc.rst shows: UAG(special) { someone, "role/op" }
2520            Some('"') => {
2521                sc.next(); // consume opening '"'
2522                let quote_opened = sc.line;
2523                if !current.is_empty() {
2524                    items.push(current.clone());
2525                    current.clear();
2526                }
2527                let mut quoted = String::new();
2528                loop {
2529                    match sc.next() {
2530                        Some('"') => break,
2531                        Some('\\') => {
2532                            if let Some(esc) = sc.next() {
2533                                quoted.push(esc);
2534                            }
2535                        }
2536                        Some('\n') | None => {
2537                            return Err(sc.reject_at(quote_opened, "unterminated quoted string"));
2538                        }
2539                        Some(c) => quoted.push(c),
2540                    }
2541                }
2542                if !quoted.is_empty() {
2543                    items.push(quoted);
2544                }
2545            }
2546            // Unquoted name: asLib_lex.l `name [a-zA-Z0-9_\-+:.\[\]<>;]`
2547            Some(c)
2548                if c.is_alphanumeric()
2549                    || matches!(c, '_' | '.' | '-' | '+' | ':' | '[' | ']' | '<' | '>' | ';') =>
2550            {
2551                current.push(c);
2552                sc.next();
2553            }
2554            Some(_) => {
2555                sc.next();
2556            }
2557            None => return Err(sc.reject_at(opened, "unterminated '{'")),
2558        }
2559    }
2560    if !current.is_empty() {
2561        items.push(current);
2562    }
2563    Ok(items)
2564}
2565
2566fn parse_asg_body(sc: &mut AcfScanner) -> CaResult<AccessSecurityGroup> {
2567    sc.skip_ws_comments();
2568    if sc.next() != Some('{') {
2569        return Err(sc.reject("expected '{' after ASG name"));
2570    }
2571    let opened = sc.line;
2572
2573    let mut asg = AccessSecurityGroup::default();
2574
2575    loop {
2576        sc.skip_ws_comments();
2577        match sc.peek() {
2578            Some('}') => {
2579                sc.next();
2580                break;
2581            }
2582            Some(_) => {
2583                let mut kw = String::new();
2584                read_word(sc, &mut kw);
2585                if kw == "RULE" {
2586                    let rule = parse_rule(sc)?;
2587                    asg.rules.push(rule);
2588                } else if let Some(stripped) = kw.strip_prefix("INP") {
2589                    // `INP(A..U)("link")` — C `asLib_lex.l:48-52`
2590                    // lexes `INP[A-U]` as one token whose `Int64`
2591                    // payload is the letter index (`yytext[3] - 'A'`).
2592                    // `asLib.y:234-243` then reads the parenthesised
2593                    // link string.
2594                    let index = match parse_inp_index(stripped) {
2595                        Some(i) => i,
2596                        None => {
2597                            return Err(sc.reject(format!(
2598                                "invalid INP link selector 'INP{stripped}' \
2599                                 (expected INPA..INPU)"
2600                            )));
2601                        }
2602                    };
2603                    let link = read_paren_name(sc)?;
2604                    asg.inp.push(AsgInp { index, link });
2605                } else if kw.is_empty() {
2606                    sc.next(); // skip unknown char
2607                }
2608                // Unknown alphanumeric keywords inside an ASG body are
2609                // skipped (forward-compat); the next loop iteration
2610                // resumes from the following token.
2611            }
2612            None => return Err(sc.reject_at(opened, "unterminated ASG")),
2613        }
2614    }
2615
2616    Ok(asg)
2617}
2618
2619/// Parse the `A..U` selector suffix of an `INP` token into a 0-based
2620/// letter index. `"A"` → 0, .. `"U"` → 20. Anything else → `None`.
2621///
2622/// Case-SENSITIVE: C `asLib_lex.l:21,47` lexes the selector with the
2623/// flex pattern `INP[A-U]` (uppercase range only), so `INPa` does not
2624/// match the `tokenINP` rule — it is a syntax error in C, not selector
2625/// index 0. Matching only uppercase here keeps that behaviour; a
2626/// lowercase suffix returns `None` and the caller rejects the ASG.
2627fn parse_inp_index(suffix: &str) -> Option<u8> {
2628    let mut it = suffix.chars();
2629    let c = it.next()?;
2630    if it.next().is_some() {
2631        return None; // INP selector is exactly one letter
2632    }
2633    if ('A'..='U').contains(&c) {
2634        Some((c as u8) - b'A')
2635    } else {
2636        None
2637    }
2638}
2639
2640fn parse_rule(sc: &mut AcfScanner) -> CaResult<AccessRule> {
2641    sc.skip_ws_comments();
2642    if sc.next() != Some('(') {
2643        return Err(sc.reject("expected '(' after RULE"));
2644    }
2645
2646    // Read level. C `asLib.y:253-258` requires `tokenINT64` and
2647    // rejects a negative or non-numeric level with `yyerror`, which
2648    // fails the whole ACF load (a fail-safe abort). Accept an optional
2649    // leading sign so a `RULE(-1, ...)` is detected and rejected
2650    // rather than silently re-read as level 1.
2651    sc.skip_ws_comments();
2652    let mut level_str = String::new();
2653    if matches!(sc.peek(), Some('+') | Some('-')) {
2654        level_str.push(sc.next().unwrap());
2655    }
2656    while let Some(c) = sc.peek() {
2657        if c.is_ascii_digit() {
2658            level_str.push(c);
2659            sc.next();
2660        } else {
2661            break;
2662        }
2663    }
2664    let level_num: i64 = match level_str.parse() {
2665        Ok(n) => n,
2666        Err(_) => {
2667            let got = sc.offending(&level_str);
2668            return Err(sc.reject(format!("RULE level must be an integer, got '{got}'")));
2669        }
2670    };
2671    if level_num < 0 {
2672        return Err(sc.reject(format!("RULE LEVEL must be positive: {level_num}")));
2673    }
2674    let level: u8 = u8::try_from(level_num)
2675        .map_err(|_| sc.reject(format!("RULE level out of range: {level_num}")))?;
2676
2677    sc.skip_ws_comments();
2678    if sc.peek() == Some(',') {
2679        sc.next();
2680    }
2681
2682    // Read access keyword. C `asLib.y:259-264` matches `NONE`/`READ`/
2683    // `WRITE` with `strcmp` (case-SENSITIVE); any other keyword triggers
2684    // `yywarn "Ignoring RULE that contains an unsupported keyword"`
2685    // and the rule is dropped. Match case-sensitively here too: a
2686    // case variant like `write` is an unsupported keyword in C, so it
2687    // must not build an active Write rule. We keep the rule but mark it
2688    // `ignore` (inert) so any unsupported keyword fails CLOSED — the
2689    // same effect as C dropping the rule.
2690    sc.skip_ws_comments();
2691    let mut access_str = String::new();
2692    read_word(sc, &mut access_str);
2693    let (access, mut ignore) = if access_str == "WRITE" {
2694        (RuleAccess::Write, false)
2695    } else if access_str == "READ" {
2696        (RuleAccess::Read, false)
2697    } else if access_str == "NONE" {
2698        (RuleAccess::None, false)
2699    } else {
2700        // `offending` and not `access_str`: `read_word` stops before the
2701        // character it cannot take, so on `RULE(1, %)` the buffer is empty
2702        // and the warning would name a keyword the operator never wrote.
2703        let keyword = sc.offending(&access_str);
2704        tracing::warn!(
2705            target: "epics_base_rs::access_security",
2706            line = sc.line,
2707            keyword = %keyword,
2708            "ACF: ignoring RULE with unsupported access keyword"
2709        );
2710        (RuleAccess::None, true)
2711    };
2712
2713    // Optional log option: `RULE(level, access, TRAPWRITE)` /
2714    // `RULE(level, access, NOTRAPWRITE)`. C `asLib.y:272-283`
2715    // (`rule_log_option`) matches both with `strcmp` (case-SENSITIVE):
2716    // a lowercase `trapwrite` matches neither and hits
2717    // `yyerror "Log options must be TRAPWRITE or NOTRAPWRITE"`, which
2718    // fails the whole ACF load. Match case-sensitively here too so a
2719    // case variant is rejected rather than silently accepted. The trap
2720    // mask is captured in `AccessRule::trap`; the `asTrapWrite`
2721    // put-logging listener that would consume it is a separate
2722    // subsystem not present in this crate (see the UNFIXED note).
2723    let mut trap = false;
2724    sc.skip_ws_comments();
2725    if sc.peek() == Some(',') {
2726        sc.next();
2727        sc.skip_ws_comments();
2728        let mut log_opt = String::new();
2729        read_word(sc, &mut log_opt);
2730        if log_opt == "TRAPWRITE" {
2731            trap = true;
2732        } else if log_opt != "NOTRAPWRITE" {
2733            let got = sc.offending(&log_opt);
2734            return Err(sc.reject(format!(
2735                "RULE log option must be TRAPWRITE or NOTRAPWRITE, got '{got}'"
2736            )));
2737        }
2738    }
2739
2740    sc.skip_ws_comments();
2741    if sc.peek() == Some(')') {
2742        sc.next();
2743    }
2744
2745    // Optional body with UAG/HAG/METHOD/AUTHORITY/CALC.
2746    let mut uag = Vec::new();
2747    let mut hag = Vec::new();
2748    let mut method = Vec::new();
2749    let mut authority = Vec::new();
2750    // The CALC clause travels with the line it was written on: the two
2751    // rejections below name the expression, and a rule body can close many
2752    // lines after the `CALC(...)` that is wrong.
2753    let mut calc: Option<(u32, String)> = None;
2754
2755    sc.skip_ws_comments();
2756    if sc.peek() == Some('{') {
2757        sc.next();
2758        loop {
2759            sc.skip_ws_comments();
2760            match sc.peek() {
2761                Some('}') => {
2762                    sc.next();
2763                    break;
2764                }
2765                Some(_) => {
2766                    let mut kw = String::new();
2767                    read_word(sc, &mut kw);
2768                    if kw == "UAG" {
2769                        let name = read_paren_name(sc)?;
2770                        uag.push(name);
2771                    } else if kw == "HAG" {
2772                        let name = read_paren_name(sc)?;
2773                        hag.push(name);
2774                    } else if kw == "METHOD" {
2775                        // PR #563: METHOD("ca", "x509", ...)
2776                        method.extend(read_paren_string_list(sc)?);
2777                    } else if kw == "AUTHORITY" {
2778                        // PR #563/#618: AUTHORITY("CA Issuer", ...)
2779                        authority.extend(read_paren_string_list(sc)?);
2780                    } else if kw == "CALC" {
2781                        // `CALC("<expr>")` — C `asLib.y:294-299`.
2782                        // The expression gates the rule against the
2783                        // ASG's INP* link values. Take the *last*
2784                        // CALC clause if several are given (matches
2785                        // C `asAsgRuleCalc` last-wins overwrite).
2786                        let at = sc.line;
2787                        let expr = read_paren_name_raw(sc)?;
2788                        calc = Some((at, expr));
2789                    } else if kw.is_empty() {
2790                        // Unknown punctuation — advance to avoid infinite loop.
2791                        sc.next();
2792                    } else {
2793                        // C `asLib.y:300-306`: a RULE body with
2794                        // an unsupported keyword is *disabled* by
2795                        // `asAsgRuleDisable`. Mark the rule inert and
2796                        // consume the keyword's `(...)` argument if
2797                        // present so parsing recovers.
2798                        tracing::warn!(
2799                            target: "epics_base_rs::access_security",
2800                            line = sc.line,
2801                            keyword = %kw,
2802                            "ACF: ignoring RULE with unsupported keyword — rule disabled"
2803                        );
2804                        ignore = true;
2805                        sc.skip_ws_comments();
2806                        if sc.peek() == Some('(') {
2807                            let _ = read_paren_name(sc)?;
2808                        }
2809                    }
2810                }
2811                None => break,
2812            }
2813        }
2814    }
2815
2816    // a CALC clause must actually gate the rule. This crate's
2817    // access-security layer has no `INP*` database-link resolution
2818    // (the `AsgInp` links are stored but never read), so the calc
2819    // expression cannot be evaluated at access-check time. C disables
2820    // any rule it cannot fully honour (`asAsgRuleDisable`); to fail
2821    // CLOSED we do the same — a present-but-unevaluable CALC condition
2822    // marks the rule inert rather than letting it become an
2823    // unconditional grant. The expression is still validated (compiled)
2824    // here so a syntactically broken CALC is rejected exactly as C's
2825    // `postfix()` rejects it in `asAsgRuleCalc`.
2826    // compile the CALC expression at parse (C `postfix()` rejects a
2827    // broken one in `asAsgRuleCalc` and stores the RPN for every later
2828    // `asComputePvt`). The rule is conditionally active and gated at
2829    // access-check time by `compute_rules`'s `calc_ok`, which resolves
2830    // the ASG's INP* links and evaluates the stored program. When no
2831    // INP* resolver is installed the evaluator returns false (fail
2832    // closed), preserving the previous deny behaviour without
2833    // hard-disabling the rule.
2834    let mut inp_used: u32 = 0;
2835    let calc_compiled = match calc {
2836        Some((at, ref expr)) => {
2837            let compiled = crate::calc::compile(expr)
2838                .map_err(|e| sc.reject_at(at, format!("bad CALC expression '{expr}': {e}")))?;
2839            // C `asAsgRuleCalc` (`asLibRoutines.c:1416-1425`) runs
2840            // `calcArgUsage` right after `postfix()` and refuses the rule when
2841            // the expression stores into an argument:
2842            //
2843            //     /* Until someone proves stores are not dangerous, don't allow them */
2844            //     if (stores) { … status = S_asLib_badCalc; … }
2845            //
2846            // `asLib.y:294-299` turns that status into `yyerror("")`, so the
2847            // WHOLE file is rejected and a running IOC keeps its previous rule
2848            // set. Accepting the rest of the file instead would be strictly
2849            // less safe than C: the operator's edit would silently install a
2850            // weaker policy than the one they wrote.
2851            //
2852            // The danger is concrete — `CALC("A:=1")` evaluates to 1 whatever
2853            // the INP links read, so the rule becomes an unconditional grant
2854            // to everyone in the group.
2855            let (used, stores) = compiled.arg_usage();
2856            if stores != 0 {
2857                return Err(sc.reject_at(
2858                    at,
2859                    format!("assignment operator used in CALC expression '{expr}'"),
2860                ));
2861            }
2862            inp_used = used;
2863            Some(compiled)
2864        }
2865        None => None,
2866    };
2867
2868    Ok(AccessRule {
2869        level,
2870        access,
2871        uag,
2872        hag,
2873        method,
2874        authority,
2875        trap,
2876        calc: calc.map(|(_, expr)| expr),
2877        calc_compiled,
2878        inp_used,
2879        ignore,
2880    })
2881}
2882
2883/// Read a parenthesised, double-quoted string verbatim — used for the
2884/// `CALC("<expr>")` clause where the expression contains operators and
2885/// spaces that `read_paren_name` would mangle (it strips whitespace).
2886/// Accepts `( "expr" )` or `( expr )`; whitespace around the parens is
2887/// skipped, whitespace *inside* a quoted body is preserved.
2888fn read_paren_name_raw(sc: &mut AcfScanner) -> CaResult<String> {
2889    sc.skip_ws_comments();
2890    if sc.next() != Some('(') {
2891        return Err(sc.reject("expected '(' after CALC"));
2892    }
2893    let opened = sc.line;
2894    sc.skip_ws_comments();
2895    let mut body = String::new();
2896    if sc.peek() == Some('"') {
2897        sc.next();
2898        while let Some(c) = sc.peek() {
2899            sc.next();
2900            if c == '"' {
2901                break;
2902            }
2903            body.push(c);
2904        }
2905        sc.skip_ws_comments();
2906        if sc.next() != Some(')') {
2907            return Err(sc.reject_at(opened, "expected ')' after CALC expression"));
2908        }
2909    } else {
2910        // Unquoted form — read until the closing paren.
2911        while let Some(c) = sc.peek() {
2912            if c == ')' {
2913                sc.next();
2914                break;
2915            }
2916            body.push(c);
2917            sc.next();
2918        }
2919    }
2920    Ok(body.trim().to_string())
2921}
2922
2923/// Parse `(item1, "item 2", ...)` — commas separate items, optional
2924/// quotes around each item are stripped. Used for METHOD/AUTHORITY
2925/// rule clauses (epics-base PR #563/#618). Whitespace inside an
2926/// unquoted item is preserved verbatim *between* word characters but
2927/// trimmed at the boundaries; the typical caller passes quoted strings.
2928fn read_paren_string_list(sc: &mut AcfScanner) -> CaResult<Vec<String>> {
2929    sc.skip_ws_comments();
2930    if sc.next() != Some('(') {
2931        return Err(sc.reject("expected '(' after METHOD/AUTHORITY"));
2932    }
2933    let opened = sc.line;
2934    let mut items = Vec::new();
2935    let mut current = String::new();
2936    let mut in_quotes = false;
2937    loop {
2938        match sc.peek() {
2939            Some('"') => {
2940                sc.next();
2941                in_quotes = !in_quotes;
2942            }
2943            Some(')') if !in_quotes => {
2944                sc.next();
2945                break;
2946            }
2947            Some(',') if !in_quotes => {
2948                sc.next();
2949                let trimmed = current.trim().to_string();
2950                if !trimmed.is_empty() {
2951                    items.push(trimmed);
2952                }
2953                current.clear();
2954            }
2955            Some(c) => {
2956                current.push(c);
2957                sc.next();
2958            }
2959            None => {
2960                return Err(sc.reject_at(opened, "unterminated METHOD/AUTHORITY list"));
2961            }
2962        }
2963    }
2964    let trimmed = current.trim().to_string();
2965    if !trimmed.is_empty() {
2966        items.push(trimmed);
2967    }
2968    Ok(items)
2969}
2970
2971#[cfg(test)]
2972mod tests {
2973    use super::*;
2974
2975    /// [`AS_CHECK_CLIENT_IP`] is a process-global flag, mirroring C's
2976    /// global IOC variable — every `parse_acf` call whose ACF has a
2977    /// `HAG(...)` block reads it via [`hag_members`]. Rust's default test
2978    /// runner runs every `#[test]` fn in this module concurrently on its
2979    /// own thread, so without serialization a test that never touches the
2980    /// flag can still observe a value flipped mid-flight by a sibling
2981    /// test's `set_as_check_client_ip(true)` / `(false)` window — that
2982    /// race is what turned `"host1"` into `"unresolved:host1"` under plain
2983    /// `cargo test`. Every test that reads or writes the flag (directly,
2984    /// or by parsing an ACF containing a `HAG` block) takes this lock for
2985    /// its whole body so at most one such test runs at a time; poisoning
2986    /// is ignored so one test's panic doesn't cascade-fail its siblings.
2987    static AS_CHECK_CLIENT_IP_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2988
2989    fn lock_as_check_client_ip() -> std::sync::MutexGuard<'static, ()> {
2990        AS_CHECK_CLIENT_IP_TEST_LOCK
2991            .lock()
2992            .unwrap_or_else(|e| e.into_inner())
2993    }
2994
2995    #[test]
2996    fn test_parse_acf_basic() {
2997        let _guard = lock_as_check_client_ip();
2998        let acf = r#"
2999UAG(admins) { user1, user2 }
3000HAG(operators) { host1, host2 }
3001ASG(DEFAULT) {
3002    RULE(1, WRITE) { UAG(admins) HAG(operators) }
3003    RULE(1, READ)
3004}
3005"#;
3006        let config = parse_acf(acf).unwrap();
3007        assert_eq!(config.uag.get("admins").unwrap(), &["user1", "user2"]);
3008        assert_eq!(config.hag.get("operators").unwrap(), &["host1", "host2"]);
3009        assert!(config.asg.contains_key("DEFAULT"));
3010        assert_eq!(config.asg["DEFAULT"].rules.len(), 2);
3011    }
3012
3013    #[test]
3014    fn test_parse_acf_hag_uag() {
3015        let _guard = lock_as_check_client_ip();
3016        // Use `.invalid` so DNS resolution is guaranteed to fail
3017        // (RFC 6761 — every resolver returns NXDOMAIN). This isolates
3018        // the test from `expand_hag_members`' soft-DNS path: the
3019        // literal entry is preserved, and no resolved IPs are
3020        // appended.
3021        let acf = r#"
3022UAG(ops) { alice, bob }
3023HAG(lab) { lab-pc1.invalid }
3024ASG(SECURE) {
3025    RULE(1, WRITE) { UAG(ops) HAG(lab) }
3026    RULE(1, READ)
3027}
3028"#;
3029        let config = parse_acf(acf).unwrap();
3030        assert_eq!(config.uag["ops"], vec!["alice", "bob"]);
3031        assert_eq!(config.hag["lab"], vec!["lab-pc1.invalid"]);
3032    }
3033
3034    /// R7-16 / C `asHagAddHost` (`asLibRoutines.c:1218-1256`): with
3035    /// `asCheckClientIP` at its default 0, a HAG host is stored as a
3036    /// lowercased **name** and nothing else — no DNS runs, and no resolved
3037    /// IP is appended. The identity it matches is the name the client
3038    /// claimed over `CA_PROTO_HOST_NAME`, so a peer IP must NOT match.
3039    ///
3040    /// The port used to append resolved IPs to every entry, because its CA
3041    /// server keyed ACF on the peer IP. Both halves are C's now.
3042    #[test]
3043    fn hag_stores_names_by_default() {
3044        let _guard = lock_as_check_client_ip();
3045        set_as_check_client_ip(false);
3046        let acf = r#"
3047HAG(local) { LocalHost }
3048ASG(DEFAULT) {
3049    RULE(1, WRITE) { HAG(local) }
3050}
3051"#;
3052        let config = parse_acf(acf).unwrap();
3053        assert_eq!(
3054            config.hag["local"],
3055            vec!["localhost"],
3056            "C stores the lowercased literal name and resolves nothing"
3057        );
3058        assert_eq!(
3059            config.check_access("DEFAULT", "localhost", "alice"),
3060            AccessLevel::ReadWrite,
3061            "the claimed host name matches the HAG"
3062        );
3063        assert_eq!(
3064            config.check_access("DEFAULT", "127.0.0.1", "alice"),
3065            AccessLevel::NoAccess,
3066            "a peer IP does not match a name HAG — that is what asCheckClientIP=1 is for"
3067        );
3068    }
3069
3070    /// The other side of C's flag: `asCheckClientIP = 1` resolves every HAG
3071    /// host to a dotted-quad IP at ACF-parse time, and the CA server keys
3072    /// on the peer IP.
3073    #[test]
3074    fn hag_stores_resolved_ips_under_as_check_client_ip() {
3075        let _guard = lock_as_check_client_ip();
3076        set_as_check_client_ip(true);
3077        let acf = r#"
3078HAG(local) { localhost }
3079ASG(DEFAULT) {
3080    RULE(1, WRITE) { HAG(local) }
3081}
3082"#;
3083        let config = parse_acf(acf).unwrap();
3084        set_as_check_client_ip(false); // restore before asserting
3085        assert_eq!(
3086            config.hag["local"],
3087            vec!["127.0.0.1"],
3088            "C resolves the host to its IP under asCheckClientIP"
3089        );
3090        assert_eq!(
3091            config.check_access("DEFAULT", "127.0.0.1", "alice"),
3092            AccessLevel::ReadWrite,
3093            "the peer IP matches the resolved HAG"
3094        );
3095    }
3096
3097    /// C `asHagAddHost` under `asCheckClientIP = 1` does not abort on a
3098    /// name it cannot resolve: it logs and stores `unresolved:<host>`, a
3099    /// sentinel that simply never matches.
3100    #[test]
3101    fn hag_unresolvable_under_as_check_client_ip_becomes_sentinel() {
3102        let _guard = lock_as_check_client_ip();
3103        set_as_check_client_ip(true);
3104        let config = parse_acf("HAG(lab) { lab-pc1.invalid }\n").unwrap();
3105        set_as_check_client_ip(false);
3106        assert_eq!(config.hag["lab"], vec!["unresolved:lab-pc1.invalid"]);
3107    }
3108
3109    #[test]
3110    fn hag_unresolvable_name_does_not_abort_parser() {
3111        let _guard = lock_as_check_client_ip();
3112        // `.invalid` TLD guarantees NXDOMAIN (RFC 6761). Pre-fix
3113        // upstream would `abort()` here; we keep the literal entries
3114        // verbatim — no resolved IPs are appended and the parser
3115        // returns Ok. Comma separator matches the brace-list parser's
3116        // tokenization (whitespace alone is consumed silently).
3117        let acf = r#"
3118HAG(quarantine) { gone.invalid, alive.invalid }
3119ASG(DEFAULT) {
3120    RULE(1, WRITE) { HAG(quarantine) }
3121}
3122"#;
3123        let config = parse_acf(acf).expect("parser must not abort on bad DNS");
3124        let entries = &config.hag["quarantine"];
3125        assert_eq!(
3126            entries.len(),
3127            2,
3128            "literal entries preserved verbatim; no resolved IPs appended"
3129        );
3130        assert_eq!(entries[0], "gone.invalid");
3131        assert_eq!(entries[1], "alive.invalid");
3132    }
3133
3134    /// UI-107 / epics-base#863 (access-security half): under
3135    /// `asCheckClientIP` the parsed `hag` holds resolution *output*
3136    /// frozen at load time. `with_refreshed_hags` re-runs `hag_members`
3137    /// over the raw spellings — the periodic refresher's engine.
3138    #[test]
3139    fn with_refreshed_hags_recovers_a_stale_resolution() {
3140        let _guard = lock_as_check_client_ip();
3141        set_as_check_client_ip(true);
3142        let mut config = parse_acf("HAG(local) { localhost }\n").unwrap();
3143        assert_eq!(config.hag_raw["local"], vec!["localhost"]);
3144
3145        // Simulate DNS moving after load: the stored quad no longer
3146        // matches what `localhost` resolves to.
3147        config.hag.insert("local".into(), vec!["192.0.2.1".into()]);
3148
3149        let refreshed = config
3150            .with_refreshed_hags()
3151            .expect("a moved resolution must produce a refreshed config");
3152        set_as_check_client_ip(false);
3153        assert_eq!(refreshed.hag["local"], vec!["127.0.0.1"]);
3154        assert_eq!(
3155            refreshed.hag_raw["local"],
3156            vec!["localhost"],
3157            "raw spellings survive the refresh for the next round"
3158        );
3159    }
3160
3161    /// An unchanged resolution yields `None` — the refresher
3162    /// republishes (re-notifying every connected client) only on real
3163    /// movement.
3164    #[test]
3165    fn with_refreshed_hags_is_none_when_resolution_is_unchanged() {
3166        let _guard = lock_as_check_client_ip();
3167        set_as_check_client_ip(true);
3168        let config = parse_acf("HAG(local) { localhost }\n").unwrap();
3169        let idempotent = config.with_refreshed_hags();
3170        set_as_check_client_ip(false);
3171        assert!(
3172            idempotent.is_none(),
3173            "a freshly parsed config re-resolves to itself"
3174        );
3175    }
3176
3177    /// In default string mode the stored members are lowercased
3178    /// literals no DNS change can move — a refresh is always a no-op
3179    /// (`spawn_hag_refresh` gates on the flag, but the method must
3180    /// hold on its own for direct callers).
3181    #[test]
3182    fn with_refreshed_hags_is_none_in_name_mode() {
3183        let _guard = lock_as_check_client_ip();
3184        set_as_check_client_ip(false);
3185        let config = parse_acf("HAG(local) { LocalHost }\n").unwrap();
3186        assert_eq!(config.hag_raw["local"], vec!["LocalHost"]);
3187        assert_eq!(config.hag["local"], vec!["localhost"]);
3188        assert!(config.with_refreshed_hags().is_none());
3189    }
3190
3191    #[test]
3192    fn test_check_access_default_rw() {
3193        let acf = "ASG(DEFAULT) { RULE(1, WRITE) RULE(1, READ) }";
3194        let config = parse_acf(acf).unwrap();
3195        assert_eq!(
3196            config.check_access("DEFAULT", "host1", "user1"),
3197            AccessLevel::ReadWrite
3198        );
3199    }
3200
3201    #[test]
3202    fn test_check_access_read_only() {
3203        let acf = r#"
3204UAG(admins) { admin1 }
3205ASG(READONLY) {
3206    RULE(1, READ)
3207    RULE(1, WRITE) { UAG(admins) }
3208}
3209"#;
3210        let config = parse_acf(acf).unwrap();
3211        // admin1 gets RW
3212        assert_eq!(
3213            config.check_access("READONLY", "host1", "admin1"),
3214            AccessLevel::ReadWrite
3215        );
3216        // Other users get read only
3217        assert_eq!(
3218            config.check_access("READONLY", "host1", "regular"),
3219            AccessLevel::Read
3220        );
3221    }
3222
3223    #[test]
3224    fn test_check_access_hag_uag_match() {
3225        let _guard = lock_as_check_client_ip();
3226        let acf = r#"
3227UAG(ops) { alice }
3228HAG(lab) { lab-pc1 }
3229ASG(CONTROLLED) {
3230    RULE(1, WRITE) { UAG(ops) HAG(lab) }
3231    RULE(1, READ)
3232}
3233"#;
3234        let config = parse_acf(acf).unwrap();
3235        // Alice on lab-pc1 gets RW
3236        assert_eq!(
3237            config.check_access("CONTROLLED", "lab-pc1", "alice"),
3238            AccessLevel::ReadWrite
3239        );
3240        // Alice on wrong host gets READ
3241        assert_eq!(
3242            config.check_access("CONTROLLED", "other-host", "alice"),
3243            AccessLevel::Read
3244        );
3245        // Wrong user on lab-pc1 gets READ
3246        assert_eq!(
3247            config.check_access("CONTROLLED", "lab-pc1", "bob"),
3248            AccessLevel::Read
3249        );
3250    }
3251
3252    #[test]
3253    fn test_check_access_unknown_user() {
3254        let acf = r#"
3255ASG(DEFAULT) {
3256    RULE(1, WRITE)
3257    RULE(1, READ)
3258}
3259"#;
3260        let config = parse_acf(acf).unwrap();
3261        // C `asComputePvt` parity: a RULE with an empty UAG list
3262        // applies to *every* client regardless of user/host — the
3263        // UAG check is skipped when `ellCount(&pasgrule->uagList)==0`.
3264        // So an unconditional `RULE(1, WRITE)` grants WRITE even to a
3265        // client with an empty/unknown user. (The old port returned
3266        // `Read` here via a `unknown_access` special-case that C does
3267        // not have.)
3268        assert_eq!(
3269            config.check_access("DEFAULT", "", ""),
3270            AccessLevel::ReadWrite
3271        );
3272    }
3273
3274    /// epics-base #871 (7e18b8cff): `asDump*` quotes every UAG and HAG
3275    /// member through `asDumpQuoted` — `"` + `epicsStrPrintEscaped` + `"`
3276    /// — so a member that needed ACF quoting (`"role/op"`, an embedded
3277    /// `"`) survives the dump unambiguously instead of printing raw.
3278    #[test]
3279    fn dump_report_quotes_uag_and_hag_members() {
3280        let cfg =
3281            parse_acf("UAG(special) { someone, \"role/op\", \"a\\\"b\" }\nHAG(hosts) { HostA }\n")
3282                .unwrap();
3283        let dump = cfg.dump_report();
3284        assert!(dump.contains("\t\"someone\"\n"), "{dump}");
3285        assert!(dump.contains("\t\"role/op\"\n"), "{dump}");
3286        assert!(dump.contains("\t\"a\\\"b\"\n"), "{dump}");
3287        // HAG members are stored lowercased; quoted the same way.
3288        assert!(dump.contains("\t\"hosta\"\n"), "{dump}");
3289    }
3290
3291    // ----- epics-base PR #563/#618: METHOD / AUTHORITY -----
3292
3293    #[test]
3294    fn parse_acf_captures_method_and_authority() {
3295        let acf = r#"
3296ASG(SECURE) {
3297    RULE(1, WRITE) {
3298        METHOD("ca", "x509")
3299        AUTHORITY("ANL CA")
3300    }
3301    RULE(1, READ)
3302}
3303"#;
3304        let config = parse_acf(acf).unwrap();
3305        let asg = &config.asg["SECURE"];
3306        assert_eq!(asg.rules.len(), 2);
3307        assert_eq!(asg.rules[0].method, vec!["ca", "x509"]);
3308        assert_eq!(asg.rules[0].authority, vec!["ANL CA"]);
3309        assert!(
3310            asg.rules[1].method.is_empty(),
3311            "READ rule must not inherit METHOD list",
3312        );
3313        assert!(asg.rules[1].authority.is_empty());
3314    }
3315
3316    #[test]
3317    fn tls_x509_acf_rule_grants_write_on_issuer_match() {
3318        // PR #641 end-to-end: an ACF rule that requires both
3319        // METHOD("x509") and AUTHORITY(<issuer>) must succeed only
3320        // when an mTLS peer presents a cert signed by that issuer.
3321        let cfg = parse_acf(
3322            r#"
3323ASG(TLS_ONLY) {
3324    RULE(1, WRITE) { METHOD("x509") AUTHORITY("CN=ops-ca, O=Lab") }
3325    RULE(1, READ)
3326}
3327"#,
3328        )
3329        .unwrap();
3330        // Plaintext (no method) → READ only.
3331        assert_eq!(
3332            cfg.check_access_method("TLS_ONLY", "h", "u", 0, "", ""),
3333            AccessLevel::Read
3334        );
3335        // mTLS, wrong issuer → READ only.
3336        assert_eq!(
3337            cfg.check_access_method("TLS_ONLY", "h", "u", 0, "x509", "CN=other-ca"),
3338            AccessLevel::Read
3339        );
3340        // mTLS, matching issuer → WRITE granted.
3341        assert_eq!(
3342            cfg.check_access_method("TLS_ONLY", "h", "u", 0, "x509", "CN=ops-ca, O=Lab"),
3343            AccessLevel::ReadWrite
3344        );
3345    }
3346
3347    #[test]
3348    fn check_access_method_gates_on_method() {
3349        let acf = r#"
3350ASG(METHOD_GATED) {
3351    RULE(1, WRITE) {
3352        METHOD("x509")
3353    }
3354    RULE(1, READ)
3355}
3356"#;
3357        let config = parse_acf(acf).unwrap();
3358        // x509 method → WRITE matches.
3359        assert_eq!(
3360            config.check_access_method("METHOD_GATED", "h", "u", 0, "x509", ""),
3361            AccessLevel::ReadWrite
3362        );
3363        // ca method → only the unconstrained READ rule matches.
3364        assert_eq!(
3365            config.check_access_method("METHOD_GATED", "h", "u", 0, "ca", ""),
3366            AccessLevel::Read
3367        );
3368    }
3369
3370    #[test]
3371    fn check_access_method_gates_on_authority() {
3372        let acf = r#"
3373ASG(AUTH_GATED) {
3374    RULE(1, WRITE) {
3375        AUTHORITY("Trusted Root")
3376    }
3377    RULE(1, READ)
3378}
3379"#;
3380        let config = parse_acf(acf).unwrap();
3381        assert_eq!(
3382            config.check_access_method("AUTH_GATED", "h", "u", 0, "x509", "Trusted Root"),
3383            AccessLevel::ReadWrite
3384        );
3385        assert_eq!(
3386            config.check_access_method("AUTH_GATED", "h", "u", 0, "x509", "Other CA"),
3387            AccessLevel::Read
3388        );
3389    }
3390
3391    #[test]
3392    fn check_access_asl_legacy_path_matches_when_method_empty() {
3393        // Legacy ACF without METHOD/AUTHORITY clauses must continue
3394        // to match every method/authority — exactly what
3395        // `check_access_asl` forwards as ("", "").
3396        let acf = r#"
3397ASG(LEGACY) {
3398    RULE(1, WRITE)
3399    RULE(1, READ)
3400}
3401"#;
3402        let config = parse_acf(acf).unwrap();
3403        assert_eq!(
3404            config.check_access_asl("LEGACY", "h", "u", 0),
3405            AccessLevel::ReadWrite
3406        );
3407    }
3408
3409    #[test]
3410    fn check_access_method_match_is_case_insensitive() {
3411        let acf = r#"
3412ASG(MIXED_CASE) {
3413    RULE(1, WRITE) {
3414        METHOD("X509")
3415    }
3416}
3417"#;
3418        let config = parse_acf(acf).unwrap();
3419        assert_eq!(
3420            config.check_access_method("MIXED_CASE", "h", "u", 0, "x509", ""),
3421            AccessLevel::ReadWrite
3422        );
3423    }
3424
3425    // ----- access security must fail CLOSED -----
3426
3427    /// an ASG declared with no RULE statements denies every
3428    /// client. C `asComputePvt` starts `access = asNOACCESS` and only
3429    /// raises it on a matching RULE — an empty rule list never raises.
3430    #[test]
3431    fn empty_rule_asg_denies_access() {
3432        let config = parse_acf("ASG(LOCKED) { }").unwrap();
3433        assert_eq!(
3434            config.check_access("LOCKED", "host", "user"),
3435            AccessLevel::NoAccess,
3436            "ASG with no RULE must deny — C asComputePvt fails closed"
3437        );
3438    }
3439
3440    /// a record whose ASG names a group not in the file resolves
3441    /// to the always-present (empty) `DEFAULT`, which denies access.
3442    #[test]
3443    fn unknown_asg_falls_back_to_empty_default_and_denies() {
3444        let config = parse_acf("UAG(ops) { alice }").unwrap();
3445        // `DEFAULT` is auto-synthesised by parse_acf (C asInitialize
3446        // always calls asAsgAdd("DEFAULT")) and has no rules.
3447        assert!(config.asg.contains_key("DEFAULT"));
3448        assert_eq!(
3449            config.check_access("TYPO", "host", "alice"),
3450            AccessLevel::NoAccess,
3451            "unknown ASG must resolve to empty DEFAULT ⇒ NoAccess"
3452        );
3453    }
3454
3455    /// Corner case: even `DEFAULT` itself, when never declared with
3456    /// rules, denies — the auto-synthesised placeholder is empty.
3457    #[test]
3458    fn default_asg_without_rules_denies() {
3459        let config = parse_acf("UAG(ops) { alice }").unwrap();
3460        assert_eq!(
3461            config.check_access("DEFAULT", "host", "alice"),
3462            AccessLevel::NoAccess
3463        );
3464    }
3465
3466    /// A diagnostic that quotes a token must quote the operator's text.
3467    ///
3468    /// Every capture in this parser comes from a "consume while it matches"
3469    /// loop, which stops BEFORE the character that broke it — so its buffer
3470    /// holds what was accepted, which is empty exactly when the input was
3471    /// rejected. Quoting the buffer reported `got ''` and sent the reader
3472    /// looking for an empty token that is not in their file.
3473    ///
3474    /// The boundary is whether the loop accepted anything at all, so both
3475    /// sides of it are here: a run the loop refused outright, a run it
3476    /// accepted in full, and a run it accepted a prefix of.
3477    #[test]
3478    fn a_rejected_token_is_quoted_as_the_operator_wrote_it() {
3479        for (acf, got) in [
3480            // Accepted nothing.
3481            ("ASG(G) { RULE(abc, READ) }", "abc"),
3482            // Accepted the sign, then nothing.
3483            ("ASG(G) { RULE(-abc, READ) }", "-abc"),
3484            // Accepted nothing; the token is pure punctuation.
3485            ("ASG(G) { RULE(1, READ, %%%) }", "%%%"),
3486            // Accepted the whole word — unchanged by the fix.
3487            ("ASG(G) { RULE(1, READ, trapwrite) }", "trapwrite"),
3488            // Accepted a prefix, then hit a character it could not take.
3489            ("ASG(G) { RULE(1, READ, TRAP%WRITE) }", "TRAP%WRITE"),
3490        ] {
3491            let e = parse_acf(acf).unwrap_err().to_string();
3492            assert!(
3493                e.contains(&format!("got '{got}'")),
3494                "expected got '{got}', of {acf:?}, got {e:?}"
3495            );
3496        }
3497
3498        // A pathological run does not put the whole file on the console.
3499        let long = "z".repeat(4096);
3500        let e = parse_acf(&format!("ASG(G) {{ RULE({long}, READ) }}"))
3501            .unwrap_err()
3502            .to_string();
3503        assert!(e.contains('…') && e.len() < 200, "uncapped quote: {e:?}");
3504    }
3505
3506    /// Every rejection out of the ACF parser names the line it belongs to.
3507    ///
3508    /// Site ACFs run to hundreds of lines, and a bare "whitespace inside
3509    /// parenthesised name" gives an operator nothing to open the editor on.
3510    /// The number is the port's own addition; C has one too but spells the
3511    /// sentence far less specifically, so the wording here stays.
3512    ///
3513    /// The cases are the two boundaries the number has, not a tour of the
3514    /// grammar: a complaint about the token under the cursor takes the
3515    /// CURRENT line, and a complaint about a construct that opened earlier
3516    /// takes the line it OPENED on. The second is the one worth pinning —
3517    /// a `{` left unclosed on line 3 is only discovered at EOF, and naming
3518    /// EOF sends the operator to the wrong end of the file.
3519    #[test]
3520    fn every_parse_rejection_names_its_line() {
3521        // Boundary 1: the offending token is under the cursor.
3522        for (acf, line, needle) in [
3523            (
3524                "UAG(a) { x }\n\nUAG(my group) { b }\n",
3525                3,
3526                "whitespace inside",
3527            ),
3528            ("ASG(G) {\n  RULE(-1, READ)\n}\n", 2, "must be positive"),
3529            (
3530                "ASG(G) {\n  RULE(1, READ, BOGUS)\n}\n",
3531                2,
3532                "TRAPWRITE or NOTRAPWRITE",
3533            ),
3534            ("UAG(a) { x }\n}\n", 2, "top-level block keyword"),
3535        ] {
3536            let e = parse_acf(acf).unwrap_err().to_string();
3537            assert!(
3538                e.contains(&format!("ACF line {line}:")) && e.contains(needle),
3539                "expected line {line} and {needle:?}, got {e:?}"
3540            );
3541        }
3542
3543        // Boundary 2: the construct opened earlier and the parser only found
3544        // out at EOF. Each of these puts five blank lines between the opening
3545        // token and the end of the file, so naming the cursor's line would
3546        // report 7 where the operator needs 1.
3547        let tail = "\n\n\n\n\n";
3548        for (acf, line, needle) in [
3549            (format!("UAG(a{tail}"), 1, "missing ')'"),
3550            (format!("UAG(\"abc{tail}"), 1, "unterminated quoted name"),
3551            (format!("UAG(a) {{ x{tail}"), 1, "unterminated '{'"),
3552            (
3553                format!("UAG(a) {{ \"unterminated{tail}"),
3554                1,
3555                "unterminated quoted string",
3556            ),
3557            (
3558                format!("ASG(G) {{ INPA(\"g\"){tail}"),
3559                1,
3560                "unterminated ASG",
3561            ),
3562            (format!("BOGUS{tail}"), 1, "unexpected token 'BOGUS'"),
3563            (
3564                // The CALC is on line 3; the rule body closes on line 4 and
3565                // the ASG on line 5.
3566                "ASG(G) {\n  RULE(1, WRITE) {\n    CALC(\"A:=1;1\")\n  }\n}\n".to_string(),
3567                3,
3568                "assignment operator",
3569            ),
3570            (
3571                "ASG(G) {\n  RULE(1, WRITE) {\n    CALC(\"A+\")\n  }\n}\n".to_string(),
3572                3,
3573                "bad CALC expression",
3574            ),
3575        ] {
3576            let e = parse_acf(&acf).unwrap_err().to_string();
3577            assert!(
3578                e.contains(&format!("ACF line {line}:")) && e.contains(needle),
3579                "expected line {line} and {needle:?}, got {e:?}"
3580            );
3581        }
3582    }
3583
3584    /// an empty ACF file, or one with only comments / only
3585    /// UAG/HAG blocks, yields a fail-closed config — every check
3586    /// denies, matching a C IOC whose only ASG is the empty DEFAULT.
3587    #[test]
3588    fn empty_acf_denies_all_access() {
3589        let _guard = lock_as_check_client_ip();
3590        for acf in ["", "# just a comment\n", "UAG(ops){alice}\nHAG(h){pc1}\n"] {
3591            let config = parse_acf(acf).unwrap();
3592            assert_eq!(
3593                config.check_access("DEFAULT", "host", "alice"),
3594                AccessLevel::NoAccess,
3595                "empty/rule-less ACF must deny (input was {acf:?})"
3596            );
3597            assert_eq!(
3598                config.check_access("ANY_GROUP", "host", "alice"),
3599                AccessLevel::NoAccess,
3600                "unknown ASG against empty ACF must deny (input was {acf:?})"
3601            );
3602        }
3603    }
3604
3605    /// A config built by hand (bypassing `parse_acf`) with no
3606    /// `DEFAULT` and an unknown ASG must still fail closed.
3607    #[test]
3608    fn handbuilt_config_missing_default_denies() {
3609        let config = AccessSecurityConfig {
3610            uag: HashMap::new(),
3611            hag: HashMap::new(),
3612            hag_raw: HashMap::new(),
3613            asg: HashMap::new(),
3614            unknown_access: AccessLevel::Read,
3615        };
3616        assert_eq!(
3617            config.check_access("WHATEVER", "host", "user"),
3618            AccessLevel::NoAccess
3619        );
3620    }
3621
3622    // ----- NONE keyword and unsupported keywords -----
3623
3624    /// `RULE(0, NONE)` grants asNOACCESS — it must not be treated as a
3625    /// READ-granting rule. With only a NONE rule, access stays denied.
3626    #[test]
3627    fn rule_none_grants_no_access() {
3628        let config = parse_acf("ASG(N) { RULE(0, NONE) }").unwrap();
3629        assert_eq!(
3630            config.check_access("N", "host", "user"),
3631            AccessLevel::NoAccess
3632        );
3633    }
3634
3635    /// A misspelled access keyword disables the rule (C warns and
3636    /// drops it) — it must not silently become a READ rule.
3637    #[test]
3638    fn rule_unsupported_access_keyword_is_inert() {
3639        let config = parse_acf("ASG(B) { RULE(0, WRIET) }").unwrap();
3640        assert_eq!(config.asg["B"].rules.len(), 1);
3641        assert!(config.asg["B"].rules[0].ignore, "bad keyword ⇒ inert rule");
3642        assert_eq!(
3643            config.check_access("B", "host", "user"),
3644            AccessLevel::NoAccess
3645        );
3646    }
3647
3648    // ----- RULE level validation -----
3649
3650    #[test]
3651    fn rule_negative_level_is_rejected() {
3652        let err = parse_acf("ASG(X) { RULE(-1, READ) }");
3653        assert!(err.is_err(), "negative RULE level must fail the parse");
3654    }
3655
3656    #[test]
3657    fn rule_non_numeric_level_is_rejected() {
3658        let err = parse_acf("ASG(X) { RULE(abc, READ) }");
3659        assert!(err.is_err(), "non-numeric RULE level must fail the parse");
3660    }
3661
3662    // ----- unknown top-level block tolerated -----
3663
3664    #[test]
3665    fn unknown_top_level_block_is_skipped_not_fatal() {
3666        let acf = r#"
3667VENDOR(extension) { whatever }
3668ASG(DEFAULT) { RULE(1, READ) }
3669"#;
3670        let config = parse_acf(acf).expect("unknown top-level block must not abort the parse");
3671        assert_eq!(
3672            config.check_access("DEFAULT", "host", "user"),
3673            AccessLevel::Read,
3674            "the ASG after the unknown block must still parse"
3675        );
3676    }
3677
3678    /// A well-formed unknown top-level block — keyword + balanced
3679    /// `(...)` head + balanced `{...}` body — must parse to `Ok` with a
3680    /// warning. Mirrors C `asLib.y` `generic_item`
3681    /// (`tokenSTRING generic_head generic_block`, asLib.y:93-97).
3682    #[test]
3683    fn unknown_well_formed_block_parses_ok_with_warning() {
3684        let acf = r#"
3685VENDOR(x) { FOO(1) }
3686ASG(DEFAULT) { RULE(1, READ) }
3687"#;
3688        let config = parse_acf(acf)
3689            .expect("a well-formed unknown top-level block must warn-and-continue, not fail");
3690        assert_eq!(
3691            config.check_access("DEFAULT", "host", "user"),
3692            AccessLevel::Read
3693        );
3694    }
3695
3696    /// The `tokenSTRING generic_head` bare form (asLib.y:98-102): an
3697    /// unknown keyword followed only by a balanced `(...)` head, no
3698    /// `{...}` body, still parses.
3699    #[test]
3700    fn unknown_block_bare_head_parses_ok() {
3701        let acf = "VENDOR(x) ASG(DEFAULT) { RULE(1, READ) }";
3702        let config = parse_acf(acf).expect("bare unknown-block head must warn-and-continue");
3703        assert!(config.asg.contains_key("DEFAULT"));
3704    }
3705
3706    /// Genuine garbage — a bare token where a top-level block keyword
3707    /// is expected, with unbalanced parens — must return `Err`. C's
3708    /// grammar has no `generic_item: tokenSTRING` alone; an unknown
3709    /// keyword *not* followed by `(` matches no production ⇒ `yyerror`
3710    /// ⇒ `asInitialize` fails. This is the `reload_rpc` regression.
3711    #[test]
3712    fn genuine_garbage_acf_is_rejected() {
3713        assert!(
3714            parse_acf("this is not valid ACF (((").is_err(),
3715            "unparseable ACF must fail, not silently skip to EOF"
3716        );
3717    }
3718
3719    /// A file containing only stray block punctuation where a
3720    /// top-level keyword is expected (`(`, `)`, `{`, `}`, `,`) is
3721    /// genuine garbage — C's grammar has no production starting with
3722    /// bare punctuation at top level ⇒ `yyerror`. It must fail, not
3723    /// silently break to a successful empty config.
3724    #[test]
3725    fn stray_top_level_punctuation_is_rejected() {
3726        assert!(
3727            parse_acf("(((").is_err(),
3728            "a file of only '(((' must fail, not silently skip to EOF"
3729        );
3730        assert!(
3731            parse_acf("}").is_err(),
3732            "a file of only '}}' must fail, not silently skip to EOF"
3733        );
3734    }
3735
3736    /// A genuinely empty file and a whitespace/comment-only file must
3737    /// still parse `Ok` — the stray-punctuation fix above must not
3738    /// touch the pre-existing empty-file divergence from C.
3739    #[test]
3740    fn empty_and_comment_only_acf_still_parses_ok() {
3741        assert!(parse_acf("").is_ok(), "empty file must parse Ok");
3742        assert!(
3743            parse_acf("   \n\t  \n").is_ok(),
3744            "whitespace-only file must parse Ok"
3745        );
3746        assert!(
3747            parse_acf("# just a comment\n# another\n").is_ok(),
3748            "comment-only file must parse Ok"
3749        );
3750    }
3751
3752    /// An unknown top-level keyword followed by another bare word (no
3753    /// `(`) is a syntax error, not a skippable block.
3754    #[test]
3755    fn unknown_keyword_without_paren_head_is_rejected() {
3756        assert!(parse_acf("VENDOR something").is_err());
3757    }
3758
3759    /// An unknown top-level keyword alone at EOF is a syntax error —
3760    /// C's `generic_head` is mandatory.
3761    #[test]
3762    fn unknown_keyword_at_eof_is_rejected() {
3763        assert!(parse_acf("VENDOR").is_err());
3764    }
3765
3766    /// An unknown block with an unbalanced `(...)` head must fail
3767    /// rather than consume to EOF.
3768    #[test]
3769    fn unknown_block_unbalanced_paren_is_rejected() {
3770        assert!(parse_acf("VENDOR(((").is_err());
3771    }
3772
3773    /// An unknown block with an unbalanced `{...}` body must fail.
3774    #[test]
3775    fn unknown_block_unbalanced_brace_is_rejected() {
3776        assert!(parse_acf("VENDOR(x) { unterminated").is_err());
3777    }
3778
3779    // ----- HAG host matching is case-insensitive -----
3780
3781    #[test]
3782    fn hag_host_match_is_case_insensitive() {
3783        let _guard = lock_as_check_client_ip();
3784        let acf = r#"
3785HAG(lab) { LabPC1.invalid }
3786ASG(C) {
3787    RULE(1, WRITE) { HAG(lab) }
3788    RULE(1, READ)
3789}
3790"#;
3791        let config = parse_acf(acf).unwrap();
3792        // Client reports a differently-cased hostname.
3793        assert_eq!(
3794            config.check_access("C", "labpc1.invalid", "user"),
3795            AccessLevel::ReadWrite,
3796            "lowercased HAG entry must match a mixed-case client host"
3797        );
3798        assert_eq!(
3799            config.check_access("C", "LABPC1.INVALID", "user"),
3800            AccessLevel::ReadWrite
3801        );
3802        // A genuinely different host still only gets READ.
3803        assert_eq!(
3804            config.check_access("C", "other.invalid", "user"),
3805            AccessLevel::Read
3806        );
3807    }
3808
3809    // ----- TRAPWRITE / NOTRAPWRITE log option parses -----
3810
3811    #[test]
3812    fn rule_trapwrite_log_option_parses() {
3813        let config =
3814            parse_acf("ASG(T) { RULE(1, WRITE, TRAPWRITE) RULE(1, READ, NOTRAPWRITE) }").unwrap();
3815        assert_eq!(config.asg["T"].rules.len(), 2);
3816        assert_eq!(config.asg["T"].rules[0].access, RuleAccess::Write);
3817        assert!(
3818            config.asg["T"].rules[0].trap,
3819            "TRAPWRITE must set the trap mask"
3820        );
3821        assert_eq!(config.asg["T"].rules[1].access, RuleAccess::Read);
3822        assert!(
3823            !config.asg["T"].rules[1].trap,
3824            "NOTRAPWRITE must clear the trap mask"
3825        );
3826    }
3827
3828    #[test]
3829    fn rule_bad_log_option_is_rejected() {
3830        assert!(parse_acf("ASG(T) { RULE(1, WRITE, BOGUS) }").is_err());
3831    }
3832
3833    #[test]
3834    fn rule_log_option_is_case_sensitive() {
3835        // C `asLib.y:274,278` matches TRAPWRITE/NOTRAPWRITE with
3836        // `strcmp`; a lowercase variant matches neither and hits
3837        // `yyerror`, failing the whole ACF load. Reject case variants
3838        // here too rather than silently accepting them.
3839        assert!(
3840            parse_acf("ASG(T) { RULE(1, WRITE, trapwrite) }").is_err(),
3841            "lowercase `trapwrite` is not a valid log option (C strcmp)"
3842        );
3843        assert!(
3844            parse_acf("ASG(T) { RULE(1, WRITE, notrapwrite) }").is_err(),
3845            "lowercase `notrapwrite` is not a valid log option (C strcmp)"
3846        );
3847    }
3848
3849    #[test]
3850    fn rule_access_keyword_is_case_sensitive() {
3851        // C `asLib.y:259-264` matches NONE/READ/WRITE with `strcmp`
3852        // (case-SENSITIVE). A lowercase `write` is an unsupported
3853        // keyword that C drops (`yywarn`, no rule added), so it must
3854        // grant nothing — not build an active Write rule.
3855        let cfg = parse_acf("ASG(L) { RULE(1, write) }").unwrap();
3856        assert_eq!(
3857            cfg.check_access_method("L", "h", "u", 0, "", ""),
3858            AccessLevel::NoAccess,
3859            "lowercase `write` is an unsupported keyword (C strcmp); grants nothing"
3860        );
3861        // Canonical uppercase still grants write at the rule's ASL.
3862        let cfg = parse_acf("ASG(U) { RULE(1, WRITE) }").unwrap();
3863        assert_eq!(
3864            cfg.check_access_method("U", "h", "u", 0, "", ""),
3865            AccessLevel::ReadWrite
3866        );
3867    }
3868
3869    // `check_access_method_trap` must return the trap mask of
3870    // the rule that resolved the access level — not a hard-coded
3871    // `true`. Mirrors C `asComputePvt`/`pasgclient->trapMask`
3872    // (`asLibRoutines.c:986`, `:1041-1042`, `:1048`).
3873    #[test]
3874    fn mr_r20_trap_mask_reflects_matched_rule() {
3875        // Three ASGs, one per trap-option shape, each granting WRITE
3876        // to the same `(host, user)`.
3877        let cfg = parse_acf(
3878            r#"
3879ASG(TRAPPED)   { RULE(0, WRITE, TRAPWRITE) }
3880ASG(UNTRAPPED) { RULE(0, WRITE, NOTRAPWRITE) }
3881ASG(PLAIN)     { RULE(0, WRITE) }
3882ASG(LOCKED)    { }
3883"#,
3884        )
3885        .unwrap();
3886
3887        // TRAPWRITE rule → granted WRITE with trap == true.
3888        let (lvl, trap) = cfg.check_access_method_trap("TRAPPED", "h", "u", 0, "", "");
3889        assert_eq!(lvl, AccessLevel::ReadWrite);
3890        assert!(trap, "a TRAPWRITE rule must resolve rule_was_trap = true");
3891
3892        // NOTRAPWRITE rule → granted WRITE but trap == false.
3893        let (lvl, trap) = cfg.check_access_method_trap("UNTRAPPED", "h", "u", 0, "", "");
3894        assert_eq!(lvl, AccessLevel::ReadWrite);
3895        assert!(
3896            !trap,
3897            "a NOTRAPWRITE rule must resolve rule_was_trap = false"
3898        );
3899
3900        // Rule with no trap option → granted WRITE, trap == false.
3901        let (lvl, trap) = cfg.check_access_method_trap("PLAIN", "h", "u", 0, "", "");
3902        assert_eq!(lvl, AccessLevel::ReadWrite);
3903        assert!(
3904            !trap,
3905            "a rule with no trap option must resolve rule_was_trap = false"
3906        );
3907
3908        // Denied (no matching rule) → trap == false, never true.
3909        let (lvl, trap) = cfg.check_access_method_trap("LOCKED", "h", "u", 0, "", "");
3910        assert_eq!(lvl, AccessLevel::NoAccess);
3911        assert!(
3912            !trap,
3913            "a denied resolution must carry rule_was_trap = false"
3914        );
3915    }
3916
3917    // when several rules raise access, the trap mask must be
3918    // the option of the *last* rule that set the level — C
3919    // `asComputePvt` copies `trapMask` together with `access` on
3920    // every raise (`asLibRoutines.c:1041-1042`).
3921    #[test]
3922    fn mr_r20_trap_mask_follows_last_access_raising_rule() {
3923        // READ (no trap) then WRITE (TRAPWRITE): WRITE is the last
3924        // raise, so the trap mask is the WRITE rule's.
3925        let cfg = parse_acf("ASG(M) { RULE(0, READ) RULE(0, WRITE, TRAPWRITE) }").unwrap();
3926        let (lvl, trap) = cfg.check_access_method_trap("M", "h", "u", 0, "", "");
3927        assert_eq!(lvl, AccessLevel::ReadWrite);
3928        assert!(
3929            trap,
3930            "trap mask must follow the WRITE rule that raised access"
3931        );
3932
3933        // READ (no trap) then WRITE (NOTRAPWRITE): same, trap false.
3934        let cfg = parse_acf("ASG(N) { RULE(0, READ) RULE(0, WRITE, NOTRAPWRITE) }").unwrap();
3935        let (lvl, trap) = cfg.check_access_method_trap("N", "h", "u", 0, "", "");
3936        assert_eq!(lvl, AccessLevel::ReadWrite);
3937        assert!(!trap, "NOTRAPWRITE on the access-raising rule must win");
3938    }
3939
3940    // ----- CALC clause gates (or disables) the rule -----
3941
3942    /// A CALC condition must never let a rule become unconditional.
3943    /// This crate cannot resolve INP* link values, so a CALC rule is
3944    /// disabled (fail closed) — it grants nothing.
3945    #[test]
3946    fn calc_rule_is_conditionally_active_and_fails_closed_without_resolver() {
3947        let config = parse_acf(r#"ASG(G) { INPA("ref") RULE(1, WRITE) { CALC("A=1") } }"#).unwrap();
3948        let rule = &config.asg["G"].rules[0];
3949        assert!(rule.calc.is_some(), "CALC clause must be parsed and stored");
3950        // a CALC rule is no longer hard-disabled — it is
3951        // conditionally active and gated at check time.
3952        assert!(
3953            !rule.ignore,
3954            "a CALC rule is conditionally active, not unconditionally ignored"
3955        );
3956        // The sync `check_access` path supplies no INP* resolver, so the
3957        // CALC rule still fails CLOSED (must not silently grant WRITE).
3958        assert_eq!(
3959            config.check_access("G", "host", "user"),
3960            AccessLevel::NoAccess,
3961            "CALC rule with no resolver must not grant WRITE"
3962        );
3963    }
3964
3965    /// The watch set the ASG `INP*` re-evaluation trigger builds: one entry
3966    /// per distinct `(record, field)`, with the `VAL` default a bare record
3967    /// name carries, and no duplicate when two ASGs read the same link.
3968    #[test]
3969    fn inp_link_targets_are_deduplicated_across_groups() {
3970        let cfg = parse_acf(
3971            r#"
3972            ASG(A) { INPA("gate") INPB("gate.RVAL") RULE(1, WRITE) { CALC("A") } }
3973            ASG(B) { INPA("gate") INPB("other.SEVR") RULE(1, WRITE) { CALC("A") } }
3974            "#,
3975        )
3976        .expect("parse");
3977        assert_eq!(
3978            cfg.inp_link_targets(),
3979            vec![
3980                ("gate".to_string(), "RVAL".to_string()),
3981                ("gate".to_string(), "VAL".to_string()),
3982                ("other".to_string(), "SEVR".to_string()),
3983            ],
3984            "`gate` is read by both groups but is one subscription"
3985        );
3986    }
3987
3988    /// with an `INP*` resolver installed, a CALC-gated rule
3989    /// grants when the expression is true, denies when false, denies on
3990    /// a bad input, and denies when no resolver is installed.
3991    #[epics_macros_rs::epics_test]
3992    async fn calc_gated_rule_evaluates_against_inp_resolver() {
3993        use std::sync::Arc;
3994        let cfg =
3995            parse_acf(r#"ASG(OPS) { INPA("permit.VAL") RULE(1, WRITE) { CALC("A=1") } }"#).unwrap();
3996        let cell = crate::server::access_security::new_acf_cell(Some(cfg));
3997        let asg_resolver: AsgAslResolver =
3998            Arc::new(|_name| Box::pin(async { ("OPS".to_string(), 0u8) }));
3999
4000        let grant = AccessGate::required(cell.clone(), asg_resolver.clone()).with_inp_resolver(
4001            Arc::new(|link: String| Box::pin(async move { (link == "permit.VAL").then_some(1.0) })),
4002        );
4003        assert!(
4004            grant.check("x", "h", "u", "ca", "").await.allows_write(),
4005            "CALC A=1 with permit=1 grants WRITE"
4006        );
4007
4008        let deny = AccessGate::required(cell.clone(), asg_resolver.clone()).with_inp_resolver(
4009            Arc::new(|link: String| Box::pin(async move { (link == "permit.VAL").then_some(0.0) })),
4010        );
4011        assert!(
4012            !deny.check("x", "h", "u", "ca", "").await.allows_write(),
4013            "CALC A=1 with permit=0 denies WRITE"
4014        );
4015
4016        let bad = AccessGate::required(cell.clone(), asg_resolver.clone())
4017            .with_inp_resolver(Arc::new(|_link: String| Box::pin(async move { None })));
4018        assert!(
4019            !bad.check("x", "h", "u", "ca", "").await.allows_write(),
4020            "a bad/disconnected INP denies the CALC-gated rule"
4021        );
4022
4023        let none = AccessGate::required(cell, asg_resolver);
4024        assert!(
4025            !none.check("x", "h", "u", "ca", "").await.allows_write(),
4026            "no INP resolver installed → CALC rule fails closed"
4027        );
4028    }
4029
4030    /// a `role/<name>` UAG member matches a client that holds
4031    /// that role; a client without it does not match.
4032    #[test]
4033    fn uag_role_member_matches_client_role() {
4034        let cfg =
4035            parse_acf(r#"UAG(special) { "role/op" } ASG(G) { RULE(1, WRITE) { UAG(special) } }"#)
4036                .unwrap();
4037        let (lvl, _) =
4038            cfg.compute_for_name("G", "h", "acct", &["op".to_string()], 0, "ca", "", None);
4039        assert_eq!(
4040            lvl,
4041            AccessLevel::ReadWrite,
4042            "role/op member matches a client holding role 'op'"
4043        );
4044        let (lvl_none, _) = cfg.compute_for_name("G", "h", "acct", &[], 0, "ca", "", None);
4045        assert_eq!(
4046            lvl_none,
4047            AccessLevel::NoAccess,
4048            "a client without role 'op' must not match role/op"
4049        );
4050    }
4051
4052    #[test]
4053    fn calc_rule_with_bad_expression_is_rejected() {
4054        assert!(
4055            parse_acf(r#"ASG(G) { RULE(1, WRITE) { CALC("A=") } }"#).is_err(),
4056            "syntactically broken CALC must fail the parse"
4057        );
4058    }
4059
4060    // ----- INP(A..U) link declarations -----
4061
4062    #[test]
4063    fn asg_inp_links_are_parsed() {
4064        let acf = r#"
4065ASG(G) {
4066    INPA("rec1.VAL")
4067    INPC("rec3.VAL")
4068    RULE(1, READ)
4069}
4070"#;
4071        let config = parse_acf(acf).unwrap();
4072        let inp = &config.asg["G"].inp;
4073        assert_eq!(inp.len(), 2);
4074        assert_eq!(inp[0].index, 0);
4075        assert_eq!(inp[0].link, "rec1.VAL");
4076        assert_eq!(inp[1].index, 2);
4077        assert_eq!(inp[1].link, "rec3.VAL");
4078    }
4079
4080    #[test]
4081    fn asg_inp_bad_selector_is_rejected() {
4082        // INPZ is out of the A..U range.
4083        assert!(parse_acf(r#"ASG(G) { INPZ("x") }"#).is_err());
4084    }
4085
4086    #[test]
4087    fn asg_inp_selector_is_case_sensitive() {
4088        // C `asLib_lex.l:21,47` lexes the selector as `INP[A-U]`
4089        // (uppercase range only), so a lowercase `INPa` is not a valid
4090        // INP token — a syntax error, not selector index 0.
4091        assert!(
4092            parse_acf(r#"ASG(G) { INPa("x") }"#).is_err(),
4093            "lowercase INP selector must be rejected (C flex [A-U])"
4094        );
4095    }
4096
4097    // ----- L-4: parenthesised name robustness -----
4098
4099    #[test]
4100    fn paren_name_rejects_embedded_whitespace() {
4101        // `UAG(my group)` must NOT silently become `mygroup`.
4102        assert!(parse_acf("UAG(my group) { x }").is_err());
4103    }
4104
4105    #[test]
4106    fn paren_name_rejects_unterminated() {
4107        assert!(parse_acf("UAG(unterminated").is_err());
4108    }
4109
4110    #[test]
4111    fn paren_name_accepts_quoted_form() {
4112        let config = parse_acf(r#"UAG("my group") { x }"#).unwrap();
4113        assert!(config.uag.contains_key("my group"));
4114    }
4115
4116    /// ASL gate still works: a low-level WRITE rule does not apply to
4117    /// a high-ASL record. C `RULE(N,…)` applies only when ASL ≤ N.
4118    #[test]
4119    fn asl_gate_still_honoured_after_fail_closed_rewrite() {
4120        let config = parse_acf("ASG(A) { RULE(0, READ) RULE(1, WRITE) }").unwrap();
4121        // ASL-0 record: READ rule applies, WRITE rule applies.
4122        assert_eq!(
4123            config.check_access_method("A", "h", "u", 0, "", ""),
4124            AccessLevel::ReadWrite
4125        );
4126        // ASL-2 record: both rules require ASL ≤ their level, so
4127        // neither applies ⇒ denied.
4128        assert_eq!(
4129            config.check_access_method("A", "h", "u", 2, "", ""),
4130            AccessLevel::NoAccess
4131        );
4132    }
4133
4134    /// Collect (op, owned-status) pairs whose `pv_name` matches `pv`,
4135    /// so a guard test is not polluted by trap dispatches from other
4136    /// tests sharing the process-global listener registry.
4137    fn trap_capture(
4138        pv: &'static str,
4139    ) -> (
4140        std::sync::Arc<std::sync::Mutex<Vec<(TrapWriteOp, Option<String>)>>>,
4141        TrapWriteListenerHandle,
4142    ) {
4143        let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4144        let sink = events.clone();
4145        let handle = register_trap_write_listener(std::sync::Arc::new(move |msg| {
4146            if msg.pv_name == pv {
4147                sink.lock()
4148                    .unwrap()
4149                    .push((msg.op, msg.status.map(str::to_owned)));
4150            }
4151        }));
4152        (events, handle)
4153    }
4154
4155    fn trap_fields(pv: &'static str) -> TrapWriteFields {
4156        TrapWriteFields {
4157            pv_name: pv.to_string(),
4158            user: "u".to_string(),
4159            host: "h".to_string(),
4160            peer: "h:5064".to_string(),
4161            value_str: "42".to_string(),
4162            dbr_type: 5,
4163            no_elements: 1,
4164            event_id: next_trap_write_event_id(),
4165            rule_was_trap: true,
4166            cancel_status: "superseded".to_string(),
4167        }
4168    }
4169
4170    /// `complete` fires exactly one AfterWrite with the real status and
4171    /// disarms Drop, so the bracket is Before+After("ok") and not a
4172    /// second AfterWrite on scope exit. Owner-path of the invariant.
4173    #[test]
4174    fn trap_write_guard_complete_fires_one_after_and_disarms_drop() {
4175        let (events, _handle) = trap_capture("guard:complete");
4176        {
4177            let mut guard = TrapWriteGuard::begin(trap_fields("guard:complete"));
4178            guard.complete("ok");
4179        } // guard dropped here — must NOT emit a second AfterWrite
4180        let got = events.lock().unwrap().clone();
4181        assert_eq!(
4182            got,
4183            vec![
4184                (TrapWriteOp::BeforeWrite, None),
4185                (TrapWriteOp::AfterWrite, Some("ok".to_string())),
4186            ]
4187        );
4188    }
4189
4190    /// A guard dropped without `complete` (the cancel / supersede /
4191    /// teardown path) still fires its AfterWrite, carrying
4192    /// `cancel_status`. Bypass-path of the invariant: this is the case
4193    /// the pre-guard explicit-dispatch emitters skipped, leaving an
4194    /// unbalanced BeforeWrite.
4195    #[test]
4196    fn trap_write_guard_drop_without_complete_fires_cancel_after() {
4197        let (events, _handle) = trap_capture("guard:cancel");
4198        {
4199            let _guard = TrapWriteGuard::begin(trap_fields("guard:cancel"));
4200            // no complete() — simulate an aborted/superseded put
4201        }
4202        let got = events.lock().unwrap().clone();
4203        assert_eq!(
4204            got,
4205            vec![
4206                (TrapWriteOp::BeforeWrite, None),
4207                (TrapWriteOp::AfterWrite, Some("superseded".to_string())),
4208            ]
4209        );
4210    }
4211}
4212
4213#[cfg(test)]
4214mod as_ca_task_tests {
4215    use super::*;
4216
4217    /// C only has an `asCaTask` between `asCaStart` and `asCaStop`, and
4218    /// nothing but `asInitCommon` calls either (`asDbLib.c:147`, `:136`), so
4219    /// `taskwdShow` on a softIoc with no access-security file lists 15 threads
4220    /// and none of them is `asCaTask`. The port welds the watcher to the cell
4221    /// so a policy loaded later cannot find it missing, which left the row
4222    /// standing on an IOC that has no policy at all.
4223    ///
4224    /// Both transitions are asserted, not just the initial absence: an
4225    /// absence-only test would also pass if the entry were simply never taken.
4226    #[epics_macros_rs::epics_test]
4227    async fn the_as_ca_task_row_tracks_the_loaded_policy() {
4228        fn table() -> String {
4229            let out = std::cell::RefCell::new(String::new());
4230            crate::runtime::taskwd::taskwd_show(1, &|line| {
4231                out.borrow_mut().push_str(line);
4232                out.borrow_mut().push('\n');
4233            });
4234            out.into_inner()
4235        }
4236        async fn wait_until(want: bool) {
4237            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
4238            while table().contains("asCaTask") != want {
4239                assert!(
4240                    std::time::Instant::now() < deadline,
4241                    "`asCaTask` never became {}:\n{}",
4242                    if want { "present" } else { "absent" },
4243                    table()
4244                );
4245                crate::runtime::task::sleep_background(std::time::Duration::from_millis(10)).await;
4246            }
4247        }
4248
4249        let db = std::sync::Arc::new(crate::server::database::PvDatabase::new());
4250        let cell = new_acf_cell_watching(None, &db);
4251        assert!(
4252            !table().contains("asCaTask"),
4253            "a cell built with no policy registered the access-security task"
4254        );
4255
4256        cell.store(Some(std::sync::Arc::new(
4257            parse_acf("ASG(DEFAULT) { RULE(1, READ) }").expect("minimal ACF"),
4258        )));
4259        wait_until(true).await;
4260
4261        cell.store(None);
4262        wait_until(false).await;
4263    }
4264}