Skip to main content

toride_ssh_config/
sshd.rs

1//! Editing of `/etc/ssh/sshd_config` for access-control directives.
2//!
3//! This reuses the lossless [`ast::ConfigAst`](crate::ast::ConfigAst) (which already handles `Match`
4//! blocks, `Include`, comments, and whitespace faithfully) to provide focused
5//! read/edit operations for the user-access directives Toride cares about:
6//! `AllowUsers` and `DenyUsers`.
7//!
8//! Writes are privileged (the file is root-owned) and go through
9//! [`toride_ssh_core::run_privileged`], which validates the result with
10//! `sshd -t` and keeps a `.bak` backup before installing — a malformed
11//! `sshd_config` would lock the user out of SSH, so an invalid config is never
12//! written.
13//!
14//! All access here is restricted to **global** directives (those outside any
15//! `Match` block). `Match`-scoped directives are intentionally left untouched
16//! — editing them blindly is dangerous, and global scope is what we expose in
17//! the UI.
18
19use toride_ssh_core::{Result, privilege::PrivilegedOp, run_privileged};
20
21use super::ast::{ConfigAst, ConfigNode, DirectiveData, Separator, parse};
22
23/// Path to the system SSH daemon configuration.
24const SSHD_CONFIG_PATH: &str = "/etc/ssh/sshd_config";
25
26/// Environment variable used to override the cross-process edit lock path.
27///
28/// Tests set this to a temp-dir lock file so the serializing behaviour can be
29/// exercised without touching `/tmp` or competing with a real toride instance.
30/// Production callers should leave it unset.
31const SSHD_EDIT_LOCK_ENV: &str = "TORIDE_SSHD_EDIT_LOCK";
32
33/// Default cross-process lock file for `sshd_config` edits.
34///
35/// Lives in `/tmp` (world-writable, sticky) so that **both** a root toride
36/// process and a non-root toride process contending on the same `sshd_config`
37/// rendezvous on the *same* file and serialize via `flock`. A lock derived
38/// from the config path (e.g. `/etc/ssh/sshd_config.lock`) would not work: a
39/// non-root process cannot create files in `/etc/ssh`, so the two processes
40/// would lock *different* files (or none at all) and the second install would
41/// clobber the first.
42const SSHD_EDIT_LOCK_DEFAULT: &str = "/tmp/toride-sshd-config.lock";
43
44/// Resolve the cross-process lock path for `sshd_config` edits.
45///
46/// Honours [`SSHD_EDIT_LOCK_ENV`] when set (tests use this); otherwise returns
47/// [`SSHD_EDIT_LOCK_DEFAULT`].
48fn edit_lock_path() -> std::path::PathBuf {
49    match std::env::var_os(SSHD_EDIT_LOCK_ENV) {
50        Some(p) if !p.is_empty() => std::path::PathBuf::from(p),
51        _ => std::path::PathBuf::from(SSHD_EDIT_LOCK_DEFAULT),
52    }
53}
54
55/// Ensure the cross-process lock file exists and is openable by every user
56/// that may contend on a `sshd_config` edit — both root and non-root toride
57/// processes, plus any other editor.
58///
59/// The lock lives in `/tmp` (sticky, world-writable). To let *any* user
60/// `open(O_RDWR)` it for `flock`, it must be world read/write (`0o666`). We
61/// create it with those perms if absent, and (best-effort) chmod it to `0o666`
62/// if we own it / are root. If it already exists and we can't change its perms,
63/// we simply proceed — `toride_fs::with_lock`'s own `open` will surface an
64/// `EACCES` as a `LockFailed` error if it genuinely cannot be opened, which is
65/// the correct fail-closed behaviour.
66fn ensure_lock_file(path: &std::path::Path) {
67    // Create if missing. `create_new` avoids a race: if two processes race
68    // here, the loser simply gets AlreadyExists, which we ignore.
69    let created = std::fs::OpenOptions::new()
70        .write(true)
71        .create_new(true)
72        .open(path);
73    if let Ok(f) = created {
74        // We created it: set world-rw immediately so other users can flock.
75        #[cfg(unix)]
76        {
77            use std::os::unix::fs::PermissionsExt;
78            let _ = f.set_permissions(std::fs::Permissions::from_mode(0o666));
79        }
80        #[cfg(not(unix))]
81        {
82            let _ = f; // silence unused on non-unix
83        }
84    }
85
86    // Best-effort: ensure existing file is world-rw (only works if we own it
87    // or are root; ignored otherwise). This recovers from a file created with
88    // default umask by an earlier version or another process.
89    #[cfg(unix)]
90    {
91        use std::os::unix::fs::PermissionsExt;
92        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666));
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Load / save / edit
98// ---------------------------------------------------------------------------
99
100/// Load and parse `/etc/ssh/sshd_config` into a lossless AST.
101///
102/// Returns an empty AST if the file does not exist.
103///
104/// # Errors
105///
106/// Returns `Error::Io` if the file exists but cannot be read.
107pub async fn load() -> Result<ConfigAst> {
108    let path = std::path::Path::new(SSHD_CONFIG_PATH);
109    if !path.exists() {
110        return Ok(ConfigAst { nodes: Vec::new() });
111    }
112    let content = tokio::fs::read_to_string(path).await?;
113    Ok(parse(&content))
114}
115
116/// Serialize the AST and install it as `/etc/ssh/sshd_config` via a privileged
117/// operation (validation + backup + atomic install).
118///
119/// `running_as_root` selects direct writes vs. the `sudo -n` path.
120///
121/// # Errors
122///
123/// Returns `Error::SshdConfigInvalid` if `sshd -t` rejects the config, or
124/// `Error::SudoFailed` if the privileged install can't run.
125pub async fn save(ast: &ConfigAst, running_as_root: bool) -> Result<()> {
126    let content = ast.to_string_lossless();
127    run_privileged(PrivilegedOp::WriteSshdConfig { content }, running_as_root).await
128}
129
130/// Acquire the cross-process edit lock on `path` and run `f` while it is held.
131///
132/// Thin wrapper around [`toride_fs::with_lock`] that first ensures the lock
133/// file is openable by every contending user. Exposed (module-private) so the
134/// serialization guarantee can be unit-tested without driving a full privileged
135/// `sshd_config` write.
136#[cfg(test)]
137fn with_edit_lock<T>(path: &std::path::Path, f: impl FnOnce() -> Result<T>) -> Result<T> {
138    ensure_lock_file(path);
139    // `toride_fs::with_lock` requires a closure returning `toride_fs::Result`,
140    // but our critical section yields `toride_ssh_core::Result`. Bridge the two
141    // by funneling the ssh error through a `toride_fs::Error::Io` (which itself
142    // carries the original message verbatim); the `.map_err` below then lifts
143    // *all* errors — both lock-acquisition failures and inner-critical-section
144    // failures — back into `toride_ssh_core::Error::Io`.
145    toride_fs::with_lock(path, || {
146        f().map_err(|e| {
147            toride_fs::Error::Io(std::io::Error::other(format!(
148                "sshd_config edit critical section failed: {e}"
149            )))
150        })
151    })
152    .map_err(|e| {
153        toride_ssh_core::Error::Io(std::io::Error::other(format!(
154            "sshd_config edit lock failed: {e}"
155        )))
156    })
157}
158
159/// Load, mutate, and save `/etc/ssh/sshd_config` in one call.
160///
161/// Mirrors [`ConfigService::edit`](super::ConfigService::edit): the closure
162/// mutates the AST in place; on success the result is validated and installed.
163///
164/// # Concurrency
165///
166/// The entire load → mutate → save critical section is serialized across
167/// processes by an advisory lock (`flock`) acquired on `edit_lock_path`
168/// **before** the read and held until the install completes. This prevents two
169/// concurrent toride instances (or toride + another editor) from each loading
170/// the same original config, applying their edit, and the second install
171/// clobbering the first.
172///
173/// Implementation note: the lock guard is held across the awaited load/save so
174/// the critical section stays non-blocking from the runtime's perspective at
175/// the `.await` points (the file I/O and the privileged `sshd -t`/install run
176/// on blocking threads inside `load`/`save`). This avoids
177/// [`tokio::task::block_in_place`] + a nested `Handle::block_on`, which would
178/// panic with "can call blocking only when running on the multi-threaded
179/// runtime" when `edit()` is driven from a current-thread runtime (the default
180/// for `#[tokio::test]` and many embedded runtimes). The caller's closure `f`
181/// stays `FnOnce` (no `Send`/`'static` bound) because the guard, lock, and
182/// backing file are all plain locals dropped in declaration order at scope end.
183///
184/// # Errors
185///
186/// Propagates any error from locking, loading, the closure, or saving.
187pub async fn edit<F>(running_as_root: bool, f: F) -> Result<()>
188where
189    F: FnOnce(&mut ConfigAst) -> Result<()>,
190{
191    let lock_path = edit_lock_path();
192    ensure_lock_file(&lock_path);
193
194    // Acquire the cross-process advisory lock. The lock, the backing file, and
195    // the write guard are all locals here; declaration order guarantees the
196    // guard (borrowing `lock`) is dropped before `lock` (and the file it owns)
197    // at scope end, releasing the flock on every return path — error, panic,
198    // or success. The lock-acquire itself is a `flock(2)` syscall (cheap; only
199    // contends with other toride instances editing sshd_config, a brief and
200    // rare contention), so it does not need to run on a blocking thread.
201    let file = std::fs::OpenOptions::new()
202        .read(true)
203        .write(true)
204        .create(true)
205        .truncate(false)
206        .open(&lock_path)
207        .map_err(|e| {
208            toride_ssh_core::Error::Io(std::io::Error::other(format!(
209                "sshd_config edit lock failed: cannot open lock file {}: {e}",
210                lock_path.display()
211            )))
212        })?;
213    let mut lock = fd_lock::RwLock::new(file);
214    let _guard = lock.write().map_err(|e| {
215        toride_ssh_core::Error::Io(std::io::Error::other(format!(
216            "sshd_config edit lock failed: cannot acquire lock on {}: {e}",
217            lock_path.display()
218        )))
219    })?;
220    tracing::debug!(path = %lock_path.display(), "sshd_config edit lock acquired");
221
222    // Genuine async critical section: no nested runtime, no block_in_place.
223    let mut ast = load().await?;
224    f(&mut ast)?;
225    save(&ast, running_as_root).await
226}
227
228// ---------------------------------------------------------------------------
229// Read
230// ---------------------------------------------------------------------------
231
232/// The current global `AllowUsers` list (ignoring `Match`-scoped directives).
233///
234/// OpenSSH treats multiple `AllowUsers` lines as additive, so values from all
235/// global occurrences are concatenated.
236#[must_use]
237pub fn get_allow_users(ast: &ConfigAst) -> Vec<String> {
238    collect_global_users(ast, "AllowUsers")
239}
240
241/// The current global `DenyUsers` list (ignoring `Match`-scoped directives).
242#[must_use]
243pub fn get_deny_users(ast: &ConfigAst) -> Vec<String> {
244    collect_global_users(ast, "DenyUsers")
245}
246
247/// The current global `AllowGroups` list (ignoring `Match`-scoped directives).
248///
249/// OpenSSH treats multiple `AllowGroups` lines as additive, so values from all
250/// global occurrences are concatenated.
251#[must_use]
252pub fn get_allow_groups(ast: &ConfigAst) -> Vec<String> {
253    collect_global_users(ast, "AllowGroups")
254}
255
256/// The current global `DenyGroups` list (ignoring `Match`-scoped directives).
257#[must_use]
258pub fn get_deny_groups(ast: &ConfigAst) -> Vec<String> {
259    collect_global_users(ast, "DenyGroups")
260}
261
262/// Collect every whitespace-separated token from all global `<key>` directives.
263///
264/// Only top-level [`ConfigNode::Directive`] nodes are considered — values
265/// inside `Match`/`Host` blocks are intentionally skipped, since those are
266/// scope-specific and not what Toride's global UI exposes.
267///
268/// **Scope-ambiguous directives are also skipped.** A top-level `<key>`
269/// directive that immediately follows (ignoring `BlankLine`/`Comment`) a
270/// `Match`/`Host` block may be a block-scoped directive that the
271/// indentation-based parser leaked to global scope (see `ast::parse_block_body`).
272/// Counting such a leaked directive as global would misreport access control
273/// in the UI, so it is excluded here.
274fn collect_global_users(ast: &ConfigAst, key: &str) -> Vec<String> {
275    let indices: Vec<usize> = ast
276        .nodes
277        .iter()
278        .enumerate()
279        .filter(
280            |(_, n)| matches!(n, ConfigNode::Directive(d) if d.keyword.eq_ignore_ascii_case(key)),
281        )
282        .map(|(i, _)| i)
283        .collect();
284
285    // Exclude any directive whose scope is ambiguous (follows a Match/Host
286    // block in document order). See `directive_follows_match_or_host`.
287    let leaked: Vec<usize> = directive_follows_match_or_host_set(&ast.nodes, &indices);
288    let leaked_set: std::collections::HashSet<usize> = leaked.into_iter().collect();
289
290    let mut out = Vec::new();
291    for &i in &indices {
292        if leaked_set.contains(&i) {
293            continue;
294        }
295        if let ConfigNode::Directive(d) = &ast.nodes[i] {
296            out.extend(d.value.split_whitespace().map(str::to_owned));
297        }
298    }
299    out
300}
301
302/// Returns `true` if any of the `directive_indices` immediately follows a
303/// `Match`/`Host` block in document order (ignoring `BlankLine`/`Comment`
304/// nodes between them).
305///
306/// This is the scope-ambiguity detector backing the fail-closed write guard
307/// in [`upsert_user_in_directive`]. Used to refuse edits that `sshd -t` cannot
308/// catch (the leaked file is syntactically valid) and that would otherwise
309/// relocate a block-scoped directive to global scope on disk.
310fn directive_follows_match_or_host(nodes: &[ConfigNode], directive_indices: &[usize]) -> bool {
311    !directive_follows_match_or_host_set(nodes, directive_indices).is_empty()
312}
313
314/// Like [`directive_follows_match_or_host`] but returns the full set of
315/// directive indices whose preceding non-trivial node is a `Match`/`Host`
316/// block. Returned in ascending index order with no duplicates.
317fn directive_follows_match_or_host_set(
318    nodes: &[ConfigNode],
319    directive_indices: &[usize],
320) -> Vec<usize> {
321    let mut leaked = Vec::new();
322    for &idx in directive_indices {
323        // Walk backwards from idx, skipping BlankLine/Comment nodes, to find
324        // the nearest "content" sibling. If that sibling is a Match/Host block,
325        // this directive's scope is ambiguous.
326        let mut j = idx;
327        while j > 0 {
328            j -= 1;
329            match &nodes[j] {
330                ConfigNode::BlankLine | ConfigNode::Comment { .. } => {}
331                ConfigNode::MatchBlock(_) | ConfigNode::HostBlock(_) => {
332                    leaked.push(idx);
333                    break;
334                }
335                ConfigNode::Directive(_) => break,
336            }
337        }
338    }
339    leaked.sort_unstable();
340    leaked.dedup();
341    leaked
342}
343
344// ---------------------------------------------------------------------------
345// Pattern detection
346// ---------------------------------------------------------------------------
347
348/// Returns `true` if `value` contains any OpenSSH pattern tokens.
349///
350/// `AllowUsers`/`DenyUsers` values are pattern strings: a token may contain
351/// glob wildcards (`*`, `?`) or a `user@host` selector. Such a directive
352/// cannot be safely edited by exact username — adding a plain name would be
353/// swallowed by an existing pattern, and "removing" against patterns would be
354/// semantically meaningless. The editor refuses to mutate such directives
355/// instead (see [`directive_has_patterns`]).
356///
357/// Each whitespace-separated token is inspected independently.
358#[must_use]
359pub fn has_pattern_tokens(value: &str) -> bool {
360    value
361        .split_whitespace()
362        .any(|tok| tok.contains('*') || tok.contains('?') || tok.contains('@'))
363}
364
365/// Returns `true` if any global `<key>` directive contains pattern tokens.
366///
367/// `Match`-scoped directives are ignored, consistent with the rest of this
368/// module's global-only scope. Scope-ambiguous directives that follow a
369/// `Match`/`Host` block (the `parse_block_body` leak — see `ast::parse_block_body`)
370/// are likewise excluded, so a leaked block-scoped `*` does not get reported as
371/// a global pattern.
372#[must_use]
373pub fn directive_has_patterns(ast: &ConfigAst, key: &str) -> bool {
374    let indices: Vec<usize> = ast
375        .nodes
376        .iter()
377        .enumerate()
378        .filter(
379            |(_, n)| matches!(n, ConfigNode::Directive(d) if d.keyword.eq_ignore_ascii_case(key)),
380        )
381        .map(|(i, _)| i)
382        .collect();
383    let leaked = directive_follows_match_or_host_set(&ast.nodes, &indices);
384    let leaked_set: std::collections::HashSet<usize> = leaked.into_iter().collect();
385
386    indices.iter().any(|&i| {
387        if leaked_set.contains(&i) {
388            return false;
389        }
390        matches!(&ast.nodes[i], ConfigNode::Directive(d) if has_pattern_tokens(&d.value))
391    })
392}
393
394// ---------------------------------------------------------------------------
395// Write
396// ---------------------------------------------------------------------------
397
398/// Add `user` to the global `AllowUsers` directive.
399///
400/// Idempotent: a no-op if the user is already listed. If no `AllowUsers`
401/// directive exists, one is appended at the end of the global section (before
402/// the first `Match`/`Host` block, or at the end of the file).
403///
404/// Multiple global `AllowUsers` lines are consolidated into the first
405/// occurrence (OpenSSH treats them as additive, so the merge is semantically
406/// equivalent).
407///
408/// # Errors
409///
410/// Returns `Error::SshdConfigInvalid` if an existing `AllowUsers` directive
411/// uses pattern tokens (`*`, `?`, `@`); the AST is left unmodified in that
412/// case. Editing a pattern directive by exact username would be semantically
413/// wrong.
414pub fn add_user_to_allow(ast: &mut ConfigAst, user: &str) -> Result<()> {
415    upsert_user_in_directive(ast, "AllowUsers", user, Action::Add)
416}
417
418/// Remove `user` from the global `AllowUsers` directive.
419///
420/// If the removal empties the list, the directive is deleted entirely (an
421/// absent `AllowUsers` means "all allowed", which is the OpenSSH default and
422/// clearer than a dangling empty line). Multiple global `AllowUsers` lines are
423/// merged first, so removing a user who lived only on a later line succeeds.
424///
425/// # Errors
426///
427/// Returns `Error::SshdConfigInvalid` if an existing `AllowUsers` directive
428/// uses pattern tokens (`*`, `?`, `@`); the AST is left unmodified.
429pub fn remove_user_from_allow(ast: &mut ConfigAst, user: &str) -> Result<()> {
430    upsert_user_in_directive(ast, "AllowUsers", user, Action::Remove)
431}
432
433/// Add `user` to the global `DenyUsers` directive. Idempotent.
434///
435/// # Errors
436///
437/// Returns `Error::SshdConfigInvalid` if an existing `DenyUsers` directive
438/// uses pattern tokens (`*`, `?`, `@`); the AST is left unmodified.
439pub fn add_user_to_deny(ast: &mut ConfigAst, user: &str) -> Result<()> {
440    upsert_user_in_directive(ast, "DenyUsers", user, Action::Add)
441}
442
443/// Remove `user` from the global `DenyUsers` directive, deleting the directive
444/// if it becomes empty.
445///
446/// # Errors
447///
448/// Returns `Error::SshdConfigInvalid` if an existing `DenyUsers` directive
449/// uses pattern tokens (`*`, `?`, `@`); the AST is left unmodified.
450pub fn remove_user_from_deny(ast: &mut ConfigAst, user: &str) -> Result<()> {
451    upsert_user_in_directive(ast, "DenyUsers", user, Action::Remove)
452}
453
454#[derive(Clone, Copy, Debug, PartialEq, Eq)]
455enum Action {
456    Add,
457    Remove,
458}
459
460/// Add or remove `user` from the global `<key>` directive(s), creating or
461/// deleting the directive as needed.
462///
463/// Refuses to touch the AST (returning [`Error::SshdConfigInvalid`]) if any
464/// global occurrence of `<key>` contains pattern tokens — editing a pattern
465/// directive by exact username would be semantically wrong.
466///
467/// When multiple global occurrences exist, they are merged into the first
468/// (deduplicated, order-preserving) and the rest are deleted. OpenSSH treats
469/// multiple lines as additive, so the merge is lossless from the daemon's
470/// perspective and keeps the file readable. A warning is logged when a merge
471/// occurs.
472fn upsert_user_in_directive(
473    ast: &mut ConfigAst,
474    key: &str,
475    user: &str,
476    action: Action,
477) -> Result<()> {
478    let indices: Vec<usize> = ast
479        .nodes
480        .iter()
481        .enumerate()
482        .filter(
483            |(_, n)| matches!(n, ConfigNode::Directive(d) if d.keyword.eq_ignore_ascii_case(key)),
484        )
485        .map(|(i, _)| i)
486        .collect();
487
488    // Fail-closed against the parse_block_body Match/Host-leak (see ast.rs
489    // `parse_block_body` doc comment): if ANY matching Directive node is
490    // immediately preceded in document order (ignoring BlankLine/Comment
491    // nodes) by a Match/Host block, its scope is genuinely ambiguous — it may
492    // be a block-scoped directive that the indentation-based parser leaked to
493    // the top level. `sshd -t` would still accept the file (it is syntactically
494    // valid), so the validation gate cannot catch this, and re-rendering at
495    // indent 0 would permanently relocate the directive to global scope. Refuse
496    // without mutating the AST. This check runs BEFORE the pattern-token check
497    // and BEFORE any mutation, so the AST is untouched on error.
498    if directive_follows_match_or_host(&ast.nodes, &indices) {
499        return Err(toride_ssh_core::Error::SshdConfigInvalid(format!(
500            "refusing to edit {key}: directive follows a Match/Host block and its \
501             scope is ambiguous (unindented Match body)"
502        )));
503    }
504
505    // Refuse to mutate any directive that already uses patterns — exact
506    // username edits against glob/@ selectors are semantically wrong. Check
507    // before touching anything so the AST is left unmodified on error.
508    if indices
509        .iter()
510        .any(|&i| matches!(&ast.nodes[i], ConfigNode::Directive(d) if has_pattern_tokens(&d.value)))
511    {
512        return Err(toride_ssh_core::Error::SshdConfigInvalid(format!(
513            "{key} uses pattern tokens (* ? @); refusing to edit by exact username"
514        )));
515    }
516
517    if indices.len() > 1 {
518        tracing::warn!(
519            "{key} appears {} times in sshd_config; merging into the first occurrence",
520            indices.len()
521        );
522    }
523
524    match indices.first() {
525        Some(&first) => {
526            // Gather the UNION of values across all global occurrences
527            // (deduplicated, order-preserving), then drop occurrences 2..N.
528            let mut merged: Vec<String> = Vec::new();
529            for &i in &indices {
530                if let ConfigNode::Directive(d) = &ast.nodes[i] {
531                    for tok in d.value.split_whitespace() {
532                        if !merged.iter().any(|m| m == tok) {
533                            merged.push(tok.to_owned());
534                        }
535                    }
536                }
537            }
538
539            // Apply the requested Add/Remove against the merged union.
540            apply_action_to_users(&mut merged, user, action);
541
542            // Write the result back into the first occurrence (or delete it if
543            // the union is now empty — an absent directive is clearer than a
544            // dangling empty line and matches the OpenSSH default).
545            if merged.is_empty() {
546                // Remove ALL occurrences (first first, then the rest in reverse
547                // so earlier indices stay valid as we drain).
548                for &i in indices.iter().rev() {
549                    ast.nodes.remove(i);
550                }
551            } else {
552                let joined = merged.join(" ");
553
554                // Inline comments attached to any occurrence are now ambiguous:
555                // merging changes the value, so a trailing `# ...` parsed from
556                // occurrence #1 would annotate a *different* set of users than
557                // it described, and the comments carried on occurrences 2..N
558                // are about to be dropped wholesale when those nodes are
559                // deleted. Both violate the lossless contract ("every byte is
560                // representable" / no misleading comment). The least-bad
561                // faithful option is to drop the inline comment on the merged
562                // directive whenever the value changed or a merge happened, so
563                // nothing stale floats over the line. Discarded comments from
564                // occurrences 2..N are surfaced via the warning above.
565                let single = indices.len() == 1;
566                if let ConfigNode::Directive(d) = &mut ast.nodes[first] {
567                    let value_changed = d.value != joined;
568                    if value_changed || !single {
569                        if d.comment.is_some() && !single {
570                            tracing::warn!(
571                                "{key}: discarding inline comment(s) while \
572                                 merging {} occurrences",
573                                indices.len()
574                            );
575                        }
576                        d.comment = None;
577                    }
578                    d.value = joined;
579                }
580                // Delete occurrences 2..N. Iterate in reverse to keep earlier
581                // indices valid as we remove.
582                for &i in indices.iter().skip(1).rev() {
583                    ast.nodes.remove(i);
584                }
585            }
586        }
587        // No directive yet: add one (only on Add; Remove of a missing user is
588        // a harmless no-op).
589        None => {
590            if action == Action::Add {
591                append_global_directive(ast, key, user);
592            }
593        }
594    }
595
596    Ok(())
597}
598
599/// Apply an Add/Remove to an in-memory user list (order-preserving).
600fn apply_action_to_users(users: &mut Vec<String>, user: &str, action: Action) {
601    match action {
602        Action::Add => {
603            if !users.iter().any(|u| u == user) {
604                users.push(user.to_owned());
605            }
606        }
607        Action::Remove => {
608            users.retain(|u| u != user);
609        }
610    }
611}
612
613/// Append a new global `<key> <value>` directive, placed before the first
614/// `Match`/`Host` block (or at the end if there are none).
615fn append_global_directive(ast: &mut ConfigAst, key: &str, value: &str) {
616    let directive = ConfigNode::Directive(Box::new(DirectiveData {
617        keyword: key.to_owned(),
618        separator: Separator::Space,
619        value: value.to_owned(),
620        comment: None,
621        indent: String::new(),
622    }));
623
624    let insert_pos = ast
625        .nodes
626        .iter()
627        .position(|n| matches!(n, ConfigNode::HostBlock(_) | ConfigNode::MatchBlock(_)))
628        .unwrap_or(ast.nodes.len());
629
630    ast.nodes.insert(insert_pos, directive);
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    fn ast(input: &str) -> ConfigAst {
638        parse(input)
639    }
640
641    #[test]
642    fn get_allow_users_reads_global_directive() {
643        let a = ast("Port 22\nAllowUsers alice bob\n");
644        assert_eq!(get_allow_users(&a), vec!["alice", "bob"]);
645    }
646
647    #[test]
648    fn get_allow_users_ignores_match_scoped() {
649        let a = ast("AllowUsers alice\nMatch User carol\n    AllowUsers bob\n");
650        // Only the global alice; the Match-scoped bob is ignored.
651        assert_eq!(get_allow_users(&a), vec!["alice"]);
652    }
653
654    #[test]
655    fn get_deny_users_is_empty_when_absent() {
656        let a = ast("Port 22\n");
657        assert_eq!(get_deny_users(&a), Vec::<String>::new());
658    }
659
660    #[test]
661    fn add_user_to_allow_creates_directive() {
662        let mut a = ast("Port 22\n");
663        add_user_to_allow(&mut a, "alice").unwrap();
664        assert_eq!(get_allow_users(&a), vec!["alice"]);
665    }
666
667    #[test]
668    fn add_user_to_allow_is_idempotent() {
669        let mut a = ast("AllowUsers alice\n");
670        add_user_to_allow(&mut a, "alice").unwrap();
671        assert_eq!(get_allow_users(&a), vec!["alice"]);
672    }
673
674    #[test]
675    fn add_user_to_allow_appends_to_existing() {
676        let mut a = ast("AllowUsers alice\n");
677        add_user_to_allow(&mut a, "bob").unwrap();
678        assert_eq!(get_allow_users(&a), vec!["alice", "bob"]);
679    }
680
681    #[test]
682    fn remove_user_from_allow_deletes_directive_when_empty() {
683        let mut a = ast("AllowUsers alice\n");
684        remove_user_from_allow(&mut a, "alice").unwrap();
685        // Directive should be gone entirely.
686        assert_eq!(get_allow_users(&a), Vec::<String>::new());
687        assert!(!a.to_string_lossless().contains("AllowUsers"));
688    }
689
690    #[test]
691    fn remove_user_from_allow_keeps_others() {
692        let mut a = ast("AllowUsers alice bob\n");
693        remove_user_from_allow(&mut a, "alice").unwrap();
694        assert_eq!(get_allow_users(&a), vec!["bob"]);
695    }
696
697    #[test]
698    fn remove_missing_user_is_noop() {
699        let mut a = ast("AllowUsers alice\n");
700        remove_user_from_allow(&mut a, "zzz").unwrap();
701        assert_eq!(get_allow_users(&a), vec!["alice"]);
702    }
703
704    #[test]
705    fn add_deny_then_reset_removes_from_both() {
706        let mut a = ast("AllowUsers alice\n");
707        add_user_to_deny(&mut a, "alice").unwrap();
708        // alice is now in both lists; reset = remove from allow and deny.
709        remove_user_from_allow(&mut a, "alice").unwrap();
710        remove_user_from_deny(&mut a, "alice").unwrap();
711        assert_eq!(get_allow_users(&a), Vec::<String>::new());
712        assert_eq!(get_deny_users(&a), Vec::<String>::new());
713    }
714
715    #[test]
716    fn round_trip_preserves_comments_and_match_blocks() {
717        let input = "# top comment\nPort 22\n\nMatch User alice\n    PermitRootLogin no\n";
718        let mut a = ast(input);
719        add_user_to_allow(&mut a, "bob").unwrap();
720        let out = a.to_string_lossless();
721        assert!(out.contains("# top comment"), "comment preserved");
722        assert!(out.contains("Match User alice"), "match block preserved");
723        assert!(out.contains("PermitRootLogin no"), "match body preserved");
724        assert!(out.contains("AllowUsers bob"));
725    }
726
727    #[test]
728    fn new_directive_inserted_before_match_block() {
729        let mut a = ast("Port 22\nMatch User alice\n    X11Forwarding no\n");
730        add_user_to_allow(&mut a, "bob").unwrap();
731        let out = a.to_string_lossless();
732        let allow_pos = out.find("AllowUsers").unwrap();
733        let match_pos = out.find("Match User").unwrap();
734        assert!(
735            allow_pos < match_pos,
736            "AllowUsers must precede the Match block"
737        );
738    }
739
740    // --- pattern-token detection ------------------------------------------
741
742    #[test]
743    fn has_pattern_tokens_detects_wildcards() {
744        assert!(has_pattern_tokens("alice * bob"));
745        assert!(has_pattern_tokens("ali?ce"));
746        assert!(has_pattern_tokens("alice@host"));
747        assert!(!has_pattern_tokens("alice bob carol"));
748        assert!(!has_pattern_tokens(""));
749    }
750
751    #[test]
752    fn directive_has_patterns_scans_global_only() {
753        // Global wildcard occurrence.
754        let a = ast("AllowUsers *\n");
755        assert!(directive_has_patterns(&a, "AllowUsers"));
756
757        // Plain usernames.
758        let a = ast("AllowUsers alice bob\n");
759        assert!(!directive_has_patterns(&a, "AllowUsers"));
760
761        // Pattern only inside a Match block is ignored (global scope).
762        let a = ast("AllowUsers alice\nMatch User carol\n    AllowUsers *\n");
763        assert!(
764            !directive_has_patterns(&a, "AllowUsers"),
765            "Match-scoped patterns must not count"
766        );
767    }
768
769    #[test]
770    fn add_user_to_allow_refuses_pattern_directive() {
771        let mut a = ast("AllowUsers *\n");
772        let before = a.to_string_lossless();
773        let err = add_user_to_allow(&mut a, "bob").unwrap_err();
774        assert!(matches!(err, toride_ssh_core::Error::SshdConfigInvalid(_)));
775        // AST must be untouched.
776        assert_eq!(a.to_string_lossless(), before);
777    }
778
779    #[test]
780    fn remove_user_from_allow_refuses_pattern_directive() {
781        let mut a = ast("AllowUsers alice *@host\n");
782        let before = a.to_string_lossless();
783        let err = remove_user_from_allow(&mut a, "alice").unwrap_err();
784        assert!(matches!(err, toride_ssh_core::Error::SshdConfigInvalid(_)));
785        assert_eq!(a.to_string_lossless(), before);
786    }
787
788    #[test]
789    fn add_user_to_deny_refuses_question_pattern() {
790        let mut a = ast("DenyUsers ?uest\n");
791        let before = a.to_string_lossless();
792        let err = add_user_to_deny(&mut a, "bob").unwrap_err();
793        assert!(matches!(err, toride_ssh_core::Error::SshdConfigInvalid(_)));
794        assert_eq!(a.to_string_lossless(), before);
795    }
796
797    // --- multi-line consolidation -----------------------------------------
798
799    #[test]
800    fn add_merges_multiple_allow_users_lines() {
801        let mut a = ast("AllowUsers alice\nPort 22\nAllowUsers bob\n");
802        add_user_to_allow(&mut a, "carol").unwrap();
803        // Union on a single line; second occurrence gone.
804        assert_eq!(get_allow_users(&a), vec!["alice", "bob", "carol"]);
805        assert_eq!(
806            a.to_string_lossless().matches("AllowUsers").count(),
807            1,
808            "exactly one AllowUsers line after merge"
809        );
810    }
811
812    #[test]
813    fn add_merge_dedupes_and_preserves_order() {
814        let mut a = ast("AllowUsers alice bob\nAllowUsers bob carol\n");
815        add_user_to_allow(&mut a, "alice").unwrap();
816        assert_eq!(get_allow_users(&a), vec!["alice", "bob", "carol"]);
817        assert_eq!(a.to_string_lossless().matches("AllowUsers").count(), 1);
818    }
819
820    #[test]
821    fn remove_across_multiple_occurrences() {
822        // bob lives only on the second line; removal must still succeed.
823        let mut a = ast("AllowUsers alice\nAllowUsers bob\n");
824        remove_user_from_allow(&mut a, "bob").unwrap();
825        assert_eq!(get_allow_users(&a), vec!["alice"]);
826        assert_eq!(a.to_string_lossless().matches("AllowUsers").count(), 1);
827    }
828
829    #[test]
830    fn remove_empties_union_deletes_all_occurrences() {
831        // alice spread across two lines; removing her empties the union.
832        let mut a = ast("AllowUsers alice\nAllowUsers alice\n");
833        remove_user_from_allow(&mut a, "alice").unwrap();
834        assert_eq!(get_allow_users(&a), Vec::<String>::new());
835        assert!(
836            !a.to_string_lossless().contains("AllowUsers"),
837            "directive deleted entirely when union is empty"
838        );
839    }
840
841    // --- inline-comment handling during merge -----------------------------
842
843    #[test]
844    fn merge_drops_stale_inline_comment_from_first_occurrence() {
845        // Comments on each occurrence become ambiguous after a merge: the first
846        // line's `# production admins` would otherwise float over a line that
847        // also contains bob/carol, and the second line's `# contractors` would
848        // be silently dropped entirely. The merged line must carry NO inline
849        // comment (not the stale `# production admins`).
850        let mut a = ast("AllowUsers alice # production admins\nAllowUsers bob # contractors\n");
851        add_user_to_allow(&mut a, "carol").unwrap();
852        let out = a.to_string_lossless();
853        assert_eq!(get_allow_users(&a), vec!["alice", "bob", "carol"]);
854        assert_eq!(out.matches("AllowUsers").count(), 1, "merged to one line");
855        assert!(
856            !out.contains("#production admins"),
857            "stale first-occurrence comment must not survive the merge"
858        );
859        assert!(
860            !out.contains("#contractors"),
861            "dropped occurrence's comment must not leak into the merged line"
862        );
863    }
864
865    #[test]
866    fn single_occurrence_value_change_drops_stale_inline_comment() {
867        // `# admins` described only alice; after adding bob the line is
868        // `alice bob` and the comment would be misleading. It must be dropped
869        // rather than producing `AllowUsers alice bob # admins`.
870        let mut a = ast("AllowUsers alice # admins\n");
871        add_user_to_allow(&mut a, "bob").unwrap();
872        let out = a.to_string_lossless();
873        assert_eq!(get_allow_users(&a), vec!["alice", "bob"]);
874        assert!(
875            !out.contains("#admins"),
876            "stale comment must be dropped when the value changes"
877        );
878    }
879
880    #[test]
881    fn single_occurrence_unchanged_keeps_inline_comment() {
882        // Idempotent add of an existing user (or noop remove) must NOT touch
883        // the inline comment — the value is unchanged so the comment is still
884        // accurate.
885        let mut a = ast("AllowUsers alice bob # both admins\n");
886        add_user_to_allow(&mut a, "alice").unwrap();
887        let out = a.to_string_lossless();
888        assert_eq!(get_allow_users(&a), vec!["alice", "bob"]);
889        assert!(
890            out.contains("#both admins"),
891            "inline comment must survive an unchanged-value edit"
892        );
893    }
894
895    // --- group getters + read-path coverage -------------------------------
896
897    #[test]
898    fn get_allow_groups_reads_global() {
899        let a = ast("AllowGroups wheel staff\nMatch User carol\n    AllowGroups extra\n");
900        assert_eq!(get_allow_groups(&a), vec!["wheel", "staff"]);
901    }
902
903    #[test]
904    fn get_deny_groups_reads_global_and_skips_match() {
905        let a = ast("DenyGroups banned\nMatch User carol\n    DenyGroups scoped\n");
906        assert_eq!(get_deny_groups(&a), vec!["banned"]);
907    }
908
909    #[test]
910    fn get_deny_users_concatenates_multiple_lines() {
911        let a = ast("DenyUsers alice\nDenyUsers bob\n");
912        assert_eq!(get_deny_users(&a), vec!["alice", "bob"]);
913    }
914
915    #[test]
916    fn get_groups_empty_when_absent() {
917        let a = ast("Port 22\n");
918        assert_eq!(get_allow_groups(&a), Vec::<String>::new());
919        assert_eq!(get_deny_groups(&a), Vec::<String>::new());
920    }
921
922    // --- F1: Match/Host-leak scope-ambiguity fail-closed -----------------
923    //
924    // parse_block_body (ast.rs) breaks a Match/Host block on the first
925    // non-indented line, so an UNINDENTED directive that OpenSSH actually
926    // scopes to the preceding block leaks to the top level. The editor MUST
927    // refuse such edits (sshd -t does not catch it — the file is still
928    // syntactically valid — and re-rendering at indent 0 would permanently
929    // relocate the directive to global scope on disk).
930
931    #[test]
932    fn upsert_refuses_directive_unindented_after_match_block() {
933        // `AllowUsers bob` is unindented after `Match User sftpuser`, so the
934        // parser leaks it to top level as a Directive. The editor must refuse.
935        let input = "Port 22\nMatch User sftpuser\nAllowUsers bob\n";
936        let mut a = ast(input);
937        let before = a.to_string_lossless();
938        let err = add_user_to_allow(&mut a, "carol").unwrap_err();
939        assert!(
940            matches!(err, toride_ssh_core::Error::SshdConfigInvalid(ref msg)
941                if msg.contains("refusing to edit")
942                && msg.contains("Match/Host")
943                && msg.contains("ambiguous")),
944            "expected SshdConfigInvalid scope-ambiguity error, got {err:?}"
945        );
946        // AST must be byte-identical (round-trip stable) — no mutation.
947        assert_eq!(a.to_string_lossless(), before);
948    }
949
950    #[test]
951    fn remove_refuses_directive_unindented_after_match_block() {
952        let input = "Match User sftpuser\nAllowUsers bob carol\n";
953        let mut a = ast(input);
954        let before = a.to_string_lossless();
955        let err = remove_user_from_allow(&mut a, "bob").unwrap_err();
956        assert!(matches!(err, toride_ssh_core::Error::SshdConfigInvalid(_)));
957        assert_eq!(a.to_string_lossless(), before);
958    }
959
960    #[test]
961    fn upsert_refuses_directive_unindented_after_host_block() {
962        // Same leak, but a Host block in an sshd_config-adjacent file.
963        let input = "Host restricted\nAllowUsers bob\n";
964        let mut a = ast(input);
965        let before = a.to_string_lossless();
966        let err = add_user_to_allow(&mut a, "carol").unwrap_err();
967        assert!(matches!(err, toride_ssh_core::Error::SshdConfigInvalid(_)));
968        assert_eq!(a.to_string_lossless(), before);
969    }
970
971    #[test]
972    fn upsert_refuses_when_only_some_occurrences_follow_a_block() {
973        // First AllowUsers is genuinely global (no preceding block); the
974        // second is leaked from a Match block. Because scope is ambiguous for
975        // the second occurrence, the whole edit must fail closed rather than
976        // merge across scopes.
977        let input = "AllowUsers alice\nMatch User sftpuser\nAllowUsers bob\n";
978        let mut a = ast(input);
979        let before = a.to_string_lossless();
980        let err = add_user_to_allow(&mut a, "carol").unwrap_err();
981        assert!(matches!(err, toride_ssh_core::Error::SshdConfigInvalid(_)));
982        assert_eq!(a.to_string_lossless(), before);
983    }
984
985    #[test]
986    fn upsert_edits_normally_when_directive_precedes_match_block() {
987        // An AllowUsers BEFORE any Match/Host block is genuinely global and
988        // must still edit normally — the guard only fires when a directive
989        // FOLLOWS a block.
990        let mut a = ast("AllowUsers alice\nMatch User sftpuser\n    PermitRootLogin no\n");
991        add_user_to_allow(&mut a, "bob").unwrap();
992        assert_eq!(get_allow_users(&a), vec!["alice", "bob"]);
993    }
994
995    #[test]
996    fn upsert_edits_when_comments_and_blanks_intervene_before_block() {
997        // Blank lines / comments between the directive and the preceding block
998        // must NOT disguise the scope ambiguity: the nearest non-trivial
999        // predecessor is still the Match block.
1000        let input = "Match User sftpuser\n# note\n\nAllowUsers bob\n";
1001        let mut a = ast(input);
1002        let before = a.to_string_lossless();
1003        let err = add_user_to_allow(&mut a, "carol").unwrap_err();
1004        assert!(matches!(err, toride_ssh_core::Error::SshdConfigInvalid(_)));
1005        assert_eq!(a.to_string_lossless(), before);
1006    }
1007
1008    #[test]
1009    fn upsert_edits_when_another_directive_intervenes() {
1010        // A non-block directive between the target and any earlier block means
1011        // the target's immediate predecessor is a Directive, not a block — so
1012        // its scope is unambiguous and the edit proceeds.
1013        let mut a = ast("Match User sftpuser\n    PermitRootLogin no\nPort 22\nAllowUsers bob\n");
1014        add_user_to_allow(&mut a, "carol").unwrap();
1015        assert_eq!(get_allow_users(&a), vec!["bob", "carol"]);
1016    }
1017
1018    // --- F1 read-path: leaked directives excluded from global view -------
1019    //
1020    // A leaked AllowUsers following a Match block must not be counted as
1021    // global, otherwise the UI misreports access control.
1022
1023    #[test]
1024    fn get_allow_users_excludes_leaked_directive_after_match() {
1025        let a = ast("AllowUsers alice\nMatch User sftpuser\nAllowUsers bob\n");
1026        // Only the genuine global alice; the leaked bob is excluded.
1027        assert_eq!(get_allow_users(&a), vec!["alice"]);
1028    }
1029
1030    #[test]
1031    fn directive_has_patterns_excludes_leaked_directive_after_match() {
1032        // A leaked `AllowUsers *` (a real pattern) following a Match block
1033        // must NOT be reported as a global pattern.
1034        let a = ast("AllowUsers alice\nMatch User sftpuser\nAllowUsers *\n");
1035        assert!(
1036            !directive_has_patterns(&a, "AllowUsers"),
1037            "leaked Match-scoped pattern must not count as global"
1038        );
1039    }
1040
1041    // --- F2: cross-process lock serializes concurrent edits ---------------
1042    //
1043    // edit() wraps its whole load→mutate→save critical section in
1044    // with_edit_lock (an advisory flock). We can't drive edit() itself in a
1045    // unit test (it reads /etc/ssh/sshd_config and runs privileged sshd -t /
1046    // sudo), but we CAN test the exact lock primitive edit() uses and prove
1047    // two concurrent holders serialize — the second cannot enter until the
1048    // first releases.
1049
1050    #[test]
1051    fn with_edit_lock_serializes_two_concurrent_holders() {
1052        use std::sync::{Arc, Barrier, mpsc};
1053        use std::thread;
1054
1055        let dir = tempfile::TempDir::new().expect("temp dir");
1056        let lock_path = dir.path().join("sshd-config.lock");
1057
1058        // Barrier so both threads try to acquire at ~the same instant, and a
1059        // channel that records the strict enter/exit order of the critical
1060        // sections. If locking did NOT serialize, both threads would emit
1061        // "enter" before either "exit".
1062        let barrier = Arc::new(Barrier::new(2));
1063        let (tx, rx) = mpsc::channel::<String>();
1064
1065        let make_thread = |label: &'static str,
1066                           lock_path: std::path::PathBuf,
1067                           barrier: Arc<Barrier>,
1068                           tx: mpsc::Sender<String>| {
1069            thread::spawn(move || {
1070                // Park both threads at the barrier so they race for the lock.
1071                barrier.wait();
1072                with_edit_lock(&lock_path, || -> Result<()> {
1073                    tx.send(format!("{label}-enter")).unwrap();
1074                    // Hold the lock briefly so the contender is forced to wait.
1075                    std::thread::sleep(std::time::Duration::from_millis(150));
1076                    tx.send(format!("{label}-exit")).unwrap();
1077                    Ok(())
1078                })
1079                .expect("with_edit_lock should succeed");
1080            })
1081        };
1082
1083        let t1 = make_thread("A", lock_path.clone(), barrier.clone(), tx.clone());
1084        let t2 = make_thread("B", lock_path, barrier, tx.clone());
1085        // Drop the sender copies held by the main thread so rx into_iter()
1086        // terminates when both threads finish.
1087        drop(tx);
1088
1089        let events: Vec<String> = rx.into_iter().collect();
1090
1091        t1.join().expect("thread A panicked");
1092        t2.join().expect("thread B panicked");
1093
1094        // Split into per-label enter/exit orderings. Regardless of which label
1095        // wins the race, serialization requires: the winner's exit precedes the
1096        // loser's enter. I.e. exactly one label's enter comes first AND its
1097        // exit comes before the other label's enter.
1098        let first_enter = events.first().expect("at least one event");
1099        let winner = if first_enter.starts_with('A') {
1100            "A"
1101        } else {
1102            "B"
1103        };
1104        let loser = if winner == "A" { "B" } else { "A" };
1105
1106        let winner_exit = events
1107            .iter()
1108            .position(|e| e == &format!("{winner}-exit"))
1109            .expect("winner exit");
1110        let loser_enter = events
1111            .iter()
1112            .position(|e| e == &format!("{loser}-enter"))
1113            .expect("loser enter");
1114
1115        assert!(
1116            winner_exit < loser_enter,
1117            "edits must serialize: winner must EXIT ({winner}-exit at #{winner_exit}) \
1118             before loser ENTERS ({loser}-enter at #{loser_enter}). Events: {events:?}"
1119        );
1120    }
1121
1122    #[test]
1123    fn with_edit_lock_releases_on_closure_error() {
1124        // If the critical section errors, the lock must still be released so a
1125        // subsequent edit can proceed. (with_edit_lock is closure-based RAII.)
1126        let dir = tempfile::TempDir::new().expect("temp dir");
1127        let lock_path = dir.path().join("sshd-config-err.lock");
1128
1129        let err = with_edit_lock(&lock_path, || -> Result<()> {
1130            Err(toride_ssh_core::Error::SshdConfigInvalid(
1131                "simulated bad config".into(),
1132            ))
1133        });
1134        assert!(err.is_err(), "first call must propagate the error");
1135
1136        // A second call must succeed without deadlocking — proving the lock
1137        // was released on the error path. (If it were still held, this would
1138        // block until the test timeout.)
1139        let result = with_edit_lock(&lock_path, || Ok(42));
1140        assert_eq!(
1141            result.expect("second call must succeed after error-path release"),
1142            42
1143        );
1144    }
1145
1146    // --- async runtime safety: edit() must not nest a runtime -------------
1147    //
1148    // edit() used to wrap its critical section in
1149    // `tokio::task::block_in_place(|| handle.block_on(...))`. block_in_place
1150    // PANICS ("can call blocking only when running on the multi-threaded
1151    // runtime") when edit() is awaited from a current-thread runtime — which
1152    // is the default for `#[tokio::test]` and many embedded/host runtimes.
1153    // The fix makes edit() a genuine async fn (no nested runtime). These tests
1154    // drive edit() on both runtime flavors and assert it completes without
1155    // panicking. The privileged save() step will report an error on a host
1156    // without sshd or root (that is expected and fine); the invariant under
1157    // test is "no panic, and the mutation closure ran".
1158    //
1159    // We point TORIDE_SSHD_EDIT_LOCK at a temp file so edit() acquires a
1160    // real flock we clean up, exercising the actual lock path.
1161
1162    fn drive_edit_on_runtime(rt: &tokio::runtime::Runtime) {
1163        let dir = tempfile::TempDir::new().expect("temp dir");
1164        let lock = dir.path().join("sshd-edit-async.lock");
1165        // SAFETY: tests are single-threaded by convention here; the env var is
1166        // process-global but scoped to this test's lifetime and restored on
1167        // exit. edition-2024 makes set_var unsafe.
1168        unsafe {
1169            std::env::set_var(SSHD_EDIT_LOCK_ENV, &lock);
1170        }
1171
1172        // The closure records that it actually ran (proving edit() reached and
1173        // executed the critical section, not just the lock acquire).
1174        let ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1175        let ran_clone = ran.clone();
1176        let result = rt.block_on(async {
1177            edit(false, |ast| {
1178                // Harmless mutation on whatever load() returned (empty AST if
1179                // /etc/ssh/sshd_config is absent on the host). This exercises
1180                // the synchronous closure inside the lock.
1181                add_user_to_allow(ast, "toride-edit-async-test-user")?;
1182                ran_clone.store(true, std::sync::atomic::Ordering::SeqCst);
1183                Ok(())
1184            })
1185            .await
1186        });
1187
1188        // SAFETY: restore env (best-effort) on the way out.
1189        unsafe {
1190            std::env::remove_var(SSHD_EDIT_LOCK_ENV);
1191        }
1192
1193        // The save() step is expected to fail on a host without sshd/root
1194        // (SshdNotFound / SudoFailed / Io). That is NOT the bug. The bug was a
1195        // panic; assert we did not panic by reaching here and that the closure
1196        // executed (so edit() genuinely drove load → mutate before save).
1197        assert!(
1198            ran.load(std::sync::atomic::Ordering::SeqCst),
1199            "edit()'s mutation closure must have executed"
1200        );
1201        // Result may be Ok (if sshd + root happen to be available and accept an
1202        // AllowUsers-only config) or Err (typical CI host). Either is fine; the
1203        // point is we got here at all instead of panicking.
1204        drop(result);
1205    }
1206
1207    #[test]
1208    fn edit_does_not_panic_on_current_thread_runtime() {
1209        // This is the exact runtime flavor that panicked under the old
1210        // block_in_place implementation.
1211        let rt = tokio::runtime::Builder::new_current_thread()
1212            .enable_all()
1213            .build()
1214            .expect("current-thread runtime");
1215        drive_edit_on_runtime(&rt);
1216    }
1217
1218    #[test]
1219    fn edit_does_not_panic_on_multi_thread_runtime() {
1220        // Sanity: the multi-thread path (used by the production app) must also
1221        // remain panic-free; the old code happened to work there but relied on
1222        // a nested block_on that the fix removes.
1223        let rt = tokio::runtime::Builder::new_multi_thread()
1224            .worker_threads(1)
1225            .enable_all()
1226            .build()
1227            .expect("multi-thread runtime");
1228        drive_edit_on_runtime(&rt);
1229    }
1230}