all-smi 0.26.2

Command-line utility for monitoring GPU hardware. It provides a real-time view of GPU utilization, memory usage, temperature, power consumption, and other metrics.
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
// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Parsed SSH target: `user@host[:port]` and hostfile loader (issue #194).
//!
//! Kept distinct from the russh-backed client so tests can lock in the
//! parsing rules without pulling the entire SSH stack into scope.

use std::fs;
use std::path::Path;

/// Default SSH port applied when the operator omits an explicit `:port`
/// suffix in `--ssh user@host` or the hostfile.
pub const DEFAULT_SSH_PORT: u16 = 22;

/// Hard ceiling on the file size we are willing to read as an
/// `--ssh-hostfile`. Matches the RemoteCollectorBuilder convention so
/// `view` mode has a single memory-safety story.
pub const MAX_HOSTFILE_BYTES: u64 = 10 * 1024 * 1024;

/// Hard ceiling on the number of SSH targets we accept from a single
/// hostfile.
pub const MAX_HOSTFILE_ENTRIES: usize = 1000;

/// Errors emitted by [`parse_ssh_target`] and [`parse_hostfile`].
#[derive(Debug, thiserror::Error)]
pub enum SshTargetError {
    #[error("invalid SSH target `{0}`: missing `user@`")]
    MissingUser(String),
    #[error("invalid SSH target `{0}`: missing host after `user@`")]
    MissingHost(String),
    #[error(
        "invalid SSH target `{input}`: host `{host}` contains unsupported characters \
         (allowed: letters, digits, `-`, `.`, `:`, `_`)"
    )]
    InvalidHostChars { input: String, host: String },
    #[error("invalid SSH target `{input}`: port `{port}` is not a valid u16")]
    InvalidPort { input: String, port: String },
    #[error("hostfile I/O error at {path}: {source}")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },
    #[error("hostfile {path} exceeds {limit}-byte limit (found {actual} bytes)")]
    FileTooLarge {
        path: String,
        actual: u64,
        limit: u64,
    },
}

/// Whether a raw host string contains only characters we expect in a
/// DNS name, IPv4 literal, bracketed IPv6 body, or an explicit
/// underscore form that some legacy DNS zones still allow. Rejecting
/// anything else keeps shell-metacharacter-looking inputs (e.g.
/// `user@;whoami`) out of logs and tab labels, even though russh goes
/// through `getaddrinfo` and not a shell.
fn valid_host_chars(s: &str) -> bool {
    !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii() && (c.is_alphanumeric() || matches!(c, '-' | '.' | ':' | '_')))
}

/// A single remote SSH target parsed from the CLI or hostfile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshTarget {
    pub user: String,
    pub host: String,
    pub port: u16,
}

impl SshTarget {
    /// Stable identifier used as the host key across the TUI and
    /// connection-status map. Always `user@host:port`, even when the
    /// caller omitted `:port` in the original input (the struct has
    /// already normalised to [`DEFAULT_SSH_PORT`]).
    pub fn host_id(&self) -> String {
        format!("{}@{}:{}", self.user, self.host, self.port)
    }

    /// Display label for the tab row — `ssh://user@host` without the
    /// port suffix when it is the default 22, for compactness.
    pub fn display_label(&self) -> String {
        if self.port == DEFAULT_SSH_PORT {
            format!("ssh://{}@{}", self.user, self.host)
        } else {
            format!("ssh://{}@{}:{}", self.user, self.host, self.port)
        }
    }

    /// Hostname-only string used as the `hostname` field on produced
    /// [`crate::device::GpuInfo`] records. Strips the user and port so
    /// per-GPU labels stay readable.
    pub fn hostname(&self) -> &str {
        &self.host
    }
}

/// Parse a single `user@host[:port]` string into [`SshTarget`].
///
/// IPv6 literals wrapped in `[]` are accepted, matching OpenSSH's
/// syntax (`user@[::1]:2222`).
pub fn parse_ssh_target(raw: &str) -> Result<SshTarget, SshTargetError> {
    let input = raw.trim();
    let at = input
        .find('@')
        .ok_or_else(|| SshTargetError::MissingUser(input.to_string()))?;
    let user = input[..at].to_string();
    if user.is_empty() {
        return Err(SshTargetError::MissingUser(input.to_string()));
    }
    let rest = &input[at + 1..];
    if rest.is_empty() {
        return Err(SshTargetError::MissingHost(input.to_string()));
    }

    // IPv6-in-brackets: `[::1]:2222`
    if let Some(stripped) = rest.strip_prefix('[') {
        let close = stripped
            .find(']')
            .ok_or_else(|| SshTargetError::MissingHost(input.to_string()))?;
        let host = stripped[..close].to_string();
        let tail = &stripped[close + 1..];
        let port = parse_port_suffix(tail, input)?;
        if !valid_host_chars(&host) {
            return Err(SshTargetError::InvalidHostChars {
                input: input.to_string(),
                host,
            });
        }
        return Ok(SshTarget { user, host, port });
    }

    // Plain host[:port]. Find the LAST colon to support hostnames that
    // legitimately contain no colons (typical case) while still rejecting
    // IPv6 literals written without brackets.
    if let Some((host_part, port_part)) = rest.rsplit_once(':') {
        // Disallow unbracketed IPv6 (multiple colons on the remote side).
        if host_part.contains(':') {
            return Err(SshTargetError::MissingHost(input.to_string()));
        }
        let port = port_part
            .parse::<u16>()
            .map_err(|_| SshTargetError::InvalidPort {
                input: input.to_string(),
                port: port_part.to_string(),
            })?;
        if host_part.is_empty() {
            return Err(SshTargetError::MissingHost(input.to_string()));
        }
        if !valid_host_chars(host_part) {
            return Err(SshTargetError::InvalidHostChars {
                input: input.to_string(),
                host: host_part.to_string(),
            });
        }
        Ok(SshTarget {
            user,
            host: host_part.to_string(),
            port,
        })
    } else {
        if !valid_host_chars(rest) {
            return Err(SshTargetError::InvalidHostChars {
                input: input.to_string(),
                host: rest.to_string(),
            });
        }
        Ok(SshTarget {
            user,
            host: rest.to_string(),
            port: DEFAULT_SSH_PORT,
        })
    }
}

fn parse_port_suffix(tail: &str, input: &str) -> Result<u16, SshTargetError> {
    if tail.is_empty() {
        return Ok(DEFAULT_SSH_PORT);
    }
    let tail = tail.strip_prefix(':').unwrap_or(tail);
    tail.parse::<u16>()
        .map_err(|_| SshTargetError::InvalidPort {
            input: input.to_string(),
            port: tail.to_string(),
        })
}

/// Parse a comma-separated `--ssh` argument into multiple targets.
pub fn parse_ssh_arg(arg: &str) -> Result<Vec<SshTarget>, SshTargetError> {
    arg.split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(parse_ssh_target)
        .collect()
}

/// Load and parse an SSH hostfile.
///
/// Format: one `user@host[:port]` per line. Blank lines and `#`-prefixed
/// comments are ignored. Inline `#` comments are also stripped. The
/// file size is capped at [`MAX_HOSTFILE_BYTES`]; the number of
/// parsed entries is capped at [`MAX_HOSTFILE_ENTRIES`].
pub fn parse_hostfile(path: impl AsRef<Path>) -> Result<Vec<SshTarget>, SshTargetError> {
    let path = path.as_ref();
    let path_str = path.display().to_string();

    let metadata = fs::metadata(path).map_err(|source| SshTargetError::Io {
        path: path_str.clone(),
        source,
    })?;
    if metadata.len() > MAX_HOSTFILE_BYTES {
        return Err(SshTargetError::FileTooLarge {
            path: path_str,
            actual: metadata.len(),
            limit: MAX_HOSTFILE_BYTES,
        });
    }

    let content = fs::read_to_string(path).map_err(|source| SshTargetError::Io {
        path: path_str,
        source,
    })?;
    parse_hostfile_content(&content)
}

pub fn parse_hostfile_content(content: &str) -> Result<Vec<SshTarget>, SshTargetError> {
    let mut out = Vec::new();
    for raw in content.lines() {
        // Strip inline `#` comments. Never strip inside a quoted
        // string — the hostfile format does not support quotes, so a
        // `#` anywhere on the line starts a comment.
        let line = match raw.find('#') {
            Some(idx) => &raw[..idx],
            None => raw,
        };
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let target = parse_ssh_target(trimmed)?;
        out.push(target);
        if out.len() >= MAX_HOSTFILE_ENTRIES {
            break;
        }
    }
    Ok(out)
}

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

    #[test]
    fn parses_user_at_host() {
        let t = parse_ssh_target("admin@dgx-01").unwrap();
        assert_eq!(t.user, "admin");
        assert_eq!(t.host, "dgx-01");
        assert_eq!(t.port, DEFAULT_SSH_PORT);
        assert_eq!(t.host_id(), "admin@dgx-01:22");
        assert_eq!(t.display_label(), "ssh://admin@dgx-01");
    }

    #[test]
    fn parses_user_at_host_with_port() {
        let t = parse_ssh_target("admin@dgx-01:2222").unwrap();
        assert_eq!(t.port, 2222);
        assert_eq!(t.display_label(), "ssh://admin@dgx-01:2222");
    }

    #[test]
    fn parses_ipv6_with_brackets_and_port() {
        let t = parse_ssh_target("admin@[fd00::1]:2222").unwrap();
        assert_eq!(t.host, "fd00::1");
        assert_eq!(t.port, 2222);
    }

    #[test]
    fn parses_ipv6_with_brackets_default_port() {
        let t = parse_ssh_target("admin@[::1]").unwrap();
        assert_eq!(t.host, "::1");
        assert_eq!(t.port, DEFAULT_SSH_PORT);
    }

    #[test]
    fn rejects_missing_user() {
        let e = parse_ssh_target("dgx-01").unwrap_err();
        assert!(matches!(e, SshTargetError::MissingUser(_)));
    }

    #[test]
    fn rejects_empty_user() {
        let e = parse_ssh_target("@dgx-01").unwrap_err();
        assert!(matches!(e, SshTargetError::MissingUser(_)));
    }

    #[test]
    fn rejects_missing_host() {
        let e = parse_ssh_target("user@").unwrap_err();
        assert!(matches!(e, SshTargetError::MissingHost(_)));
    }

    #[test]
    fn rejects_bad_port() {
        let e = parse_ssh_target("user@host:not-a-number").unwrap_err();
        assert!(matches!(e, SshTargetError::InvalidPort { .. }));
    }

    #[test]
    fn rejects_unbracketed_ipv6() {
        // `user@fd00::1` is ambiguous (is `1` a port or a hostname
        // fragment?). OpenSSH rejects this form; so do we.
        let e = parse_ssh_target("user@fd00::1").unwrap_err();
        assert!(matches!(
            e,
            SshTargetError::MissingHost(_) | SshTargetError::InvalidPort { .. }
        ));
    }

    #[test]
    fn rejects_host_with_shell_metacharacters() {
        // M6 regression: injected characters like `;` or `$` must be
        // rejected up front so they never land in logs / tab labels.
        let e = parse_ssh_target("user@;whoami").unwrap_err();
        assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));

        let e = parse_ssh_target("user@$(cat /etc/passwd)").unwrap_err();
        assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));

        let e = parse_ssh_target("user@host name").unwrap_err();
        assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));

        let e = parse_ssh_target("user@ho|st").unwrap_err();
        assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));
    }

    #[test]
    fn rejects_ipv6_body_with_invalid_chars() {
        // The bracketed IPv6 body goes through the same charset
        // check so injection via `user@[::1$(evil)]` cannot slip in.
        let e = parse_ssh_target("user@[::1;payload]").unwrap_err();
        assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));
    }

    #[test]
    fn accepts_legitimate_hostnames_and_ips() {
        // Make sure the charset check isn't too tight for common
        // real-world inputs: DNS names, IPv4 literals, underscore
        // hostnames (legacy), plain bracketed IPv6.
        assert!(parse_ssh_target("user@host-1.example.com").is_ok());
        assert!(parse_ssh_target("user@10.0.0.1").is_ok());
        assert!(parse_ssh_target("user@under_score_host").is_ok());
        assert!(parse_ssh_target("user@[fd00::1]").is_ok());
        // Zone-ID separator `%` is intentionally rejected: SSH clients
        // rarely need link-local addressing and allowing `%` widens
        // the attack surface (URL-encoded injection). Operators who
        // need zone IDs can file a follow-up issue.
        assert!(matches!(
            parse_ssh_target("user@[fe80::1%eth0]").unwrap_err(),
            SshTargetError::InvalidHostChars { .. }
        ));
    }

    #[test]
    fn parse_ssh_arg_splits_comma_list() {
        let t = parse_ssh_arg("a@h1,b@h2:2222,c@h3").unwrap();
        assert_eq!(t.len(), 3);
        assert_eq!(t[0].user, "a");
        assert_eq!(t[1].port, 2222);
        assert_eq!(t[2].host, "h3");
    }

    #[test]
    fn parse_ssh_arg_tolerates_whitespace_and_empty_slots() {
        let t = parse_ssh_arg(" a@h1 , , b@h2 ").unwrap();
        assert_eq!(t.len(), 2);
        assert_eq!(t[0].user, "a");
        assert_eq!(t[1].user, "b");
    }

    #[test]
    fn hostfile_ignores_comments_and_blanks() {
        let content = "# a comment\n\nuser1@host1\n   \nuser2@host2:2222 # inline comment\n";
        let t = parse_hostfile_content(content).unwrap();
        assert_eq!(t.len(), 2);
        assert_eq!(t[0].user, "user1");
        assert_eq!(t[1].port, 2222);
    }

    #[test]
    fn hostfile_propagates_parse_errors() {
        let content = "this-is-not-valid\n";
        assert!(parse_hostfile_content(content).is_err());
    }

    #[test]
    fn hostfile_enforces_entry_cap() {
        // Synthesize a large hostfile; parser must stop at
        // MAX_HOSTFILE_ENTRIES without blowing out memory.
        let mut content = String::new();
        for i in 0..(MAX_HOSTFILE_ENTRIES + 100) {
            content.push_str(&format!("u@host{i}\n"));
        }
        let t = parse_hostfile_content(&content).unwrap();
        assert_eq!(t.len(), MAX_HOSTFILE_ENTRIES);
    }

    #[test]
    fn reads_real_hostfile_from_disk() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(tmp.path(), "alice@a\nbob@b:2200\n").unwrap();
        let t = parse_hostfile(tmp.path()).unwrap();
        assert_eq!(t.len(), 2);
        assert_eq!(t[0].user, "alice");
        assert_eq!(t[1].port, 2200);
    }
}