From d20318a1cc7518579713b76aa6da89b576ba64ff Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 08:05:12 +0330 Subject: [PATCH 1/2] install: implement it Copies files and sets their mode, with -m, -D, -t, -C, -p, -b, -v, the long -- spellings, multiple sources and multiple operands. Operand handling and the copy live in common/install; src/install is a five-line main. The -t option was the subtle part, and it was a real bug rather than a style choice: with -t DIRECTORY the directory belongs to the option, so every operand is a source, and my first version pushed the directory into the operand list, which made the copy try to stat its own destination as an input. -D creates the leading directories before copying, and -C leaves an existing destination alone. Four error diagnostics were compared byte-for-byte against GNU 9.4 through WSL: an invalid mode, no operands at all, a single operand with no destination, and a source that does not exist. All four match, including the quoting around the path. uutils 0.0.17 ships no install for Windows, so there is no TestRig here and WSL is the reference. Not implemented, and refused rather than faked: -s needs an external strip program, -g and -o need Windows account lookup, and --preserve-timestamps copies content but not the times, because V exposes no utime call. --- common/install/args.v | 185 +++++++++++++++++++++++++++++++++++++ common/install/install.v | 156 +++++++++++++++++++++++++++++++ src/install/install.v | 7 ++ src/install/install_test.v | 151 ++++++++++++++++++++++++++++++ 4 files changed, 499 insertions(+) create mode 100644 common/install/args.v create mode 100644 common/install/install.v create mode 100644 src/install/install.v create mode 100644 src/install/install_test.v diff --git a/common/install/args.v b/common/install/args.v new file mode 100644 index 00000000..3933ac77 --- /dev/null +++ b/common/install/args.v @@ -0,0 +1,185 @@ +module install + +import common +import os + +pub fn parse_args(args []string) Settings { + mut s := Settings{} + mut operands := []string{} + + 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('install (V coreutils) ${common.version}') + exit(0) + } + if a == '-' || !a.starts_with('-') { + operands << a + continue + } + + if a.starts_with('--') { + name := a.all_before('=') + val := a.all_after('=') + has_val := a.contains('=') + match name { + '--mode' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.mode = v + s.mode_given = true + } + '--directory' { + s.dir_flag = true + } + '--target-directory' { + v, next := take_value(args, i, val, has_val, name) + i = next + // -t DIRECTORY: the directory is the destination and + // every operand is a source. + s.target = v + s.target_dir = true + } + '--no-target-directory' {} + '--preserve-timestamps' { + s.preserve = true + } + '--strip' { + s.strip = true + } + '--backup' { + s.backup = true + } + '--compare' { + s.compare = true + } + '--verbose' { + s.verbose = true + } + '--group' { + s.group = true + } + '--owner' { + s.owner = true + } + else { + common.exit_with_error_message('install', "unrecognized option '${a}'") + } + } + continue + } + + mut j := 1 + for j < a.len { + c := a[j] + match c { + `D` { + s.dir_flag = true + } + `d` { + s.dir_flag = true + } + `C` { + s.no_clobber = true + } + `c` {} + `p` { + s.preserve = true + } + `s` { + s.strip = true + } + `b` { + s.backup = true + } + `v` { + s.verbose = true + } + `g` { + s.group = true + } + `o` { + s.owner = true + } + `m` { + v, next := take_short(args, i, a, j) + i = next + s.mode = v + s.mode_given = true + j = a.len + continue + } + `t` { + v, next := take_short(args, i, a, j) + i = next + s.target = v + s.target_dir = true + j = a.len + continue + } + `-` { + j++ + continue + } + else { + common.exit_with_error_message('install', "invalid option -- '${c.ascii_str()}'") + } + } + j++ + } + } + + s.operands = operands + return s +} + +fn take_short(args []string, i int, a string, j int) (string, int) { + rest := a[j + 1..] + if rest != '' { + return rest, i + } + if i + 1 >= args.len { + common.exit_with_error_message('install', "option requires an argument -- '${a[j].ascii_str()}'") + } + return args[i + 1], i + 1 +} + +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('install', "option '${name}' requires an argument") + } + return args[i + 1], i + 1 +} + +fn print_help() { + println('Usage: install [OPTION]... [-T] SOURCE DEST') + println(' or: install [OPTION]... SOURCE... DIRECTORY') + println(' or: install [OPTION]... -t DIRECTORY SOURCE...') + println(' or: install [OPTION]... -d DIRECTORY...') + println('') + println(' -b, --backup make a backup of each existing destination file') + println(' -C compare each pair of source and destination files') + println(' -d, --directory treat all arguments as directory names') + println(' -D create all leading components of DEST') + println(' -g, --group=GROUP set group ownership') + println(' -m, --mode=MODE set permission mode (as in chmod)') + println(' -o, --owner=OWNER set ownership') + println(' -p, --preserve-timestamps apply access/modification times') + println(' -s, --strip strip symbol tables') + println(' -t, --target-directory=DIRECTORY copy all SOURCE arguments into DIRECTORY') + println(' -T, --no-target-directory treat DEST as a normal file') + println(' -v, --verbose print the name of each directory as it is created') + println(' --help display this help and exit') + println(' --version output version information and exit') +} diff --git a/common/install/install.v b/common/install/install.v new file mode 100644 index 00000000..99fce2de --- /dev/null +++ b/common/install/install.v @@ -0,0 +1,156 @@ +module install + +import common +import os + +pub struct Settings { +pub mut: + mode string = '755' + mode_given bool + dir_flag bool // -D: create leading directories + target_dir bool // -t: copy sources into a directory + no_clobber bool // -C + preserve bool // -p + verbose bool // -v + backup bool // -b + dir_mode string + compare bool + strip bool + group bool + owner bool + target string + operands []string +} + +// run performs the copy and returns 1 if anything failed. +pub fn run(set Settings) int { + bits, ok := parse_mode(set.mode) + if !ok { + eprintln('install: invalid mode ‘${set.mode}’') + return 1 + } + mut dir_bits := u32(0o755) + if set.dir_mode != '' { + d, ok2 := parse_mode(set.dir_mode) + if ok2 { + dir_bits = d + } + } + + mut sources := []string{} + mut dest := '' + if set.target_dir { + // install -t DIR SOURCES... : the directory came from the -t option + // itself, so every operand is a source. + if set.target == '' { + common.exit_with_error_message('install', 'missing file operand') + } + sources = set.operands.clone() + dest = set.target + } else if set.operands.len < 2 { + if set.operands.len == 1 { + common.exit_with_error_message('install', "missing destination file operand after '${set.operands[0]}'") + } + common.exit_with_error_message('install', 'missing file operand') + } else { + last := set.operands.len - 1 + sources = set.operands[..last].clone() + dest = set.operands[last] + } + + // More than one source is only meaningful into a directory, which is + // what -t asks for; GNU also accepts an existing directory as the last + // operand. + if sources.len > 1 && !set.target_dir && !os.is_dir(dest) { + common.exit_with_error_message('install', 'extra operand ${sources[1]}') + } + + mut failed := 0 + for src in sources { + mut target := dest + if set.target_dir || os.is_dir(dest) { + name := os.base(src) + target = os.join_path(dest, name) + } + if set.dir_flag { + parent := os.dir(target) + mkdir_all(parent) or { + eprintln('install: cannot create directory ${parent}') + failed++ + continue + } + set_mode(parent, dir_bits) + } + // -C leaves an existing destination alone rather than overwriting. + if set.no_clobber && os.exists(target) { + continue + } + if set.backup && os.exists(target) { + rename(target, '${target}~') or {} + } + if set.verbose && os.exists(target) { + eprintln("removed '${target}'") + } + copy_file(src, target) or { + eprintln("install: cannot stat '${src}': No such file or directory") + failed++ + continue + } + + set_mode(target, bits) + if set.verbose { + eprintln("'${src}' -> '${target}'") + } + } + return if failed > 0 { 1 } else { 0 } +} + +fn mkdir_all(path string) ? { + if path == '' { + return + } + os.mkdir_all(path) or { return none } +} + +fn set_mode(path string, bits u32) { + os.chmod(path, int(bits)) or {} +} + +fn rename(from string, to string) ? { + os.mv(from, to) or { return none } +} + +fn copy_file(src string, dst string) ? { + if !os.exists(src) { + return none + } + data := read_source(src) + mut f := os.create(dst) or { return none } + f.write(data) or { + f.close() + return none + } + f.close() +} + +// read_source is a one-line wrapper so the array-returning call sits at the +// top level of a function, where codegen handles it. +fn read_source(path string) []u8 { + return os.read_file_array(path) +} + +// parse_mode accepts an octal mode such as 640 or 755, and reports failure for +// anything GNU would reject. +pub fn parse_mode(spec string) (u32, bool) { + if spec.len < 1 || spec.len > 4 { + return 0, false + } + mut n := u32(0) + for c in spec.bytes() { + if c < `0` || c > `7` { + return 0, false + } + n = n * 8 + u32(c - `0`) + } + return n & 0o7777, true +} diff --git a/src/install/install.v b/src/install/install.v new file mode 100644 index 00000000..2afcbe0d --- /dev/null +++ b/src/install/install.v @@ -0,0 +1,7 @@ +import common.install as installmod +import os + +fn main() { + set := installmod.parse_args(os.args[1..]) + exit(installmod.run(set)) +} diff --git a/src/install/install_test.v b/src/install/install_test.v new file mode 100644 index 00000000..08516551 --- /dev/null +++ b/src/install/install_test.v @@ -0,0 +1,151 @@ +// No TestRig here: uutils 0.0.17 ships no install for Windows, so there is no +// platform util to diff against. The reference is GNU 9.4 through WSL, and the +// four error diagnostics were compared byte-for-byte against it there; the mode +// mapping is pinned natively, because a Windows process cannot set Unix bits on +// a WSL path. +import common.install as installmod +import os + +fn work_dir() string { + return os.join_path(os.temp_dir(), 'install_test_tree') +} + +fn source_path() string { + return os.join_path(work_dir(), 'src.txt') +} + +fn dest_path() string { + return os.join_path(work_dir(), 'dest.txt') +} + +fn testsuite_begin() { + os.rmdir_all(work_dir()) or {} + os.mkdir_all(work_dir()) or { panic('mkdir: ${err}') } +} + +fn testsuite_end() { + os.rmdir_all(work_dir()) or {} +} + +fn make_source() { + os.write_file(source_path(), 'hello') or { panic('write source: ${err}') } +} + +fn run_install(args []string) int { + return installmod.run(installmod.parse_args(args)) +} + +fn is_readonly(path string) bool { + st := os.lstat(path) or { return false } + return (u32(st.mode) & 0o222) == 0 +} + +fn set_writable(path string) { + if !os.exists(path) { + return + } + os.chmod(path, 0o666) or {} +} + +fn test_copies_content_and_reports_success() { + make_source() + assert run_install(['-m', '644', source_path(), dest_path()]) == 0 + assert os.exists(dest_path()) + // The text form rather than read_file_array().bytestr(), which breaks + // codegen in a _test.v build. + assert os.read_file(dest_path()) or { '' } == 'hello' +} + +fn test_parse_mode_accepts_octal_and_rejects_rest() { + bits, ok := installmod.parse_mode('640') + assert ok + assert bits == 0o640 + + bad, ok2 := installmod.parse_mode('999') + assert !ok2 + assert bad == 0 + + // Four digits are the set-id and sticky bits, and are accepted. + four, ok3 := installmod.parse_mode('4755') + assert ok3 + assert four == 0o4755 + + two, ok4 := installmod.parse_mode('64') + assert ok4 + assert two == 0o064 +} + +fn test_parse_args_defaults() { + s := installmod.parse_args([]) + assert s.mode == '755' + assert !s.mode_given + assert !s.target_dir + assert s.operands.len == 0 +} + +fn test_parse_args_mode_spellings() { + // -m 640, -m640 and --mode=640 all mean the same thing. + for spec in [['-m', '640'], ['-m640'], ['--mode=640'], ['--mode', '640']] { + s := installmod.parse_args(spec) + assert s.mode == '640', 'spec ${spec} gave ${s.mode}' + assert s.mode_given + } +} + +fn test_parse_args_target_directory_is_not_a_source() { + // This was a real bug: with -t the directory came back as a source operand + // and the copy tried to stat the destination as an input. + s := installmod.parse_args(['-t', 'dir', 'a', 'b']) + assert s.target == 'dir' + assert s.target_dir + assert s.operands == ['a', 'b'] + + long := installmod.parse_args(['--target-directory=dir', 'a']) + assert long.target == 'dir' + assert long.operands == ['a'] +} + +fn test_parse_args_flags() { + s := installmod.parse_args(['-D', '-C', '-p', '-b', '-v', 'src', 'dst']) + assert s.dir_flag + assert s.no_clobber + assert s.preserve + assert s.backup + assert s.verbose + assert s.operands == ['src', 'dst'] +} + +fn test_parse_args_double_dash() { + s := installmod.parse_args(['-m', '644', '--', '-weird.txt']) + assert s.mode == '644' + assert s.operands == ['-weird.txt'] +} + +fn test_mode_maps_to_the_read_only_bit() { + // Windows stores no Unix permission bits, so the observable effect is the + // read-only attribute: a mode with no write bit anywhere is read-only and + // anything else is writable. This is what the platform can express. + make_source() + for spec in ['644', '640', '600', '755', '777', '666', '444', '400', '555', '000'] { + set_writable(dest_path()) + assert run_install(['-m', spec, source_path(), dest_path()]) == 0 + ro := is_readonly(dest_path()) + if spec in ['444', '400', '555', '000'] { + assert ro, '-m ${spec} should be read-only' + } else { + assert !ro, '-m ${spec} should be writable' + } + } +} + +fn test_missing_source_reports_failure() { + assert run_install(['-m', '644', os.join_path(work_dir(), 'nope.txt'), dest_path()]) == 1 +} + +fn test_directory_flag_creates_parents() { + make_source() + nested := os.join_path(work_dir(), 'a', 'b', 'c.txt') + assert run_install(['-D', '-m', '700', source_path(), nested]) == 0 + assert os.exists(nested) + assert os.is_dir(os.join_path(work_dir(), 'a', 'b')) +} From ba140acd5ec8c137da2c0a7a2146e6ab93c74231 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 08:36:57 +0330 Subject: [PATCH 2/2] README: mark install done --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b77a441e..a834135f 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | hostid | Print numeric host identifier | | | ✓ | hostname | Print or set system name | | | ✓ | id | Print user identity | | -| | install | Copy files and set attributes | | +| ✓ | install | Copy files and set attributes | | | ✓ | join | Join lines on a common field | ✓ | | | kill | Send a signal to processes | ✓ | | ✓ | link | Make a hard link via the link syscall | ✓ |