Skip to content

chmod Calculator — Rust source

Compute Unix file permissions between octal (e.g. 755), symbolic (rwxr-xr-x), and decimal - including setuid, setgid, and sticky bits. Toggle permissions interactively, fully client-side, with a shareable link.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! chmod-calculator — POSIX permission mode converter (octal <-> symbolic).
//!
//! Language: Rust (edition 2021, standard library only)
//! Source:   CosmoDev polyglot showcase port of the Chmod Calculator tool,
//!           ported from src/lib/chmod.ts (the canonical TypeScript lib) and
//!           held in lock-step with cli/chmod-calculator/chmod-calculator.go.
//! License:  display source — part of CosmoDev's polyglot tool pages.
//!
//! Design goals:
//!   - Pure + deterministic; never panics (failable public API returns Option).
//!   - Functionally equivalent to the TS/Go references: same inputs -> same outputs.
//!   - Self-contained: std only (no crates.io dependencies).
//!
//! Converts between 3-4 digit octal ("755" / "4755"), 9-char symbolic
//! ("rwxr-xr-x"), and the raw decimal mode, including the setuid / setgid /
//! sticky special bits (the s/S and t/T markers in the exec slot).

/// Which permission class a triplet describes — governs which special-bit
/// marker (s/S for owner+group, t/T for other) is legal in its exec slot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pos {
    Owner,
    Group,
    Other,
}

/// Full chmod breakdown — the Rust mirror of the TS `ChmodResult` / Go `Result`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChmodResult {
    /// 4-digit zero-padded octal, e.g. "0755".
    pub octal: String,
    /// 9-char `rwxrwxrwx` with special markers, e.g. "rwsr-xr-x".
    pub symbolic: String,
    /// Raw integer mode (0-4095).
    pub decimal: i32,
    pub setuid: bool,
    pub setgid: bool,
    pub sticky: bool,
}

/// Parse a 3-char rwx triplet at `pos`. The exec slot may carry a special-bit
/// marker: s/S (setuid in owner, setgid in group) or t/T (sticky in other).
/// Returns `(digit, special)` on success, or `None` if any byte is invalid.
///
/// Operates on raw bytes so a stray multibyte code point can never panic the
/// indexing (byte slicing a `&[u8]` after a length check is always in-bounds).
fn parse_triplet(tri: &[u8], pos: Pos) -> Option<(i32, i32)> {
    if tri.len() != 3 {
        return None;
    }
    let mut digit = 0;
    match tri[0] {
        b'r' => digit |= 4,
        b'-' => {}
        _ => return None,
    }
    match tri[1] {
        b'w' => digit |= 2,
        b'-' => {}
        _ => return None,
    }
    let mut special = 0;
    match tri[2] {
        b'x' => digit |= 1,
        b'-' => {}
        b's' | b'S' if pos == Pos::Owner || pos == Pos::Group => {
            if tri[2] == b's' {
                digit |= 1;
            }
            special = if pos == Pos::Owner { 4 } else { 2 };
        }
        b't' | b'T' if pos == Pos::Other => {
            if tri[2] == b't' {
                digit |= 1;
            }
            special = 1;
        }
        _ => return None,
    }
    Some((digit, special))
}

/// Render a 0-7 digit + optional special bit as a 3-char triplet. `marker` is
/// 's' (owner/group) or 't' (other); upper-cased when the exec bit is absent —
/// yielding 'S' / 'T'. Builds the string from `char`s so it can never panic.
fn format_triplet(digit: i32, has_special: bool, marker: char) -> String {
    let mut out = String::with_capacity(3);
    out.push(if digit & 4 != 0 { 'r' } else { '-' });
    out.push(if digit & 2 != 0 { 'w' } else { '-' });
    let exec = digit & 1 != 0;
    let third = if has_special && exec {
        marker
    } else if has_special {
        marker.to_ascii_uppercase()
    } else if exec {
        'x'
    } else {
        '-'
    };
    out.push(third);
    out
}

/// Parse symbolic notation ("rwxr-xr-x") into a raw mode integer, or `None`.
pub fn symbolic_to_mode(sym: &str) -> Option<i32> {
    let b = sym.trim().as_bytes();
    if b.len() != 9 {
        return None;
    }
    let (od, os_) = parse_triplet(&b[0..3], Pos::Owner)?;
    let (gd, gs) = parse_triplet(&b[3..6], Pos::Group)?;
    let (otd, ots) = parse_triplet(&b[6..9], Pos::Other)?;
    let special = os_ | gs | ots;
    Some(special * 0o1000 + (od << 6) + (gd << 3) + otd)
}

/// Parse a 3-4 digit octal string ("755" / "4755") into a raw mode, or `None`.
pub fn octal_to_mode(octal: &str) -> Option<i32> {
    let s = octal.trim();
    if s.len() != 3 && s.len() != 4 {
        return None;
    }
    if !s.bytes().all(|c| (b'0'..=b'7').contains(&c)) {
        return None;
    }
    i32::from_str_radix(s, 8).ok()
}

/// Render a raw mode as 9-char symbolic notation.
pub fn mode_to_symbolic(mode: i32) -> String {
    let special = (mode >> 9) & 7;
    format_triplet((mode >> 6) & 7, special & 4 != 0, 's')
        + &format_triplet((mode >> 3) & 7, special & 2 != 0, 's')
        + &format_triplet(mode & 7, special & 1 != 0, 't')
}

/// Render a raw mode as a 4-digit zero-padded octal string.
pub fn mode_to_octal(mode: i32) -> String {
    format!("{:04o}", mode & 0o7777)
}

fn build_result(mode: i32) -> ChmodResult {
    let special = (mode >> 9) & 7;
    ChmodResult {
        octal: mode_to_octal(mode),
        symbolic: mode_to_symbolic(mode),
        decimal: mode & 0o7777,
        setuid: special & 4 != 0,
        setgid: special & 2 != 0,
        sticky: special & 1 != 0,
    }
}

/// Build a full result from symbolic notation, or `None` if invalid.
pub fn from_symbolic(sym: &str) -> Option<ChmodResult> {
    symbolic_to_mode(sym).map(build_result)
}

/// Build a full result from an octal string, or `None` if invalid.
pub fn from_octal(octal: &str) -> Option<ChmodResult> {
    octal_to_mode(octal).map(build_result)
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn octal_to_symbolic_basic() {
        let r = from_octal("755").unwrap();
        assert_eq!(r.octal, "0755");
        assert_eq!(r.symbolic, "rwxr-xr-x");
        assert_eq!(r.decimal, 0o755);
        assert!(!r.setuid && !r.setgid && !r.sticky);
    }

    #[test]
    fn symbolic_to_octal_basic() {
        assert_eq!(from_symbolic("rwxr-xr-x").unwrap().octal, "0755");
    }

    #[test]
    fn setuid_with_exec() {
        // 4755: setuid (4xxx) over rwxr-xr-x -> exec slot becomes 's'.
        let r = from_octal("4755").unwrap();
        assert_eq!(r.symbolic, "rwsr-xr-x");
        assert_eq!(r.decimal, 0o4755);
        assert!(r.setuid);
        assert!(!r.sticky);
    }

    #[test]
    fn sticky_without_exec() {
        // 1644: sticky (1xxx) over rw-r--r--, no exec -> marker upper-cases to 'T'.
        let r = from_octal("1644").unwrap();
        assert_eq!(r.symbolic, "rw-r--r-T");
        assert_eq!(r.decimal, 0o1644);
        assert!(r.sticky);
        assert!(!r.setuid);
    }

    #[test]
    fn rejects_invalid_input_and_zero() {
        assert!(octal_to_mode("999").is_none()); // '9' is not an octal digit
        assert!(symbolic_to_mode("rwx").is_none()); // wrong length
        // round-trip of zero
        assert_eq!(from_octal("0000").unwrap().symbolic, "---------");
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →