runex 0.1.14

Cross-shell abbreviation engine that expands short tokens into full commands
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
//! Filesystem-backed reads and writes for the user's config file.
//!
//! Phase D D3 split this out of `app/config.rs`. The contract is:
//!
//! - `app/config.rs` holds *parsing* and *validation* (pure, no
//!   I/O — exercised by `parse_config` / `collect_validation_issues`).
//! - This module holds every `std::fs::*` call against
//!   `~/.config/runex/config.toml` (and the `RUNEX_CONFIG` override
//!   path), wrapped behind functions that return `ConfigError` so the
//!   call sites stay typed.
//!
//! The architecture test `no_filesystem_calls_in_app_layer` enforces
//! the split: any `std::fs::write` / `OpenOptions` / `rename` /
//! `remove_file` in `app/` is a regression and will fail CI.
//!
//! ## Why `infra → app::config::ConfigError` is OK
//!
//! `ConfigError` is a *type* defined in `app::config`; this module
//! re-uses it so callers don't have to convert between two error
//! enums. That's a leaf type import, not a behaviour cycle —
//! `infra::config_store` doesn't call any function from `app::config`,
//! it only constructs `ConfigError::Io(_)` / `ConfigError::FileTooLarge`
//! / `ConfigError::NoConfigDir` variants. A future refactor could lift
//! `ConfigError` into `domain/errors.rs` to remove even the type
//! reference, but that's purely cosmetic.

use std::path::{Path, PathBuf};

use crate::app::config::ConfigError;

const MAX_CONFIG_FILE_BYTES: u64 = 10 * 1024 * 1024; // 10 MB

/// Default config file path: `$XDG_CONFIG_HOME/runex/config.toml`,
/// falling back to `~/.config/runex/config.toml` when `XDG_CONFIG_HOME`
/// is unset. All platforms use this same resolution order. Overridden
/// by the `RUNEX_CONFIG` env var.
pub(crate) fn default_config_path() -> Result<PathBuf, ConfigError> {
    if let Ok(p) = std::env::var("RUNEX_CONFIG") {
        if !p.is_empty() {
            return Ok(PathBuf::from(p));
        }
    }
    let dir = crate::infra::env::xdg_config_home_with(&crate::infra::env::SystemHomeDir);
    Ok(dir.ok_or(ConfigError::NoConfigDir)?.join("runex").join("config.toml"))
}

/// Read a config file into a string with the safety guarantees that
/// `app::config::load_config` relies on:
///
/// - Single fd for metadata + read (no TOCTOU between size check and
///   read).
/// - Rejects non-regular files (FIFO / device nodes) which can bypass
///   the size guard by reporting `len() == 0`.
/// - Enforces a 10 MB size cap.
///
/// On Unix, `O_NONBLOCK` prevents `open()` from blocking on a named
/// pipe with no writer.
///
/// ## Symlinks: deliberately allowed
///
/// `~/.config/runex/config.toml` is commonly a symlink into a dotfiles
/// repository (`~/dotfiles/runex/config.toml`). We canonicalise the
/// path before opening so this idiom keeps working — `O_NOFOLLOW` is
/// then applied on the *resolved* path, which means it's effectively a
/// no-op for this code path. The trade-off is documented: an attacker
/// who can already write to the user's config directory can redirect
/// this read to any file. That's a strictly weaker capability than
/// what they already have (writing arbitrary commands into the
/// abbreviation table), so we accept the risk in exchange for keeping
/// the dotfiles pattern frictionless.
pub(crate) fn read_config_source(path: &Path) -> Result<String, ConfigError> {
    use std::io::Read;
    #[cfg(unix)]
    let mut file = {
        use std::os::unix::fs::OpenOptionsExt;
        let resolved = path.canonicalize()?;
        std::fs::OpenOptions::new()
            .read(true)
            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
            .open(&resolved)?
    };
    #[cfg(not(unix))]
    let mut file = std::fs::File::open(path)?;
    let meta = file.metadata()?;
    if !meta.is_file() {
        return Err(ConfigError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "config path must be a regular file",
        )));
    }
    if meta.len() > MAX_CONFIG_FILE_BYTES {
        return Err(ConfigError::FileTooLarge);
    }
    let mut content = String::new();
    file.read_to_string(&mut content)?;
    Ok(content)
}

/// Open a config file for append/write, rejecting symlinks at the
/// final path component on Unix. Prevents an attacker who controls the
/// config directory from redirecting writes to a sensitive file via a
/// swapped symlink.
#[cfg(unix)]
fn open_config_for_append_safely(path: &Path) -> std::io::Result<std::fs::File> {
    use std::os::unix::fs::OpenOptionsExt;
    std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .custom_flags(libc::O_NOFOLLOW)
        .open(path)
}

#[cfg(not(unix))]
fn open_config_for_append_safely(path: &Path) -> std::io::Result<std::fs::File> {
    // Windows has no portable O_NOFOLLOW equivalent at open() time;
    // rely on NTFS permissions at the config dir level.
    std::fs::OpenOptions::new().create(true).append(true).open(path)
}

/// Atomically replace a config file: write to a sibling temp file then
/// rename. On Unix the temp file is created with `O_NOFOLLOW` so a
/// pre-existing symlink at the temp path cannot redirect the write.
fn atomically_write_config(path: &Path, contents: &str) -> Result<(), ConfigError> {
    use std::io::Write;
    let parent = path.parent().ok_or_else(|| {
        ConfigError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "config path has no parent directory",
        ))
    })?;
    let file_name = path
        .file_name()
        .and_then(|n| n.to_str())
        .ok_or_else(|| {
            ConfigError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "config path has no file name",
            ))
        })?;
    let tmp = parent.join(format!(".{file_name}.runex.tmp"));

    // Best-effort cleanup of a stale temp file from a previous crash.
    let _ = std::fs::remove_file(&tmp);

    #[cfg(unix)]
    let mut file = {
        use std::os::unix::fs::OpenOptionsExt;
        std::fs::OpenOptions::new()
            .create_new(true)
            .write(true)
            .custom_flags(libc::O_NOFOLLOW)
            .open(&tmp)
            .map_err(ConfigError::Io)?
    };
    #[cfg(not(unix))]
    let mut file = std::fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&tmp)
        .map_err(ConfigError::Io)?;

    file.write_all(contents.as_bytes()).map_err(ConfigError::Io)?;
    file.sync_all().map_err(ConfigError::Io)?;
    drop(file);

    std::fs::rename(&tmp, path).map_err(|e| {
        let _ = std::fs::remove_file(&tmp);
        ConfigError::Io(e)
    })
}

/// Append a `[[abbr]]` block to `path`, preserving existing content
/// and formatting. The caller is responsible for validating the
/// `key`, `expand`, and `when_command_exists` values — the wrapper in
/// `app::abbr` (Phase D D4) does that before calling here. Rejects
/// symlinks at the final path component on Unix.
pub(crate) fn append_abbr_block(
    path: &Path,
    key: &str,
    expand: &str,
    when_command_exists: Option<&[String]>,
) -> Result<(), ConfigError> {
    let mut block = String::from("\n[[abbr]]\n");
    block.push_str(&format!("key = {}\n", toml_quote(key)));
    block.push_str(&format!("expand = {}\n", toml_quote(expand)));
    if let Some(cmds) = when_command_exists {
        let quoted: Vec<String> = cmds.iter().map(|c| toml_quote(c)).collect();
        block.push_str(&format!("when_command_exists = [{}]\n", quoted.join(", ")));
    }

    use std::io::Write;
    let mut file = open_config_for_append_safely(path).map_err(ConfigError::Io)?;
    file.write_all(block.as_bytes()).map_err(ConfigError::Io)?;
    Ok(())
}

/// Remove all abbreviation rules with `key` from `path`. Uses
/// `toml_edit` to preserve formatting; writes atomically via a
/// sibling temp + rename. Returns the number of rules removed.
pub(crate) fn remove_abbr_block(path: &Path, key: &str) -> Result<usize, ConfigError> {
    let content = read_config_source(path)?;
    let mut doc = content.parse::<toml_edit::DocumentMut>().map_err(|_| {
        ConfigError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "failed to parse config as editable TOML",
        ))
    })?;

    let removed = if let Some(toml_edit::Item::ArrayOfTables(arr)) = doc.get_mut("abbr") {
        let before = arr.len();
        let mut i = 0;
        while i < arr.len() {
            let matches = arr
                .get(i)
                .and_then(|t| t.get("key"))
                .and_then(|v| v.as_str())
                .map(|k| k == key)
                .unwrap_or(false);
            if matches {
                arr.remove(i);
            } else {
                i += 1;
            }
        }
        before - arr.len()
    } else {
        0
    };

    if removed > 0 {
        atomically_write_config(path, &doc.to_string())?;
    }
    Ok(removed)
}

/// Quote a string value for TOML output.
fn toml_quote(s: &str) -> String {
    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
    format!("\"{}\"", escaped)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::config::load_config;
    use serial_test::serial;

    /// Filesystem-shape tests that were previously colocated with
    /// `load_config` in `app/config.rs`. Moved to `infra` in Phase D
    /// D3b alongside `read_config_source` itself; the symbol the
    /// test exercises (the size cap, the FIFO rejection, the symlink
    /// follow rules) lives here now.
    #[test]
    fn load_config_rejects_oversized_file() {
        use std::io::Write;
        let mut f = tempfile::NamedTempFile::new().unwrap();
        f.write_all(&vec![b'x'; 11 * 1024 * 1024]).unwrap();
        f.flush().unwrap();
        assert!(
            load_config(f.path()).is_err(),
            "must reject files larger than 10 MB"
        );
    }

    /// On Linux a symlink to /dev/zero reports `metadata().len() == 0`,
    /// bypassing the size guard. `read_config_source` must reject
    /// non-regular files via `is_file()`.
    #[test]
    #[cfg(unix)]
    fn load_config_rejects_symlink_to_dev_zero() {
        let dir = tempfile::tempdir().unwrap();
        let link = dir.path().join("fake_config.toml");
        std::os::unix::fs::symlink("/dev/zero", &link).unwrap();
        assert!(
            load_config(&link).is_err(),
            "load_config must reject a symlink to /dev/zero"
        );
    }

    /// A symlink pointing to a regular TOML file must be followed —
    /// the dotfiles-symlink idiom relies on it.
    #[test]
    #[cfg(unix)]
    fn load_config_follows_symlink_to_regular_file() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("target.toml");
        std::fs::write(&target, b"version = 1\n").unwrap();
        let link = dir.path().join("link_config.toml");
        std::os::unix::fs::symlink(&target, &link).unwrap();
        let result = load_config(&link);
        assert!(
            result.is_ok(),
            "load_config must follow a symlink to a regular file: {result:?}"
        );
    }

    /// A named pipe reports `metadata().len() == 0` and
    /// `read_to_string()` blocks. The non-regular file guard rejects
    /// it before any read happens.
    #[test]
    #[cfg(unix)]
    fn load_config_rejects_named_pipe() {
        use std::ffi::CString;
        let dir = tempfile::tempdir().unwrap();
        let pipe = dir.path().join("fake_config.toml");
        let path_c = CString::new(pipe.to_str().unwrap()).unwrap();
        unsafe { libc::mkfifo(path_c.as_ptr(), 0o600) };
        assert!(
            load_config(&pipe).is_err(),
            "load_config must reject a named pipe"
        );
    }

    /// Round-trip: write a config, read it back. Mostly a smoke test
    /// for `read_config_source` + `parse_config` working together
    /// across the layer boundary.
    #[test]
    fn load_config_from_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("config.toml");
        std::fs::write(
            &path,
            r#"
version = 1

[[abbr]]
key = "gcm"
expand = "git commit -m"
"#,
        )
        .unwrap();

        let config = load_config(&path).unwrap();
        assert_eq!(config.version, 1);
        assert_eq!(config.abbr[0].key, "gcm");
    }

    /// Safety: env mutation is serialized via `#[serial]`. Inherited
    /// from the original `app::config` test — the contract being
    /// pinned (RUNEX_CONFIG overrides the XDG path) is unchanged.
    #[test]
    #[serial]
    fn default_config_path_env_override() {
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
        unsafe { std::env::set_var("RUNEX_CONFIG", "/tmp/custom.toml") };
        let path = default_config_path().unwrap();
        unsafe { std::env::remove_var("RUNEX_CONFIG") };
        assert_eq!(path, PathBuf::from("/tmp/custom.toml"));
    }

    /// Safety: see `default_config_path_env_override`.
    #[test]
    #[serial]
    fn default_config_path_uses_xdg_config_home() {
        unsafe { std::env::remove_var("RUNEX_CONFIG") };
        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-runex-test") };
        let path = default_config_path().unwrap();
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
        assert_eq!(
            path,
            PathBuf::from("/tmp/xdg-runex-test/runex/config.toml")
        );
    }

    /// Safety: see `default_config_path_env_override`.
    #[test]
    #[serial]
    fn default_config_path_ignores_empty_runex_config() {
        unsafe { std::env::set_var("RUNEX_CONFIG", "") };
        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-empty-test") };
        let path = default_config_path().unwrap();
        unsafe { std::env::remove_var("RUNEX_CONFIG") };
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
        assert_eq!(
            path,
            PathBuf::from("/tmp/xdg-empty-test/runex/config.toml"),
            "empty RUNEX_CONFIG must fall through to XDG resolution"
        );
    }

    /// Safety: see `default_config_path_env_override`.
    #[test]
    #[serial]
    fn xdg_config_home_with_system_resolver_uses_env_var() {
        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test") };
        let dir = crate::infra::env::xdg_config_home_with(&crate::infra::env::SystemHomeDir).unwrap();
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
        assert_eq!(dir, PathBuf::from("/tmp/xdg-test"));
    }

    /// Safety: see `default_config_path_env_override`.
    #[test]
    #[serial]
    fn xdg_config_home_with_system_resolver_empty_env_falls_back() {
        unsafe { std::env::set_var("XDG_CONFIG_HOME", "") };
        let dir = crate::infra::env::xdg_config_home_with(&crate::infra::env::SystemHomeDir).unwrap();
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
        assert!(
            dir.ends_with(".config"),
            "expected ~/.config fallback, got {dir:?}"
        );
    }
}