Skip to main content

hardware_enclave/internal/wsl/
shell_config.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! Managed block injection/removal for shell config files.
5//!
6//! Supports injecting and removing comment-delimited blocks in `.bashrc`,
7//! `.zshrc`, `.profile`, etc. Each block is parameterized by application name
8//! so multiple enclave apps can coexist without conflicts.
9#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
10
11use crate::internal::core::Result;
12use std::path::Path;
13
14/// Configuration for managed shell blocks.
15#[derive(Debug)]
16pub struct ShellBlockConfig {
17    /// Application name, used in markers.
18    pub app_name: String,
19    /// The content to inject between markers (the shell script body).
20    pub block_content: String,
21}
22
23impl ShellBlockConfig {
24    pub fn new(app_name: &str, block_content: &str) -> Self {
25        ShellBlockConfig {
26            app_name: app_name.to_string(),
27            block_content: block_content.to_string(),
28        }
29    }
30
31    fn begin_marker(&self) -> String {
32        format!("# BEGIN {} managed block -- do not edit", self.app_name)
33    }
34
35    fn end_marker(&self) -> String {
36        format!("# END {} managed block", self.app_name)
37    }
38
39    fn full_block(&self) -> String {
40        format!(
41            "{}\n{}\n{}",
42            self.begin_marker(),
43            self.block_content,
44            self.end_marker()
45        )
46    }
47}
48
49/// Result of an install operation.
50#[derive(Debug, PartialEq, Eq)]
51pub enum InstallResult {
52    Installed,
53    AlreadyPresent,
54}
55
56/// Result of an uninstall operation.
57#[derive(Debug, PartialEq, Eq)]
58pub enum UninstallResult {
59    Removed,
60    NotPresent,
61}
62
63/// Check if a managed block is present in the given file.
64pub fn is_installed(path: &Path, config: &ShellBlockConfig) -> Result<bool> {
65    if !path.exists() {
66        return Ok(false);
67    }
68    let content = std::fs::read_to_string(path)?;
69    Ok(content.contains(&config.begin_marker()))
70}
71
72/// Install a managed block into a shell config file.
73///
74/// Three outcomes:
75///
76/// - **Block missing** → append the new block at the end of the
77///   file, separated by a blank line from any existing content.
78///   Returns `Installed`.
79/// - **Block present with the *same* content** → no-op.
80///   Returns `AlreadyPresent`.
81/// - **Block present with *different* content** (e.g. an older
82///   release of the same app shipped a different shell block) →
83///   replace the block in place, preserving everything outside the
84///   markers. Returns `Installed`. This is what makes
85///   `sshenc install` on a newer release actually update the
86///   bashrc — without this, an older marker keeps the old block
87///   alive forever and the user never gets the v0.6.37 native-only
88///   transport.
89///
90/// Creates the file and parent directories if needed.
91pub fn install_block(path: &Path, config: &ShellBlockConfig) -> Result<InstallResult> {
92    let content = if path.exists() {
93        std::fs::read_to_string(path)?
94    } else {
95        String::new()
96    };
97
98    // Normalize line endings
99    let content = content.replace("\r\n", "\n");
100    let begin = config.begin_marker();
101    let end = config.end_marker();
102    let new_block = config.full_block();
103
104    // Block already present — diff it against the current shipped
105    // content. If identical, no-op; if different, replace in place.
106    if let Some(begin_idx) = content.find(&begin) {
107        if let Some(rel_end_idx) = content[begin_idx..].find(&end) {
108            let block_end = begin_idx + rel_end_idx + end.len();
109            let existing = &content[begin_idx..block_end];
110            if existing == new_block {
111                return Ok(InstallResult::AlreadyPresent);
112            }
113            // Replace the block in place — keep everything before
114            // and after the markers exactly as-is.
115            let mut output = String::with_capacity(content.len() + new_block.len());
116            output.push_str(&content[..begin_idx]);
117            output.push_str(&new_block);
118            output.push_str(&content[block_end..]);
119            std::fs::write(path, &output)?;
120            return Ok(InstallResult::Installed);
121        }
122        // BEGIN marker present but no END marker — file got
123        // truncated or hand-edited. Fall through to append, even
124        // though that produces a messy file; better than refusing
125        // to install. The duplicate BEGIN will be picked up by
126        // `uninstall_block` and `is_installed`.
127    }
128
129    // Ensure parent directory exists
130    if let Some(parent) = path.parent() {
131        std::fs::create_dir_all(parent)?;
132    }
133
134    let mut output = content;
135    // Ensure existing content ends with newline
136    if !output.is_empty() && !output.ends_with('\n') {
137        output.push('\n');
138    }
139    // Add blank separator line if there's existing content
140    if !output.is_empty() {
141        output.push('\n');
142    }
143    output.push_str(&new_block);
144    output.push('\n');
145
146    std::fs::write(path, &output)?;
147    Ok(InstallResult::Installed)
148}
149
150/// Remove a managed block from a shell config file.
151///
152/// Removes everything between (and including) the BEGIN and END markers,
153/// plus any single blank line immediately before the block.
154pub fn uninstall_block(path: &Path, config: &ShellBlockConfig) -> Result<UninstallResult> {
155    if !path.exists() {
156        return Ok(UninstallResult::NotPresent);
157    }
158
159    let content = std::fs::read_to_string(path)?;
160    let content = content.replace("\r\n", "\n");
161
162    if !content.contains(&config.begin_marker()) {
163        return Ok(UninstallResult::NotPresent);
164    }
165
166    let begin = &config.begin_marker();
167    let end = &config.end_marker();
168
169    let lines: Vec<&str> = content.lines().collect();
170    let mut new_lines: Vec<&str> = Vec::new();
171    let mut in_block = false;
172
173    for line in &lines {
174        if line.contains(begin.as_str()) {
175            in_block = true;
176            // Remove a trailing blank line before the block
177            if let Some(last) = new_lines.last() {
178                if last.is_empty() {
179                    new_lines.pop();
180                }
181            }
182            continue;
183        }
184        if in_block {
185            if line.contains(end.as_str()) {
186                in_block = false;
187            }
188            continue;
189        }
190        new_lines.push(line);
191    }
192
193    // Rebuild content
194    let mut result = new_lines.join("\n");
195    // Trim trailing whitespace but keep a final newline if file is non-empty
196    let trimmed = result.trim_end().to_string();
197    result = if trimmed.is_empty() {
198        trimmed
199    } else {
200        trimmed + "\n"
201    };
202
203    std::fs::write(path, &result)?;
204    Ok(UninstallResult::Removed)
205}
206
207/// Validate shell config syntax by running `bash -n` or `zsh -n` on the file.
208///
209/// Returns `Ok(())` if valid or if the shell is not available.
210/// Returns `Err` with details if the syntax check fails.
211pub fn validate_shell_syntax(path: &Path, shell: &str) -> Result<()> {
212    use crate::internal::core::timeout::{run_with_timeout, TimeoutResult};
213    use std::time::Duration;
214    let mut cmd = std::process::Command::new(shell);
215    cmd.arg("-n").arg(path);
216    // bash/zsh `-n` parses without executing — always fast. Cap at 10s
217    // so an unexpected hang (stuck interactive init, wedged shim) can't
218    // freeze install/uninstall.
219    let output = run_with_timeout(cmd, Duration::from_secs(10));
220
221    match output {
222        Ok(TimeoutResult::Completed(o)) if o.status.success() => Ok(()),
223        Ok(TimeoutResult::TimedOut) => Err(crate::internal::core::Error::Config(format!(
224            "{shell} syntax check timed out after 10s"
225        ))),
226        Ok(TimeoutResult::Completed(o)) => {
227            let stderr = String::from_utf8_lossy(&o.stderr);
228            Err(crate::internal::core::Error::Config(format!(
229                "{shell} syntax check failed: {stderr}"
230            )))
231        }
232        Err(_) => {
233            // Shell not available, skip validation
234            Ok(())
235        }
236    }
237}
238
239/// Shell config file candidates in priority order.
240pub fn shell_config_paths(home: &Path) -> Vec<(&'static str, std::path::PathBuf)> {
241    vec![
242        ("bash", home.join(".bashrc")),
243        ("zsh", home.join(".zshrc")),
244        ("bash", home.join(".profile")),
245    ]
246}
247
248#[cfg(test)]
249#[allow(clippy::unwrap_used, clippy::panic, let_underscore_drop)]
250mod tests {
251    use super::*;
252    use std::path::PathBuf;
253    use std::sync::atomic::{AtomicU64, Ordering};
254
255    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
256
257    fn test_dir(name: &str) -> PathBuf {
258        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
259        let pid = std::process::id();
260        let dir = std::env::temp_dir().join(format!("enclaveapp-wsl-test-{pid}-{id}-{name}"));
261        let _ = std::fs::remove_dir_all(&dir);
262        std::fs::create_dir_all(&dir).unwrap();
263        dir
264    }
265
266    fn test_config() -> ShellBlockConfig {
267        ShellBlockConfig::new(
268            "sshenc",
269            "export SSH_AUTH_SOCK=\"$HOME/.sshenc/agent.sock\"",
270        )
271    }
272
273    #[test]
274    fn test_install_new_file() {
275        let dir = test_dir("install-new");
276        let path = dir.join(".bashrc");
277        let config = test_config();
278
279        let result = install_block(&path, &config).unwrap();
280        assert_eq!(result, InstallResult::Installed);
281
282        let content = std::fs::read_to_string(&path).unwrap();
283        assert!(content.contains(&config.begin_marker()));
284        assert!(content.contains("SSH_AUTH_SOCK"));
285        assert!(content.contains(&config.end_marker()));
286        // Should end with newline
287        assert!(content.ends_with('\n'));
288
289        std::fs::remove_dir_all(&dir).unwrap();
290    }
291
292    #[test]
293    fn test_install_existing_file() {
294        let dir = test_dir("install-existing");
295        let path = dir.join(".bashrc");
296        let config = test_config();
297
298        std::fs::write(&path, "# existing config\nexport PATH=/usr/bin\n").unwrap();
299
300        let result = install_block(&path, &config).unwrap();
301        assert_eq!(result, InstallResult::Installed);
302
303        let content = std::fs::read_to_string(&path).unwrap();
304        assert!(content.starts_with("# existing config"));
305        assert!(content.contains(&config.begin_marker()));
306        // Blank separator line between existing content and block
307        assert!(content.contains("PATH=/usr/bin\n\n"));
308
309        std::fs::remove_dir_all(&dir).unwrap();
310    }
311
312    #[test]
313    fn test_install_idempotent() {
314        let dir = test_dir("install-idempotent");
315        let path = dir.join(".bashrc");
316        let config = test_config();
317
318        let result1 = install_block(&path, &config).unwrap();
319        assert_eq!(result1, InstallResult::Installed);
320        let content_first = std::fs::read_to_string(&path).unwrap();
321
322        let result2 = install_block(&path, &config).unwrap();
323        assert_eq!(result2, InstallResult::AlreadyPresent);
324        let content_second = std::fs::read_to_string(&path).unwrap();
325
326        assert_eq!(content_first, content_second);
327
328        std::fs::remove_dir_all(&dir).unwrap();
329    }
330
331    #[test]
332    fn test_install_replaces_outdated_block() {
333        // The exact scenario `sshenc install` v0.6.37 hit on hosts
334        // upgraded from v0.6.33: a managed block already exists,
335        // but its content is the older shipped block (e.g. socat
336        // fallback). The new install must overwrite it in place,
337        // keeping everything outside the markers untouched.
338        let dir = test_dir("install-replaces-outdated");
339        let path = dir.join(".bashrc");
340
341        // Stamp an old block with the same markers but different
342        // body, surrounded by user content.
343        let old_config = ShellBlockConfig::new("testapp", "export OLD=1");
344        std::fs::write(
345            &path,
346            format!(
347                "# user pre-content\nexport USER_VAR=keep\n\n{}\n\n# user post-content\nalias ll='ls -la'\n",
348                old_config.full_block()
349            ),
350        )
351        .unwrap();
352
353        // Install a *new* block under the same app name.
354        let new_config = ShellBlockConfig::new("testapp", "export NEW=v2");
355        let result = install_block(&path, &new_config).unwrap();
356        assert_eq!(result, InstallResult::Installed);
357
358        let content = std::fs::read_to_string(&path).unwrap();
359        // New body present, old body gone.
360        assert!(content.contains("export NEW=v2"), "new body missing");
361        assert!(!content.contains("export OLD=1"), "old body still present");
362        // User content outside the markers untouched.
363        assert!(content.contains("export USER_VAR=keep"));
364        assert!(content.contains("alias ll='ls -la'"));
365        // Exactly one block, not two appended.
366        let block_count = content.matches(&new_config.begin_marker()).count();
367        assert_eq!(
368            block_count, 1,
369            "expected exactly one block, got {block_count}"
370        );
371
372        std::fs::remove_dir_all(&dir).unwrap();
373    }
374
375    #[test]
376    fn test_uninstall_removes_block() {
377        let dir = test_dir("uninstall-removes");
378        let path = dir.join(".bashrc");
379        let config = test_config();
380
381        install_block(&path, &config).unwrap();
382
383        let result = uninstall_block(&path, &config).unwrap();
384        assert_eq!(result, UninstallResult::Removed);
385
386        let content = std::fs::read_to_string(&path).unwrap();
387        assert!(!content.contains(&config.begin_marker()));
388        assert!(!content.contains(&config.end_marker()));
389        assert!(!content.contains("SSH_AUTH_SOCK"));
390
391        std::fs::remove_dir_all(&dir).unwrap();
392    }
393
394    #[test]
395    fn test_uninstall_not_present() {
396        let dir = test_dir("uninstall-not-present");
397        let path = dir.join(".bashrc");
398        let config = test_config();
399
400        std::fs::write(&path, "# just a comment\n").unwrap();
401
402        let result = uninstall_block(&path, &config).unwrap();
403        assert_eq!(result, UninstallResult::NotPresent);
404
405        std::fs::remove_dir_all(&dir).unwrap();
406    }
407
408    #[test]
409    fn test_uninstall_missing_file() {
410        let config = test_config();
411        let result = uninstall_block(Path::new("/nonexistent/path/.bashrc"), &config).unwrap();
412        assert_eq!(result, UninstallResult::NotPresent);
413    }
414
415    #[test]
416    fn test_is_installed_true() {
417        let dir = test_dir("is-installed-true");
418        let path = dir.join(".bashrc");
419        let config = test_config();
420
421        install_block(&path, &config).unwrap();
422        assert!(is_installed(&path, &config).unwrap());
423
424        std::fs::remove_dir_all(&dir).unwrap();
425    }
426
427    #[test]
428    fn test_is_installed_false() {
429        let dir = test_dir("is-installed-false");
430        let path = dir.join(".bashrc");
431        let config = test_config();
432
433        std::fs::write(&path, "").unwrap();
434        assert!(!is_installed(&path, &config).unwrap());
435
436        std::fs::remove_dir_all(&dir).unwrap();
437    }
438
439    #[test]
440    fn test_uninstall_preserves_other_content() {
441        let dir = test_dir("uninstall-preserves");
442        let path = dir.join(".bashrc");
443        let config = test_config();
444
445        std::fs::write(&path, "# before\nexport FOO=bar\n").unwrap();
446        install_block(&path, &config).unwrap();
447
448        uninstall_block(&path, &config).unwrap();
449
450        let content = std::fs::read_to_string(&path).unwrap();
451        assert!(content.contains("# before"));
452        assert!(content.contains("export FOO=bar"));
453        assert!(!content.contains(&config.begin_marker()));
454        assert!(!content.contains("SSH_AUTH_SOCK"));
455
456        std::fs::remove_dir_all(&dir).unwrap();
457    }
458
459    #[test]
460    fn test_install_block_content() {
461        let dir = test_dir("block-content");
462        let path = dir.join(".bashrc");
463        let config = test_config();
464
465        install_block(&path, &config).unwrap();
466
467        let content = std::fs::read_to_string(&path).unwrap();
468        // Verify exact structure: begin marker, content, end marker
469        let begin = content.find(&config.begin_marker()).unwrap();
470        let end = content.find(&config.end_marker()).unwrap();
471        assert!(begin < end);
472
473        let block_body = &content[begin + config.begin_marker().len()..end];
474        assert!(block_body.contains("SSH_AUTH_SOCK"));
475
476        std::fs::remove_dir_all(&dir).unwrap();
477    }
478
479    #[test]
480    fn test_custom_app_name() {
481        let sshenc = ShellBlockConfig::new("sshenc", "# sshenc stuff");
482        let awsenc = ShellBlockConfig::new("awsenc", "# awsenc stuff");
483
484        assert_ne!(sshenc.begin_marker(), awsenc.begin_marker());
485        assert_ne!(sshenc.end_marker(), awsenc.end_marker());
486
487        assert!(sshenc.begin_marker().contains("sshenc"));
488        assert!(awsenc.begin_marker().contains("awsenc"));
489    }
490
491    #[test]
492    fn test_crlf_normalization() {
493        let dir = test_dir("crlf");
494        let path = dir.join(".bashrc");
495        let config = test_config();
496
497        // Write file with CRLF line endings
498        std::fs::write(&path, "# existing\r\nexport FOO=bar\r\n").unwrap();
499
500        let result = install_block(&path, &config).unwrap();
501        assert_eq!(result, InstallResult::Installed);
502
503        let content = std::fs::read_to_string(&path).unwrap();
504        // CRLF should have been normalized to LF
505        assert!(!content.contains("\r\n"));
506        assert!(content.contains("# existing"));
507        assert!(content.contains(&config.begin_marker()));
508
509        std::fs::remove_dir_all(&dir).unwrap();
510    }
511
512    #[test]
513    fn test_shell_config_paths() {
514        let home = PathBuf::from("/home/testuser");
515        let paths = shell_config_paths(&home);
516
517        assert_eq!(paths.len(), 3);
518        assert_eq!(paths[0].0, "bash");
519        assert_eq!(paths[0].1, home.join(".bashrc"));
520        assert_eq!(paths[1].0, "zsh");
521        assert_eq!(paths[1].1, home.join(".zshrc"));
522        assert_eq!(paths[2].0, "bash");
523        assert_eq!(paths[2].1, home.join(".profile"));
524    }
525
526    #[test]
527    fn test_install_block_special_characters() {
528        let dir = test_dir("special-chars");
529        let path = dir.join(".bashrc");
530        let config = ShellBlockConfig::new(
531            "sshenc",
532            r#"export FOO="$HOME/.sshenc/agent.sock"
533export BAR=`whoami`
534export BAZ=\\escaped"#,
535        );
536
537        let result = install_block(&path, &config).unwrap();
538        assert_eq!(result, InstallResult::Installed);
539
540        let content = std::fs::read_to_string(&path).unwrap();
541        assert!(content.contains("$HOME"));
542        assert!(content.contains("`whoami`"));
543        assert!(content.contains("\\\\escaped"));
544        assert!(content.contains(&config.begin_marker()));
545        assert!(content.contains(&config.end_marker()));
546
547        std::fs::remove_dir_all(&dir).unwrap();
548    }
549
550    #[test]
551    fn test_uninstall_preserves_content_before_and_after_exactly() {
552        let dir = test_dir("preserve-exact");
553        let path = dir.join(".bashrc");
554        let config = ShellBlockConfig::new("sshenc", "export X=1");
555
556        let before = "# line one\nexport PATH=/usr/bin\n";
557        let after = "# line three\nexport Y=2\n";
558        // Write before content, install block, then append after content
559        std::fs::write(&path, before).unwrap();
560        install_block(&path, &config).unwrap();
561        // Append content after the block
562        let mut content = std::fs::read_to_string(&path).unwrap();
563        content.push_str(after);
564        std::fs::write(&path, &content).unwrap();
565
566        uninstall_block(&path, &config).unwrap();
567
568        let result = std::fs::read_to_string(&path).unwrap();
569        assert!(result.contains("# line one"));
570        assert!(result.contains("export PATH=/usr/bin"));
571        assert!(result.contains("# line three"));
572        assert!(result.contains("export Y=2"));
573        assert!(!result.contains(&config.begin_marker()));
574        assert!(!result.contains("export X=1"));
575
576        std::fs::remove_dir_all(&dir).unwrap();
577    }
578
579    #[test]
580    fn test_multiple_different_app_blocks_coexist() {
581        let dir = test_dir("multi-blocks");
582        let path = dir.join(".bashrc");
583        let sshenc_config =
584            ShellBlockConfig::new("sshenc", "export SSH_AUTH_SOCK=/tmp/sshenc.sock");
585        let awsenc_config = ShellBlockConfig::new("awsenc", "export AWS_PROFILE=default");
586
587        install_block(&path, &sshenc_config).unwrap();
588        install_block(&path, &awsenc_config).unwrap();
589
590        let content = std::fs::read_to_string(&path).unwrap();
591        assert!(content.contains(&sshenc_config.begin_marker()));
592        assert!(content.contains(&sshenc_config.end_marker()));
593        assert!(content.contains(&awsenc_config.begin_marker()));
594        assert!(content.contains(&awsenc_config.end_marker()));
595        assert!(content.contains("SSH_AUTH_SOCK"));
596        assert!(content.contains("AWS_PROFILE"));
597
598        // Remove sshenc, awsenc should remain
599        uninstall_block(&path, &sshenc_config).unwrap();
600        let content = std::fs::read_to_string(&path).unwrap();
601        assert!(!content.contains(&sshenc_config.begin_marker()));
602        assert!(content.contains(&awsenc_config.begin_marker()));
603        assert!(content.contains("AWS_PROFILE"));
604
605        std::fs::remove_dir_all(&dir).unwrap();
606    }
607
608    #[test]
609    fn test_install_then_update_pattern() {
610        let dir = test_dir("install-update");
611        let path = dir.join(".bashrc");
612        let config_v1 = ShellBlockConfig::new("sshenc", "export VERSION=1");
613
614        install_block(&path, &config_v1).unwrap();
615        let content_v1 = std::fs::read_to_string(&path).unwrap();
616        assert!(content_v1.contains("VERSION=1"));
617
618        // Remove old, install new
619        uninstall_block(&path, &config_v1).unwrap();
620        let config_v2 = ShellBlockConfig::new("sshenc", "export VERSION=2");
621        install_block(&path, &config_v2).unwrap();
622
623        let content_v2 = std::fs::read_to_string(&path).unwrap();
624        assert!(!content_v2.contains("VERSION=1"));
625        assert!(content_v2.contains("VERSION=2"));
626        assert!(content_v2.contains(&config_v2.begin_marker()));
627
628        std::fs::remove_dir_all(&dir).unwrap();
629    }
630
631    // Pure helper unit tests for ShellBlockConfig markers
632
633    #[test]
634    fn begin_marker_contains_app_name() {
635        let config = ShellBlockConfig::new("myapp", "content");
636        assert!(config.begin_marker().contains("myapp"));
637    }
638
639    #[test]
640    fn begin_marker_format_is_correct() {
641        let config = ShellBlockConfig::new("sshenc", "content");
642        assert_eq!(
643            config.begin_marker(),
644            "# BEGIN sshenc managed block -- do not edit"
645        );
646    }
647
648    #[test]
649    fn end_marker_contains_app_name() {
650        let config = ShellBlockConfig::new("myapp", "content");
651        assert!(config.end_marker().contains("myapp"));
652    }
653
654    #[test]
655    fn end_marker_format_is_correct() {
656        let config = ShellBlockConfig::new("sshenc", "content");
657        assert_eq!(config.end_marker(), "# END sshenc managed block");
658    }
659
660    #[test]
661    fn full_block_contains_begin_and_end_markers() {
662        let config = ShellBlockConfig::new("sshenc", "export SSH_AUTH_SOCK=test");
663        let block = config.full_block();
664        assert!(block.contains(&config.begin_marker()));
665        assert!(block.contains(&config.end_marker()));
666    }
667
668    #[test]
669    fn full_block_contains_block_content() {
670        let config = ShellBlockConfig::new("sshenc", "export SSH_AUTH_SOCK=test");
671        let block = config.full_block();
672        assert!(block.contains("export SSH_AUTH_SOCK=test"));
673    }
674
675    #[test]
676    fn full_block_structure_is_begin_content_end() {
677        let config = ShellBlockConfig::new("app", "body_line");
678        let block = config.full_block();
679        let begin_pos = block.find(&config.begin_marker()).unwrap();
680        let content_pos = block.find("body_line").unwrap();
681        let end_pos = block.find(&config.end_marker()).unwrap();
682        assert!(begin_pos < content_pos);
683        assert!(content_pos < end_pos);
684    }
685
686    #[test]
687    fn shell_config_paths_contains_bashrc() {
688        let home = Path::new("/home/user");
689        let paths = shell_config_paths(home);
690        assert!(paths.iter().any(|(_, p)| p.ends_with(".bashrc")));
691    }
692
693    #[test]
694    fn shell_config_paths_contains_zshrc() {
695        let home = Path::new("/home/user");
696        let paths = shell_config_paths(home);
697        assert!(paths.iter().any(|(_, p)| p.ends_with(".zshrc")));
698    }
699
700    #[test]
701    fn shell_config_paths_contains_profile() {
702        let home = Path::new("/home/user");
703        let paths = shell_config_paths(home);
704        assert!(paths.iter().any(|(_, p)| p.ends_with(".profile")));
705    }
706
707    #[test]
708    fn shell_config_paths_all_under_home() {
709        let home = Path::new("/home/testuser");
710        let paths = shell_config_paths(home);
711        assert!(!paths.is_empty());
712        for (_, path) in &paths {
713            assert!(
714                path.starts_with(home),
715                "path {path:?} should be under home dir"
716            );
717        }
718    }
719}