rch-common 1.0.26

Shared types and utilities for Remote Compilation Helper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
//! Worker discovery from SSH config and shell aliases.
//!
//! This module provides functionality to automatically discover potential
//! worker machines from the user's existing SSH configuration and shell aliases.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// A host discovered from SSH config or shell aliases.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredHost {
    /// The alias or short name (e.g., "css", "worker1")
    pub alias: String,
    /// The actual hostname or IP address
    pub hostname: String,
    /// SSH username
    pub user: String,
    /// Path to SSH identity file (private key)
    pub identity_file: Option<String>,
    /// SSH port (default 22)
    pub port: u16,
    /// Where this host was discovered from
    pub source: DiscoverySource,
}

/// Source of a discovered host.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiscoverySource {
    /// From ~/.ssh/config
    SshConfig,
    /// From ~/.bashrc
    Bashrc,
    /// From ~/.zshrc
    Zshrc,
    /// From ~/.bash_aliases
    BashAliases,
    /// From ~/.zsh_aliases
    ZshAliases,
}

impl std::fmt::Display for DiscoverySource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::SshConfig => write!(f, "~/.ssh/config"),
            Self::Bashrc => write!(f, "~/.bashrc"),
            Self::Zshrc => write!(f, "~/.zshrc"),
            Self::BashAliases => write!(f, "~/.bash_aliases"),
            Self::ZshAliases => write!(f, "~/.zsh_aliases"),
        }
    }
}

/// Parse ~/.ssh/config and extract potential worker hosts.
///
/// SSH config format:
/// ```text
/// Host fmd
///     HostName 51.222.245.56
///     User ubuntu
///     IdentityFile ~/.ssh/my_key.pem
///
/// Host yto
///     HostName 37.187.75.150
///     User ubuntu
///     IdentityFile ~/.ssh/my_key.pem
/// ```
///
/// # Returns
/// A list of discovered hosts. Returns empty vec if config doesn't exist.
pub fn parse_ssh_config() -> Result<Vec<DiscoveredHost>> {
    let home = dirs::home_dir().context("Could not determine home directory")?;
    let ssh_config_path = home.join(".ssh").join("config");

    if !ssh_config_path.exists() {
        return Ok(vec![]);
    }

    parse_ssh_config_file(&ssh_config_path)
}

/// Parse an SSH config file at the given path.
pub fn parse_ssh_config_file(path: &PathBuf) -> Result<Vec<DiscoveredHost>> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read SSH config: {}", path.display()))?;

    parse_ssh_config_content(&content)
}

/// Parse SSH config content and extract hosts.
pub fn parse_ssh_config_content(content: &str) -> Result<Vec<DiscoveredHost>> {
    let mut hosts = Vec::new();
    let mut current_host: Option<SshConfigHost> = None;

    for line in content.lines() {
        let line = line.trim();

        // Skip comments and empty lines
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        // Parse the line into key-value
        let (key, value) = match parse_ssh_config_line(line) {
            Some(kv) => kv,
            None => continue,
        };

        match key.to_lowercase().as_str() {
            "host" => {
                // Save previous host if valid
                if let Some(host) = current_host.take()
                    && let Some(discovered) = host.into_discovered()
                {
                    hosts.push(discovered);
                }

                // Start new host block
                // Handle multiple aliases on one line: "Host foo bar baz"
                let aliases: Vec<&str> = value.split_whitespace().collect();
                if let Some(first_alias) = aliases.first() {
                    // Skip wildcards and special patterns
                    if !first_alias.contains('*') && !first_alias.contains('?') {
                        current_host = Some(SshConfigHost::new(first_alias.to_string()));
                    }
                }
            }
            "hostname" => {
                if let Some(ref mut host) = current_host {
                    host.hostname = Some(value.to_string());
                }
            }
            "user" => {
                if let Some(ref mut host) = current_host {
                    host.user = Some(value.to_string());
                }
            }
            "identityfile" => {
                if let Some(ref mut host) = current_host {
                    host.identity_file = Some(expand_tilde(value));
                }
            }
            "port" => {
                if let Some(ref mut host) = current_host {
                    host.port = value.parse().ok();
                }
            }
            _ => {
                // Ignore other SSH config options
            }
        }
    }

    // Don't forget the last host
    if let Some(host) = current_host
        && let Some(discovered) = host.into_discovered()
    {
        hosts.push(discovered);
    }

    // Filter out hosts that are clearly not workers
    let hosts = hosts
        .into_iter()
        .filter(|h| is_potential_worker(&h.alias, &h.hostname))
        .collect();

    Ok(hosts)
}

/// Internal struct for parsing SSH config blocks.
struct SshConfigHost {
    alias: String,
    hostname: Option<String>,
    user: Option<String>,
    identity_file: Option<String>,
    port: Option<u16>,
}

impl SshConfigHost {
    fn new(alias: String) -> Self {
        Self {
            alias,
            hostname: None,
            user: None,
            identity_file: None,
            port: None,
        }
    }

    fn into_discovered(self) -> Option<DiscoveredHost> {
        // Must have at least a hostname to be useful
        // If no hostname, use alias as hostname (common for simple configs)
        let hostname = self.hostname.unwrap_or_else(|| self.alias.clone());

        // Get current username as default
        let default_user = std::env::var("USER")
            .or_else(|_| std::env::var("USERNAME"))
            .unwrap_or_else(|_| "ubuntu".to_string());

        Some(DiscoveredHost {
            alias: self.alias,
            hostname,
            user: self.user.unwrap_or(default_user),
            identity_file: self.identity_file,
            port: self.port.unwrap_or(22),
            source: DiscoverySource::SshConfig,
        })
    }
}

/// Parse a single SSH config line into key-value pair.
fn parse_ssh_config_line(line: &str) -> Option<(&str, &str)> {
    // SSH config uses whitespace or = as separator
    // Examples:
    //   Host foo
    //   HostName=192.168.1.1
    //   User ubuntu

    let line = line.trim();
    if line.is_empty() || line.starts_with('#') {
        return None;
    }

    // Try = separator first
    if let Some((key, value)) = line.split_once('=') {
        return Some((key.trim(), value.trim()));
    }

    // Try whitespace separator
    if let Some((key, value)) = line.split_once(char::is_whitespace) {
        return Some((key.trim(), value.trim()));
    }

    None
}

/// Expand ~ to home directory in paths.
fn expand_tilde(path: &str) -> String {
    if let Some(rest) = path.strip_prefix("~/")
        && let Some(home) = dirs::home_dir()
    {
        return home.join(rest).display().to_string();
    }
    path.to_string()
}

/// Parse shell RC files for SSH aliases.
///
/// Looks for patterns like:
/// - `alias css='ssh -i ~/.ssh/key.pem ubuntu@192.168.1.100'`
/// - `alias csd="ssh user@host"`
/// - `alias foo='ssh host'`
pub fn parse_shell_aliases() -> Result<Vec<DiscoveredHost>> {
    let home = dirs::home_dir().context("Could not determine home directory")?;
    let mut all_hosts = Vec::new();

    // List of shell RC files to check
    let rc_files = [
        (home.join(".bashrc"), DiscoverySource::Bashrc),
        (home.join(".zshrc"), DiscoverySource::Zshrc),
        (home.join(".bash_aliases"), DiscoverySource::BashAliases),
        (home.join(".zsh_aliases"), DiscoverySource::ZshAliases),
    ];

    for (path, source) in &rc_files {
        if path.exists() {
            match parse_shell_aliases_file(path, source.clone()) {
                Ok(hosts) => all_hosts.extend(hosts),
                Err(_) => continue, // Ignore parse errors in individual files
            }
        }
    }

    Ok(all_hosts)
}

/// Parse a shell RC file for SSH aliases.
pub fn parse_shell_aliases_file(
    path: &PathBuf,
    source: DiscoverySource,
) -> Result<Vec<DiscoveredHost>> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read shell RC file: {}", path.display()))?;
    parse_shell_aliases_content(&content, source)
}

/// Parse shell alias content for SSH commands.
pub fn parse_shell_aliases_content(
    content: &str,
    source: DiscoverySource,
) -> Result<Vec<DiscoveredHost>> {
    use regex::Regex;

    let mut hosts = Vec::new();

    // Match alias definitions with ssh commands
    // Handles: alias NAME='ssh ...' or alias NAME="ssh ..."
    let alias_re = Regex::new(r#"(?m)^\s*alias\s+(\w+)\s*=\s*['"]ssh\s+(.*)['"]"#)
        .context("Failed to compile alias regex")?;

    // Extract -i identity file
    let identity_re = Regex::new(r"-i\s+(\S+)").context("Failed to compile identity regex")?;

    // Extract -p port
    let port_re = Regex::new(r"-p\s+(\d+)").context("Failed to compile port regex")?;

    for caps in alias_re.captures_iter(content) {
        let alias_name = match caps.get(1) {
            Some(m) => m.as_str().to_string(),
            None => continue,
        };
        let ssh_args = match caps.get(2) {
            Some(m) => m.as_str(),
            None => continue,
        };

        // Extract identity file if present
        let identity_file = identity_re
            .captures(ssh_args)
            .and_then(|c| c.get(1))
            .map(|m| expand_tilde(m.as_str()));

        // Extract port if present
        let port = port_re
            .captures(ssh_args)
            .and_then(|c| c.get(1))
            .and_then(|m| m.as_str().parse::<u16>().ok())
            .unwrap_or(22);

        // Extract user@host from the end of the command
        // Strip all options first (anything starting with -)
        let args_without_options: Vec<&str> = ssh_args
            .split_whitespace()
            .filter(|s| !s.starts_with('-'))
            .filter(|s| {
                // Also filter out values that follow -i or -p
                if let Some(prev_idx) = ssh_args.find(s)
                    && prev_idx > 0
                {
                    let before = &ssh_args[..prev_idx].trim_end();
                    if before.ends_with("-i") || before.ends_with("-p") {
                        return false;
                    }
                }
                true
            })
            .collect();

        // The host specification is typically the last non-option argument
        let host_spec = match args_without_options.last() {
            Some(s) => *s,
            None => continue,
        };

        // Parse user@host or just host
        let (user, hostname) = if let Some((u, h)) = host_spec.split_once('@') {
            (u.to_string(), h.to_string())
        } else {
            // No user specified, use current user
            let default_user = std::env::var("USER")
                .or_else(|_| std::env::var("USERNAME"))
                .unwrap_or_else(|_| "ubuntu".to_string());
            (default_user, host_spec.to_string())
        };

        // Skip if we couldn't extract a valid host
        if hostname.is_empty() {
            continue;
        }

        // Filter out non-workers
        if !is_potential_worker(&alias_name, &hostname) {
            continue;
        }

        hosts.push(DiscoveredHost {
            alias: alias_name,
            hostname,
            user,
            identity_file,
            port,
            source: source.clone(),
        });
    }

    Ok(hosts)
}

/// Discover all potential workers from all sources.
pub fn discover_all() -> Result<Vec<DiscoveredHost>> {
    let mut all_hosts = Vec::new();

    // Parse SSH config
    if let Ok(hosts) = parse_ssh_config() {
        all_hosts.extend(hosts);
    }

    // Parse shell aliases
    if let Ok(hosts) = parse_shell_aliases() {
        all_hosts.extend(hosts);
    }

    // Deduplicate by hostname (keep first occurrence, which is typically SSH config)
    let mut seen_hostnames = std::collections::HashSet::new();
    all_hosts.retain(|h| seen_hostnames.insert(h.hostname.clone()));

    Ok(all_hosts)
}

/// Check if a host is potentially a worker (not a common non-worker host).
fn is_potential_worker(alias: &str, hostname: &str) -> bool {
    let skip_patterns = [
        "github.com",
        "gitlab.com",
        "bitbucket.org",
        "localhost",
        "127.0.0.1",
        "::1",
    ];

    let skip_aliases = ["github", "gitlab", "bitbucket", "local"];

    // Check hostname
    for pattern in skip_patterns {
        if hostname.contains(pattern) {
            return false;
        }
    }

    // Check alias
    let alias_lower = alias.to_lowercase();
    for skip in skip_aliases {
        if alias_lower == skip {
            return false;
        }
    }

    true
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_basic_ssh_config() {
        let content = r#"
Host fmd
    HostName 51.222.245.56
    User ubuntu
    IdentityFile ~/.ssh/my_key.pem

Host yto
    HostName 37.187.75.150
    User root
    IdentityFile ~/.ssh/other_key.pem
    Port 2222
"#;

        let hosts = parse_ssh_config_content(content).unwrap();
        assert_eq!(hosts.len(), 2);

        let fmd = &hosts[0];
        assert_eq!(fmd.alias, "fmd");
        assert_eq!(fmd.hostname, "51.222.245.56");
        assert_eq!(fmd.user, "ubuntu");
        assert!(fmd.identity_file.as_ref().unwrap().contains("my_key.pem"));
        assert_eq!(fmd.port, 22);
        assert_eq!(fmd.source, DiscoverySource::SshConfig);

        let yto = &hosts[1];
        assert_eq!(yto.alias, "yto");
        assert_eq!(yto.hostname, "37.187.75.150");
        assert_eq!(yto.user, "root");
        assert_eq!(yto.port, 2222);
    }

    #[test]
    fn test_skip_wildcard_hosts() {
        let content = r#"
Host *
    ServerAliveInterval 60

Host worker1
    HostName 192.168.1.10
    User ubuntu
"#;

        let hosts = parse_ssh_config_content(content).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "worker1");
    }

    #[test]
    fn test_skip_github() {
        let content = r#"
Host github.com
    HostName github.com
    User git
    IdentityFile ~/.ssh/github_key

Host worker1
    HostName 192.168.1.10
    User ubuntu
"#;

        let hosts = parse_ssh_config_content(content).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "worker1");
    }

    #[test]
    fn test_handle_multiple_aliases() {
        let content = r#"
Host foo bar baz
    HostName 192.168.1.10
    User ubuntu
"#;

        let hosts = parse_ssh_config_content(content).unwrap();
        // Should use first alias
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "foo");
    }

    #[test]
    fn test_handle_equals_separator() {
        let content = r#"
Host worker
    HostName=192.168.1.10
    User=ubuntu
"#;

        let hosts = parse_ssh_config_content(content).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].hostname, "192.168.1.10");
        assert_eq!(hosts[0].user, "ubuntu");
    }

    #[test]
    fn test_handle_comments() {
        let content = r#"
# This is a comment
Host worker1
    # Another comment
    HostName 192.168.1.10
    User ubuntu
"#;

        let hosts = parse_ssh_config_content(content).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "worker1");
    }

    #[test]
    fn test_empty_config() {
        let content = "";
        let hosts = parse_ssh_config_content(content).unwrap();
        assert!(hosts.is_empty());
    }

    #[test]
    fn test_host_without_hostname_uses_alias() {
        let content = r#"
Host myserver
    User ubuntu
    IdentityFile ~/.ssh/key.pem
"#;

        let hosts = parse_ssh_config_content(content).unwrap();
        assert_eq!(hosts.len(), 1);
        // When no HostName, alias is used as hostname
        assert_eq!(hosts[0].hostname, "myserver");
    }

    #[test]
    fn test_expand_tilde() {
        let path = "~/.ssh/key.pem";
        let expanded = expand_tilde(path);
        assert!(!expanded.starts_with("~"));
        assert!(expanded.contains(".ssh/key.pem"));
    }

    #[test]
    fn test_expand_tilde_no_tilde() {
        let path = "/absolute/path/key.pem";
        assert_eq!(expand_tilde(path), path);
    }

    #[test]
    fn test_is_potential_worker() {
        assert!(is_potential_worker("worker1", "192.168.1.10"));
        assert!(is_potential_worker("css", "209.145.54.164"));
        assert!(!is_potential_worker("github", "github.com"));
        assert!(!is_potential_worker("local", "localhost"));
        assert!(!is_potential_worker("home", "127.0.0.1"));
    }

    // Shell alias parsing tests

    #[test]
    fn test_parse_shell_aliases_basic() {
        let content = r#"
# Some other config
export PATH="/usr/local/bin:$PATH"

alias ll='ls -la'
alias css='ssh -i ~/.ssh/key.pem ubuntu@192.168.1.100'
alias csd='ssh root@10.0.0.5'
"#;

        let hosts = parse_shell_aliases_content(content, DiscoverySource::Bashrc).unwrap();
        assert_eq!(hosts.len(), 2);

        let css = hosts.iter().find(|h| h.alias == "css").unwrap();
        assert_eq!(css.hostname, "192.168.1.100");
        assert_eq!(css.user, "ubuntu");
        assert!(css.identity_file.is_some());
        assert_eq!(css.source, DiscoverySource::Bashrc);

        let csd = hosts.iter().find(|h| h.alias == "csd").unwrap();
        assert_eq!(csd.hostname, "10.0.0.5");
        assert_eq!(csd.user, "root");
    }

    #[test]
    fn test_parse_shell_aliases_double_quotes() {
        let content = r#"
alias server="ssh -i ~/.ssh/id_rsa admin@example.com"
"#;

        let hosts = parse_shell_aliases_content(content, DiscoverySource::Zshrc).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "server");
        assert_eq!(hosts[0].hostname, "example.com");
        assert_eq!(hosts[0].user, "admin");
    }

    #[test]
    fn test_parse_shell_aliases_with_port() {
        let content = r#"
alias custom='ssh -p 2222 user@192.168.1.50'
"#;

        let hosts = parse_shell_aliases_content(content, DiscoverySource::Bashrc).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].port, 2222);
    }

    #[test]
    fn test_parse_shell_aliases_simple_host() {
        let content = r#"
alias myserver='ssh myserver.example.com'
"#;

        let hosts = parse_shell_aliases_content(content, DiscoverySource::Bashrc).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].hostname, "myserver.example.com");
        // User should default to current user or ubuntu
        assert!(!hosts[0].user.is_empty());
    }

    #[test]
    fn test_parse_shell_aliases_skips_localhost() {
        let content = r#"
alias local='ssh localhost'
alias loopback='ssh 127.0.0.1'
alias remote='ssh 192.168.1.1'
"#;

        let hosts = parse_shell_aliases_content(content, DiscoverySource::Bashrc).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "remote");
    }

    #[test]
    fn test_parse_shell_aliases_skips_non_ssh() {
        let content = r#"
alias ll='ls -la'
alias grep='grep --color=auto'
alias ssh_host='ssh worker@192.168.1.10'
"#;

        let hosts = parse_shell_aliases_content(content, DiscoverySource::Bashrc).unwrap();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "ssh_host");
    }

    #[test]
    fn test_parse_shell_aliases_empty() {
        let content = "";
        let hosts = parse_shell_aliases_content(content, DiscoverySource::Bashrc).unwrap();
        assert!(hosts.is_empty());
    }
}