Skip to content

chmod Calculator — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! chmod-calculator — POSIX permission mode converter (octal <-> symbolic).
//!
//! Language: Zig 0.13 — standard library only (std).
//! 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; failable public API returns ?T (optional, not an
//!     error union — invalid input is an expected outcome, not exceptional).
//!   - Functionally equivalent to the TS/Go references: same inputs -> same outputs.
//!   - Self-contained: std only.
//!
//! 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).

const std = @import("std");

/// 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.
const Pos = enum { owner, group, other };

/// Full chmod breakdown — the Zig mirror of the TS `ChmodResult` / Go `Result`.
pub const ChmodResult = struct {
    /// 4-digit zero-padded octal ASCII, e.g. "0755".
    octal: [4]u8,
    /// 9-char rwxrwxrwx with special markers, e.g. "rwsr-xr-x".
    symbolic: [9]u8,
    /// Raw integer mode (0-4095).
    decimal: u16,
    setuid: bool,
    setgid: bool,
    sticky: bool,
};

/// ASCII whitespace trimmed from both ends — mirrors .strip() / .trim().
const WS = " \t\r\n\x0b\x0c";

/// 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 null if any byte is invalid.
fn parseTriplet(tri: []const u8, pos: Pos) ?struct { digit: u4, special: u4 } {
    if (tri.len != 3) return null;
    var digit: u4 = 0;
    switch (tri[0]) {
        'r' => digit |= 4,
        '-' => {},
        else => return null,
    }
    switch (tri[1]) {
        'w' => digit |= 2,
        '-' => {},
        else => return null,
    }
    var special: u4 = 0;
    switch (tri[2]) {
        'x' => digit |= 1,
        '-' => {},
        // s/S (exec present/absent) — setuid in owner, setgid in group.
        's', 'S' => switch (pos) {
            .owner, .group => {
                if (tri[2] == 's') digit |= 1;
                special = if (pos == .owner) 4 else 2;
            },
            .other => return null,
        },
        // t/T (exec present/absent) — sticky, only legal in other.
        't', 'T' => switch (pos) {
            .other => {
                if (tri[2] == 't') digit |= 1;
                special = 1;
            },
            .owner, .group => return null,
        },
        else => return null,
    }
    return .{ .digit = digit, .special = 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'.
fn formatTriplet(digit: u4, has_special: bool, marker: u8) [3]u8 {
    var out: [3]u8 = undefined;
    out[0] = if (digit & 4 != 0) 'r' else '-';
    out[1] = if (digit & 2 != 0) 'w' else '-';
    const exec = digit & 1 != 0;
    out[2] = if (has_special and exec)
        marker
    else if (has_special)
        marker - ('a' - 'A') // 's' -> 'S', 't' -> 'T'
    else if (exec)
        'x'
    else
        '-';
    return out;
}

/// Parse symbolic notation ("rwxr-xr-x") into a raw mode integer, or null.
pub fn symbolicToMode(sym: []const u8) ?u16 {
    const s = std.mem.trim(u8, sym, WS);
    if (s.len != 9) return null;
    const o = parseTriplet(s[0..3], .owner) orelse return null;
    const g = parseTriplet(s[3..6], .group) orelse return null;
    const ot = parseTriplet(s[6..9], .other) orelse return null;
    const special: u16 = o.special | g.special | ot.special;
    return special * 0o1000 + (@as(u16, o.digit) << 6) + (@as(u16, g.digit) << 3) + ot.digit;
}

/// Parse a 3-4 digit octal string ("755" / "4755") into a raw mode, or null.
pub fn octalToMode(octal: []const u8) ?u16 {
    const s = std.mem.trim(u8, octal, WS);
    if (s.len != 3 and s.len != 4) return null;
    var value: u16 = 0;
    for (s) |c| {
        if (c < '0' or c > '7') return null;
        value = value * 8 + (c - '0');
    }
    return value;
}

/// Render a raw mode as 9-char symbolic notation.
pub fn modeToSymbolic(mode: u16) [9]u8 {
    const special = (mode >> 9) & 7;
    var out: [9]u8 = undefined;
    out[0..3].* = formatTriplet(@intCast((mode >> 6) & 7), special & 4 != 0, 's');
    out[3..6].* = formatTriplet(@intCast((mode >> 3) & 7), special & 2 != 0, 's');
    out[6..9].* = formatTriplet(@intCast(mode & 7), special & 1 != 0, 't');
    return out;
}

/// Render a raw mode as a 4-digit zero-padded octal ASCII string.
pub fn modeToOctal(mode: u16) [4]u8 {
    const value = mode & 0o7777;
    var out: [4]u8 = undefined;
    const shifts = [4]u4{ 9, 6, 3, 0 };
    for (shifts, 0..) |shift, i| {
        out[i] = @as(u8, @intCast((value >> shift) & 7)) + '0';
    }
    return out;
}

fn buildResult(mode: u16) ChmodResult {
    const special = (mode >> 9) & 7;
    return .{
        .octal = modeToOctal(mode),
        .symbolic = modeToSymbolic(mode),
        .decimal = mode & 0o7777,
        .setuid = special & 4 != 0,
        .setgid = special & 2 != 0,
        .sticky = special & 1 != 0,
    };
}

/// Build a full result from symbolic notation, or null if invalid.
pub fn fromSymbolic(sym: []const u8) ?ChmodResult {
    return buildResult(symbolicToMode(sym) orelse return null);
}

/// Build a full result from an octal string, or null if invalid.
pub fn fromOctal(octal: []const u8) ?ChmodResult {
    return buildResult(octalToMode(octal) orelse return null);
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
test "octal to symbolic basic" {
    const r = fromOctal("755").?;
    try std.testing.expectEqualStrings("0755", &r.octal);
    try std.testing.expectEqualStrings("rwxr-xr-x", &r.symbolic);
    try std.testing.expectEqual(@as(u16, 0o755), r.decimal);
    try std.testing.expect(!r.setuid and !r.setgid and !r.sticky);
}

test "symbolic to octal basic" {
    try std.testing.expectEqualStrings("0755", &fromSymbolic("rwxr-xr-x").?.octal);
}

test "setuid with exec" {
    // 4755: setuid (4xxx) over rwxr-xr-x -> exec slot becomes 's'.
    const r = fromOctal("4755").?;
    try std.testing.expectEqualStrings("rwsr-xr-x", &r.symbolic);
    try std.testing.expectEqual(@as(u16, 0o4755), r.decimal);
    try std.testing.expect(r.setuid);
    try std.testing.expect(!r.sticky);
}

test "sticky without exec" {
    // 1644: sticky (1xxx) over rw-r--r--, no exec -> marker upper-cases to 'T'.
    const r = fromOctal("1644").?;
    try std.testing.expectEqualStrings("rw-r--r-T", &r.symbolic);
    try std.testing.expectEqual(@as(u16, 0o1644), r.decimal);
    try std.testing.expect(r.sticky);
    try std.testing.expect(!r.setuid);
}

test "rejects invalid input and zero" {
    try std.testing.expect(octalToMode("999") == null); // '9' is not an octal digit
    try std.testing.expect(symbolicToMode("rwx") == null); // wrong length
    // round-trip of zero
    try std.testing.expectEqualStrings("---------", &fromOctal("0000").?.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 →