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}