DEV Community

Dev Nestio
Dev Nestio

Posted on

I Built a Visual Chmod Calculator in Pure Vanilla JS — Checkboxes ↔ Numeric ↔ Symbolic, 222 Tests

Every Linux and macOS developer has typed chmod 755 on autopilot — but what does 7 actually mean? Why is 644 the default for files? And what happens to the execute bit when you set the setuid flag?

Most chmod reference pages list a table. I wanted something interactive: flip a checkbox, see the octal update. Type rwsr-xr-x, watch the checkboxes respond. So I built one — in the browser, no server, no framework.

👉 https://chmod-calculator.pages.dev

What It Does

  • Checkboxes for Owner / Group / Others × Read / Write / Execute — check one, everything updates instantly
  • Numeric input (e.g. 755) — type any valid octal, the UI reflects it
  • Symbolic input (e.g. rwxr-xr-x) — edit the string, checkboxes follow
  • Octal display (e.g. 0755) with the full 4-digit form
  • Binary display (e.g. 111 101 101) — see exactly which bits are set
  • Special bits — Setuid (4000), Setgid (2000), Sticky (1000) as labeled cards with descriptions
  • Presets — one click for 644, 755, 777, 600, 700, 400, 4755, 1777
  • chmod command generator with an editable filename field and a copy button
  • Zero external dependencies — single HTML file, works offline

The Core Logic

Unix permissions are a 12-bit integer. The lower 9 bits are the familiar rwx groups. Bits 9–11 are the special flags.

Bit layout (octal prefix → lower 9 bits):
  bit 11 (0x800) = setuid
  bit 10 (0x400) = setgid
  bit  9 (0x200) = sticky
  bits 8-6       = owner  r/w/x  (0x100, 0x80, 0x40)
  bits 5-3       = group  r/w/x  (0x20,  0x10, 0x08)
  bits 2-0       = others r/w/x  (0x04,  0x02, 0x01)
Enter fullscreen mode Exit fullscreen mode

Numeric → Symbolic

function modeToSymbolic(mode) {
  const bits = [
    [0x100, 'r'], [0x80, 'w'], [0x40, 'x'],
    [0x20,  'r'], [0x10, 'w'], [0x08, 'x'],
    [0x04,  'r'], [0x02, 'w'], [0x01, 'x']
  ];
  let sym = '';
  for (let i = 0; i < 9; i++) {
    sym += (mode & bits[i][0]) ? bits[i][1] : '-';
  }
  const setuid = !!(mode & 0x800);
  const setgid = !!(mode & 0x400);
  const sticky = !!(mode & 0x200);

  // Special bits replace the execute position
  if (setuid) sym = sym.slice(0, 2) + ((mode & 0x40) ? 's' : 'S') + sym.slice(3);
  if (setgid) sym = sym.slice(0, 5) + ((mode & 0x08) ? 's' : 'S') + sym.slice(6);
  if (sticky) sym = sym.slice(0, 8) + ((mode & 0x01) ? 't' : 'T');

  return sym;
}
Enter fullscreen mode Exit fullscreen mode

The special bit substitution follows the POSIX convention: lowercase s/t when the execute bit is also set, uppercase S/T when it is not. This lets ls -l pack two pieces of information into one character.

Symbolic → Numeric

function symbolicToMode(sym) {
  if (!/^[rwxsStT-]{9}$/.test(sym)) return null;

  const values = [0x100, 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01];
  let mode = 0;
  for (let i = 0; i < 9; i++) {
    const c = sym[i];
    if (c === 'r' || c === 'w' || c === 'x') {
      mode |= values[i];
    } else if (c === 's') {
      mode |= values[i];                         // execute bit
      if (i === 2) mode |= 0x800;               // setuid
      else if (i === 5) mode |= 0x400;          // setgid
    } else if (c === 'S') {
      if (i === 2) mode |= 0x800;               // setuid without execute
      else if (i === 5) mode |= 0x400;
    } else if (c === 't') {
      mode |= values[i];                         // others execute
      mode |= 0x200;                             // sticky
    } else if (c === 'T') {
      mode |= 0x200;                             // sticky without execute
    }
  }
  return mode;
}
Enter fullscreen mode Exit fullscreen mode

Position matters: s at index 2 is setuid, s at index 5 is setgid. The regex guard [rwxsStT-]{9} rejects anything that can't be a valid symbolic string before any bit-twiddling begins.

Why s vs S — and t vs T

chmod 4755 /usr/bin/sudo   →  rwsr-xr-x   (setuid, owner exec set)
chmod 4644 myfile          →  rwSr--r--   (setuid, owner exec NOT set)
chmod 1777 /tmp            →  rwxrwxrwt   (sticky, others exec set)
chmod 1776 /tmp            →  rwxrwxrwT   (sticky, others exec NOT set)
Enter fullscreen mode Exit fullscreen mode

The uppercase form is a warning: the special bit is set but has no execute bit to "attach" to — which is unusual and often unintentional.

Octal formatting

function modeToOctal(mode) {
  const special = (mode >> 9) & 7;
  const owner   = (mode >> 6) & 7;
  const group   = (mode >> 3) & 7;
  const other   = mode & 7;
  // Prefix 0 when no special bits, otherwise lead with special digit
  return special ? `${special}${owner}${group}${other}` : `0${owner}${group}${other}`;
}
Enter fullscreen mode Exit fullscreen mode

0644 and 4755 both have 4 characters. The leading 0 for a plain mode signals "no special bits" without adding a fifth digit.

Binary representation

function modeToBinary(mode) {
  const owner = ((mode >> 6) & 7).toString(2).padStart(3, '0');
  const group = ((mode >> 3) & 7).toString(2).padStart(3, '0');
  const other = (mode & 7).toString(2).padStart(3, '0');
  return `${owner} ${group} ${other}`;
}
// 755 → "111 101 101"
// 644 → "110 100 100"
Enter fullscreen mode Exit fullscreen mode

Three groups of three bits, each padded to width 3. This makes the r/w/x mapping visually obvious — a 1 in position 0 of each group means read, position 1 means write, position 2 means execute.

Keeping All Representations in Sync

The state is a single integer currentMode. Every input (checkbox, numeric field, symbolic field, preset button) calls updateFromMode(mode) after computing the new mode value. That function pushes the new state to every output element — no separate reconciliation loop needed.

let currentMode = 0o644;

function updateFromMode(mode) {
  currentMode = mode;
  // Update all 12 checkboxes
  document.getElementById('ur').checked = !!(mode & 0x100);
  // ... and the rest
  // Update numeric input
  document.getElementById('numInput').value = modeToOctal(mode).replace(/^0/, '');
  // Update symbolic input
  document.getElementById('symInput').value = modeToSymbolic(mode);
  // Update display cards and chmod command
}
Enter fullscreen mode Exit fullscreen mode

A suppressUpdate flag prevents re-entrant updates when the function itself sets input values (which would otherwise trigger their input event handlers).

Common Permissions Reference

Mode Symbolic Typical use
644 rw-r--r-- Regular files (web content, configs)
755 rwxr-xr-x Directories, executables
600 rw------- Private keys, .env files
700 rwx------ Private directories
400 r-------- Read-only secrets
4755 rwsr-xr-x Setuid executables (sudo, ping)
1777 rwxrwxrwt Sticky directories (/tmp)

Testing Without a Framework

222 tests, no test runner, no dependencies — just Node.js built-ins:

let passed = 0, failed = 0;

function eq(a, b, label) {
  if (a === b) { console.log(`  ✓ ${label}`); passed++; }
  else {
    console.error(`  ✗ ${label}\n    got:      ${JSON.stringify(a)}\n    expected: ${JSON.stringify(b)}`);
    failed++;
  }
}

// Round-trip test: mode → symbolic → mode
function roundTrip(mode, label) {
  const sym = modeToSymbolic(mode);
  const back = symbolicToMode(sym);
  eq(back, mode, `round-trip ${label}`);
}

roundTrip(0o755, '755');
roundTrip(0o4755, '4755 setuid');
roundTrip(0o1777, '1777 sticky');
// ... 18 round-trips total
Enter fullscreen mode Exit fullscreen mode

The round-trip tests (symbolic → numeric → symbolic → numeric) catch any asymmetry between the encode and decode paths. If they both have the same bug, round-trips still pass — so I also test both directions against known expected values.

All 222 tests PASSED
Enter fullscreen mode Exit fullscreen mode

Try It

https://chmod-calculator.pages.dev

Source is a single self-contained HTML file — open DevTools, read the script block, and you have the full implementation. Part of devnestio, a collection of browser-only developer tools.

Top comments (0)