From c44fc7cff953715e6c703711cc9c43dcea240c92 Mon Sep 17 00:00:00 2001 From: ajithakdev Date: Mon, 7 Sep 2026 23:56:36 +0530 Subject: [PATCH 1/3] feat(chmod): add Linux file permissions calculator tool (closes #82) - Interactive permission matrix with checkboxes for User, Group, Other - Read (4), Write (2), Execute (1) weights - Special bits: SUID (4000), SGID (2000), Sticky Bit (1000) - Live outputs: numeric octal, symbolic notation, chmod command, ls -la preview - Common security presets (644, 755, 600, 700, 777, 1777, 4755, 2775) - Reverse lookup (input octal to update matrix) - Full unit and component test coverage (17 tests) --- src/pages/Landing.tsx | 2 +- src/tools/chmod/ChmodTool.tsx | 405 +++++++++++++++++++++++++++++ src/tools/chmod/chmod.test.ts | 139 ++++++++++ src/tools/chmod/chmod.ts | 134 ++++++++++ src/tools/chmod/chmodTool.test.tsx | 126 +++++++++ src/tools/registry.tsx | 12 +- 6 files changed, 816 insertions(+), 2 deletions(-) create mode 100644 src/tools/chmod/ChmodTool.tsx create mode 100644 src/tools/chmod/chmod.test.ts create mode 100644 src/tools/chmod/chmod.ts create mode 100644 src/tools/chmod/chmodTool.test.tsx diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index 265bd0c..c1027cc 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -12,7 +12,7 @@ const CATEGORIES = [ { name: 'Converters & Formatters', slugs: ['json', 'color', 'json-to-ts'] }, { name: 'API & Networking', slugs: ['api-tester'] }, { name: 'Security & Crypto', slugs: ['hash', 'jwt'] }, - { name: 'Utilities', slugs: ['timestamp', 'regex', 'markdown'] } + { name: 'Utilities', slugs: ['timestamp', 'regex', 'markdown', 'chmod'] } ]; function RecentPill({ tool }: { tool: { slug: string; icon: React.ReactNode; title: string } }) { diff --git a/src/tools/chmod/ChmodTool.tsx b/src/tools/chmod/ChmodTool.tsx new file mode 100644 index 0000000..1b5d1db --- /dev/null +++ b/src/tools/chmod/ChmodTool.tsx @@ -0,0 +1,405 @@ +import { useState, useMemo } from 'react'; +import { ToolLayout } from '../../components/ToolLayout'; +import { Button } from '../../components/ui/Button'; +import { CopyButton } from '../../components/ui/CopyButton'; +import { useUrlState } from '../../hooks/useUrlState'; +import { + toOctal, + toSymbolic, + toSymbolicCommand, + parseOctal, + DEFAULT_CHMOD_STATE, + PRESETS, + type ChmodState, + type PermissionState, + type SpecialBitsState, +} from './chmod'; + +export default function ChmodTool() { + const [octalParam, setOctalParam] = useUrlState('v', '755', String, String); + const [filename, setFilename] = useState('script.sh'); + + const [state, setState] = useState(() => { + return parseOctal(octalParam) || DEFAULT_CHMOD_STATE; + }); + + const octal = useMemo(() => toOctal(state), [state]); + const symbolic = useMemo(() => toSymbolic(state), [state]); + const symbolicCmd = useMemo(() => toSymbolicCommand(state), [state]); + + const updateState = (updater: (prev: ChmodState) => ChmodState) => { + setState((prev) => { + const next = updater(prev); + setOctalParam(toOctal(next)); + return next; + }); + }; + + const handleOctalInput = (val: string) => { + const parsed = parseOctal(val); + if (parsed) { + setState(parsed); + setOctalParam(toOctal(parsed)); + } + }; + + const setPerm = (role: 'user' | 'group' | 'other', perm: keyof PermissionState, val: boolean) => { + updateState((prev) => ({ + ...prev, + [role]: { + ...prev[role], + [perm]: val, + }, + })); + }; + + const setSpecial = (bit: keyof SpecialBitsState, val: boolean) => { + updateState((prev) => ({ + ...prev, + special: { + ...prev.special, + [bit]: val, + }, + })); + }; + + const applyPreset = (octalValue: string) => { + const parsed = parseOctal(octalValue); + if (parsed) { + setState(parsed); + setOctalParam(octalValue); + } + }; + + const reset = () => { + setState(DEFAULT_CHMOD_STATE); + setOctalParam('755'); + }; + + const chmodCmd = `chmod ${octal} ${filename}`; + const chmodSymCmd = `chmod ${symbolicCmd} ${filename}`; + const isDirectory = state.special.sticky || octal.endsWith('5'); + const lsStyle = `${isDirectory ? 'd' : '-'}${symbolic} 1 user staff 4096 Sep 7 12:00 ${filename}`; + + return ( + +
+ {/* Quick Presets */} +
+
+ Common Security Presets +
+
+ {PRESETS.map((p) => { + const active = octal === p.octal; + return ( + + ); + })} +
+
+ + {/* Live Output Cards */} +
+ {/* Octal */} +
+
+ Octal Notation +
+
+ handleOctalInput(e.target.value)} + maxLength={4} + aria-label="Octal permission value" + style={{ + fontFamily: 'var(--font-mono)', + fontSize: 24, + fontWeight: 700, + color: 'var(--accent)', + background: 'transparent', + border: 'none', + outline: 'none', + width: '90px', + }} + /> + +
+
+ + {/* Symbolic */} +
+
+ Symbolic Notation +
+
+ + {symbolic} + + +
+
+
+ + {/* Permission Matrix */} +
+
+ Permission Matrix +
+
+ + + + + + + + + + + {(['user', 'group', 'other'] as const).map((role) => { + const label = role === 'user' ? 'Owner / User (u)' : role === 'group' ? 'Group (g)' : 'Others / Public (o)'; + return ( + + + + + + + ); + })} + +
EntityRead (4)Write (2)Execute (1)
{label} + + + + + +
+
+
+ + {/* Special Flags */} +
+
Special Bits
+
+ + + + + +
+
+ + {/* Commands & File Name */} +
+
+ Target file name: + setFilename(e.target.value)} + placeholder="filename.sh" + style={{ + padding: '6px 10px', + borderRadius: 8, + border: '1px solid var(--line)', + background: 'var(--surface-input)', + fontSize: 13, + fontFamily: 'var(--font-mono)', + color: 'var(--ink)', + outline: 'none', + }} + /> +
+ +
+ + {chmodCmd} + + +
+ +
+ + {chmodSymCmd} + + +
+ +
+ + {lsStyle} + + +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/tools/chmod/chmod.test.ts b/src/tools/chmod/chmod.test.ts new file mode 100644 index 0000000..4ef664a --- /dev/null +++ b/src/tools/chmod/chmod.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { + toOctal, + toSymbolic, + toSymbolicCommand, + parseOctal, + DEFAULT_CHMOD_STATE, + PRESETS, + calculateOctalPart, + calculateSpecialPart, + type ChmodState, +} from './chmod'; + +describe('chmod calculation logic', () => { + it('calculates octal and symbolic for default 755', () => { + expect(toOctal(DEFAULT_CHMOD_STATE)).toBe('755'); + expect(toSymbolic(DEFAULT_CHMOD_STATE)).toBe('rwxr-xr-x'); + expect(toSymbolicCommand(DEFAULT_CHMOD_STATE)).toBe('u=rwx,g=rx,o=rx'); + }); + + it('calculates permissions for 644 (standard file)', () => { + const state: ChmodState = { + special: { suid: false, sgid: false, sticky: false }, + user: { read: true, write: true, execute: false }, + group: { read: true, write: false, execute: false }, + other: { read: true, write: false, execute: false }, + }; + expect(toOctal(state)).toBe('644'); + expect(toSymbolic(state)).toBe('rw-r--r--'); + expect(toSymbolicCommand(state)).toBe('u=rw,g=r,o=r'); + }); + + it('calculates permissions for 600 (SSH key)', () => { + const state: ChmodState = { + special: { suid: false, sgid: false, sticky: false }, + user: { read: true, write: true, execute: false }, + group: { read: false, write: false, execute: false }, + other: { read: false, write: false, execute: false }, + }; + expect(toOctal(state)).toBe('600'); + expect(toSymbolic(state)).toBe('rw-------'); + expect(toSymbolicCommand(state)).toBe('u=rw'); + }); + + it('calculates permissions with special bits: SUID (4755)', () => { + const state: ChmodState = { + special: { suid: true, sgid: false, sticky: false }, + user: { read: true, write: true, execute: true }, + group: { read: true, write: false, execute: true }, + other: { read: true, write: false, execute: true }, + }; + expect(toOctal(state)).toBe('4755'); + expect(toSymbolic(state)).toBe('rwsr-xr-x'); + expect(toSymbolicCommand(state)).toBe('u=rwxs,g=rx,o=rx'); + }); + + it('calculates permissions with special bits: SUID without execute (capital S)', () => { + const state: ChmodState = { + special: { suid: true, sgid: false, sticky: false }, + user: { read: true, write: true, execute: false }, + group: { read: true, write: false, execute: true }, + other: { read: true, write: false, execute: true }, + }; + expect(toOctal(state)).toBe('4655'); + expect(toSymbolic(state)).toBe('rwSr-xr-x'); + }); + + it('calculates permissions with Sticky Bit (1777)', () => { + const state: ChmodState = { + special: { suid: false, sgid: false, sticky: true }, + user: { read: true, write: true, execute: true }, + group: { read: true, write: true, execute: true }, + other: { read: true, write: true, execute: true }, + }; + expect(toOctal(state)).toBe('1777'); + expect(toSymbolic(state)).toBe('rwxrwxrwt'); + expect(toSymbolicCommand(state)).toBe('u=rwx,g=rwx,o=rwxt'); + }); + + it('calculates permissions with SGID (2775)', () => { + const state: ChmodState = { + special: { suid: false, sgid: true, sticky: false }, + user: { read: true, write: true, execute: true }, + group: { read: true, write: true, execute: true }, + other: { read: true, write: false, execute: true }, + }; + expect(toOctal(state)).toBe('2775'); + expect(toSymbolic(state)).toBe('rwxrwsr-x'); + }); + + it('parses octal strings correctly into state', () => { + const parsed755 = parseOctal('755'); + expect(parsed755).not.toBeNull(); + expect(toOctal(parsed755!)).toBe('755'); + + const parsed1777 = parseOctal('1777'); + expect(parsed1777).not.toBeNull(); + expect(toOctal(parsed1777!)).toBe('1777'); + expect(parsed1777!.special.sticky).toBe(true); + + const parsed600 = parseOctal('600'); + expect(parsed600).not.toBeNull(); + expect(toOctal(parsed600!)).toBe('600'); + }); + + it('returns null for invalid octal inputs', () => { + expect(parseOctal('')).toBeNull(); + expect(parseOctal('888')).toBeNull(); + expect(parseOctal('abc')).toBeNull(); + expect(parseOctal('12')).toBeNull(); + expect(parseOctal('12345')).toBeNull(); + }); + + it('validates all presets parse and format back cleanly', () => { + PRESETS.forEach((preset) => { + const parsed = parseOctal(preset.octal); + expect(parsed).not.toBeNull(); + expect(toOctal(parsed!)).toBe(preset.octal); + }); + }); + + it('handles empty permissions properly in symbolic command', () => { + const empty: ChmodState = { + special: { suid: false, sgid: false, sticky: false }, + user: { read: false, write: false, execute: false }, + group: { read: false, write: false, execute: false }, + other: { read: false, write: false, execute: false }, + }; + expect(toOctal(empty)).toBe('000'); + expect(toSymbolic(empty)).toBe('---------'); + expect(toSymbolicCommand(empty)).toBe('ugo='); + }); + + it('computes individual bit values accurately', () => { + expect(calculateOctalPart({ read: true, write: true, execute: true })).toBe(7); + expect(calculateOctalPart({ read: true, write: false, execute: true })).toBe(5); + expect(calculateSpecialPart({ suid: true, sgid: true, sticky: true })).toBe(7); + }); +}); diff --git a/src/tools/chmod/chmod.ts b/src/tools/chmod/chmod.ts new file mode 100644 index 0000000..ef7163d --- /dev/null +++ b/src/tools/chmod/chmod.ts @@ -0,0 +1,134 @@ +export interface PermissionState { + read: boolean; + write: boolean; + execute: boolean; +} + +export interface SpecialBitsState { + suid: boolean; + sgid: boolean; + sticky: boolean; +} + +export interface ChmodState { + user: PermissionState; + group: PermissionState; + other: PermissionState; + special: SpecialBitsState; +} + +export interface ChmodPreset { + name: string; + octal: string; + description: string; +} + +export const PRESETS: ChmodPreset[] = [ + { name: 'Standard File (644)', octal: '644', description: 'Owner read/write, others read-only' }, + { name: 'Executable / Directory (755)', octal: '755', description: 'Owner full, others read/execute' }, + { name: 'Private Key / Secrets (600)', octal: '600', description: 'Owner read/write only, no access for others' }, + { name: 'Private Directory / Script (700)', octal: '700', description: 'Owner full access only' }, + { name: 'Full Access (777)', octal: '777', description: 'Read, write, execute for everyone' }, + { name: 'Sticky Temp Directory (1777)', octal: '1777', description: 'Shared directory where only owner can delete' }, + { name: 'SUID Executable (4755)', octal: '4755', description: 'Runs with permissions of file owner' }, + { name: 'SGID Directory (2775)', octal: '2775', description: 'New files inherit group ownership' }, +]; + +export function calculateOctalPart(perm: PermissionState): number { + return (perm.read ? 4 : 0) + (perm.write ? 2 : 0) + (perm.execute ? 1 : 0); +} + +export function calculateSpecialPart(special: SpecialBitsState): number { + return (special.suid ? 4 : 0) + (special.sgid ? 2 : 0) + (special.sticky ? 1 : 0); +} + +export function toOctal(state: ChmodState): string { + const u = calculateOctalPart(state.user); + const g = calculateOctalPart(state.group); + const o = calculateOctalPart(state.other); + const s = calculateSpecialPart(state.special); + + return s > 0 ? `${s}${u}${g}${o}` : `${u}${g}${o}`; +} + +export function toSymbolic(state: ChmodState): string { + const uR = state.user.read ? 'r' : '-'; + const uW = state.user.write ? 'w' : '-'; + let uX = '-'; + if (state.special.suid && state.user.execute) uX = 's'; + else if (state.special.suid && !state.user.execute) uX = 'S'; + else if (state.user.execute) uX = 'x'; + + const gR = state.group.read ? 'r' : '-'; + const gW = state.group.write ? 'w' : '-'; + let gX = '-'; + if (state.special.sgid && state.group.execute) gX = 's'; + else if (state.special.sgid && !state.group.execute) gX = 'S'; + else if (state.group.execute) gX = 'x'; + + const oR = state.other.read ? 'r' : '-'; + const oW = state.other.write ? 'w' : '-'; + let oX = '-'; + if (state.special.sticky && state.other.execute) oX = 't'; + else if (state.special.sticky && !state.other.execute) oX = 'T'; + else if (state.other.execute) oX = 'x'; + + return `${uR}${uW}${uX}${gR}${gW}${gX}${oR}${oW}${oX}`; +} + +export function toSymbolicCommand(state: ChmodState): string { + const parts: string[] = []; + + const getPermStr = (p: PermissionState) => { + let str = ''; + if (p.read) str += 'r'; + if (p.write) str += 'w'; + if (p.execute) str += 'x'; + return str; + }; + + const u = getPermStr(state.user) + (state.special.suid ? 's' : ''); + const g = getPermStr(state.group) + (state.special.sgid ? 's' : ''); + const o = getPermStr(state.other) + (state.special.sticky ? 't' : ''); + + if (u) parts.push(`u=${u}`); + if (g) parts.push(`g=${g}`); + if (o) parts.push(`o=${o}`); + + return parts.length > 0 ? parts.join(',') : 'ugo='; +} + +export function parseOctal(input: string): ChmodState | null { + const clean = input.trim(); + if (!/^[0-7]{3,4}$/.test(clean)) return null; + + const padded = clean.length === 3 ? `0${clean}` : clean; + const s = parseInt(padded[0], 10); + const u = parseInt(padded[1], 10); + const g = parseInt(padded[2], 10); + const o = parseInt(padded[3], 10); + + const parsePart = (val: number): PermissionState => ({ + read: (val & 4) !== 0, + write: (val & 2) !== 0, + execute: (val & 1) !== 0, + }); + + return { + special: { + suid: (s & 4) !== 0, + sgid: (s & 2) !== 0, + sticky: (s & 1) !== 0, + }, + user: parsePart(u), + group: parsePart(g), + other: parsePart(o), + }; +} + +export const DEFAULT_CHMOD_STATE: ChmodState = { + special: { suid: false, sgid: false, sticky: false }, + user: { read: true, write: true, execute: true }, + group: { read: true, write: false, execute: true }, + other: { read: true, write: false, execute: true }, +}; diff --git a/src/tools/chmod/chmodTool.test.tsx b/src/tools/chmod/chmodTool.test.tsx new file mode 100644 index 0000000..38c4abd --- /dev/null +++ b/src/tools/chmod/chmodTool.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import ChmodTool from './ChmodTool'; +import { ToastProvider } from '../../components/ui/Toast'; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe('ChmodTool Component', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + localStorage.clear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + function renderTool(route = '/tools/chmod') { + act(() => { + root.render( + + + + } /> + + + + ); + }); + } + + it('renders default octal 755 and symbolic rwxr-xr-x', () => { + renderTool(); + + expect(container.textContent).toContain('Linux chmod Calculator'); + expect(container.textContent).toContain('rwxr-xr-x'); + expect(container.textContent).toContain('chmod 755 script.sh'); + }); + + it('switches to preset when clicked', async () => { + renderTool(); + + const buttons = Array.from(container.querySelectorAll('button')); + const preset600 = buttons.find((b) => b.textContent?.includes('600')); + expect(preset600).toBeDefined(); + + await act(async () => { + preset600?.click(); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(container.textContent).toContain('rw-------'); + expect(container.textContent).toContain('chmod 600 script.sh'); + }); + + it('toggles permission checkboxes and updates outputs', async () => { + renderTool(); + + const checkboxes = Array.from( + container.querySelectorAll('input[type="checkbox"]') + ) as HTMLInputElement[]; + const firstCheckbox = checkboxes[0]; // User read + + await act(async () => { + firstCheckbox.click(); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(container.textContent).toContain('chmod'); + }); + + it('handles reverse lookup via octal text input', async () => { + renderTool(); + + const octalInput = container.querySelector( + 'input[aria-label="Octal permission value"]' + ) as HTMLInputElement; + expect(octalInput).not.toBeNull(); + + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set; + nativeSetter?.call(octalInput, '777'); + octalInput.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(container.textContent).toContain('rwxrwxrwx'); + }); + + it('resets to 755 on clicking reset', async () => { + renderTool(); + + // Click 644 first + const buttons = Array.from(container.querySelectorAll('button')); + const preset644 = buttons.find((b) => b.textContent?.includes('644')); + await act(async () => { + preset644?.click(); + await new Promise((r) => setTimeout(r, 50)); + }); + expect(container.textContent).toContain('chmod 644 script.sh'); + + // Click Reset + const resetBtn = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Reset to 755') + ); + await act(async () => { + resetBtn?.click(); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(container.textContent).toContain('chmod 755 script.sh'); + }); +}); diff --git a/src/tools/registry.tsx b/src/tools/registry.tsx index c7658b2..a6e0d5a 100644 --- a/src/tools/registry.tsx +++ b/src/tools/registry.tsx @@ -16,7 +16,8 @@ import { Code2, Globe2, QrCode, - FileText + FileText, + Terminal, } from 'lucide-react'; export interface ToolMeta { @@ -183,6 +184,15 @@ export const tools: ToolMeta[] = [ tint: 'linear-gradient(135deg, #bfdbfe, #c4b5fd)', Component: lazy(() => import('./markdown/MarkdownTool')), }, + { + slug: 'chmod', + title: 'Linux chmod Calculator', + short: 'File permissions & octal', + description: 'Calculate numeric octal and symbolic Linux file permissions with special bits.', + icon: , + tint: 'linear-gradient(135deg, #a7f3d0, #fed7aa)', + Component: lazy(() => import('./chmod/ChmodTool')), + }, ]; export const toolBySlug = (slug: string): ToolMeta | undefined => From d6861465dc6261746498b9c3fe40df44de24ad74 Mon Sep 17 00:00:00 2001 From: ajithakdev Date: Tue, 8 Sep 2026 00:18:13 +0530 Subject: [PATCH 2/3] fix(chmod): address code review findings for symbolic commands, octal editing, and shell escaping --- src/tools/chmod/ChmodTool.tsx | 106 ++++++++++++++++++++++------- src/tools/chmod/chmod.test.ts | 16 ++++- src/tools/chmod/chmod.ts | 17 +++-- src/tools/chmod/chmodTool.test.tsx | 74 ++++++++++++++++++++ 4 files changed, 180 insertions(+), 33 deletions(-) diff --git a/src/tools/chmod/ChmodTool.tsx b/src/tools/chmod/ChmodTool.tsx index 1b5d1db..1415e5b 100644 --- a/src/tools/chmod/ChmodTool.tsx +++ b/src/tools/chmod/ChmodTool.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { ToolLayout } from '../../components/ToolLayout'; import { Button } from '../../components/ui/Button'; import { CopyButton } from '../../components/ui/CopyButton'; @@ -8,6 +8,7 @@ import { toSymbolic, toSymbolicCommand, parseOctal, + escapeShellArg, DEFAULT_CHMOD_STATE, PRESETS, type ChmodState, @@ -18,12 +19,19 @@ import { export default function ChmodTool() { const [octalParam, setOctalParam] = useUrlState('v', '755', String, String); const [filename, setFilename] = useState('script.sh'); + const [fileType, setFileType] = useState<'file' | 'dir'>('file'); const [state, setState] = useState(() => { return parseOctal(octalParam) || DEFAULT_CHMOD_STATE; }); const octal = useMemo(() => toOctal(state), [state]); + const [octalInput, setOctalInput] = useState(octal); + + useEffect(() => { + setOctalInput(octal); + }, [octal]); + const symbolic = useMemo(() => toSymbolic(state), [state]); const symbolicCmd = useMemo(() => toSymbolicCommand(state), [state]); @@ -36,13 +44,19 @@ export default function ChmodTool() { }; const handleOctalInput = (val: string) => { - const parsed = parseOctal(val); + const filtered = val.replace(/[^0-7]/g, '').slice(0, 4); + setOctalInput(filtered); + const parsed = parseOctal(filtered); if (parsed) { setState(parsed); setOctalParam(toOctal(parsed)); } }; + const handleOctalBlur = () => { + setOctalInput(octal); + }; + const setPerm = (role: 'user' | 'group' | 'other', perm: keyof PermissionState, val: boolean) => { updateState((prev) => ({ ...prev, @@ -76,10 +90,12 @@ export default function ChmodTool() { setOctalParam('755'); }; - const chmodCmd = `chmod ${octal} ${filename}`; - const chmodSymCmd = `chmod ${symbolicCmd} ${filename}`; - const isDirectory = state.special.sticky || octal.endsWith('5'); - const lsStyle = `${isDirectory ? 'd' : '-'}${symbolic} 1 user staff 4096 Sep 7 12:00 ${filename}`; + const isDirectory = fileType === 'dir'; + const targetName = filename || (isDirectory ? 'directory' : 'script.sh'); + const safeFilename = escapeShellArg(targetName); + const chmodCmd = `chmod ${octal} ${safeFilename}`; + const chmodSymCmd = `chmod ${symbolicCmd} ${safeFilename}`; + const lsStyle = `${isDirectory ? 'd' : '-'}${symbolic} 1 user staff 4096 Sep 7 12:00 ${targetName}`; return ( @@ -143,8 +159,9 @@ export default function ChmodTool() {
handleOctalInput(e.target.value)} + onBlur={handleOctalBlur} maxLength={4} aria-label="Octal permission value" style={{ @@ -319,24 +336,63 @@ export default function ChmodTool() { {/* Commands & File Name */}
-
- Target file name: - setFilename(e.target.value)} - placeholder="filename.sh" - style={{ - padding: '6px 10px', - borderRadius: 8, - border: '1px solid var(--line)', - background: 'var(--surface-input)', - fontSize: 13, - fontFamily: 'var(--font-mono)', - color: 'var(--ink)', - outline: 'none', - }} - /> +
+
+ Target name: + setFilename(e.target.value)} + placeholder={isDirectory ? 'directory' : 'script.sh'} + aria-label="Target file name" + style={{ + padding: '6px 10px', + borderRadius: 8, + border: '1px solid var(--line)', + background: 'var(--surface-input)', + fontSize: 13, + fontFamily: 'var(--font-mono)', + color: 'var(--ink)', + outline: 'none', + }} + /> +
+ +
+ Type: + + +
{ }; expect(toOctal(state)).toBe('600'); expect(toSymbolic(state)).toBe('rw-------'); - expect(toSymbolicCommand(state)).toBe('u=rw'); + expect(toSymbolicCommand(state)).toBe('u=rw,g=,o='); }); it('calculates permissions with special bits: SUID (4755)', () => { @@ -128,7 +129,7 @@ describe('chmod calculation logic', () => { }; expect(toOctal(empty)).toBe('000'); expect(toSymbolic(empty)).toBe('---------'); - expect(toSymbolicCommand(empty)).toBe('ugo='); + expect(toSymbolicCommand(empty)).toBe('u=,g=,o='); }); it('computes individual bit values accurately', () => { @@ -136,4 +137,15 @@ describe('chmod calculation logic', () => { expect(calculateOctalPart({ read: true, write: false, execute: true })).toBe(5); expect(calculateSpecialPart({ suid: true, sgid: true, sticky: true })).toBe(7); }); + + it('escapes shell arguments to prevent command injection', () => { + expect(escapeShellArg('script.sh')).toBe('script.sh'); + expect(escapeShellArg('my-script_v1.0.sh')).toBe('my-script_v1.0.sh'); + expect(escapeShellArg('path/to/my_file.py')).toBe('path/to/my_file.py'); + expect(escapeShellArg('')).toBe("''"); + expect(escapeShellArg('file with spaces.txt')).toBe("'file with spaces.txt'"); + expect(escapeShellArg('script.sh; rm -rf /')).toBe("'script.sh; rm -rf /'"); + expect(escapeShellArg('file$(whoami)test')).toBe("'file$(whoami)test'"); + expect(escapeShellArg("it's a file")).toBe("'it'\\''s a file'"); + }); }); diff --git a/src/tools/chmod/chmod.ts b/src/tools/chmod/chmod.ts index ef7163d..04b1fd4 100644 --- a/src/tools/chmod/chmod.ts +++ b/src/tools/chmod/chmod.ts @@ -77,8 +77,6 @@ export function toSymbolic(state: ChmodState): string { } export function toSymbolicCommand(state: ChmodState): string { - const parts: string[] = []; - const getPermStr = (p: PermissionState) => { let str = ''; if (p.read) str += 'r'; @@ -91,11 +89,18 @@ export function toSymbolicCommand(state: ChmodState): string { const g = getPermStr(state.group) + (state.special.sgid ? 's' : ''); const o = getPermStr(state.other) + (state.special.sticky ? 't' : ''); - if (u) parts.push(`u=${u}`); - if (g) parts.push(`g=${g}`); - if (o) parts.push(`o=${o}`); + return `u=${u},g=${g},o=${o}`; +} - return parts.length > 0 ? parts.join(',') : 'ugo='; +/** + * Safely escapes an argument for POSIX shells to prevent command injection. + */ +export function escapeShellArg(arg: string): string { + if (!arg) return "''"; + if (/^[a-zA-Z0-9._/-]+$/.test(arg)) { + return arg; + } + return `'${arg.replace(/'/g, `'\\''`)}'`; } export function parseOctal(input: string): ChmodState | null { diff --git a/src/tools/chmod/chmodTool.test.tsx b/src/tools/chmod/chmodTool.test.tsx index 38c4abd..d34e4a6 100644 --- a/src/tools/chmod/chmodTool.test.tsx +++ b/src/tools/chmod/chmodTool.test.tsx @@ -123,4 +123,78 @@ describe('ChmodTool Component', () => { expect(container.textContent).toContain('chmod 755 script.sh'); }); + + it('allows intermediate editing in octal input and resets on blur if invalid', async () => { + renderTool(); + + const octalInput = container.querySelector( + 'input[aria-label="Octal permission value"]' + ) as HTMLInputElement; + + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set; + + // Type intermediate 2 digits: "75" + await act(async () => { + nativeSetter?.call(octalInput, '75'); + octalInput.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(octalInput.value).toBe('75'); + + // Blur without completing 3rd digit -> resets to canonical octal + await act(async () => { + octalInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + octalInput.dispatchEvent(new FocusEvent('blur', { bubbles: true })); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(octalInput.value).toBe('755'); + }); + + it('toggles between file and directory types for ls-style preview', async () => { + renderTool(); + + // Default is file with '-' prefix + expect(container.textContent).toContain('-rwxr-xr-x'); + + const dirButton = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Directory (d)') + ); + expect(dirButton).toBeDefined(); + + await act(async () => { + dirButton?.click(); + await new Promise((r) => setTimeout(r, 20)); + }); + + // Switches to 'd' prefix for directory + expect(container.textContent).toContain('drwxr-xr-x'); + }); + + it('escapes filenames with shell metacharacters in generated commands', async () => { + renderTool(); + + const filenameInput = container.querySelector( + 'input[aria-label="Target file name"]' + ) as HTMLInputElement; + expect(filenameInput).not.toBeNull(); + + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set; + + await act(async () => { + nativeSetter?.call(filenameInput, 'my script; rm -rf /'); + filenameInput.dispatchEvent(new Event('input', { bubbles: true })); + filenameInput.dispatchEvent(new Event('change', { bubbles: true })); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(container.textContent).toContain("chmod 755 'my script; rm -rf /'"); + }); }); From 13f79bb48ced7735100888950728b707a467f4b7 Mon Sep 17 00:00:00 2001 From: ajithakdev Date: Tue, 8 Sep 2026 00:25:17 +0530 Subject: [PATCH 3/3] fix(chmod): sync URL state on history navigation and assert concrete permission changes in tests --- src/tools/chmod/ChmodTool.tsx | 7 +++++++ src/tools/chmod/chmodTool.test.tsx | 33 +++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/tools/chmod/ChmodTool.tsx b/src/tools/chmod/ChmodTool.tsx index 1415e5b..5327bf3 100644 --- a/src/tools/chmod/ChmodTool.tsx +++ b/src/tools/chmod/ChmodTool.tsx @@ -32,6 +32,13 @@ export default function ChmodTool() { setOctalInput(octal); }, [octal]); + useEffect(() => { + const parsed = parseOctal(octalParam); + if (parsed) { + setState(parsed); + } + }, [octalParam]); + const symbolic = useMemo(() => toSymbolic(state), [state]); const symbolicCmd = useMemo(() => toSymbolicCommand(state), [state]); diff --git a/src/tools/chmod/chmodTool.test.tsx b/src/tools/chmod/chmodTool.test.tsx index d34e4a6..2939825 100644 --- a/src/tools/chmod/chmodTool.test.tsx +++ b/src/tools/chmod/chmodTool.test.tsx @@ -66,17 +66,44 @@ describe('ChmodTool Component', () => { it('toggles permission checkboxes and updates outputs', async () => { renderTool(); + // Verify initial state (755, rwxr-xr-x) + expect(container.textContent).toContain('755'); + expect(container.textContent).toContain('rwxr-xr-x'); + expect(container.textContent).toContain('chmod 755 script.sh'); + const checkboxes = Array.from( container.querySelectorAll('input[type="checkbox"]') ) as HTMLInputElement[]; - const firstCheckbox = checkboxes[0]; // User read + const userReadCheckbox = checkboxes[0]; // User read + + // Uncheck User Read -> transforms 755 to 355 (-wxr-xr-x) + await act(async () => { + userReadCheckbox.click(); + await new Promise((r) => setTimeout(r, 50)); + }); + expect(container.textContent).toContain('355'); + expect(container.textContent).toContain('-wxr-xr-x'); + expect(container.textContent).toContain('chmod 355 script.sh'); + expect(container.textContent).toContain('u=wx,g=rx,o=rx'); + + // Check User Read again -> restores 755 (rwxr-xr-x) await act(async () => { - firstCheckbox.click(); + userReadCheckbox.click(); await new Promise((r) => setTimeout(r, 50)); }); - expect(container.textContent).toContain('chmod'); + expect(container.textContent).toContain('755'); + expect(container.textContent).toContain('rwxr-xr-x'); + expect(container.textContent).toContain('chmod 755 script.sh'); + }); + + it('loads and reflects permissions from URL parameters (?v=644)', () => { + renderTool('/tools/chmod?v=644'); + + expect(container.textContent).toContain('644'); + expect(container.textContent).toContain('rw-r--r--'); + expect(container.textContent).toContain('chmod 644 script.sh'); }); it('handles reverse lookup via octal text input', async () => {