Re: [MLUG] Digest for mlug-au@googlegroups.com - 1 update in 1 topic

1 view
Skip to first unread message

Ian Ajzenszmidt

unread,
Feb 14, 2026, 10:56:37 AM (9 days ago) Feb 14
to mlu...@googlegroups.com
Excellent 👍 — here is a clean, hardened, production-quality rewrite that:

✔ Precisely follows the PDF requirement: set HISTFILE when launching the terminal

✔ Mirrors the .xinitrc style window layout

✔ Uses TOML with proper argv arrays (no broken quoting)

✔ Never injects -e or other terminal flags

✔ Avoids shell evaluation

✔ Uses strict validation and safe file handling

✔ Is actually “hardened”, not over-engineered

---

🔐 Hardened Rust HISTFILE Window Launcher (TOML-based)

---

📁 Directory Layout

histwin/
 ├── Cargo.toml
 └── src/
     └── main.rs

---

📦 Cargo.toml

[package]
name = "histwin"
version = "1.0.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"

Only two minimal dependencies:

serde

toml

No shell parsing crates. No unsafe code.

---

🧠 Config File (TOML)

Create:

~/.config/histwin/windows.toml

hist_dir = "~/histfiles"

[[window]]
name = "win0"
program = "xterm"
args = ["-geometry", "88x46+0+0", "-e", "bash"]

[[window]]
name = "win1"
program = "xterm"
args = ["-geometry", "88x46-0+0", "-e", "bash"]

[[window]]
name = "debug"
program = "xterm"
args = [
  "-geometry", "142x41+1024+1536",
  "-title", "/var/log/debug",
  "-e", "sh", "-c", "tail --follow=name /var/log/debug"
]

✔ No quoting hacks
✔ Full argv preserved
✔ Exactly mirrors the slide examples

---

🛡 Hardened main.rs

/*
===============================================================================
HISTWIN — Hardened Per-Window Bash History Launcher

Implements slide concept:
Set HISTFILE when launching terminal 
Use named windows like .xinitrc example 

SECURITY PROPERTIES
- No shell evaluation
- Strict name validation
- No path traversal
- HISTFILE created 0600 (Unix)
- No terminal flag injection
===============================================================================
*/

use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};

#[derive(Deserialize)]
struct Config {
    hist_dir: String,
    window: Vec<Window>,
}

#[derive(Deserialize)]
struct Window {
    name: String,
    program: String,
    args: Vec<String>,
}

fn main() {
    let config_path = config_path();
    let config_text = fs::read_to_string(&config_path)
        .expect("Cannot read config file");

    let config: Config = toml::from_str(&config_text)
        .expect("Invalid TOML format");

    let hist_dir = expand_tilde(&config.hist_dir);
    fs::create_dir_all(&hist_dir)
        .expect("Cannot create history directory");

    for win in config.window {
        launch(&hist_dir, win);
    }
}

fn config_path() -> PathBuf {
    let home = env::var("HOME").expect("HOME not set");
    PathBuf::from(home).join(".config/histwin/windows.toml")
}

fn expand_tilde(path: &str) -> PathBuf {
    if path.starts_with("~/") {
        let home = env::var("HOME").unwrap();
        PathBuf::from(home).join(&path[2..])
    } else {
        PathBuf::from(path)
    }
}

fn validate_name(name: &str) {
    if name.is_empty()
        || !name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
    {
        panic!("Unsafe window name: {}", name);
    }
}

fn safe_histfile(hist_dir: &Path, name: &str) -> PathBuf {
    validate_name(name);

    let rel = PathBuf::from(name);

    for comp in rel.components() {
        match comp {
            Component::Normal(_) => {}
            _ => panic!("Path traversal attempt"),
        }
    }

    hist_dir.join(rel)
}

fn launch(hist_dir: &Path, win: Window) {
    let histfile = safe_histfile(hist_dir, &win.name);

    if !histfile.exists() {
        fs::write(&histfile, "").expect("Cannot create HISTFILE");
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&histfile, fs::Permissions::from_mode(0o600))
            .expect("Cannot set permissions");
    }

    let mut cmd = Command::new(&win.program);
    cmd.args(&win.args);
    cmd.env("HISTFILE", &histfile);

    cmd.stdin(Stdio::null());
    cmd.stdout(Stdio::null());
    cmd.stderr(Stdio::null());

    cmd.spawn().expect("Failed to spawn terminal");
}

---

🧪 Build

cargo build --release

Binary:

target/release/histwin

Run:

target/release/histwin

---

🔒 Why This Version Is Properly Hardened

Risk Status

Shell injection Impossible
Terminal flag injection Impossible
Path traversal Blocked
Overly broad permissions Prevented
Quoting errors Eliminated
Environment pollution HISTFILE only set for child

---

📌 Optional .bashrc Hardening

From the slides, if you don’t want HISTFILE lingering in your environment :

case "$HISTFILE" in
  "$HOME/histfiles/"*) ;;
  *) export -n HISTFILE ;;
esac

---

🎯 Result

You now have:

A correct implementation of the slide concept

A truly hardened Rust system

Clean config separation

Proper argument handling

No quoting hacks

No shell

---

If you’d like next:

1. Add --kill-all mode (like the slide demo)

2. Add geometry presets like xt function

3. Add logging

4. Add root-shell window enforcement

5. Convert to a systemd user service

6. Make it a Debian package

What level do you want to take this to, Ian?


On Sat, 14 Feb 2026, 11:08 am , <mlu...@googlegroups.com> wrote:
Duncan Roe <dunca...@optusnet.com.au>: Feb 14 09:12AM +1100

Hi Everyone,
 
Next meeting is in 9 days time, on the Monday after next.
 
Recently, I've been developing a system to save per-window directory
stacks. I could show that if there's any interest (would be a short
talk). (After developing per-window bash command histories as per
https://mlug-au.org/lib/exe/fetch.php?media=20250224-bash_histories.pdf
I still got frustrated about losing my cd history. Saving the directory
stack addresses this).
 
So we know when everyone who's coming has got there, will anyone else
intending to come please put their name on the attendance list.
 
See you all a week Monday,
 
Cheers ... Duncan.
 
On Sun, Feb 01, 2026 at 11:53:18AM +1100, mlug wrote:
You received this digest because you're subscribed to updates for this group. You can change your settings on the group membership page.
To unsubscribe from this group and stop receiving emails from it send an email to mlug-au+u...@googlegroups.com.

Ian Ajzenszmidt

unread,
Feb 14, 2026, 11:18:31 AM (9 days ago) Feb 14
to mlu...@googlegroups.com


/data/user/0/tech.ula/files/support/dbclient: Caution, skipping hostkey check for localhost

Welcome to Ubuntu in UserLAnd!
userland@localhost:~$ cargo new dirstack
    Creating binary (application) `dirstack` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
userland@localhost:~$ cd dirstackuserland@localhost:~/dirstack$ rm Cargo
rm: cannot remove 'Cargo': No such file or directory
userland@localhost:~/dirstack$ rm Cargo.toml
userland@localhost:~/dirstack$ nano Cargo.toml
userland@localhost:~/dirstack$ rm src/main.rs
userland@localhost:~/dirstack$ nano src/main.rs
userland@localhost:~/dirstack$ cargo run
    Updating crates.io index
     Locking 11 packages to latest compatible versions
  Downloaded zmij v1.0.21
  Downloaded syn v2.0.115
  Downloaded 2 crates (324.5KiB) in 1.23s
   Compiling proc-macro2 v1.0.106
   Compiling quote v1.0.44
   Compiling unicode-ident v1.0.23
   Compiling serde_core v1.0.228
   Compiling zmij v1.0.21
   Compiling serde v1.0.228
   Compiling serde_json v1.0.149
   Compiling memchr v2.8.0
   Compiling itoa v1.0.17
   Compiling syn v2.0.115
   Compiling serde_derive v1.0.228
   Compiling dirstack v0.2.0 (/home/userland/dirstack)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 25.05s
     Running `target/debug/dirstack`
Usage:
  dirstack [--window KEY] <command> [args]

Commands:
  push [DIR]        Push DIR (or current dir if omitted). Prints new top.
  pop               Pop top. Prints new top (or nothing if empty).
  peek              Print current top (or nothing if empty).
  list              Print stack entries, top first.
  clear             Clear this window's stack.
  gc [--days N]     Garbage-collect old window stacks (default 30 days).

Options:
  --window KEY      Override window key.
Environment:
  DIRSTACK_WINDOW_KEY  If set, used as the window key (unless --window is used).
userland@localhost:~/dirstack$ lsCargo.lock  src
Cargo.toml  target
userland@localhost:~/dirstack$ cd target
userland@localhost:~/dirstack/tar
get$ ls
CACHEDIR.TAG  debug
userland@localhost:~/dirstack/tar
get$ cd debug
userland@localhost:~/dirstack/tar
get/debug$ ls
build  dirstack    examples
deps   dirstack.d  incremental
userland@localhost:~/dirstack/tar
get/debug$ ./dirstack
Usage:
  dirstack [--window KEY] <command> [args]

Commands:
  push [DIR]        Push DIR (or current dir if omitted). Prints new top.
  pop               Pop top. Prints new top (or nothing if empty).
  peek              Print current top (or nothing if empty).
  list              Print stack entries, top first.
  clear             Clear this window's stack.
  gc [--days N]     Garbage-collect old window stacks (default 30 days).

Options:
  --window KEY      Override window key.
Environment:
  DIRSTACK_WINDOW_KEY  If set, used as the window key (unless --window is used).
userland@localhost:~/dirstack/tar
get/debug$ ls
build  dirstack    examples
deps   dirstack.d  incremental
userland@localhost:~/dirstack/tar
get/debug$ cd examples
userland@localhost:~/dirstack/tar
get/debug/examples$ ls
userland@localhost:~/dirstack/tar
get/debug/examples$ cd
userland@localhost:~$ cd dirstackuserland@localhost:~/dirstack$ cat Cargo.toml
[package]
name = "dirstack"
version = "0.2.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
userland@localhost:~/dirstack$ cat src/main.rs
/*
================================================================================
DATA DICTIONARY / DESIGN NOTES (Hardened "Per-Window Directory Stacks")
================================================================================

PURPOSE
-------
Maintain a persistent directory stack for each "window" (terminal instance,
tmux pane, X11 window, etc.) so push/pop operations in one window do not affect
another.

WINDOW KEY RESOLUTION (highest priority first)
----------------------------------------------
1) --window KEY                (CLI override)
2) DIRSTACK_WINDOW_KEY         (environment override; recommended for reliability)
3) TMUX_PANE                   (tmux pane id)
4) WINDOWID                    (X11 window id, when available)
5) WEZTERM_PANE, KITTY_WINDOW_ID, TERM_SESSION_ID
6) SSH_TTY, TTY
7) fallback: "default"

PERSISTENCE MODEL
-----------------
Single JSON state file:
  window_stacks.json
Location:
  $XDG_STATE_HOME/dirstack/window_stacks.json
  or ~/.local/state/dirstack/window_stacks.json

BACKUP MODEL
------------
Before replacing the state file, we create a best-effort backup:
  window_stacks.json.bak
This enables manual recovery if disk or other anomalies occur.

LOCKING MODEL (best-effort, hardened with retry)
------------------------------------------------
We use a lock file created with create_new:
  window_stacks.lock

To reduce spurious failures, we retry with exponential backoff.
Stale lock handling is conservative: only if lock age > LOCK_STALE_SECS.

ATOMIC + DURABLE WRITES (Unix)
------------------------------
Write safety goals:
- Avoid partial/torn writes: write temp + fsync(temp) + rename(temp, target)
- Improve durability on power loss (Unix): fsync(parent directory) after rename

DATA STRUCTURES
---------------
AppState:
  - version: u32
  - windows: HashMap<String, WindowState>

WindowState:
  - stack: Vec<String>      // top first
  - touched_unix: i64       // unix seconds (UTC)

INVARIANTS
----------
- Stored paths are absolute and exist and are directories (push validates).
- Stack is bounded by MAX_STACK to prevent unbounded growth.
- De-dup: a pushed directory is moved to the top.

CLI CONTRACT
------------
- push [DIR]  : push DIR (or current dir). Prints new top.
- pop         : pop current top. Prints new top (or nothing if empty).
- peek        : prints current top (or nothing if empty).
- list        : prints stack entries top-first.
- clear       : clears this window stack.
- gc --days N : remove window stacks not touched in N days (default 30).

SHELL INTEGRATION
-----------------
A child process cannot change parent shell cwd. Use shell functions to cd to
the printed output of pop/peek if desired.
================================================================================
*/

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::thread::sleep;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

const STATE_VERSION: u32 = 1;
const MAX_STACK: usize = 200;

const DEFAULT_GC_DAYS: i64 = 30;

// Locking: retry + conservative stale threshold
const LOCK_STALE_SECS: u64 = 300; // 5 minutes
const LOCK_RETRY_MAX_ATTEMPTS: u32 = 10;
const LOCK_RETRY_INITIAL_MS: u64 = 15;
const LOCK_RETRY_MAX_MS: u64 = 250;

#[derive(Debug, Serialize, Deserialize)]
struct AppState {
    version: u32,
    windows: HashMap<String, WindowState>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
struct WindowState {
    stack: Vec<String>, // top first
    touched_unix: i64,
}

impl Default for AppState {
    fn default() -> Self {
        Self {
            version: STATE_VERSION,
            windows: HashMap::new(),
        }
    }
}

fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or(Duration::from_secs(0))
        .as_secs() as i64
}

fn eprintln_err(msg: &str) {
    eprintln!("dirstack: {msg}");
}

fn usage() -> &'static str {
    "Usage:
  dirstack [--window KEY] <command> [args]

Commands:
  push [DIR]        Push DIR (or current dir if omitted). Prints new top.
  pop               Pop top. Prints new top (or nothing if empty).
  peek              Print current top (or nothing if empty).
  list              Print stack entries, top first.
  clear             Clear this window's stack.
  gc [--days N]     Garbage-collect old window stacks (default 30 days).

Options:
  --window KEY      Override window key.
Environment:
  DIRSTACK_WINDOW_KEY  If set, used as the window key (unless --window is used).
"
}

fn state_dir() -> io::Result<PathBuf> {
    if let Ok(xdg) = env::var("XDG_STATE_HOME") {
        let mut p = PathBuf::from(xdg);
        p.push("dirstack");
        return Ok(p);
    }
    let home = env::var_os("HOME").ok_or_else(|| {
        io::Error::new(io::ErrorKind::NotFound, "HOME not set and XDG_STATE_HOME not set")
    })?;
    let mut p = PathBuf::from(home);
    p.push(".local");
    p.push("state");
    p.push("dirstack");
    Ok(p)
}

fn ensure_state_dir_secure(d: &Path) -> io::Result<()> {
    fs::create_dir_all(d)?;

    // Best-effort permission tightening on Unix.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = fs::set_permissions(d, fs::Permissions::from_mode(0o700));
    }
    Ok(())
}

fn state_path() -> io::Result<PathBuf> {
    let d = state_dir()?;
    ensure_state_dir_secure(&d)?;
    Ok(d.join("window_stacks.json"))
}

fn backup_path(state_file: &Path) -> PathBuf {
    // window_stacks.json.bak
    let mut s = state_file.as_os_str().to_os_string();
    s.push(".bak");
    PathBuf::from(s)
}

fn lock_path() -> io::Result<PathBuf> {
    let d = state_dir()?;
    ensure_state_dir_secure(&d)?;
    Ok(d.join("window_stacks.lock"))
}

fn derive_window_key(explicit: Option<String>) -> String {
    // CLI override wins
    if let Some(k) = explicit {
        if !k.trim().is_empty() {
            return k;
        }
    }

    // Env override next (recommended)
    if let Ok(v) = env::var("DIRSTACK_WINDOW_KEY") {
        let v = v.trim();
        if !v.is_empty() {
            return v.to_string();
        }
    }

    let candidates = [
        "TMUX_PANE",
        "WINDOWID",
        "WEZTERM_PANE",
        "KITTY_WINDOW_ID",
        "TERM_SESSION_ID",
        "SSH_TTY",
        "TTY",
    ];

    for &k in &candidates {
        if let Ok(v) = env::var(k) {
            let v = v.trim();
            if !v.is_empty() {
                return format!("{k}:{v}");
            }
        }
    }

    "default".to_string()
}

fn parse_args() -> Result<(Option<String>, String, Vec<String>), String> {
    let mut args: Vec<OsString> = env::args_os().collect();
    args.remove(0);

    let mut window_override: Option<String> = None;

    let mut i = 0usize;
    while i < args.len() {
        let a = args[i].to_string_lossy().to_string();
        if a == "--window" {
            if i + 1 >= args.len() {
                return Err("missing value for --window".to_string());
            }
            let v = args[i + 1].to_string_lossy().to_string();
            window_override = Some(v);
            args.drain(i..=i + 1);
            continue;
        }
        if a == "-h" || a == "--help" {
            return Err("__HELP__".to_string());
        }
        i += 1;
    }

    if args.is_empty() {
        return Err("__HELP__".to_string());
    }

    let cmd = args[0].to_string_lossy().to_string();
    let rest = args[1..]
        .iter()
        .map(|x| x.to_string_lossy().to_string())
        .collect();
    Ok((window_override, cmd, rest))
}

fn normalize_dir(input: Option<&str>) -> io::Result<String> {
    let p = match input {
        Some(s) => PathBuf::from(s),
        None => env::current_dir()?,
    };

    let canon = fs::canonicalize(&p).map_err(|_| {
        io::Error::new(
            io::ErrorKind::NotFound,
            format!("directory not found: {}", p.display()),
        )
    })?;

    let md = fs::metadata(&canon)?;
    if !md.is_dir() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("not a directory: {}", canon.display()),
        ));
    }

    Ok(canon.to_string_lossy().to_string())
}

fn ensure_window<'a>(st: &'a mut AppState, key: &str) -> &'a mut WindowState {
    st.windows.entry(key.to_string()).or_insert_with(|| WindowState {
        stack: Vec::new(),
        touched_unix: now_unix(),
    })
}

fn push_dir(ws: &mut WindowState, dir: String) {
    ws.stack.retain(|x| x != &dir);
    ws.stack.insert(0, dir);
    if ws.stack.len() > MAX_STACK {
        ws.stack.truncate(MAX_STACK);
    }
    ws.touched_unix = now_unix();
}

fn pop_dir(ws: &mut WindowState) -> Option<String> {
    if ws.stack.is_empty() {
        return None;
    }
    ws.stack.remove(0);
    ws.touched_unix = now_unix();
    ws.stack.get(0).cloned()
}

fn peek_dir(ws: &WindowState) -> Option<String> {
    ws.stack.get(0).cloned()
}

fn list_dirs(ws: &WindowState) {
    for d in &ws.stack {
        println!("{d}");
    }
}

fn clear_dirs(ws: &mut WindowState) {
    ws.stack.clear();
    ws.touched_unix = now_unix();
}

fn gc_old(st: &mut AppState, days: i64) -> usize {
    let cutoff = now_unix() - days.saturating_mul(24 * 3600);
    let before = st.windows.len();
    st.windows.retain(|_, ws| ws.touched_unix >= cutoff);
    before - st.windows.len()
}

fn load_state(path: &Path) -> io::Result<AppState> {
    match File::open(path) {
        Ok(mut f) => {
            let mut s = String::new();
            f.read_to_string(&mut s)?;
            if s.trim().is_empty() {
                return Ok(AppState::default());
            }
            let st: AppState = serde_json::from_str(&s).map_err(|e| {
                io::Error::new(io::ErrorKind::InvalidData, format!("invalid JSON state: {e}"))
            })?;
            if st.version != STATE_VERSION {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    format!("unsupported state version {}", st.version),
                ));
            }
            Ok(st)
        }
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(AppState::default()),
        Err(e) => Err(e),
    }
}

fn is_lock_stale(lp: &Path) -> io::Result<bool> {
    let meta = fs::metadata(lp)?;
    let mtime = meta.modified()?;
    let age = SystemTime::now()
        .duration_since(mtime)
        .unwrap_or(Duration::from_secs(0));
    Ok(age.as_secs() > LOCK_STALE_SECS)
}

struct LockGuard {
    path: PathBuf,
}

impl Drop for LockGuard {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.path);
    }
}

fn try_create_lock(lp: &Path) -> io::Result<LockGuard> {
    #[cfg(unix)]
    let mut f = OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(lp)?;

    #[cfg(not(unix))]
    let mut f = OpenOptions::new().write(true).create_new(true).open(lp)?;

    let pid = std::process::id();
    let ts = now_unix();
    writeln!(f, "pid={pid}\nts={ts}")?;
    Ok(LockGuard { path: lp.to_path_buf() })
}

fn acquire_lock_with_retry() -> io::Result<LockGuard> {
    let lp = lock_path()?;

    let mut delay_ms = LOCK_RETRY_INITIAL_MS;

    for attempt in 0..LOCK_RETRY_MAX_ATTEMPTS {
        match try_create_lock(&lp) {
            Ok(g) => return Ok(g),
            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
                // Conservative stale handling: only if old enough.
                if attempt + 1 == LOCK_RETRY_MAX_ATTEMPTS {
                    // Last attempt: consider stale
                    if is_lock_stale(&lp).unwrap_or(false) {
                        let _ = fs::remove_file(&lp);
                        // one final try after removal
                        return try_create_lock(&lp);
                    }
                    return Err(io::Error::new(
                        io::ErrorKind::WouldBlock,
                        "state is locked by another process",
                    ));
                }

                sleep(Duration::from_millis(delay_ms));
                delay_ms = (delay_ms * 2).min(LOCK_RETRY_MAX_MS);
            }
            Err(e) => return Err(e),
        }
    }

    Err(io::Error::new(
        io::ErrorKind::WouldBlock,
        "state is locked by another process",
    ))
}

fn best_effort_backup(state_file: &Path) {
    let bak = backup_path(state_file);
    if state_file.exists() {
        // Copy to .bak; ignore failures (best effort).
        let _ = fs::copy(state_file, bak);
    }
}

#[cfg(unix)]
fn fsync_dir(dir: &Path) -> io::Result<()> {
    // Open directory and sync it to ensure rename durability.
    let f = File::open(dir)?;
    f.sync_all()
}

#[cfg(not(unix))]
fn fsync_dir(_dir: &Path) -> io::Result<()> {
    // No-op on non-Unix in this hardened baseline.
    Ok(())
}

fn atomic_write_durable(state_file: &Path, bytes: &[u8]) -> io::Result<()> {
    let dir = state_file
        .parent()
        .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no parent dir"))?;

    // Best-effort backup of current state
    best_effort_backup(state_file);

    // Temp file in same directory (required for atomic rename).
    let tmp_name = format!(".window_stacks.json.tmp.{}", now_unix());
    let tmp_path = dir.join(tmp_name);

    #[cfg(unix)]
    let mut f = OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&tmp_path)?;

    #[cfg(not(unix))]
    let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp_path)?;

    f.write_all(bytes)?;
    f.sync_all()?; // fsync temp file contents

    // Atomic replace (Unix). If Windows rename semantics bite, this may need a
    // platform-specific replace strategy.
    fs::rename(&tmp_path, state_file)?;

    // Ensure directory entry is durable (Unix)
    fsync_dir(dir)?;

    Ok(())
}

fn main() {
    if let Err(e) = real_main() {
        if e == "__HELP__" {
            print!("{}", usage());
            std::process::exit(0);
        }
        eprintln_err(&e);
        std::process::exit(2);
    }
}

fn real_main() -> Result<(), String> {
    let (window_override, cmd, rest) = parse_args()?;
    let window_key = derive_window_key(window_override);

    let sp = state_path().map_err(|e| e.to_string())?;
    let _lock = acquire_lock_with_retry().map_err(|e| {
        if e.kind() == io::ErrorKind::WouldBlock {
            "could not acquire lock (another process is using the state)".to_string()
        } else {
            format!("lock error: {e}")
        }
    })?;

    let mut st = load_state(&sp).map_err(|e| e.to_string())?;

    match cmd.as_str() {
        "push" => {
            let dir = if rest.is_empty() {
                normalize_dir(None).map_err(|e| e.to_string())?
            } else if rest.len() == 1 {
                normalize_dir(Some(&rest[0])).map_err(|e| e.to_string())?
            } else {
                return Err("push takes at most one argument".to_string());
            };

            let ws = ensure_window(&mut st, &window_key);
            push_dir(ws, dir);
            let top = peek_dir(ws).unwrap_or_default();

            let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
            atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;

            if !top.is_empty() {
                println!("{top}");
            }
        }
        "pop" => {
            let ws = ensure_window(&mut st, &window_key);
            let new_top = pop_dir(ws);

            let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
            atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;

            if let Some(t) = new_top {
                println!("{t}");
            }
        }
        "peek" => {
            let ws = ensure_window(&mut st, &window_key);
            if let Some(t) = peek_dir(ws) {
                println!("{t}");
            }
        }
        "list" => {
            let ws = ensure_window(&mut st, &window_key);
            list_dirs(ws);
        }
        "clear" => {
            let ws = ensure_window(&mut st, &window_key);
            clear_dirs(ws);

            let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
            atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;
        }
        "gc" => {
            let mut days = DEFAULT_GC_DAYS;
            if !rest.is_empty() {
                if rest.len() == 2 && rest[0] == "--days" {
                    days = rest[1]
                        .parse::<i64>()
                        .map_err(|_| "invalid --days value".to_string())?;
                    if days < 1 {
                        return Err("--days must be >= 1".to_string());
                    }
                } else {
                    return Err("gc syntax: gc [--days N]".to_string());
                }
            }

            let removed = gc_old(&mut st, days);
            let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
            atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;
            eprintln!("dirstack: gc removed {removed} window stacks older than {days} day(s)");
        }
        _ => return Err("__HELP__".to_string()),
    }

    Ok(())
}
userland@localhost:~/dirstack$ cduserland@localhost:~$ nano histwin_setup.sh
userland@localhost:~$ chmod u+x histwin_setup.sh
userland@localhost:~$ ./histwin_setup.sh
==> Creating directories
==> Writing Cargo.toml (idempotent)
==> Writing src/main.rs (idempotent)
==> Writing default config to /home/userland/.config/histwin/windows.toml
==> Building (cargo build --release)
    Updating crates.io index
     Locking 17 packages to latest compatible versions
      Adding toml v0.8.23 (available: v1.0.1+spec-1.1.0)
  Downloaded toml_write v0.1.2
  Downloaded toml_datetime v0.6.11
  Downloaded toml v0.8.23
  Downloaded serde_spanned v0.6.9
  Downloaded toml_edit v0.22.27
  Downloaded winnow v0.7.14
  Downloaded 6 crates (333.4KiB) in 1.90s
   Compiling proc-macro2 v1.0.106
   Compiling quote v1.0.44
   Compiling unicode-ident v1.0.23
   Compiling serde_core v1.0.228
   Compiling serde v1.0.228
   Compiling hashbrown v0.16.1
   Compiling equivalent v1.0.2
   Compiling toml_write v0.1.2
   Compiling indexmap v2.13.0
   Compiling winnow v0.7.14
   Compiling syn v2.0.115
   Compiling serde_derive v1.0.228
   Compiling toml_datetime v0.6.11
   Compiling serde_spanned v0.6.9
   Compiling toml_edit v0.22.27
   Compiling toml v0.8.23
   Compiling histwin v1.0.0 (/home/userland/histwin)
    Finished `release` profile [optimized] target(s) in 28.83s
==> Running: /home/userland/histwin/target/release/histwin
==> Done.
==> Histories will appear in: /home/userland/histfiles
userland@localhost:~$ cat ./histwin_setup.sh
#!/usr/bin/env bash
# ==============================================================================
# histwin_setup.sh — Idempotent setup + build + run for the hardened Rust
# per-window HISTFILE launcher (TOML-based).
#
# Creates:
#   ~/histwin/            (Cargo project)
#   ~/.config/histwin/    (config)
#   ~/histfiles/          (history files directory)
#
# Then builds and runs:
#   ~/histwin/target/release/histwin
#
# Requirements:
#   - Linux/Unix-ish system
#   - rust + cargo installed (rustc, cargo in PATH)
#   - xterm installed (or change config to your terminal)
# ==============================================================================

set -euo pipefail

say() { printf '%s\n' "==> $*"; }
die() { printf '%s\n' "ERROR: $*" >&2; exit 1; }

need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; }

need_cmd cargo
need_cmd rustc

HOME_DIR="${HOME:?HOME not set}"

PROJ_DIR="$HOME_DIR/histwin"
SRC_DIR="$PROJ_DIR/src"
CFG_DIR="$HOME_DIR/.config/histwin"
CFG_FILE="$CFG_DIR/windows.toml"
HIST_DIR="$HOME_DIR/histfiles"

say "Creating directories"
mkdir -p "$SRC_DIR" "$CFG_DIR" "$HIST_DIR"

say "Writing Cargo.toml (idempotent)"
cat > "$PROJ_DIR/Cargo.toml" <<'TOML'
[package]
name = "histwin"
version = "1.0.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"
TOML

say "Writing src/main.rs (idempotent)"
cat > "$SRC_DIR/main.rs" <<'RS'
/*
===============================================================================
HISTWIN — Hardened Per-Window Bash History Launcher

Implements slide concept:
- Set HISTFILE when launching terminal (per process).
- Use named windows like .xinitrc example.

SECURITY PROPERTIES
- No shell evaluation
- Strict name validation
- No path traversal
- HISTFILE created 0600 (Unix)
- No terminal flag injection (args fully provided in config)
===============================================================================
*/

use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};

#[derive(Deserialize)]
struct Config {
    hist_dir: String,
    window: Vec<Window>,
}

#[derive(Deserialize)]
struct Window {
    name: String,
    program: String,
    args: Vec<String>,
}

fn main() {
    let config_path = default_config_path();
    let config_text = fs::read_to_string(&config_path)
        .unwrap_or_else(|e| panic!("Cannot read config {}: {}", config_path.display(), e));

    let config: Config =
        toml::from_str(&config_text).unwrap_or_else(|e| panic!("Invalid TOML: {}", e));

    let hist_dir = expand_tilde(&config.hist_dir);
    fs::create_dir_all(&hist_dir)
        .unwrap_or_else(|e| panic!("Cannot create history directory {}: {}", hist_dir.display(), e));

    for win in config.window {
        launch(&hist_dir, win);
    }
}

fn default_config_path() -> PathBuf {
    let home = env::var("HOME").expect("HOME not set");
    PathBuf::from(home).join(".config/histwin/windows.toml")
}

fn expand_tilde(path: &str) -> PathBuf {
    if path == "~" {
        return PathBuf::from(env::var("HOME").expect("HOME not set"));
    }
    if let Some(rest) = path.strip_prefix("~/") {
        return PathBuf::from(env::var("HOME").expect("HOME not set")).join(rest);
    }
    PathBuf::from(path)
}

fn validate_name(name: &str) {
    if name.is_empty()
        || !name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
    {
        panic!(
            "Unsafe window name '{}'. Allowed: A-Z a-z 0-9 - _ .",
            name
        );
    }
}

fn safe_histfile(hist_dir: &Path, name: &str) -> PathBuf {
    validate_name(name);

    let rel = PathBuf::from(name);
    if rel.is_absolute() {
        panic!("Refusing absolute history path");
    }
    for comp in rel.components() {
        match comp {
            Component::Normal(_) => {}
            _ => panic!("Path traversal attempt in history path"),
        }
    }

    hist_dir.join(rel)
}

fn launch(hist_dir: &Path, win: Window) {
    let histfile = safe_histfile(hist_dir, &win.name);

    if let Some(parent) = histfile.parent() {
        fs::create_dir_all(parent).unwrap_or_else(|e| {
            panic!("Cannot create history parent {}: {}", parent.display(), e)
        });
    }

    if !histfile.exists() {
        fs::write(&histfile, b"").unwrap_or_else(|e| {
            panic!("Cannot create HISTFILE {}: {}", histfile.display(), e)
        });
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&histfile, fs::Permissions::from_mode(0o600)).unwrap_or_else(|e| {
            panic!("Cannot set permissions on {}: {}", histfile.display(), e)
        });
    }

    let mut cmd = Command::new(&win.program);
    cmd.args(&win.args);
    cmd.env("HISTFILE", &histfile);

    // quiet + detached
    cmd.stdin(Stdio::null());
    cmd.stdout(Stdio::null());
    cmd.stderr(Stdio::null());

    cmd.spawn().unwrap_or_else(|e| {
        panic!(
            "Failed to spawn terminal '{}' for window '{}': {}",
            win.program, win.name, e
        )
    });
}
RS

if [[ ! -f "$CFG_FILE" ]]; then
  say "Writing default config to $CFG_FILE"
  cat > "$CFG_FILE" <<'TOML'
# histwin config (TOML)
# Each window launches a terminal with its own HISTFILE at: hist_dir/<name>

hist_dir = "~/histfiles"

[[window]]
name = "win0"
program = "xterm"
args = ["-geometry", "88x46+0+0", "-e", "bash"]

[[window]]
name = "win1"
program = "xterm"
args = ["-geometry", "88x46-0+0", "-e", "bash"]

[[window]]
name = "debug"
program = "xterm"
args = [
  "-geometry", "142x41+1024+1536",
  "-title", "/var/log/debug",
  "-e", "sh", "-c", "tail --follow=name /var/log/debug"
]
TOML
else
  say "Config already exists at $CFG_FILE (leaving it unchanged)"
fi

say "Building (cargo build --release)"
cd "$PROJ_DIR"
cargo build --release

BIN="$PROJ_DIR/target/release/histwin"
[[ -x "$BIN" ]] || die "Build did not produce executable: $BIN"

say "Running: $BIN"
# This will open windows immediately (xterm). If xterm isn't installed, update config.
"$BIN"

say "Done."
say "Histories will appear in: $HIST_DIR"

Ian Ajzenszmidt

unread,
Feb 14, 2026, 11:36:09 AM (9 days ago) Feb 14
to mlu...@googlegroups.com
Assisted by Chatgpt generative artificial intelligence large language model.

Use it

chmod +x histwin_setup.sh
./histwin_setup.sh

If you don’t have xterm, edit ~/.config/histwin/windows.toml and change program/args to your terminal’s command line (keeping -e bash or equivalent in the args).

Duncan Roe

unread,
Feb 14, 2026, 8:05:30 PM (9 days ago) Feb 14
to mlu...@googlegroups.com
Hi Ian,

Thanks indeed for your 3 emails! I've put it on my todo list to try them, but
the list is rather long so may be after next meeting when I do.

dirstack appears to be a somewhat different concept from the system I'll be
showing at the next meeting. Spoiler alert: my system saves and restores bash's
builtin directory stack. Hope you can attend, look forward to hearing your
comments.

Cheers ... Duncan.
> >> <https://groups.google.com/forum/?utm_source=digest&utm_medium=email#!forum/mlug-au/topics> [image:
> >> Google Groups Logo]
> >> <https://groups.google.com/forum/?utm_source=digest&utm_medium=email/#!overview> Google
> >> Groups
> >> <https://groups.google.com/forum/?utm_source=digest&utm_medium=email/#!overview>
> >> Topic digest
> >> View all topics
> >> <https://groups.google.com/forum/?utm_source=digest&utm_medium=email#!forum/mlug-au/topics>
> >>
> >> - REMINDER: Next Meeting: 23rd February 2026
> >> <#m_-8503731956152296397_m_-3929100067532264723_group_thread_0> - 1
> >> Update
> >>
> >> REMINDER: Next Meeting: 23rd February 2026
> >> <http://groups.google.com/group/mlug-au/t/fd90b02a45ea44c0?utm_source=digest&utm_medium=email>
> >> Duncan Roe <dunca...@optusnet.com.au>: Feb 14 09:12AM +1100
> >>
> >> Hi Everyone,
> >>
> >> Next meeting is in 9 days time, on the Monday after next.
> >>
> >> Recently, I've been developing a system to save per-window directory
> >> stacks. I could show that if there's any interest (would be a short
> >> talk). (After developing per-window bash command histories as per
> >> https://mlug-au.org/lib/exe/fetch.php?media=20250224-bash_histories.pdf
> >> I still got frustrated about losing my cd history. Saving the directory
> >> stack addresses this).
> >>
> >> So we know when everyone who's coming has got there, will anyone else
> >> intending to come please put their name on the attendance list.
> >>
> >> See you all a week Monday,
> >>
> >> Cheers ... Duncan.
> >>
> >> On Sun, Feb 01, 2026 at 11:53:18AM +1100, mlug wrote:
> >> Back to top <#m_-8503731956152296397_m_-3929100067532264723_digest_top>
> >> You received this digest because you're subscribed to updates for this
> >> group. You can change your settings on the group membership page
> >> <https://groups.google.com/forum/?utm_source=digest&utm_medium=email#!forum/mlug-au/join>
> >> .
> >> To unsubscribe from this group and stop receiving emails from it send an
> >> email to mlug-au+u...@googlegroups.com.
> >>
> >
>
> --
> You received this message because you are subscribed to the Google Groups "mlug-au" group.
> To unsubscribe from this group and stop receiving emails from it, send an email to mlug-au+u...@googlegroups.com.
> To view this discussion visit https://groups.google.com/d/msgid/mlug-au/CAO%2BCdqR7hCtUj9T27og8yDHbMiTbtw5P9-uN8JVa1vMEyz1WTQ%40mail.gmail.com.
Reply all
Reply to author
Forward
0 new messages