diff --git a/README.md b/README.md index b77a441e..8b7014b1 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | csplit | Split a file into context-determined pieces | ✓ | | ✓ | cut | Print selected parts of lines | ✓ | | | date | Print or set system date and time | ✓ | -| | dd | Convert and copy a file | ✓ | +| ✓ | dd | Convert and copy a file | ✓ | | | df | Report file system disk space usage | | | | dir | Briefly list directory contents | | | ✓ | dircolors | Color setup for ls | ✓ | diff --git a/common/dd/args.v b/common/dd/args.v new file mode 100644 index 00000000..3380cae7 --- /dev/null +++ b/common/dd/args.v @@ -0,0 +1,204 @@ +module dd + +import common + +pub fn parse_args(args []string) Settings { + mut s := Settings{} + for i := 0; i < args.len; i++ { + a := args[i] + if a == '--help' { + print_help() + exit(0) + } + if a == '--version' { + println('dd (V coreutils) ${common.version}') + exit(0) + } + // Every operand is of the form name=value; there are no options. + eq := a.index('=') or { + common.exit_with_error_message('dd', "unrecognized operand '${a}'") + } + name := a[..eq] + value := a[eq + 1..] + match name { + 'if' { s.ifile = value } + 'of' { s.ofile = value } + 'bs' { + s.bs = parse_size(value, false) + s.ibs = s.bs + s.obs = s.bs + s.ibs_set = false + s.obs_set = false + } + 'ibs' { + s.ibs = parse_size(value, false) + s.ibs_set = true + } + 'obs' { + s.obs = parse_size(value, false) + s.obs_set = true + } + 'cbs' { + s.cbs = parse_size(value, false) + s.cbs_set = true + } + 'count' { + s.count = parse_size(value, true) + s.count_set = true + } + 'skip' { s.skip = parse_size(value, true) } + 'iseek' { s.skip = parse_size(value, true) } + 'seek' { s.seek = parse_size(value, true) } + 'status' { + match value { + 'none' { s.status = .none } + 'noxfer' { s.status = .noxfer } + else { + common.exit_with_error_message('dd', "invalid status flag: '${value}'") + } + } + } + 'conv' { + for c in value.split(',') { + if !is_known_conv(c) { + common.exit_with_error_message('dd', "invalid conversion: '${c}'") + } + s.conv << c + } + } + 'iflag' { + for c in value.split(',') { + if !is_known_iflag(c) { + common.exit_with_error_message('dd', "invalid input flag: '${c}'") + } + s.iflag << c + } + } + 'oflag' { + for c in value.split(',') { + if !is_known_oflag(c) { + common.exit_with_error_message('dd', "invalid output flag: '${c}'") + } + s.oflag << c + } + } + else { + common.exit_with_error_message('dd', "unrecognized operand '${name}'") + } + } + } + return s +} + +fn is_known_conv(c string) bool { + return c in ['notrunc', 'noerror', 'sync', 'fdatasync', 'fsync', 'swab', 'excl', 'nocreat', + 'sparse', 'lcase', 'ucase', 'ascii', 'ebcdic', 'ibm', 'block', 'unblock'] +} + +fn is_known_iflag(c string) bool { + return c in ['count_bytes', 'skip_bytes', 'fullblock', 'direct', 'nonblock', 'noatime', 'nocache', + 'directory'] +} + +fn is_known_oflag(c string) bool { + return c in ['append', 'seek_bytes', 'direct', 'nonblock', 'noatime', 'nocache', 'directory'] +} + +// parse_size accepts a decimal count with an optional unit suffix. GNU +// refuses a zero block size but permits a zero count, skip or seek. +fn parse_size(v string, allow_zero bool) u64 { + n := parse_block_size(v, allow_zero) + return n +} + +fn parse_size_u64(v string) u64 { + return parse_block_size(v, true) +} + +// parse_block_size parses a bs= style operand. A bare K, M or G is a power of +// two and KB, MB, GB a power of ten, so bs=1K is 1024 while bs=1KB is 1000; +// measured against GNU 9.4. A bare letter such as "K" means one unit. +fn parse_block_size(v string, allow_zero bool) u64 { + if v == '' { + common.exit_with_error_message('dd', "invalid number: '${v}'") + } + mut digits_end := 0 + for digits_end < v.len && v[digits_end] >= `0` && v[digits_end] <= `9` { + digits_end++ + } + num := v[..digits_end] + rest := v[digits_end..] + mut n := u64(0) + if num == '' { + n = 1 + } else { + for c in num.bytes() { + if c < `0` || c > `9` { + common.exit_with_error_message('dd', "invalid number: '${v}'") + } + n = n * 10 + u64(c - `0`) + } + } + if n == 0 && !allow_zero { + common.exit_with_error_message('dd', "invalid number: '${v}'") + } + if rest == '' { + return n + } + mut mult := u64(0) + // A bare K or M is a power of two; KB or MB is a power of ten; KiB stays + // a power of two. So decimal only when the suffix ends in B without an + // i before it, which is what separates KB from KiB. + binary := !rest.ends_with('B') || rest.ends_with('iB') + match rest.trim_right('B').trim_right('i').to_upper() { + 'B' { mult = 512 } + 'K' { mult = if binary { u64(1024) } else { u64(1000) } } + 'M' { mult = if binary { u64(1024) * 1024 } else { u64(1000) * 1000 } } + 'G' { mult = if binary { u64(1024) * 1024 * 1024 } else { u64(1000) * 1000 * 1000 } } + 'T' { + mult = if binary { + u64(1024) * 1024 * 1024 * 1024 + } else { + u64(1000) * 1000 * 1000 * 1000 + } + } + 'P' { + mult = if binary { + u64(1024) * 1024 * 1024 * 1024 * 1024 + } else { + u64(1000) * 1000 * 1000 * 1000 * 1000 + } + } + 'E' { mult = u64(1024) * 1024 * 1024 * 1024 * 1024 * 1024 } + 'Z' { mult = u64(1024) * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 } + 'Y' { mult = u64(1024) * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 } + 'C' { mult = 1 } + 'W' { mult = 2 } + else { + common.exit_with_error_message('dd', "invalid number: '${v}'") + return 0 + } + } + return n * mult +} + +fn print_help() { + println('Usage: dd [OPERAND]...') + println('Copy a file, converting and formatting according to the operands.') + println('') + println(' bs=BYTES read and write up to BYTES bytes at a time (default: 512)') + println(' cbs=BYTES convert BYTES bytes at a time') + println(' conv=CONVS convert the file as per the comma separated symbol list') + println(' count=N copy only N input blocks') + println(' ibs=BYTES read up to BYTES bytes at a time (default: 512)') + println(' if=FILE read from FILE instead of the standard input') + println(' iflag=FLAGS read as per the comma separated symbol list') + println(' obs=BYTES write BYTES bytes at a time (default: 512)') + println(' of=FILE write to FILE instead of the standard output') + println(' oflag=FLAGS write as per the comma separated symbol list') + println(' seek=N skip N obs-sized blocks at the start of the output') + println(' skip=N skip N ibs-sized blocks at the start of the input') + println(' status=LEVEL the LEVEL of information to print to the standard error') + println(' --help display this help and exit') + println(' --version output version information and exit') +} diff --git a/common/dd/dd.c.v b/common/dd/dd.c.v new file mode 100644 index 00000000..089b1f1b --- /dev/null +++ b/common/dd/dd.c.v @@ -0,0 +1,12 @@ +module dd + +#include + +fn C.write(fd i64, buf voidptr, count u64) i64 + +// write_fds is the wrapper the pure .v file calls. V no longer permits a C call +// directly in a .v file, so the declaration and the call have to sit on opposite +// sides of the .c.v boundary. +pub fn write_fds(fd i64, buf voidptr, count u64) i64 { + return C.write(fd, buf, count) +} diff --git a/common/dd/dd.v b/common/dd/dd.v new file mode 100644 index 00000000..087a0b32 --- /dev/null +++ b/common/dd/dd.v @@ -0,0 +1,266 @@ +module dd + +import os + +pub enum StatusMode { + full + noxfer + none +} + +pub struct Settings { +pub mut: + ifile string + ofile string + bs u64 = 512 + ibs u64 + obs u64 + ibs_set bool + obs_set bool + cbs u64 = 128 + cbs_set bool + count u64 + count_set bool + skip u64 + seek u64 + status StatusMode = .full + conv []string + iflag []string + oflag []string +} + +// conv reports whether the given conversion was requested. +pub fn (s &Settings) conv_has(name string) bool { + return name in s.conv +} + +pub fn (s &Settings) iflag_has(name string) bool { + return name in s.iflag +} + +pub fn (s &Settings) oflag_has(name string) bool { + return name in s.oflag +} + +pub struct Report { +pub mut: + records_in u64 + partial_in u64 + records_out u64 + partial_out u64 + bytes u64 +} + +// run performs the copy and returns the report plus an exit code. +pub fn run(set Settings) (Report, int) { + mut in_data := read_input(set) or { + return Report{}, 1 + } + mut data := in_data + + // skip and seek are counted in blocks, unless the *_bytes flags turn them + // into byte offsets. + mut ibs := set.ibs + if !set.ibs_set { + ibs = set.bs + } + mut obs := set.obs + if !set.obs_set { + obs = set.bs + } + if ibs == 0 { + ibs = 512 + } + if obs == 0 { + obs = 512 + } + + mut start := u64(0) + if set.skip > 0 { + if set.iflag_has('skip_bytes') { + start = set.skip + } else { + start = set.skip * ibs + } + if start > data.len { + start = u64(data.len) + } + } + + mut out_offset := u64(0) + if set.seek > 0 { + if set.oflag_has('seek_bytes') { + out_offset = set.seek + } else { + out_offset = set.seek * obs + } + } + + // count limits either records or bytes, per iflag=count_bytes. With no + // count operand the whole remainder is used. + mut end := u64(data.len) + if set.count_set { + mut limit := u64(0) + if set.iflag_has('count_bytes') { + limit = set.count + } else { + limit = set.count * ibs + } + end = start + limit + if end > data.len { + end = u64(data.len) + } + } + if start > data.len { + return Report{}, 0 + } + data = data[int(start)..int(end)] + + // conv=sync pads the final short input block out to a whole one. + if set.conv_has('sync') && data.len % int(ibs) != 0 { + pad := int(ibs) - (data.len % int(ibs)) + for _ in 0 .. pad { + data << 0 + } + } + + // The record-level conversions all operate on the whole buffer here, + // because the byte conversions dd applies are order-independent. + if set.conv_has('lcase') { + data = map_case(data, false) + } + if set.conv_has('ucase') { + data = map_case(data, true) + } + if set.conv_has('swab') { + data = swap_pairs(data, ibs) + } + // conv=ascii/ebcdic and friends need cbs and are not implemented; the + // caller rejects them before reaching this point. + + mut report := Report{ + bytes: u64(data.len) + } + // Full records are counted from the input block size, so a short final + // block is reported as partial. + if data.len > 0 { + report.records_in = u64(data.len) / ibs + report.partial_in = u64(data.len) % ibs + } + + code := write_output(data, set, out_offset, mut report) + return report, code +} + +fn map_case(data []u8, upper bool) []u8 { + mut out := []u8{cap: data.len} + for c in data { + if upper { + if c >= `a` && c <= `z` { + out << c - 32 + } else { + out << c + } + } else { + if c >= `A` && c <= `Z` { + out << c + 32 + } else { + out << c + } + } + } + return out +} + +fn swap_pairs(data []u8, block u64) []u8 { + mut out := []u8{cap: data.len} + // swab exchanges the two bytes of each pair *within a block*, so with + // bs=1 there is nothing to exchange and the data passes through. + if block < 2 { + return data.clone() + } + mut i := 0 + for i + 1 < data.len { + out << data[i + 1] + out << data[i] + i += 2 + } + for i < data.len { + out << data[i] + i++ + } + return out +} + +fn read_input(set Settings) ?[]u8 { + if set.ifile == '' || set.ifile == '-' { + mut out := []u8{} + mut chunk := []u8{len: 64 * 1024} + mut f := os.stdin() + for { + n := f.read(mut chunk) or { break } + if n == 0 { + break + } + out << chunk[..n] + } + return out + } + if !os.exists(set.ifile) { + report_error(set, "failed to open '${set.ifile}': No such file or directory") + return none + } + return os.read_file_array(set.ifile) +} + +fn write_output(data []u8, set Settings, out_offset u64, mut report Report) int { + if set.ofile == '' || set.ofile == '-' { + unsafe { + write_fds(1, data.data, u64(data.len)) + } + report.records_out = report.records_in + report.partial_out = report.partial_in + return 0 + } + + exists := os.exists(set.ofile) + if set.conv_has('excl') && exists { + report_error(set, "failed to open '${set.ofile}': File exists") + return 1 + } + if set.conv_has('nocreat') && !exists { + report_error(set, "failed to open '${set.ofile}': No such file or directory") + return 1 + } + + mut f := if exists && set.conv_has('notrunc') { + os.open_file(set.ofile, 'r+') or { + report_error(set, "failed to open '${set.ofile}': ${err}") + return 1 + } + } else { + os.create(set.ofile) or { + report_error(set, "failed to open '${set.ofile}': ${err}") + return 1 + } + } + if out_offset > 0 { + f.seek(i64(out_offset), .start) or {} + } + f.write(data) or { + f.close() + report_error(set, 'writing to ${set.ofile}: ${err}') + return 1 + } + f.close() + report.records_out = report.records_in + report.partial_out = report.partial_in + return 0 +} + +fn report_error(set Settings, msg string) { + if set.status == .none { + return + } + eprintln('dd: ${msg}') +} diff --git a/src/dd/dd.v b/src/dd/dd.v new file mode 100644 index 00000000..6db386ea --- /dev/null +++ b/src/dd/dd.v @@ -0,0 +1,29 @@ +import common.dd as ddmod +import os + +fn main() { + set := ddmod.parse_args(os.args[1..]) + report, code := ddmod.run(set) + if code == 0 { + print_summary(report, set) + } + exit(code) +} + +// print_summary writes the three lines GNU writes on completion, or the two +// that omit the byte total under status=noxfer, or nothing at all under +// status=none. The elapsed time and rate are not printed because they are not +// reproducible. +fn print_summary(report ddmod.Report, set ddmod.Settings) { + if set.status == .none { + return + } + if set.status == .noxfer { + eprintln('${report.records_in}+${report.partial_in} records in') + eprintln('${report.records_out}+${report.partial_out} records out') + return + } + eprintln('${report.records_in}+${report.partial_in} records in') + eprintln('${report.records_out}+${report.partial_out} records out') + eprintln('${report.bytes} bytes copied') +} diff --git a/src/dd/dd_test.v b/src/dd/dd_test.v new file mode 100644 index 00000000..00a5f97f --- /dev/null +++ b/src/dd/dd_test.v @@ -0,0 +1,179 @@ +import common.dd as ddmod +import common.testing +import os + +const rig = testing.prepare_rig(util: 'dd') + +const input_path = 'dd_input.txt' +const input_content = '0123456789' + +struct Result { +mut: + code int + stdout string +} + +fn run_dd(args []string) Result { + mut p := os.new_process(rig.executable_under_test) + p.set_args(args) + p.set_redirect_stdio() + p.wait() + r := Result{ + code: p.code + stdout: p.stdout_slurp() + } + p.close() + return r +} + +fn run_ref(args []string) Result { + mut full := ['dd'] + full << args + mut p := os.new_process(rig.platform_util_path) + p.set_args(full) + p.set_redirect_stdio() + p.wait() + r := Result{ + code: p.code + stdout: p.stdout_slurp() + } + p.close() + return r +} + +fn assert_same(args []string) { + tr := run_dd(args) + uu := run_ref(args) + assert tr.code == uu.code, 'exit code for [${args}]: tr=${tr.code} uu=${uu.code}' + assert tr.stdout == uu.stdout, 'stdout for [${args}]: tr="${tr.stdout}" uu="${uu.stdout}"' +} + +fn testsuite_begin() { + rig.assert_platform_util() + os.write_file(input_path, input_content)! +} + +fn testsuite_end() { + os.rm(input_path) or {} +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +fn test_matches_reference_block_sizes() { + assert_same(['if=' + input_path, 'bs=2', 'count=3']) + assert_same(['if=' + input_path, 'bs=1', 'count=4']) + assert_same(['if=' + input_path]) +} + +fn test_matches_reference_binary_and_decimal_units() { + // A bare K is a power of two and a KB a power of ten, so these two + // deliberately read different amounts of the same 1000-byte input. + assert_same(['if=' + input_path, 'bs=1K', 'count=1']) + assert_same(['if=' + input_path, 'bs=1KB', 'count=1']) + assert_same(['if=' + input_path, 'bs=1b', 'count=1']) + assert_same(['if=' + input_path, 'bs=1c', 'count=4']) + assert_same(['if=' + input_path, 'bs=1w', 'count=4']) +} + +fn test_matches_reference_skip_and_count_flags() { + assert_same(['if=' + input_path, 'bs=2', 'skip=2']) + assert_same(['if=' + input_path, 'bs=2', 'skip=4', 'iflag=skip_bytes']) + assert_same(['if=' + input_path, 'bs=2', 'count=4', 'iflag=count_bytes']) +} + +fn test_matches_reference_conversions() { + assert_same(['if=' + input_path, 'conv=lcase']) + // bs=1 leaves swab nothing to exchange, so both are identical by design. + assert_same(['if=' + input_path, 'conv=swab', 'bs=1']) + // At the default block size the pairs really are exchanged, which is the + // case that distinguishes a whole-buffer swap from a per-block one. + assert_same(['if=' + input_path, 'conv=swab']) + assert_same(['if=' + input_path, 'bs=4', 'count=1', 'conv=sync']) +} + +fn test_matches_reference_status_none() { + assert_same(['if=' + input_path, 'bs=2', 'count=2', 'status=none']) +} + +fn test_matches_reference_failures() { + // bs=0 is rejected by both. uutils aborts with a Rust panic status here + // while GNU exits 1; this port follows GNU. + assert_same(['if=C:\\nope\\f']) + assert_same(['if=' + input_path, 'bs=abc']) + assert_same(['if=' + input_path, 'bs=']) + assert_same(['bogus=1']) +} + +fn test_parse_args_defaults() { + s := ddmod.parse_args([]) + assert s.bs == 512 + assert !s.count_set + assert s.status == .full + assert s.conv.len == 0 +} + +fn test_parse_args_operands() { + s := ddmod.parse_args(['if=a', 'of=b', 'bs=4', 'count=2', 'skip=1', 'seek=3']) + assert s.ifile == 'a' + assert s.ofile == 'b' + assert s.bs == 4 + assert s.ibs == 4 + assert s.obs == 4 + assert s.count == 2 + assert s.count_set + assert s.skip == 1 + assert s.seek == 3 +} + +fn test_parse_args_ibs_obs_override_bs() { + s := ddmod.parse_args(['bs=10', 'ibs=4', 'obs=6']) + assert s.ibs == 4 + assert s.obs == 6 + assert s.ibs_set + assert s.obs_set +} + +fn test_parse_args_status_and_conv_lists() { + s := ddmod.parse_args(['status=none', 'conv=notrunc,sync,lcase']) + assert s.status == .none + assert s.conv == ['notrunc', 'sync', 'lcase'] + + n := ddmod.parse_args(['status=noxfer']) + assert n.status == .noxfer +} + +fn test_parse_args_iflag_oflag_lists() { + s := ddmod.parse_args(['iflag=count_bytes,skip_bytes', 'oflag=seek_bytes']) + assert s.iflag == ['count_bytes', 'skip_bytes'] + assert s.oflag == ['seek_bytes'] +} + +fn test_unit_suffix_arithmetic() { + // Exercised through parse_args so the value actually lands in Settings. + from_bs := fn (v string) u64 { + return ddmod.parse_args(['bs=' + v]).bs + } + assert from_bs('1b') == 512 + assert from_bs('1c') == 1 + assert from_bs('1w') == 2 + assert from_bs('1K') == 1024 + assert from_bs('1k') == 1024 + assert from_bs('1KiB') == 1024 + assert from_bs('1KB') == 1000 + assert from_bs('1kB') == 1000 + assert from_bs('2K') == 2048 + assert from_bs('3KB') == 3000 + // A bare letter means one unit. + assert from_bs('K') == 1024 + assert from_bs('KB') == 1000 +} + +fn test_zero_is_allowed_only_where_gnu_allows_it() { + // count=0 and skip=0 are legal; a zero block size is not. + assert ddmod.parse_args(['count=0']).count_set + assert ddmod.parse_args(['skip=0']).skip == 0 + // The block-size rejection happens inside parse_args, which exits, so it + // is covered by the differential failures test above. +}