diff --git a/cmake/Config.cmake b/cmake/Config.cmake index 36ef655..84fdf8d 100644 --- a/cmake/Config.cmake +++ b/cmake/Config.cmake @@ -30,6 +30,7 @@ set(CFBOX_APPLETS clear which mount mountpoint mdev chmod chown chgrp umount swapoff reboot nc ifconfig ip route netstat ping traceroute + dd ) foreach(applet IN LISTS CFBOX_APPLETS) diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in index bab4c4b..1ba998c 100644 --- a/include/cfbox/applet_config.hpp.in +++ b/include/cfbox/applet_config.hpp.in @@ -7,6 +7,7 @@ #cmakedefine01 CFBOX_ENABLE_ECHO #cmakedefine01 CFBOX_ENABLE_PRINTF #cmakedefine01 CFBOX_ENABLE_CAT +#cmakedefine01 CFBOX_ENABLE_DD #cmakedefine01 CFBOX_ENABLE_HEAD #cmakedefine01 CFBOX_ENABLE_TAIL #cmakedefine01 CFBOX_ENABLE_WC diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index 12cdf5f..2457d7f 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -13,6 +13,9 @@ extern auto printf_main(int argc, char* argv[]) -> int; #if CFBOX_ENABLE_CAT extern auto cat_main(int argc, char* argv[]) -> int; #endif +#if CFBOX_ENABLE_DD +extern auto dd_main(int argc, char* argv[]) -> int; +#endif #if CFBOX_ENABLE_HEAD extern auto head_main(int argc, char* argv[]) -> int; #endif @@ -398,6 +401,9 @@ constexpr auto APPLET_REGISTRY = std::to_array({ #if CFBOX_ENABLE_CAT {"cat", cat_main, "concatenate files and print"}, #endif +#if CFBOX_ENABLE_DD + {"dd", dd_main, "convert and copy a file"}, +#endif #if CFBOX_ENABLE_HEAD {"head", head_main, "output the first part of files"}, #endif diff --git a/src/applets/cut.cpp b/src/applets/cut.cpp index d868945..721f803 100644 --- a/src/applets/cut.cpp +++ b/src/applets/cut.cpp @@ -109,12 +109,9 @@ auto cut_main(int argc, char* argv[]) -> int { for (auto p : paths) { auto result = cfbox::stream::for_each_line(p, [&](const std::string& line, std::size_t) { if (char_mode) { - bool first = true; for (int idx : indices) { if (idx >= 1 && static_cast(idx - 1) < line.size()) { - if (!first) std::putchar(delim); std::putchar(line[idx - 1]); - first = false; } } std::putchar('\n'); diff --git a/src/applets/dd.cpp b/src/applets/dd.cpp new file mode 100644 index 0000000..1041176 --- /dev/null +++ b/src/applets/dd.cpp @@ -0,0 +1,286 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "dd", + .version = CFBOX_VERSION_STRING, + .one_line = "convert and copy a file", + .usage = "dd [OPERAND...]", + .options = " bs=BYTES read/write BYTES at a time (sets ibs and obs)\n" + " ibs=BYTES read BYTES at a time (default 512)\n" + " obs=BYTES write BYTES at a time (default 512)\n" + " count=N copy only N input blocks\n" + " if=FILE read from FILE (default stdin)\n" + " of=FILE write to FILE (default stdout)\n" + " skip=N skip N ibs-sized blocks at start of input\n" + " seek=N skip N obs-sized blocks at start of output\n" + " conv=CONVS comma-separated: notrunc,noerror,sync,fsync,fdatasync\n" + " status=LV none | noxfer | progress", + .extra = "BYTES suffix: c=1 w=2 b=512 K=1024 M=1048576 G T (KiB/MiB/GiB also)", +}; + +// Parse a byte-count / numeric operand with optional suffix. Returns false on +// malformed input; never throws. +auto parse_num(std::string_view s, std::uint64_t& out) -> bool { + if (s.empty()) return false; + std::uint64_t n = 0; + std::size_t i = 0; + while (i < s.size() && s[i] >= '0' && s[i] <= '9') { + n = n * 10 + static_cast(s[i] - '0'); + ++i; + } + if (i == 0) return false; // must start with a digit + std::uint64_t mult = 1; + if (i < s.size()) { + std::string_view suf = s.substr(i); + if (suf == "c") mult = 1; + else if (suf == "w") mult = 2; + else if (suf == "b") mult = 512; + else if (suf == "K" || suf == "KiB" || suf == "k") mult = 1024; + else if (suf == "M" || suf == "MiB") mult = 1024ull * 1024; + else if (suf == "G" || suf == "GiB") mult = 1024ull * 1024 * 1024; + else if (suf == "T" || suf == "TiB") mult = 1024ull * 1024 * 1024 * 1024; + else return false; + } + out = n * mult; + return true; +} + +auto to_lower(std::string_view s) -> std::string { + std::string out(s); + for (auto& c : out) c = static_cast(std::tolower(static_cast(c))); + return out; +} + +struct DdOpts { + std::size_t ibs = 512; + std::size_t obs = 512; + std::uint64_t count = 0; // 0 = unlimited + std::uint64_t skip = 0; + std::uint64_t seek = 0; + std::string if_path; + std::string of_path; + bool has_if = false; + bool has_of = false; + bool conv_notrunc = false; + bool conv_noerror = false; + bool conv_sync = false; + bool conv_fsync = false; + bool conv_fdatasync = false; + enum Status { kDefault, kNone, kNoxfer }; + Status status = kDefault; +}; + +auto parse_conv(DdOpts& o, std::string_view csv) -> bool { + std::size_t start = 0; + while (start <= csv.size()) { + auto comma = csv.find(',', start); + auto end = (comma == std::string_view::npos) ? csv.size() : comma; + std::string token = to_lower(csv.substr(start, end - start)); + if (token == "notrunc") o.conv_notrunc = true; + else if (token == "noerror") o.conv_noerror = true; + else if (token == "sync") o.conv_sync = true; + else if (token == "fsync") o.conv_fsync = true; + else if (token == "fdatasync") o.conv_fdatasync = true; + else return false; + if (comma == std::string_view::npos) break; + start = comma + 1; + } + return true; +} + +// Write exactly n bytes; loop over short writes. Returns false on hard error. +auto write_full(int fd, const unsigned char* buf, std::size_t n) -> bool { + std::size_t done = 0; + while (done < n) { + ssize_t w = ::write(fd, buf + done, n - done); + if (w < 0) { + if (errno == EINTR) continue; + return false; + } + done += static_cast(w); + } + return true; +} + +} // namespace + +auto dd_main(int argc, char* argv[]) -> int { + DdOpts o; + + for (int i = 1; i < argc; ++i) { + std::string_view arg(argv[i]); + if (arg == "--help") { cfbox::help::print_help(HELP); return 0; } + if (arg == "--version") { cfbox::help::print_version(HELP); return 0; } + + auto eq = arg.find('='); + if (eq == std::string_view::npos) { + CFBOX_ERR("dd", "unrecognized operand '%s'", std::string(arg).c_str()); + return 1; + } + std::string_view key = arg.substr(0, eq); + std::string_view val = arg.substr(eq + 1); + std::uint64_t n = 0; + + if (key == "bs") { + if (!parse_num(val, n) || n == 0) { CFBOX_ERR("dd", "invalid bs: %s", std::string(val).c_str()); return 1; } + o.ibs = o.obs = static_cast(n); + } else if (key == "ibs") { + if (!parse_num(val, n) || n == 0) { CFBOX_ERR("dd", "invalid ibs: %s", std::string(val).c_str()); return 1; } + o.ibs = static_cast(n); + } else if (key == "obs") { + if (!parse_num(val, n) || n == 0) { CFBOX_ERR("dd", "invalid obs: %s", std::string(val).c_str()); return 1; } + o.obs = static_cast(n); + } else if (key == "count" || key == "skip" || key == "seek") { + if (!parse_num(val, n)) { CFBOX_ERR("dd", "invalid %.*s: %s", static_cast(key.size()), key.data(), std::string(val).c_str()); return 1; } + if (key == "count") o.count = n; + else if (key == "skip") o.skip = n; + else o.seek = n; + } else if (key == "if") { + o.if_path = std::string(val); o.has_if = true; + } else if (key == "of") { + o.of_path = std::string(val); o.has_of = true; + } else if (key == "conv") { + if (!parse_conv(o, val)) { CFBOX_ERR("dd", "invalid conv: %s", std::string(val).c_str()); return 1; } + } else if (key == "status") { + std::string s = to_lower(val); + if (s == "none") o.status = DdOpts::kNone; + else if (s == "noxfer") o.status = DdOpts::kNoxfer; + else if (s == "progress") o.status = DdOpts::kDefault; + else { CFBOX_ERR("dd", "invalid status: %s", std::string(val).c_str()); return 1; } + } else { + CFBOX_ERR("dd", "unrecognized operand '%s'", std::string(arg).c_str()); + return 1; + } + } + + // open input (default stdin) + int ifd = STDIN_FILENO; + cfbox::io::unique_fd ifd_guard; + if (o.has_if) { + int fd = ::open(o.if_path.c_str(), O_RDONLY); + if (fd < 0) { CFBOX_ERR("dd", "cannot open '%s': %s", o.if_path.c_str(), std::strerror(errno)); return 1; } + ifd = fd; ifd_guard.reset(fd); + } + + // open output (default stdout). notrunc keeps existing content; otherwise O_TRUNC. + int ofd = STDOUT_FILENO; + cfbox::io::unique_fd ofd_guard; + if (o.has_of) { + int flags = O_WRONLY | O_CREAT | (o.conv_notrunc ? 0 : O_TRUNC); + int fd = ::open(o.of_path.c_str(), flags, 0666); + if (fd < 0) { CFBOX_ERR("dd", "cannot open '%s': %s", o.of_path.c_str(), std::strerror(errno)); return 1; } + ofd = fd; ofd_guard.reset(fd); + } + + // skip input blocks (lseek if seekable, else drain by reading) + std::vector skip_buf(o.ibs); + if (o.skip > 0 && ::lseek(ifd, static_cast(o.skip * o.ibs), SEEK_SET) < 0) { + for (std::uint64_t k = 0; k < o.skip; ++k) { + std::size_t got = 0; + while (got < o.ibs) { + ssize_t r = ::read(ifd, skip_buf.data() + got, o.ibs - got); + if (r == 0) break; // EOF + if (r < 0) { + if (errno == EINTR) continue; + if (o.conv_noerror) break; + CFBOX_ERR("dd", "read error (skip): %s", std::strerror(errno)); return 1; + } + got += static_cast(r); + } + } + } + + // seek output blocks (only meaningful for seekable of) + if (o.seek > 0) { + ::lseek(ofd, static_cast(o.seek * o.obs), SEEK_SET); + } + + // main copy loop. Input is read in ibs blocks; output is assembled into obs + // blocks (reblocking when ibs != obs). conv=sync zero-pads short reads to ibs. + std::vector in_buf(o.ibs); + std::vector out_buf(o.obs); + std::size_t out_len = 0; + std::uint64_t rec_in_full = 0, rec_in_partial = 0; + std::uint64_t rec_out_full = 0, rec_out_partial = 0; + std::uint64_t total_bytes = 0; + bool eof = false; + + auto flush_out = [&]() { + if (out_len > 0) { + if (!write_full(ofd, out_buf.data(), out_len)) { + CFBOX_ERR("dd", "write error: %s", std::strerror(errno)); + return false; + } + ++rec_out_partial; + total_bytes += out_len; + out_len = 0; + } + return true; + }; + + while (!eof && (o.count == 0 || rec_in_full + rec_in_partial < o.count)) { + ssize_t r = ::read(ifd, in_buf.data(), o.ibs); + if (r < 0) { + if (errno == EINTR) continue; + if (o.conv_noerror) { ++rec_in_partial; continue; } + CFBOX_ERR("dd", "read error: %s", std::strerror(errno)); return 1; + } + if (r == 0) { eof = true; break; } + std::size_t got = static_cast(r); + if (got == o.ibs) ++rec_in_full; else ++rec_in_partial; + if (o.conv_sync) std::memset(in_buf.data() + got, 0, o.ibs - got); + + // reblock got bytes (padded to ibs if sync) into obs-sized output + std::size_t in_off = 0; + std::size_t avail = o.conv_sync ? o.ibs : got; + while (in_off < avail) { + std::size_t space = o.obs - out_len; + std::size_t chunk = (space < avail - in_off) ? space : (avail - in_off); + std::memcpy(out_buf.data() + out_len, in_buf.data() + in_off, chunk); + out_len += chunk; + in_off += chunk; + if (out_len == o.obs) { + if (!write_full(ofd, out_buf.data(), o.obs)) { + CFBOX_ERR("dd", "write error: %s", std::strerror(errno)); return 1; + } + ++rec_out_full; + total_bytes += o.obs; + out_len = 0; + } + } + } + if (!flush_out()) return 1; + + if (o.conv_fdatasync) ::fdatasync(ofd); + if (o.conv_fsync) ::fsync(ofd); + + // transfer statistics to stderr (unless status=none) + if (o.status != DdOpts::kNone) { + std::fflush(nullptr); + std::fprintf(stderr, "%llu+%llu records in\n", + static_cast(rec_in_full), + static_cast(rec_in_partial)); + std::fprintf(stderr, "%llu+%llu records out\n", + static_cast(rec_out_full), + static_cast(rec_out_partial)); + if (o.status != DdOpts::kNoxfer) { + std::fprintf(stderr, "%llu bytes transferred\n", + static_cast(total_bytes)); + } + } + return 0; +} diff --git a/src/applets/sh/sh_builtins.cpp b/src/applets/sh/sh_builtins.cpp index 270178f..7771562 100644 --- a/src/applets/sh/sh_builtins.cpp +++ b/src/applets/sh/sh_builtins.cpp @@ -6,7 +6,11 @@ #include #include #include +#include #include +#include +#include +#include #include #include #include @@ -313,6 +317,277 @@ static int builtin_trap(std::vector& args, ShellState& state) { return 0; } +// Locate an external command in $PATH; returns "" if not found or if name +// already contains a slash that isn't executable. +static auto find_in_path(const std::string& name, ShellState& state) -> std::string { + if (name.empty()) return {}; + if (name.find('/') != std::string::npos) { + return (::access(name.c_str(), X_OK) == 0) ? name : std::string{}; + } + std::string path = state.get_var("PATH"); + if (path.empty()) path = "/bin:/usr/bin"; + std::istringstream ss(path); + std::string dir; + while (std::getline(ss, dir, ':')) { + if (dir.empty()) continue; + std::string full = dir + "/" + name; + if (::access(full.c_str(), X_OK) == 0) return full; + } + return {}; +} + +static int builtin_type(std::vector& args, ShellState& state) { + int rc = 0; + for (std::size_t i = 1; i < args.size(); ++i) { + const std::string& name = args[i]; + if (is_builtin(name)) { + std::printf("%s is a shell builtin\n", name.c_str()); + } else if (state.is_function(name)) { + std::printf("%s is a shell function\n", name.c_str()); + } else { + std::string p = find_in_path(name, state); + if (!p.empty()) { + std::printf("%s is %s\n", name.c_str(), p.c_str()); + } else { + std::fprintf(stderr, "sh: type: %s: not found\n", name.c_str()); + rc = 1; + } + } + } + return rc; +} + +static int builtin_command(std::vector& args, ShellState& state) { + std::size_t i = 1; + bool describe = false; // -v: one-line description + bool describe_v = false; // -V: verbose + while (i < args.size() && args[i].size() >= 2 && args[i][0] == '-' && args[i] != "--") { + if (args[i] == "-v") { describe = true; ++i; } + else if (args[i] == "-V") { describe_v = true; ++i; } + else if (args[i] == "-p") { ++i; } // use default PATH — ignored + else break; + } + if (i < args.size() && args[i] == "--") ++i; + if (i >= args.size()) return 0; + + const std::string& name = args[i]; + if (describe || describe_v) { + if (is_builtin(name) || state.is_function(name)) { + std::printf("%s\n", name.c_str()); + return 0; + } + std::string p = find_in_path(name, state); + if (!p.empty()) { std::printf("%s\n", p.c_str()); return 0; } + return 1; + } + + // Execute name without consulting functions — fork + execvp. + std::vector store(args.begin() + static_cast(i), args.end()); + pid_t pid = ::fork(); + if (pid < 0) { CFBOX_ERR("sh", "command: fork failed"); return 1; } + if (pid == 0) { + std::vector av; + for (auto& s : store) av.push_back(const_cast(s.c_str())); + av.push_back(nullptr); + ::execvp(name.c_str(), av.data()); + _exit(127); + } + int status = 0; + ::waitpid(pid, &status, 0); + return WIFEXITED(status) ? WEXITSTATUS(status) : 128; +} + +static int builtin_exec(std::vector& args, ShellState& /*state*/) { + if (args.size() <= 1) return 0; // only redirections (none here) + std::vector av; + for (std::size_t i = 1; i < args.size(); ++i) av.push_back(const_cast(args[i].c_str())); + av.push_back(nullptr); + ::execvp(args[1].c_str(), av.data()); + CFBOX_ERR("sh", "exec: %s: %s", args[1].c_str(), std::strerror(errno)); + return 127; // exec failed; the caller decides whether to abort +} + +static int builtin_getopts(std::vector& args, ShellState& state) { + if (args.size() < 3) { + CFBOX_ERR("sh", "getopts: usage: getopts optstring name [arg ...]"); + return 2; + } + const std::string& optstring = args[1]; + const std::string& varname = args[2]; + + int optind = 0; + { + std::string s = state.get_var("OPTIND"); + if (!s.empty()) optind = std::atoi(s.c_str()); + } + if (optind < 1) optind = 1; + + std::vector parse_args; + if (args.size() > 3) { + parse_args.assign(args.begin() + 3, args.end()); + } else { + parse_args = state.positional_params(); + } + + if (optind > static_cast(parse_args.size())) { + state.set_var(varname, "?"); + return 1; // no more options + } + const std::string& cur = parse_args[optind - 1]; + if (cur.size() < 2 || cur[0] != '-' || cur == "--") { + state.set_var(varname, "?"); + return 1; // not an option + } + + char opt = cur[1]; + auto pos = optstring.find(opt); + if (pos == std::string::npos) { + state.set_var(varname, "?"); + state.set_var("OPTIND", std::to_string(optind + 1)); + return 0; + } + bool takes_arg = (pos + 1 < optstring.size() && optstring[pos + 1] == ':'); + if (takes_arg) { + if (cur.size() > 2) { + state.set_var("OPTARG", cur.substr(2)); + state.set_var("OPTIND", std::to_string(optind + 1)); + } else if (optind < static_cast(parse_args.size())) { + state.set_var("OPTARG", parse_args[optind]); + state.set_var("OPTIND", std::to_string(optind + 2)); + } else { + state.set_var(varname, "?"); + state.unset_var("OPTARG"); + CFBOX_ERR("sh", "getopts: option requires an argument -- %c", opt); + return 1; + } + } else { + state.set_var("OPTIND", std::to_string(optind + 1)); + } + state.set_var(varname, std::string(1, opt)); + return 0; +} + +static int builtin_hash(std::vector& /*args*/, ShellState& /*state*/) { + // cfbox does not cache command lookups across invocations; hash is a + // no-op that reports success (POSIX permits, since there is nothing to forget). + return 0; +} + +static int builtin_umask(std::vector& args, ShellState& /*state*/) { + mode_t old = ::umask(0); + if (args.size() == 1) { + ::umask(old); + std::printf("%03o\n", static_cast(old)); + return 0; + } + const std::string& s = args[1]; + if (s == "-S") { + ::umask(old); + auto perm = [](mode_t m, char who) { + std::string out; + out += who; out += '='; + if (!(m & 0400)) out += 'r'; + if (!(m & 0200)) out += 'w'; + if (!(m & 0100)) out += 'x'; + return out; + }; + std::printf("%s,%s,%s\n", perm(old, 'u').c_str(), perm(old >> 3, 'g').c_str(), + perm(old >> 6, 'o').c_str()); + return 0; + } + char* end = nullptr; + long m = std::strtol(s.c_str(), &end, 8); + if (*end != '\0' || m < 0 || m > 0777) { + ::umask(old); + CFBOX_ERR("sh", "umask: %s: octal number out of range", s.c_str()); + return 1; + } + ::umask(static_cast(m)); + return 0; +} + +static int builtin_ulimit(std::vector& args, ShellState& /*state*/) { + bool all = false; + int resource = RLIMIT_FSIZE; + std::size_t i = 1; + while (i < args.size() && args[i].size() >= 2 && args[i][0] == '-') { + for (char c : args[i].substr(1)) { + switch (c) { + case 'a': all = true; break; + case 'f': resource = RLIMIT_FSIZE; break; + case 'c': resource = RLIMIT_CORE; break; + case 'd': resource = RLIMIT_DATA; break; + case 's': resource = RLIMIT_STACK; break; + case 'n': resource = RLIMIT_NOFILE; break; + case 'v': resource = RLIMIT_AS; break; + default: break; + } + } + ++i; + } + + auto print_one = [](int res, const char* label) { + struct rlimit rl {}; + if (::getrlimit(res, &rl) == 0) { + std::printf("%-20s %lu\n", label, static_cast(rl.rlim_cur)); + } + }; + + if (all) { + print_one(RLIMIT_CORE, "core file size"); + print_one(RLIMIT_DATA, "data seg size"); + print_one(RLIMIT_FSIZE, "file size"); + print_one(RLIMIT_NOFILE, "open files"); + print_one(RLIMIT_STACK, "stack size"); + print_one(RLIMIT_AS, "virtual memory"); + return 0; + } + + struct rlimit rl {}; + ::getrlimit(resource, &rl); + if (i < args.size()) { + rlim_t val = static_cast(std::strtoul(args[i].c_str(), nullptr, 10)); + rl.rlim_cur = val; + if (val > rl.rlim_max) rl.rlim_max = val; + if (::setrlimit(resource, &rl) != 0) { + CFBOX_ERR("sh", "ulimit: %s", std::strerror(errno)); + return 1; + } + return 0; + } + std::printf("%lu\n", static_cast(rl.rlim_cur)); + return 0; +} + +static int builtin_wait(std::vector& args, ShellState& /*state*/) { + int status = 0; + pid_t target = (args.size() > 1) ? static_cast(std::atoi(args[1].c_str())) : -1; + if (::waitpid(target, &status, 0) < 0) { + CFBOX_ERR("sh", "wait: %s", std::strerror(errno)); + return 1; + } + return WIFEXITED(status) ? WEXITSTATUS(status) : 1; +} + +static int builtin_times(std::vector& /*args*/, ShellState& /*state*/) { + struct rusage self {}, children {}; + ::getrusage(RUSAGE_SELF, &self); + ::getrusage(RUSAGE_CHILDREN, &children); + auto fmt = [](const struct timeval& u, const struct timeval& s) { + std::printf("%ldm%.03lds %ldm%.03lds\n", + static_cast(u.tv_sec / 60), static_cast(u.tv_sec % 60), + static_cast(s.tv_sec / 60), static_cast(s.tv_sec % 60)); + }; + fmt(self.ru_utime, self.ru_stime); + fmt(children.ru_utime, children.ru_stime); + return 0; +} + +static int builtin_unalias(std::vector& /*args*/, ShellState& /*state*/) { + // cfbox has no alias mechanism; unalias is a successful no-op (nothing to remove). + return 0; +} + auto get_builtins() -> const std::unordered_map& { static const std::unordered_map builtins = { {"echo", builtin_echo}, @@ -335,6 +610,16 @@ auto get_builtins() -> const std::unordered_map& { {"break", builtin_break}, {"continue", builtin_continue}, {"trap", builtin_trap}, + {"type", builtin_type}, + {"command", builtin_command}, + {"exec", builtin_exec}, + {"getopts", builtin_getopts}, + {"hash", builtin_hash}, + {"umask", builtin_umask}, + {"ulimit", builtin_ulimit}, + {"wait", builtin_wait}, + {"times", builtin_times}, + {"unalias", builtin_unalias}, }; return builtins; } diff --git a/src/applets/wc.cpp b/src/applets/wc.cpp index 6d349e9..81b4cdf 100644 --- a/src/applets/wc.cpp +++ b/src/applets/wc.cpp @@ -47,10 +47,10 @@ auto wc_count(std::FILE* f) -> WcCounts { } auto print_counts(const WcCounts& c, bool show_lines, bool show_words, - bool show_bytes, bool all) -> void { - if (all || show_lines) std::printf("%8ld", c.lines); - if (all || show_words) std::printf("%8ld", c.words); - if (all || show_bytes) std::printf("%8ld", c.bytes); + bool show_bytes, bool all, bool pad) -> void { + if (all || show_lines) { if (pad) std::printf("%8ld", c.lines); else std::printf("%ld", c.lines); } + if (all || show_words) { if (pad) std::printf("%8ld", c.words); else std::printf("%ld", c.words); } + if (all || show_bytes) { if (pad) std::printf("%8ld", c.bytes); else std::printf("%ld", c.bytes); } } auto wc_file(std::string_view path) -> cfbox::base::Result { @@ -91,7 +91,7 @@ auto wc_main(int argc, char* argv[]) -> int { CFBOX_ERR("wc", "%s", result.error().msg.c_str()); return 1; } - print_counts(*result, show_lines, show_words, show_bytes, all); + print_counts(*result, show_lines, show_words, show_bytes, all, /*pad=*/false); std::putchar('\n'); return 0; } @@ -102,7 +102,7 @@ auto wc_main(int argc, char* argv[]) -> int { CFBOX_ERR("wc", "%s", result.error().msg.c_str()); return 1; } - print_counts(*result, show_lines, show_words, show_bytes, all); + print_counts(*result, show_lines, show_words, show_bytes, all, /*pad=*/false); std::printf(" %s\n", std::string{pos[0]}.c_str()); return 0; } @@ -120,11 +120,11 @@ auto wc_main(int argc, char* argv[]) -> int { total.lines += result->lines; total.words += result->words; total.bytes += result->bytes; - print_counts(*result, show_lines, show_words, show_bytes, all); + print_counts(*result, show_lines, show_words, show_bytes, all, /*pad=*/true); std::printf(" %s\n", std::string{p}.c_str()); } - print_counts(total, show_lines, show_words, show_bytes, all); + print_counts(total, show_lines, show_words, show_bytes, all, /*pad=*/true); std::puts(" total"); return rc; } diff --git a/tests/differential/framework.sh b/tests/differential/framework.sh new file mode 100755 index 0000000..3033955 --- /dev/null +++ b/tests/differential/framework.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# tests/differential/framework.sh — differential testing harness. +# +# Runs the same applet invocation through cfbox and a reference (busybox), +# classifies the result: MATCH (identical stdout + exit), or FAIL (divergence +# worth investigating). KNOWN intentional differences can be filtered with +# skip_if_known, but the default is to surface every divergence so it can be +# triaged. +# +# Env: +# CFBOX cfbox binary (default: build-size/cfbox or build/cfbox) +# BUSYBOX reference oracle (default: competition/busybox/busybox) +# +# Source this file; then call run_diff / run_diff_in and diff_summary. +set -euo pipefail + +DIFF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$DIFF_DIR/../.." && pwd)" + +CFBOX="${CFBOX:-}" +if [[ -z "$CFBOX" ]]; then + for c in "$PROJECT_DIR/build-size/cfbox" "$PROJECT_DIR/build/cfbox"; do + [[ -x "$c" ]] && CFBOX="$c" && break + done +fi +BUSYBOX="${BUSYBOX:-$PROJECT_DIR/competition/busybox/busybox}" + +DIFF_PASS=0 +DIFF_FAIL=0 + +diff_check_oracles() { + [[ -x "$CFBOX" ]] || { echo "framework: CFBOX not found (build cfbox first)" >&2; exit 2; } + [[ -x "$BUSYBOX" ]] || { echo "framework: BUSYBOX not found ($BUSYBOX) — run 'make' in competition/busybox" >&2; exit 2; } +} + +# run_diff DESC -- APPLET ARGS... +run_diff() { + local desc="$1"; shift + [[ "${1:-}" == "--" ]] && shift + local cf_out cf_rc bb_out bb_rc + set +e + cf_out=$("$CFBOX" "$@" 2>/dev/null); cf_rc=$? + bb_out=$("$BUSYBOX" "$@" 2>/dev/null); bb_rc=$? + set -e + if [[ "$cf_out" == "$bb_out" && "$cf_rc" == "$bb_rc" ]]; then + DIFF_PASS=$((DIFF_PASS + 1)) + else + echo "FAIL [$desc] cfbox: $CFBOX $*" + echo " cfbox (rc=$cf_rc): ${cf_out:0:140}" + echo " busybox(rc=$bb_rc): ${bb_out:0:140}" + DIFF_FAIL=$((DIFF_FAIL + 1)) + fi +} + +# run_diff_in DESC INPUT -- APPLET ARGS... (INPUT fed on stdin to both) +run_diff_in() { + local desc="$1"; local input="$2"; shift 2 + [[ "${1:-}" == "--" ]] && shift + local cf_out cf_rc bb_out bb_rc + set +e + cf_out=$(printf '%s' "$input" | "$CFBOX" "$@" 2>/dev/null); cf_rc=$? + bb_out=$(printf '%s' "$input" | "$BUSYBOX" "$@" 2>/dev/null); bb_rc=$? + set -e + if [[ "$cf_out" == "$bb_out" && "$cf_rc" == "$bb_rc" ]]; then + DIFF_PASS=$((DIFF_PASS + 1)) + else + echo "FAIL [$desc] stdin fed, args: $*" + echo " cfbox (rc=$cf_rc): ${cf_out:0:140}" + echo " busybox(rc=$bb_rc): ${bb_out:0:140}" + DIFF_FAIL=$((DIFF_FAIL + 1)) + fi +} + +diff_summary() { + local name="${1:-differential}" + echo "$name: $DIFF_PASS match, $DIFF_FAIL fail" + [[ $DIFF_FAIL -eq 0 ]] +} + +diff_check_oracles diff --git a/tests/differential/run_all.sh b/tests/differential/run_all.sh new file mode 100755 index 0000000..d55220c --- /dev/null +++ b/tests/differential/run_all.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Run all differential tests (cfbox vs busybox). NOT in CI by default — needs +# a built busybox oracle (competition/busybox/busybox). +set -euo pipefail +cd "$(dirname "$0")" + +fail=0 +total_pass=0 +for t in test_diff_*.sh; do + echo "=== $t ===" + if bash "$t"; then + : + else + fail=1 + fi +done + +if [[ $fail -eq 0 ]]; then + echo "All differential tests passed." +else + echo "Some differential tests reported divergences (see FAIL lines above)." +fi +exit $fail diff --git a/tests/differential/test_diff_coreutils.sh b/tests/differential/test_diff_coreutils.sh new file mode 100755 index 0000000..100512f --- /dev/null +++ b/tests/differential/test_diff_coreutils.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Differential tests: cfbox vs busybox on core text-processing applets. +set -euo pipefail +source "$(dirname "$0")/framework.sh" + +# --- echo --- +run_diff "echo basic" -- echo hello world +run_diff "echo -n" -- echo -n nonewline +run_diff "echo empty" -- echo +run_diff "echo flags" -- echo -e 'a\tb' + +# --- cat --- +run_diff_in "cat stdin" "line1 +line2 +line3" -- cat +run_diff_in "cat -n" "a +b" -- cat -n + +# --- wc --- +run_diff_in "wc -l" "a +b +c" -- wc -l +run_diff_in "wc -w" "one two three" -- wc -w +run_diff_in "wc -c" "abcdef" -- wc -c + +# --- head / tail --- +run_diff_in "head -n1" "a +b +c" -- head -n1 +run_diff_in "head -n2" "1 +2 +3" -- head -n2 +run_diff_in "tail -n1" "a +b +c" -- tail -n1 + +# --- sort --- +run_diff_in "sort alpha" "c +a +b" -- sort +run_diff_in "sort -n" "10 +2 +1" -- sort -n +run_diff_in "sort -r" "a +b +c" -- sort -r +run_diff_in "sort -u" "a +a +b +b" -- sort -u + +# --- uniq --- +run_diff_in "uniq basic" "a +a +b +a" -- uniq +run_diff_in "uniq -c" "x +x +y" -- uniq -c + +# --- rev / basename / dirname --- +run_diff_in "rev" "hello" -- rev +run_diff "basename" -- basename /a/b/c.txt +run_diff "basename suf" -- basename /a/b/c.txt .txt +run_diff "dirname" -- dirname /a/b/c + +# --- tr --- +run_diff_in "tr a-z A-Z" "hello" -- tr a-z A-Z +run_diff_in "tr -d" "hello" -- tr -d l + +# --- cut --- +run_diff_in "cut -f2" "a:b:c +1:2:3" -- cut -d: -f2 +run_diff_in "cut -c1-3" "abcdef +ghijkl" -- cut -c1-3 + +# --- seq / fold / expand --- +run_diff "seq 1 5" -- seq 1 5 +run_diff "seq -w 1 3" -- seq -w 1 3 +run_diff_in "fold -w3" "abcdef" -- fold -w3 +run_diff_in "expand" "a b" -- expand + +# --- printf / true / false --- +run_diff "printf str" -- printf '%s\n' hello +run_diff "true rc" -- true +run_diff "false rc" -- false + +diff_summary "diff-coreutils" diff --git a/tests/integration/test_dd.sh b/tests/integration/test_dd.sh new file mode 100755 index 0000000..520f283 --- /dev/null +++ b/tests/integration/test_dd.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +echo -n "hello world" > "$tmpdir/in" + +# basic file copy +"$CFBOX" dd if="$tmpdir/in" of="$tmpdir/out" bs=1 2>/dev/null +if [[ "$(cat "$tmpdir/out")" == "hello world" ]]; then ((++pass)); else echo "FAIL [basic]"; ((++fail)); fi + +# count limits bytes +"$CFBOX" dd if="$tmpdir/in" of="$tmpdir/out2" bs=1 count=5 2>/dev/null +if [[ "$(cat "$tmpdir/out2")" == "hello" ]]; then ((++pass)); else echo "FAIL [count]"; ((++fail)); fi + +# stdin -> stdout (bs=1 count=5) +actual=$(echo -n "stdin data" | "$CFBOX" dd bs=1 count=5 2>/dev/null) +if [[ "$actual" == "stdin" ]]; then ((++pass)); else echo "FAIL [stdin]: '$actual'"; ((++fail)); fi + +# bs size suffix (1K) +head -c 2048 /dev/zero | tr '\0' 'x' > "$tmpdir/big" +"$CFBOX" dd if="$tmpdir/big" of="$tmpdir/bigout" bs=1K 2>/dev/null +if [[ $(wc -c < "$tmpdir/bigout") -eq 2048 ]]; then ((++pass)); else echo "FAIL [bs suffix]"; ((++fail)); fi + +# conv=notrunc keeps the tail +echo -n "XXXXXXXXXX" > "$tmpdir/nt" +echo -n "YYY" > "$tmpdir/y" +"$CFBOX" dd if="$tmpdir/y" of="$tmpdir/nt" bs=1 count=3 conv=notrunc 2>/dev/null +if [[ "$(cat "$tmpdir/nt")" == "YYYXXXXXXX" ]]; then ((++pass)); else echo "FAIL [notrunc]"; ((++fail)); fi + +# skip +echo -n "ABCDEFGHIJ" > "$tmpdir/abc" +"$CFBOX" dd if="$tmpdir/abc" of="$tmpdir/abco" bs=1 skip=2 count=3 2>/dev/null +if [[ "$(cat "$tmpdir/abco")" == "CDE" ]]; then ((++pass)); else echo "FAIL [skip]"; ((++fail)); fi + +# status=none suppresses the transfer stats on stderr +actual=$(echo -n "x" | "$CFBOX" dd bs=1 status=none 2>&1 >/dev/null) +if [[ -z "$actual" ]]; then ((++pass)); else echo "FAIL [status=none]: '$actual'"; ((++fail)); fi + +# default prints stats to stderr +actual=$(echo -n "x" | "$CFBOX" dd bs=1 2>&1 >/dev/null) +if [[ "$actual" == *"records out"* ]]; then ((++pass)); else echo "FAIL [default stats]"; ((++fail)); fi + +echo "dd: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/integration/test_sh_builtins.sh b/tests/integration/test_sh_builtins.sh new file mode 100755 index 0000000..8a92cf2 --- /dev/null +++ b/tests/integration/test_sh_builtins.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# POSIX mandatory shell builtins coverage (cfbox sh). +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 + +ck() { # name expected command — exact match of `cfbox sh -c command` stdout(+stderr) + local name="$1" exp="$2" cmd="$3" got + got=$("$CFBOX" sh -c "$cmd" 2>&1) + if [[ "$got" == "$exp" ]]; then ((++pass)); else + echo "FAIL [$name]: want=$(printf '%q' "$exp") got=$(printf '%q' "$got")"; ((++fail)) + fi +} +re() { # name pattern command — regex match + local name="$1" pat="$2" cmd="$3" got + got=$("$CFBOX" sh -c "$cmd" 2>&1) + if [[ "$got" =~ $pat ]]; then ((++pass)); else + echo "FAIL [$name]: want=~$pat got=$(printf '%q' "$got")"; ((++fail)) + fi +} + +ck "type builtin" "echo is a shell builtin" 'type echo' +ck "command -v echo" "echo" 'command -v echo' +ck "exec replaces" "EXECED" 'exec echo EXECED' +ck "umask set+read" "077" 'umask 077; umask' +ck "hash success" "0" 'hash; echo $?' +ck "unalias success" "0" 'unalias nope; echo $?' +ck "getopts" "a" 'while getopts ab: opt -a; do echo $opt; break; done' + +re "umask read" '^[0-7]{3}$' 'umask' +re "ulimit -n" '^[0-9]+$' 'ulimit -n' +re "times" 'm[0-9]+s' 'times' +ck "wait is builtin" "wait is a shell builtin" 'type wait' # registered (job-wait needs & job machinery, separate work) + +echo "sh_builtins: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/posix/baseline b/tests/posix/baseline index b73d318..94f222b 100644 --- a/tests/posix/baseline +++ b/tests/posix/baseline @@ -1,2 +1,2 @@ -utility 61 -builtin 10 +utility 62 +builtin 20 diff --git a/tests/unit/test_dd.cpp b/tests/unit/test_dd.cpp new file mode 100644 index 0000000..2af70da --- /dev/null +++ b/tests/unit/test_dd.cpp @@ -0,0 +1,120 @@ +#include +#include +#include +#include +#include "test_capture.hpp" + +#if CFBOX_ENABLE_DD + +using namespace cfbox::test; + +namespace { +// Run dd with the given operand strings (e.g. "if=...", "bs=1"). +auto run_dd(std::vector args) -> int { + std::vector> storage; + std::vector argv; + argv.push_back(const_cast("dd")); + for (auto& a : args) { + storage.emplace_back(a.begin(), a.end()); + storage.back().push_back('\0'); + argv.push_back(storage.back().data()); + } + argv.push_back(nullptr); + return dd_main(static_cast(argv.size() - 1), argv.data()); +} + +auto read_file(const std::string& p) -> std::string { + auto r = cfbox::io::read_all(p); + return r ? *r : std::string{}; +} +} // namespace + +TEST(DdTest, BasicFileCopy) { + TempDir tmp; + tmp.write_file("in", "hello world"); + auto in = (tmp.path / "in").string(); + auto out = (tmp.path / "out").string(); + EXPECT_EQ(run_dd({"if=" + in, "of=" + out, "bs=1"}), 0); + EXPECT_EQ(read_file(out), "hello world"); +} + +TEST(DdTest, CountLimitsBytes) { + TempDir tmp; + tmp.write_file("in", "hello world"); + auto in = (tmp.path / "in").string(); + auto out = (tmp.path / "out").string(); + EXPECT_EQ(run_dd({"if=" + in, "of=" + out, "bs=1", "count=5"}), 0); + EXPECT_EQ(read_file(out), "hello"); +} + +TEST(DdTest, BsSizeSuffix) { + TempDir tmp; + tmp.write_file("in", std::string(2048, 'x')); + auto in = (tmp.path / "in").string(); + auto out = (tmp.path / "out").string(); + EXPECT_EQ(run_dd({"if=" + in, "of=" + out, "bs=1K"}), 0); + EXPECT_EQ(read_file(out).size(), 2048u); +} + +TEST(DdTest, SkipAndSeek) { + TempDir tmp; + tmp.write_file("in", "ABCDEFGHIJ"); + auto in = (tmp.path / "in").string(); + auto out = (tmp.path / "out").string(); + EXPECT_EQ(run_dd({"if=" + in, "of=" + out, "bs=1", "skip=2", "seek=1", "count=3"}), 0); + // skip 2 input bytes (CDE), seek 1 output byte → CDE at offset 1; byte 0 is a hole (\0) + auto r = read_file(out); + EXPECT_EQ(r.size(), 4u); + EXPECT_EQ(r.substr(1), "CDE"); +} + +TEST(DdTest, ConvNotruncKeepsTail) { + TempDir tmp; + tmp.write_file("out", "XXXXXXXXXX"); + tmp.write_file("in", std::string(3, 'Y')); + auto in = (tmp.path / "in").string(); + auto out = (tmp.path / "out").string(); + EXPECT_EQ(run_dd({"if=" + in, "of=" + out, "bs=1", "count=3", "conv=notrunc"}), 0); + EXPECT_EQ(read_file(out), "YYYXXXXXXX"); +} + +TEST(DdTest, ConvSyncPadsBlock) { + TempDir tmp; + tmp.write_file("in", "hello"); // 5 bytes; ibs=8 short-read, sync zero-pads to 8 + auto in = (tmp.path / "in").string(); + auto out = (tmp.path / "out").string(); + EXPECT_EQ(run_dd({"if=" + in, "of=" + out, "bs=8", "count=1", "conv=sync"}), 0); + auto r = read_file(out); + EXPECT_EQ(r.size(), 8u); + EXPECT_EQ(r, std::string("hello\0\0\0", 8)); +} + +TEST(DdTest, LargeBsReblocks) { + TempDir tmp; + tmp.write_file("in", std::string(10000, 'z')); + auto in = (tmp.path / "in").string(); + auto out = (tmp.path / "out").string(); + // ibs=512 obs=4096: read 512 blocks, write 4096 blocks → must reblock correctly + EXPECT_EQ(run_dd({"if=" + in, "of=" + out, "ibs=512", "obs=4096"}), 0); + EXPECT_EQ(read_file(out), std::string(10000, 'z')); +} + +TEST(DdTest, InvalidOperandRejected) { + TempDir tmp; + auto out = (tmp.path / "out").string(); + EXPECT_NE(run_dd({"bogus=1", "of=" + out}), 0); +} + +TEST(DdTest, InvalidBsRejected) { + TempDir tmp; + auto out = (tmp.path / "out").string(); + EXPECT_NE(run_dd({"of=" + out, "bs=xyz"}), 0); +} + +TEST(DdTest, ZeroBsRejected) { + TempDir tmp; + auto out = (tmp.path / "out").string(); + EXPECT_NE(run_dd({"of=" + out, "bs=0"}), 0); +} + +#endif // CFBOX_ENABLE_DD