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
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
// 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.

//! TOML configuration file support for `all-smi` (issue #192).
//!
//! This module exposes:
//! - [`Settings`] — the root runtime configuration struct, built from
//!   compiled defaults merged with TOML file values and environment
//!   variable overrides.
//! - [`load`] — the primary loader. Accepts an explicit `Option<Path>`
//!   from `--config`, falls back to platform-appropriate paths, and
//!   returns a fully merged [`Settings`] plus any unknown-keys report.
//! - [`LoadOutcome`] — what `load` returns: the settings, the resolved
//!   path (when one was actually read), and any unknown keys for
//!   `config print` to warn about.
//! - [`SUPPORTED_SCHEMA_VERSION`] — version gate; mismatching `schema_version`
//!   produces a hard error.
//!
//! Precedence rule (highest → lowest): CLI flag > env var > config file
//! > compiled default. CLI merging lives in the caller — this module
//! > handles the file + env layer only.
//!
//! Module layout:
//! - [`crate::common::config_schema`] defines the on-disk TOML types
//!   (pure serde data projections).
//! - [`crate::common::config_env`] applies env-var overrides to a
//!   [`Settings`] (canonical + backward-compat legacy aliases).

use std::collections::BTreeSet;
use std::io;
use std::path::{Path, PathBuf};

use toml::Value as TomlValue;

use crate::common::config::{AlertConfig, EnergyConfig};
use crate::common::config_apply;
use crate::common::config_env;
use crate::common::config_schema::{KNOWN_TOP_LEVEL, RawConfig};
use crate::common::paths;

/// Hard ceiling on the size of a config file we are willing to read.
///
/// A legitimate `config.toml` is hundreds of bytes; even with every
/// commented option expanded it stays well under a kilobyte. Capping
/// the read at 1 MiB prevents a malicious or accidental oversized file
/// (e.g. a log file renamed over the config path, a `/dev/zero`
/// symlink, etc.) from OOM-ing the process at startup. The cap is
/// enforced both via metadata (fast path) and via `Read::take`
/// (authoritative — covers named pipes, devices, and any path where
/// the metadata length is a lie).
pub(crate) const MAX_CONFIG_BYTES: u64 = 1 << 20; // 1 MiB

// Re-export the on-disk schema types so existing callers can keep
// importing through this module.
pub use crate::common::config_schema::SocketSetting;

/// Schema version the current binary understands. Future versions may
/// bump this and migrate fields; for v1 we reject mismatches outright.
pub const SUPPORTED_SCHEMA_VERSION: u32 = 1;

// ---------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------

/// Errors produced by [`load`] and [`parse_toml`]. Distinguished enough
/// for callers (CLI, `config validate`) to emit the right exit codes.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// I/O error while reading the file.
    #[error("config file I/O error at {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    /// The TOML parser rejected the file. Includes the underlying
    /// message which already names line/column.
    #[error("config file parse error: {0}")]
    Parse(String),
    /// `schema_version` in the file is newer than
    /// [`SUPPORTED_SCHEMA_VERSION`], or otherwise out of range.
    #[error(
        "config file schema_version = {found} is not supported \
         (this build understands schema_version = {supported})"
    )]
    SchemaVersion { found: u32, supported: u32 },
    /// A semantic validation failure (e.g. `theme = "rainbow"`).
    #[error("config file semantic error: {0}")]
    Semantic(String),
    /// With `--strict`, any unknown key at the top-level or inside a
    /// known section is reported here. The key is pre-escaped via
    /// [`escape_printable`] so a quoted TOML key containing ANSI
    /// escapes cannot inject cursor-control sequences into operator
    /// terminals.
    #[error("config file unknown key: {0}")]
    UnknownKey(String),
}

/// Escape control characters in a string so it is safe to print to a
/// terminal.
///
/// TOML permits arbitrary codepoints inside quoted keys
/// (e.g. `"foo\u001b[31mRED"`). Blindly passing such keys to
/// `eprintln!` would allow a hostile config file to inject cursor
/// movement, colour, or clear-screen sequences into the operator's
/// terminal — a meaningful attack surface for multi-tenant hosts where
/// one user controls the config file and another reads the validator
/// output.
///
/// This helper replaces every control character (`U+0000` – `U+001F`
/// and `U+007F`) with its `\u{XXXX}` escape so output stays printable
/// and auditable. Non-control characters pass through unchanged.
pub fn escape_printable(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        let code = c as u32;
        if code < 0x20 || code == 0x7f {
            out.push_str(&format!("\\u{{{code:04X}}}"));
        } else {
            out.push(c);
        }
    }
    out
}

// ---------------------------------------------------------------------
// Runtime Settings — merged view
// ---------------------------------------------------------------------

/// Merged configuration handed to every subcommand entry point. Each
/// field is populated via the precedence chain (defaults → file → env)
/// inside [`load`]; the CLI layer overrides [`Settings`] further.
#[derive(Debug, Clone)]
pub struct Settings {
    pub general: GeneralSettings,
    pub local: LocalSettings,
    pub view: ViewSettings,
    pub api: ApiSettings,
    pub alerts: AlertConfig,
    pub energy: EnergyConfig,
    pub display: DisplaySettings,
    pub record: RecordSettings,
    pub snapshot: SnapshotSettings,
    /// Path the settings were actually loaded from. `None` means we
    /// used compiled defaults + env overrides only.
    pub source_path: Option<PathBuf>,
    /// Unknown top-level / section keys encountered during parse.
    /// Populated even when `--strict` is off so `config print` can warn.
    pub unknown_keys: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct GeneralSettings {
    pub default_mode: String,
    pub theme: String,
    pub locale: String,
}

#[derive(Debug, Clone, Default)]
pub struct LocalSettings {
    pub interval_secs: Option<u64>,
}

#[derive(Debug, Clone, Default)]
pub struct ViewSettings {
    pub hostfile: Option<String>,
    pub hosts: Vec<String>,
    pub interval_secs: Option<u64>,
    // Agentless SSH transport (issue #194). Empty / None means "use CLI
    // defaults". `ssh` (targets) and `ssh_hostfile` are *additive* with
    // CLI: a TOML-provided target list is merged with `--ssh` on the
    // command line at dispatch time.
    pub ssh: Vec<String>,
    pub ssh_hostfile: Option<String>,
    pub ssh_key: Option<String>,
    pub ssh_config: Option<String>,
    pub ssh_strict_host_key: Option<String>,
    pub ssh_timeout_secs: Option<u64>,
    pub ssh_fallback: Option<String>,
    pub ssh_known_hosts: Option<String>,
    pub ssh_concurrency: Option<usize>,
}

#[derive(Debug, Clone)]
pub struct ApiSettings {
    pub port: u16,
    pub socket: SocketSetting,
    pub processes: bool,
    pub interval_secs: u64,
}

#[derive(Debug, Clone)]
pub struct DisplaySettings {
    pub color_scheme: String,
    pub gauge_style: String,
    pub show_led_grid: bool,
}

impl Default for DisplaySettings {
    fn default() -> Self {
        Self {
            color_scheme: "default".to_string(),
            gauge_style: "blocks".to_string(),
            show_led_grid: true,
        }
    }
}

#[derive(Debug, Clone)]
pub struct RecordSettings {
    /// Operator-supplied output directory for `all-smi record`. `None`
    /// (the compiled default) means "use the platform cache helper" —
    /// the resolver in [`crate::record::RecorderOptions`] joins
    /// [`crate::common::paths::cache_dir`] with `"records"`. `Some(s)`
    /// is honored verbatim after `expand_tilde`. Issue #229.
    pub output_dir: Option<String>,
    pub compress: String,
}

#[derive(Debug, Clone)]
pub struct SnapshotSettings {
    pub default_format: String,
    pub default_pretty: bool,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            general: GeneralSettings {
                default_mode: "local".to_string(),
                theme: "auto".to_string(),
                locale: "en".to_string(),
            },
            local: LocalSettings::default(),
            view: ViewSettings::default(),
            api: ApiSettings {
                port: 9090,
                socket: SocketSetting::Unset,
                processes: false,
                interval_secs: 3,
            },
            alerts: AlertConfig::default(),
            energy: EnergyConfig::default(),
            display: DisplaySettings {
                color_scheme: "default".to_string(),
                gauge_style: "blocks".to_string(),
                show_led_grid: true,
            },
            record: RecordSettings {
                output_dir: None,
                compress: "zstd".to_string(),
            },
            snapshot: SnapshotSettings {
                default_format: "json".to_string(),
                default_pretty: true,
            },
            source_path: None,
            unknown_keys: Vec::new(),
        }
    }
}

// ---------------------------------------------------------------------
// Outcome + entry points
// ---------------------------------------------------------------------

/// Result returned by [`load`]. Callers that need to warn on unknown
/// keys (`config print`) read `settings.unknown_keys`; callers that
/// need to enforce strictness (`config validate --strict`) should
/// instead consult [`validate_file`].
#[derive(Debug)]
pub struct LoadOutcome {
    pub settings: Settings,
    /// Warnings emitted during the merge (e.g., ignored env values).
    pub warnings: Vec<String>,
}

/// Load configuration from the given path (or the platform default).
///
/// * `explicit` — path supplied via `--config`. When `Some`, the loader
///   fails if the file does not exist or cannot be read. When `None`,
///   the loader probes [`crate::common::paths::candidate_config_paths`]
///   and silently falls back to defaults when no file is found.
///
/// Env-var overrides are applied on top of the file values before the
/// final [`Settings`] is returned. CLI-flag precedence is applied later
/// in the caller (see `src/main.rs`).
pub fn load(explicit: Option<&Path>) -> Result<LoadOutcome, ConfigError> {
    let mut warnings = Vec::new();

    let (raw, source_path, unknown_keys): (RawConfig, Option<PathBuf>, Vec<String>) =
        if let Some(path) = explicit {
            let contents = read_config_capped(path).map_err(|e| ConfigError::Io {
                path: path.to_path_buf(),
                source: e,
            })?;
            let (raw, unknown) = parse_toml(&contents)?;
            check_schema_version(&raw)?;
            (raw, Some(path.to_path_buf()), unknown)
        } else if let Some(path) = paths::discover_existing_config() {
            match read_config_capped(&path) {
                Ok(contents) => {
                    let (raw, unknown) = parse_toml(&contents)?;
                    check_schema_version(&raw)?;
                    (raw, Some(path), unknown)
                }
                Err(e) => {
                    // The file existed when `discover_existing_config`
                    // was called but disappeared under us (race); treat
                    // as "no file" rather than a hard error.
                    warnings.push(format!(
                        "config: could not read {} ({e}); using defaults",
                        path.display()
                    ));
                    (RawConfig::default(), None, Vec::new())
                }
            }
        } else {
            (RawConfig::default(), None, Vec::new())
        };

    let mut settings = Settings {
        source_path,
        unknown_keys,
        ..Settings::default()
    };

    config_apply::apply_file(&raw, &mut settings)?;
    config_env::apply_env(&mut settings, &mut warnings);

    Ok(LoadOutcome { settings, warnings })
}

/// Schema-version gate. Returning an error short-circuits `load`.
fn check_schema_version(raw: &RawConfig) -> Result<(), ConfigError> {
    if let Some(v) = raw.schema_version
        && v != SUPPORTED_SCHEMA_VERSION
    {
        return Err(ConfigError::SchemaVersion {
            found: v,
            supported: SUPPORTED_SCHEMA_VERSION,
        });
    }
    Ok(())
}

/// Public validation entry point used by `all-smi config validate
/// --strict`. Returns `Ok(())` when the file parses and no unknown keys
/// are present. Without `strict`, unknown keys are allowed — the caller
/// should still call [`load`] to get the semantic errors.
pub fn validate_file(path: &Path, strict: bool) -> Result<Settings, ConfigError> {
    let contents = read_config_capped(path).map_err(|e| ConfigError::Io {
        path: path.to_path_buf(),
        source: e,
    })?;
    let (raw, unknown_keys) = parse_toml(&contents)?;
    check_schema_version(&raw)?;

    if strict && !unknown_keys.is_empty() {
        return Err(ConfigError::UnknownKey(unknown_keys.join(", ")));
    }

    let mut settings = Settings {
        source_path: Some(path.to_path_buf()),
        unknown_keys,
        ..Settings::default()
    };
    config_apply::apply_file(&raw, &mut settings)?;

    Ok(settings)
}

// ---------------------------------------------------------------------
// Parser + merge helpers
// ---------------------------------------------------------------------

/// Parse a TOML document and report any unknown top-level keys.
///
/// Implementation detail: we parse once into a generic `toml::Value`
/// so we can enumerate the top-level keys independently of the typed
/// deserializer (which silently ignores unknown keys when
/// `deny_unknown_fields` is not set). The same parsed value is then
/// fed into `TomlValue::try_into` to materialise the typed
/// [`RawConfig`] — no second parse of the source text, saving one full
/// traversal of the document plus its allocations on every load.
pub fn parse_toml(contents: &str) -> Result<(RawConfig, Vec<String>), ConfigError> {
    let value: TomlValue =
        toml::from_str(contents).map_err(|e| ConfigError::Parse(e.to_string()))?;
    let raw: RawConfig = value
        .clone()
        .try_into()
        .map_err(|e: toml::de::Error| ConfigError::Parse(e.to_string()))?;

    let mut unknown = BTreeSet::new();
    if let TomlValue::Table(top) = &value {
        for key in top.keys() {
            if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
                // Pre-escape so downstream `eprintln!` sites cannot
                // have ANSI / cursor-control sequences injected into
                // operator terminals via a hostile config key. A
                // quoted TOML key like `"x\u001b[31m"` is parsed
                // verbatim; without this step the unknown-key warning
                // would paint the rest of the stderr output red.
                unknown.insert(escape_printable(key));
            }
        }
        scan_unknown_subkeys(top, &mut unknown);
    }

    Ok((raw, unknown.into_iter().collect()))
}

/// Read a config file into a `String`, enforcing a hard size cap of
/// [`MAX_CONFIG_BYTES`] and refusing to follow symlinks.
///
/// Defence-in-depth rationale:
/// * **Size cap.** A legitimate config is well under a kilobyte. Without
///   a cap, a renamed log file or a malicious attempt to OOM the daemon
///   at startup would be read unconditionally. We check the metadata
///   length first (fast path) and then also wrap the read in
///   `Read::take(MAX + 1)` so pipes / devices / anything where
///   `metadata().len()` lies still gets capped.
/// * **Symlink refusal.** Even with `read_to_string`, a symlink at the
///   canonical config path (e.g. `~/.config/all-smi/config.toml ->
///   /etc/shadow`) would happily be followed and the target's contents
///   leaked through parse-error messages. We refuse to read symlinks
///   outright; if the operator genuinely wants to point at a file
///   elsewhere they can use `--config /actual/path.toml`.
fn read_config_capped(path: &Path) -> io::Result<String> {
    use std::io::Read;

    // Symlink rejection. `symlink_metadata` does NOT follow; a plain
    // `metadata()` call would.
    match std::fs::symlink_metadata(path) {
        Ok(md) if md.file_type().is_symlink() => {
            return Err(io::Error::new(
                io::ErrorKind::PermissionDenied,
                format!(
                    "refusing to read config at {} — path is a symlink",
                    path.display()
                ),
            ));
        }
        Ok(_) => {}
        // Fall through on metadata errors so `File::open` below produces
        // the canonical NotFound / permission error the caller expects.
        Err(_) => {}
    }

    let f = std::fs::File::open(path)?;
    if let Ok(md) = f.metadata()
        && md.len() > MAX_CONFIG_BYTES
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("config file > {MAX_CONFIG_BYTES} bytes; refusing to read"),
        ));
    }
    let mut buf = String::new();
    // `take(MAX + 1)` so that if we DO read past the cap we can tell the
    // difference between "file is exactly at the cap" and "file exceeded
    // the cap" without allocating beyond the cap + 1 byte.
    f.take(MAX_CONFIG_BYTES + 1).read_to_string(&mut buf)?;
    if buf.len() as u64 > MAX_CONFIG_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("config file > {MAX_CONFIG_BYTES} bytes; refusing to parse"),
        ));
    }
    Ok(buf)
}

/// Walk each known section and record any unrecognised sub-keys. Keeps
/// unknown keys fully qualified (e.g. `api.foo`). Sub-keys containing
/// control characters are escaped via [`escape_printable`] so downstream
/// consumers can print them safely — see the top-level key path for
/// rationale.
fn scan_unknown_subkeys(top: &toml::map::Map<String, TomlValue>, out: &mut BTreeSet<String>) {
    use toml::map::Map;
    let check = |name: &str, known: &[&str], out: &mut BTreeSet<String>, top: &Map<_, _>| {
        if let Some(TomlValue::Table(sec)) = top.get(name) {
            for k in sec.keys() {
                if !known.contains(&k.as_str()) {
                    out.insert(format!("{name}.{}", escape_printable(k)));
                }
            }
        }
    };
    check("general", &["default_mode", "theme", "locale"], out, top);
    check("local", &["interval_secs"], out, top);
    check("view", &["hostfile", "hosts", "interval_secs"], out, top);
    check(
        "api",
        &["port", "socket", "processes", "interval_secs"],
        out,
        top,
    );
    check(
        "alerts",
        &[
            "enabled",
            "temp_warn_c",
            "temp_crit_c",
            "util_idle_pct",
            "util_idle_warn_mins",
            "hysteresis_c",
            "bell_on_critical",
            "webhook_url",
            "power_crit_w",
        ],
        out,
        top,
    );
    check(
        "energy",
        &[
            "price_per_kwh",
            "currency",
            "show_cost",
            "wal_path",
            "gap_interpolate_seconds",
            "wal_enabled",
        ],
        out,
        top,
    );
    check(
        "display",
        &["color_scheme", "gauge_style", "show_led_grid"],
        out,
        top,
    );
    check("record", &["output_dir", "compress"], out, top);
    check("snapshot", &["default_format", "default_pretty"], out, top);
}

// Test module is in `config_file_tests.rs` to keep this file under the
// 500-line soft limit.
#[cfg(test)]
#[path = "config_file_tests.rs"]
mod tests;