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}