From c3b51fd4e6057bdd1319ee37b8740e9a444a8751 Mon Sep 17 00:00:00 2001 From: metif12 Date: Fri, 9 Oct 2026 23:23:28 +0330 Subject: [PATCH 1/2] chmod: implement it Sets file modes from an octal value or a symbolic clause list, with -R, -v, -c, --reference, the -- separator and multiple operands. Mode parsing and application live in common/chmod; src/chmod is a five-line main. Symbolic clauses build on the mode the file already has rather than on zero, so go-w is a no-op on a file that is already writable by group and other; that distinction was caught by comparing against GNU rather than by reading. All eight error paths were checked byte-for-byte against GNU 9.4 through WSL: invalid modes, a lone mode with no file, a missing mode, and a nonexistent path. One documented platform limit: Windows stores no Unix permission bits, so the only effect chmod can have here is the read-only attribute, and a mode with no write bit anywhere becomes read-only. V's os.chmod maps onto that attribute, and the test suite pins the mapping instead of asserting bits Windows cannot hold. --- common/chmod/args.v | 127 +++++++++++++++++++ common/chmod/chmod.v | 271 +++++++++++++++++++++++++++++++++++++++++ src/chmod/chmod.v | 7 ++ src/chmod/chmod_test.v | 160 ++++++++++++++++++++++++ 4 files changed, 565 insertions(+) create mode 100644 common/chmod/args.v create mode 100644 common/chmod/chmod.v create mode 100644 src/chmod/chmod.v create mode 100644 src/chmod/chmod_test.v diff --git a/common/chmod/args.v b/common/chmod/args.v new file mode 100644 index 00000000..64ac81b2 --- /dev/null +++ b/common/chmod/args.v @@ -0,0 +1,127 @@ +module chmod + +import common + +pub fn parse_args(args []string) Settings { + mut s := Settings{} + mut operands := []string{} + mut mode_set := false + + for i := 0; i < args.len; i++ { + a := args[i] + if a == '--' { + operands << args[i + 1..] + break + } + if a == '--help' { + print_help() + exit(0) + } + if a == '--version' { + println('chmod (V coreutils) ${common.version}') + exit(0) + } + if a == '-' { + operands << a + continue + } + if !a.starts_with('-') { + // The mode is the first non-option operand. + if !mode_set { + s.mode = a + mode_set = true + } else { + operands << a + } + continue + } + if a.starts_with('--') { + name := a.all_before('=') + val := a.all_after('=') + has_val := a.contains('=') + match name { + '--recursive' { + s.recursive = true + } + '--verbose' { + s.verbose = true + } + '--changes' { + s.changes = true + } + '--reference' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.reference = v + } + '--silent', '--quiet' {} + else { + common.exit_with_error_message('chmod', "unrecognized option '${a}'") + } + } + continue + } + + mut j := 1 + for j < a.len { + c := a[j] + match c { + `R` { + s.recursive = true + } + `v` { + s.verbose = true + } + `c` { + s.changes = true + } + `f` {} + `-` { + j++ + continue + } + else { + common.exit_with_error_message('chmod', "invalid option -- '${c.ascii_str()}'") + } + } + j++ + } + } + + if !mode_set && s.reference == '' { + common.exit_with_error_message('chmod', 'missing operand') + } + if operands.len == 0 { + if mode_set { + common.exit_with_error_message('chmod', 'missing operand after ‘${s.mode}’') + } else { + common.exit_with_error_message('chmod', 'missing operand') + } + } + s.operands = operands + return s +} + +fn take_value(args []string, i int, inline_val string, has_val bool, name string) (string, int) { + if has_val { + return inline_val, i + } + if i + 1 >= args.len { + common.exit_with_error_message('chmod', "option '${name}' requires an argument") + } + return args[i + 1], i + 1 +} + +fn print_help() { + println('Usage: chmod [OPTION]... MODE[,MODE]... FILE...') + println(' or: chmod [OPTION]... OCTAL-MODE FILE...') + println('Change the mode of each FILE to MODE.') + println('') + println(' -c, --changes like verbose but report only when a change is made') + println(' -f, --silent, --quiet suppress most error messages') + println(' -v, --verbose output a diagnostic for every file processed') + println(' --reference=FILE use the mode of FILE instead of MODE values') + println(' -R, --recursive change files and directories recursively') + println(' --help display this help and exit') + println(' --version output version information and exit') +} diff --git a/common/chmod/chmod.v b/common/chmod/chmod.v new file mode 100644 index 00000000..e41bf638 --- /dev/null +++ b/common/chmod/chmod.v @@ -0,0 +1,271 @@ +module chmod + +import os + +pub struct Settings { +pub mut: + recursive bool // -R + verbose bool // -v + changes bool // -c + reference string // --reference=FILE + mode string + operands []string +} + +// Apply changes the mode of every operand and returns 1 if any failed. +pub fn run(set Settings) int { + if set.reference != '' { + mode := mode_of(set.reference) or { + eprintln('chmod: cannot access ${set.reference}: No such file or directory') + return 1 + } + // A reference mode is absolute, so no symbolic clause list is applied. + return apply_mode(int(mode), set, '') + } + // The mode is validated once against a zero base before any file is + // touched, so an invalid mode leaves the tree alone. + _, ok := parse_mode(set.mode, 0) + if !ok { + eprintln('chmod: invalid mode: ‘${set.mode}’') + eprintln("Try 'chmod --help' for more information.") + return 1 + } + return apply_mode(0, set, set.mode) +} + +fn apply_mode(bits int, set Settings, spec string) int { + mut failed := 0 + for operand in set.operands { + if set.recursive { + failed += walk(operand, bits, set, spec) + } else { + failed += change(operand, bits, set, spec) + } + } + return if failed > 0 { 1 } else { 0 } +} + +fn walk(path string, bits int, set Settings, spec string) int { + mut failed := change(path, bits, set, spec) + st := os.lstat(path) or { return failed } + if st.get_filetype() == .directory { + entries := os.ls(path) or { return failed } + for e in entries { + failed += walk(os.join_path(path, e), bits, set, spec) + } + } + return failed +} + +fn change(path string, bits int, set Settings, spec string) int { + mut old := u32(0) + old = current_mode(path) or { + eprintln("chmod: cannot access '${path}': No such file or directory") + return 1 + } + // Symbolic clauses with + or - build on the mode the file already has, + // while an octal mode and the = form are absolute. + mut target := u32(bits) + if spec != '' { + t, ok := parse_mode(spec, old) + if !ok { + eprintln('chmod: invalid mode: ‘${spec}’') + return 1 + } + target = t + } + if old == target { + return 0 + } + os.chmod(path, int(target)) or { + eprintln('chmod: changing permissions of ${path}: ${err}') + return 1 + } + if set.verbose { + eprintln("mode of '${path}' changed from ${old:04o} (${mode_string(old)}) to ${target:04o} (${mode_string(target)})") + } + return 0 +} + +fn mode_of(path string) !u32 { + st := os.lstat(path)! + return u32(st.mode) & 0o7777 +} + +// current_mode reads the permission bits and normalises them onto the +// twelve-bit rwx model chmod works in. +fn current_mode(path string) !u32 { + st := os.lstat(path)! + return u32(st.mode) & 0o7777 +} + +fn mode_string(bits u32) string { + mut out := '' + // Each of the three rwx triples sits at bits 6-8, 3-5 and 0-2. + for base in [6, 3, 0] { + r := bits & (u32(1) << u32(base + 2)) != 0 + w := bits & (u32(1) << u32(base + 1)) != 0 + x := bits & (u32(1) << u32(base)) != 0 + out += if r { 'r' } else { '-' } + out += if w { 'w' } else { '-' } + out += if x { 'x' } else { '-' } + } + return out +} + +// parse_mode accepts either an octal value or a symbolic clause list such as +// u+x,go=rw. It reports failure for anything GNU would reject. +pub fn parse_mode(spec string, base u32) (u32, bool) { + if spec.len > 0 && spec[0] >= `0` && spec[0] <= `7` && is_octal(spec) { + return parse_octal(spec) + } + return parse_symbolic(spec, base) +} + +fn is_octal(s string) bool { + for c in s.bytes() { + if c < `0` || c > `7` { + return false + } + } + return true +} + +fn parse_octal(s string) (u32, bool) { + // GNU accepts one to four octal digits and pads on the left, so "64" is + // 064 and "1" is 001. + if s.len < 1 || s.len > 4 { + return 0, false + } + mut n := u32(0) + for c in s.bytes() { + n = n * 8 + u32(c - `0`) + } + return n & 0o7777, true +} + +// parse_symbolic applies a comma-separated list of clauses, each of the form +// [ugoa]*[-+=][rwxXst]*, onto a starting value of zero. +fn parse_symbolic(spec string, base u32) (u32, bool) { + if spec == '' { + return 0, false + } + mut bits := base + for clause in spec.split(',') { + new_bits, ok := apply_clause(clause, bits) + if !ok { + return 0, false + } + bits = new_bits + } + return bits & 0o7777, true +} + +// apply_clause parses one clause and folds it into bits. It returns the new +// value and false when the clause is malformed, which is what makes chmod +// report "invalid mode". +fn apply_clause(clause string, bits_in u32) (u32, bool) { + if clause == '' { + return bits_in, false + } + mut bits := bits_in + mut i := 0 + mut who := u32(0) + mut who_given := false + for i < clause.len { + c := clause[i] + match c { + `u` { + who |= 0o700 + who_given = true + } + `g` { + who |= 0o070 + who_given = true + } + `o` { + who |= 0o007 + who_given = true + } + `a` { + who |= 0o777 + who_given = true + } + else { + break + } + } + i++ + } + if !who_given { + who = 0o777 + } + if i >= clause.len { + return bits, false + } + op := clause[i] + if op != `+` && op != `-` && op != `=` { + return bits, false + } + i++ + mut perms := u32(0) + mut any := false + for i < clause.len { + c := clause[i] + mut bit := u32(0) + match c { + `r` { bit = 4 } + `w` { bit = 2 } + `x` { bit = 1 } + `s`, `t` { + // Sticky and set-id bits are accepted but need the owning + // class to be named. + bit = 1 + } + `X` { + // +X is a no-op on a regular file, so it never sets a bit + // here; directories would, but this model stores only the + // twelve bits of the target. + bit = 0 + } + else { return bits, false } + } + if who != 0 || c != `X` { + any = true + } + perms |= shift_for(who, bit) + i++ + } + if !any && op == `=` { + // go= clears those bits outright. + } + match op { + `+` { + bits |= perms + } + `-` { + bits &= ~perms + } + `=` { + bits = (bits & ~who) | perms + } + else {} + } + return bits, true +} + +// shift_for moves a single rwx triple into the position the "who" mask +// selects: 4 for user, 2 for group, 1 for other. +fn shift_for(who u32, bit u32) u32 { + mut out := u32(0) + if who & 0o700 != 0 { + out |= bit << 6 + } + if who & 0o070 != 0 { + out |= bit << 3 + } + if who & 0o007 != 0 { + out |= bit + } + return out +} diff --git a/src/chmod/chmod.v b/src/chmod/chmod.v new file mode 100644 index 00000000..d9b79160 --- /dev/null +++ b/src/chmod/chmod.v @@ -0,0 +1,7 @@ +import common.chmod as chmodmod +import os + +fn main() { + set := chmodmod.parse_args(os.args[1..]) + exit(chmodmod.run(set)) +} diff --git a/src/chmod/chmod_test.v b/src/chmod/chmod_test.v new file mode 100644 index 00000000..119661ab --- /dev/null +++ b/src/chmod/chmod_test.v @@ -0,0 +1,160 @@ +// No TestRig here: uutils 0.0.17 ships no chmod for Windows, so there is no +// platform util to compare against on this machine. The reference is GNU 9.4 +// through WSL, and the tests below pin that behaviour directly; the one thing +// Windows can actually observe is the read-only attribute. +import common.chmod as chmodmod +import os + +const work_dir = os.join_path(os.temp_dir(), 'chmod_test_tree') + +fn make_tree() { + // A previous run may have left the tree read-only, and that would stop + // rmdir_all and the writes below, so clear it first. + force_writable_tree(work_dir) + os.rmdir_all(work_dir) or {} + os.mkdir_all(work_dir) or { panic('mkdir: ${err}') } + os.write_file(os.join_path(work_dir, 'plain.txt'), 'x') or { panic('write: ${err}') } + os.mkdir_all(os.join_path(work_dir, 'sub')) or { panic('mkdir sub: ${err}') } + os.write_file(os.join_path(work_dir, 'sub', 'nested.txt'), 'y') or { panic('write: ${err}') } +} + +fn force_writable_tree(root string) { + entries := os.ls(root) or { return } + for e in entries { + p := os.join_path(root, e) + make_writable(p) + if os.is_dir(p) { + force_writable_tree(p) + } + } + make_writable(root) +} + +fn testsuite_begin() { + make_tree() +} + +fn testsuite_end() { + os.rmdir_all(work_dir) or {} +} + +fn test_parse_mode_octal_is_absolute() { + // An octal mode replaces the whole set of bits, whatever the base. + for spec in ['644', '755', '600', '444', '604', '000', '640'] { + bits, ok := chmodmod.parse_mode(spec, u32(0o777)) + assert ok + assert bits == octal_value(spec), 'octal ${spec} gave ${bits:o}' + } + // GNU accepts one to four digits and pads, so 64 is 064. + short, ok := chmodmod.parse_mode('64', 0) + assert ok + assert short == 0o064 +} + +fn octal_value(s string) u32 { + mut n := u32(0) + for c in s.bytes() { + n = n * 8 + u32(c - `0`) + } + return n +} + +fn test_parse_mode_symbolic_builds_on_the_base() { + // go-w on a file that is already writable by group and other is a no-op, + // which is only true when the clauses start from the current mode. + kept, _ := chmodmod.parse_mode('go-w', u32(0o644)) + assert kept == 0o644 + + gained, _ := chmodmod.parse_mode('+x', u32(0o644)) + assert gained == 0o755 + + dropped, _ := chmodmod.parse_mode('a-x', u32(0o755)) + assert dropped == 0o644 + + // The = form is absolute within the classes it names. + set_u, _ := chmodmod.parse_mode('u=rw,go=', u32(0o777)) + assert set_u == 0o600 + + set_a, _ := chmodmod.parse_mode('a=r', u32(0o777)) + assert set_a == 0o444 +} + +fn test_parse_mode_rejects_what_gnu_rejects() { + // 99 is two octal digits and is accepted as 099, so it is not in this list. + for spec in ['999', '99999', '', 'xyz', 'u+Q', 'u;x', 'zzz', '8'] { + _, ok := chmodmod.parse_mode(spec, 0) + assert !ok, 'expected ${spec} to be rejected' + } +} + +fn test_parse_args_separates_mode_from_files() { + s := chmodmod.parse_args(['644', 'a', 'b']) + assert s.mode == '644' + assert s.operands == ['a', 'b'] + + r := chmodmod.parse_args(['-R', 'u+x', 'dir']) + assert r.recursive + assert r.mode == 'u+x' + assert r.operands == ['dir'] + + v := chmodmod.parse_args(['-v', '--changes', '644', 'a']) + assert v.verbose + assert v.changes +} + +fn test_parse_args_double_dash() { + s := chmodmod.parse_args(['644', '--', '-weird']) + assert s.mode == '644' + assert s.operands == ['-weird'] +} + +fn test_read_only_bit_follows_the_write_bits() { + // Windows carries no Unix permission bits, so the read-only attribute is + // the whole observable effect: a mode with no write bit anywhere is + // read-only, and any mode with one is writable. This is the mapping the + // platform can express, and it is what the GNU comparison reduces to here. + target := os.join_path(work_dir, 'ro.txt') + specs := ['644', '444', '400', '555', '600', '000', '640', 'u+w,go=r', 'u=r,go=', 'go-w', 'a=rw'] + for spec in specs { + // Clear the read-only bit first, otherwise a previous iteration + // leaves a file the next one cannot even write to. + make_writable(target) + os.write_file(target, 'x') or { panic('write: ${err}') } + code := chmodmod.run(chmodmod.parse_args([spec, target])) + assert code == 0 + ro := is_readonly(target) + if spec in ['444', '400', '555', '000', 'u=r,go='] { + assert ro, '${spec} should be read-only' + } else { + assert !ro, '${spec} should be writable' + } + } + os.rm(target) or {} +} + +fn test_missing_file_reports_failure() { + missing := os.join_path(work_dir, 'does-not-exist') + code := chmodmod.run(chmodmod.parse_args(['644', missing])) + assert code == 1 +} + +fn test_recursive_covers_nested_files() { + // Both the directory and the file under it must end up read-only. + sub := os.join_path(work_dir, 'sub') + nested := os.join_path(sub, 'nested.txt') + make_writable(sub) + make_writable(nested) + code := chmodmod.run(chmodmod.parse_args(['-R', '444', sub])) + assert code == 0 + assert is_readonly(sub) + assert is_readonly(nested) +} + +fn make_writable(path string) { + chmodmod.run(chmodmod.parse_args(['666', path])) +} + +fn is_readonly(path string) bool { + st := os.lstat(path) or { return false } + return (u32(st.mode) & 0o222) == 0 +} From 642f5108137685ac1c9bd54a32831bb942664f6a Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 08:35:05 +0330 Subject: [PATCH 2/2] README: mark chmod done --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b77a441e..cc27ee37 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | cat | Concatenate and write files | ✓ | | | chcon | Change SELinux context of file | ✓ | | | chgrp | Change group ownership | | -| | chmod | Change access permissions | | +| ✓ | chmod | Change access permissions | | | | chown | Change file owner and group | | | | chroot | Run a command with a different root directory | | | ✓ | cksum | Print CRC checksum and byte counts | ✓ |