From 40ee4953deb941caf10ccd606ed8f05821147f9c Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 08:53:51 +0800 Subject: [PATCH 1/8] docs(plan): record structure/perf cross-cutting batches, set Phase 3 next PLAN and ROADMAP had stalled at 'Phase 2 complete' while PR#17 (structure gates + STRUCTURE-TASTE) and PR#18 (PERFORMANCE + io/tar/cmp/md5sum/sed streaming + google-benchmark harness) landed on main. Sync the batch tables, restate baseline 436 GTest / 439 KB / 123 applet, point next at Phase 3 network, and leave a backfilled batch note. --- document/ai/PLAN.md | 19 ++++++++-- document/ai/ROADMAP.md | 2 +- .../notes/2026-07-06-structure-performance.md | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 document/notes/2026-07-06-structure-performance.md diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index 93cc710..55f04f2 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -1,9 +1,9 @@ # CFBox — 当前焦点(批级进度) -> Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md)。 -> **Phase 1.5 代码质量审查 ✅ 完成**(体积 -14%、消 iostream/stoi、统一错误宏、fs 封装扩展,379 测试全绿)。 -> **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。基线 399 测试 / 418 KB / 123 applet。 -> ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)。下一站:Phase 3 网络最小闭环。 +> Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 +> **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 +> ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 +> 🔄 **下一站:Phase 3 网络最小闭环**(socket/http/net_util/icmp 基础设施 + ip/ifconfig/route/netstat/ping/traceroute/nslookup/wget/nc/tftp + hostname 深化)。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -45,6 +45,17 @@ > 新增 6 applet(mount/mdev/umount/swapoff/reboot/poweroff),117→123。完整故事见 [changelogs/v0.3.0.md](../../changelogs/v0.3.0.md)。 +## ✅ 结构与性能标尺(横切批)— 已合并 PR#17 / PR#18(2026-07) + +> Phase 2 收尾后的两轮横切改进,建立两条季级标尺并落地首波优化。非 ROADMAP 某个 Phase,是跨 Phase 的工艺/性能基线(对标 Phase 1.5 的代码质量审查,但聚焦结构与性能维度)。 + +| 批 | 范围 | 状态 | Commit | 测试 | +|----|------|------|--------|------| +| 结构标尺(PR#17 `feat/test-floor-gates`) | [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md) 季级标尺(职责/DRY/边界/机械护栏)+ 清 banned-pattern + 合并 helper + [tests/check_structure_gates.sh](../../tests/check_structure_gates.sh) banned-pattern/layering gate(CI 守护) | ✅ | a1c1028 / 1ef38be / 623fca7(merge fefcbc2) | 436/0(gate 独立于 GTest,CI 跑) | +| 性能基线(PR#18 `feat/performance`) | [PERFORMANCE.md](PERFORMANCE.md) 季级标尺(wall-clock 不动输出/4 步闭环)+ google-benchmark harness + io/tar/cmp/md5sum/sed 流式化(line reader ~7x、tar O(1) 内存、sed 预编译 ~4x、cmp 早退)+ end-to-end timing 脚本 + armhf `-Wconversion`/charconv 修 | ✅ | e229f05 … 4f154e9(merge f979b8f) | 436/0(benchmark 独立于 GTest) | + +> 批级记录见 [notes/2026-07-06-structure-performance.md](../notes/2026-07-06-structure-performance.md)。两批均未增删 applet,GTest 基线沿用 Phase 2 末 436;size-opt 体积 v0.3.0 的 418 KB → 439 KB(+21 KB,主因 io/tar 流式缓冲与 benchmark 链接产物,仍在 ≤ 550 KB 预算内)。 + ## OPEN GOTCHAS(跨批陷阱,改前必看) 1. **APPLET_REGISTRY + CMake 开关双改**:新增/裁剪 applet 必须**同时**改 [include/cfbox/applets.hpp](../../include/cfbox/applets.hpp) 注册表与 CMake 的 `CFBOX_ENABLE_*`(生成于 [applet_config.hpp.in](../../include/cfbox/applet_config.hpp.in));漏一处 → 编译期静默跳过或链接错。 diff --git a/document/ai/ROADMAP.md b/document/ai/ROADMAP.md index eaa71d2..3f31ed1 100644 --- a/document/ai/ROADMAP.md +++ b/document/ai/ROADMAP.md @@ -22,7 +22,7 @@ - 兼容性裁决见 [compatibility-policy.md](../todo/compatibility-policy.md);v1.0 验收边界见 [v1-production-criteria.md](../todo/v1-production-criteria.md)。 ## 当前焦点 -**Phase 2 核心命令深化 ✅ 全部完成**(2026-06-28)。批2-5c:`cp -a`、`test` POSIX、`ls -R`/`--color`、`grep -A/-B/-C`、`find` 布尔、`sh` 全收(算术/case/函数/here-doc/高级`${}`/break N/read/trap)。基线 **436 GTest + 57 集成(sh)/ 438 KB** size-opt(v0.3.0 基线 399/418)。批级记录见 [PLAN.md](PLAN.md) 与 [notes/](../notes/)。 +**Phase 2 核心命令深化 ✅ 全部完成**(2026-06-28)+ **结构与性能标尺横切批 ✅ 完成**(2026-07,PR#17 / PR#18)。批2-5c:`cp -a`、`test` POSIX、`ls -R`/`--color`、`grep -A/-B/-C`、`find` 布尔、`sh` 全收(算术/case/函数/here-doc/高级`${}`/break N/read/trap);横切批落 STRUCTURE-TASTE + banned-pattern/layering gate(CI 守护)、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化(line reader ~7x、tar O(1)、sed 预编译 ~4x)+ google-benchmark 脚手架。基线 **436 GTest + 57 集成(sh)/ 439 KB** size-opt(v0.3.0 基线 399/418)。批级记录见 [PLAN.md](PLAN.md) 与 [notes/](../notes/)。 > **下一站**:Phase 3 网络最小闭环(基础网络配置/诊断/下载)。 ## 当前焦点之后下一个可启动的 diff --git a/document/notes/2026-07-06-structure-performance.md b/document/notes/2026-07-06-structure-performance.md new file mode 100644 index 0000000..d2e918e --- /dev/null +++ b/document/notes/2026-07-06-structure-performance.md @@ -0,0 +1,37 @@ +# 2026-07-06 — 结构与性能标尺横切批(PR#17 / PR#18) + +## 背景 +补档:Phase 2 收尾(2026-06-28 sh trap)后到 Phase 3 启动之间,main 上落了两轮横切改进,但 PLAN/ROADMAP 批次表一度滞后(resume 可信度受损)。本批补文档债 + 留批级记录。 + +两批均**不增删 applet、不增 GTest**,落的是跨 Phase 的工艺/性能基线(对标 Phase 1.5 代码质量审查,但聚焦结构与性能维度)。 + +## 批1:结构标尺(PR#17 `feat/test-floor-gates`) + +- **季级标尺** [document/ai/STRUCTURE-TASTE.md](../ai/STRUCTURE-TASTE.md):职责/DRY/边界/机械护栏。 +- **refactor**:清 banned-pattern(`using namespace`、`std::endl`、C 风格裸 cast 等)+ 合并散落 helper。 +- **机械护栏** [tests/check_structure_gates.sh](../../tests/check_structure_gates.sh)(53 行,banned-pattern grep + 头文件 layering 检查)挂进 [ci.yml](../../.github/workflows/ci.yml),CI 守护。 +- commit:a1c1028(docs)/ 1ef38be(refactor)/ 623fca7(test gate)/ merge fefcbc2。 + +## 批2:性能基线(PR#18 `feat/performance`) + +- **季级标尺** [document/ai/PERFORMANCE.md](../ai/PERFORMANCE.md):wall-clock 不动输出 / 4 步闭环(基线→改→测→回归)/ Phase 0 基建。 +- **google-benchmark harness**(e229f05):sort baseline,可复现微基准。 +- **流式化 5 个 applet**(核心是把"全量缓冲"换成 O(1) 或分块流,对齐 BusyBox 行为): + - `io/for_each_line`(58782bf):块读 + memchr 找换行,~7x 快(grep/wc/cat 已走该路径)。 + - `tar`(64286e6):流式生成 archive,不再缓冲整档。 + - `cmp`(c9ba7b4):双文件 lockstep 流,首字节差即早退。 + - `md5sum`(50d1b85):64 KiB 块流式哈希,O(1) 内存(原 2x file)。 + - `sed`(3479cf1):substitute regex 每 command 预编译一次,~4x 快。 +- **end-to-end timing 脚本**(626749a):cfbox vs coreutils 端到端计时。 +- **交叉编译修**:`parse_int` 改 strtol 甩掉 ``(e547e26,CI 交叉编译 OOM);`tar` `file_size` 取模 cast size_t(4f154e9,armhf 32 位 `-Wconversion`)。 +- merge f979b8f。 + +## 验证(补档时回测,2026-07-06) +- x86 GTest **436/0**(沿用 Phase 2 末,未动)。 +- size-opt **439 KB**(v0.3.0 基线 418 KB → +21 KB;主因 io/tar 流式缓冲与 benchmark 链接产物,≤ 550 KB 预算内)。 + +## 陷阱(留给后续批/维护者) +- **benchmark 与 GTest 是两套**:google-benchmark 不计入 `ctest` 的 436;perf 回归看 benchmark + timing 脚本,不是看 GTest 数。结构与性能改动**不会**自动反映在 GTest 基线上,验收要换标尺。 +- **structure gate 是 CI-only**:本地 `ctest` 不跑 [check_structure_gates.sh](../../tests/check_structure_gates.sh);新增 `using namespace` / 越层 include 在本地绿、CI 红。改公共头后本地最好 `bash tests/check_structure_gates.sh` 先自检。 +- **perf 批未做 armhf 冒烟**:流式化改了公共 IO 路径(for_each_line),armhf 32 位盲区(PLAN GOTCHA #2)在 PR#18 里只覆盖到 `-Wconversion` 编译错,没跑 qemu 直执行冒烟——Phase 3 启动前补(armhf static 交叉编译 + qemu 直执行冒烟,本机基线记在本地 memory `cfbox-armhf-smoke-environment`)。 +- 文档债教训:批级工作必须**当批**写进 PLAN 批次表 + notes,否则 `/resume`、`/status` 漂移(本批就是还这个债)。 From 10f811f0e96e1862837c194531183b021fb7e4ff Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 09:16:55 +0800 Subject: [PATCH 2/8] feat(nc): add socket primitives and netcat applet (Phase 3 wave 0) First slice of Phase 3 networking: a header-only cfbox::socket primitive layer and the first applet that consumes it. - include/cfbox/socket.hpp: make/resolve/connect_fd/set_reuseaddr/dial/ listen_on/bound_port/accept_one/format_addr. Reuses cfbox::io::unique_fd for fd lifetime (DRY: a socket is a fd; no separate SocketFd class). Plaintext, dual-stack, errors via base::Result. - src/applets/nc.cpp: connect mode (nc HOST PORT) and listen mode (nc -l -p PORT). Single-process poll(2) relay bridges stdin->sock and sock->stdout; SHUT_WR half-close on stdin EOF so a peer can still reply. No fork (keeps cfbox process state clean). - Registered in APPLET_REGISTRY, Config.cmake CFBOX_APPLETS, and applet_config.hpp.in. - tests/unit/test_socket.cpp: loopback echo + resolve + v4/v6 format. - tests/integration/test_nc.sh: server listens, client sends one line. Verified: 440/440 GTest (+4 SocketTest), structure gate PASS, size-opt 451 KB (+12, <=550 budget), 55 integration scripts green. --- cmake/Config.cmake | 1 + include/cfbox/applet_config.hpp.in | 1 + include/cfbox/applets.hpp | 6 + include/cfbox/socket.hpp | 171 +++++++++++++++++++++++++++++ src/applets/nc.cpp | 168 ++++++++++++++++++++++++++++ tests/integration/test_nc.sh | 39 +++++++ tests/unit/test_socket.cpp | 63 +++++++++++ 7 files changed, 449 insertions(+) create mode 100644 include/cfbox/socket.hpp create mode 100644 src/applets/nc.cpp create mode 100755 tests/integration/test_nc.sh create mode 100644 tests/unit/test_socket.cpp diff --git a/cmake/Config.cmake b/cmake/Config.cmake index 5676fb1..f09ae1b 100644 --- a/cmake/Config.cmake +++ b/cmake/Config.cmake @@ -29,6 +29,7 @@ set(CFBOX_APPLETS dmesg hexdump more rev cal renice clear which mount mountpoint mdev chmod chown chgrp umount swapoff reboot + nc ) foreach(applet IN LISTS CFBOX_APPLETS) diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in index 7c12eed..4a00498 100644 --- a/include/cfbox/applet_config.hpp.in +++ b/include/cfbox/applet_config.hpp.in @@ -124,3 +124,4 @@ #cmakedefine01 CFBOX_ENABLE_UMOUNT #cmakedefine01 CFBOX_ENABLE_SWAPOFF #cmakedefine01 CFBOX_ENABLE_REBOOT +#cmakedefine01 CFBOX_ENABLE_NC diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index 21f62e1..8c1eb51 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -365,6 +365,9 @@ extern auto swapoff_main(int argc, char* argv[]) -> int; extern auto reboot_main(int argc, char* argv[]) -> int; extern auto poweroff_main(int argc, char* argv[]) -> int; #endif +#if CFBOX_ENABLE_NC +extern auto nc_main(int argc, char* argv[]) -> int; +#endif // registry — one line per applet, conditionally compiled constexpr auto APPLET_REGISTRY = std::to_array({ @@ -731,4 +734,7 @@ constexpr auto APPLET_REGISTRY = std::to_array({ {"reboot", reboot_main, "reboot the system"}, {"poweroff", poweroff_main, "power off the system"}, #endif +#if CFBOX_ENABLE_NC + {"nc", nc_main, "TCP connect/listen and relay"}, +#endif }); diff --git a/include/cfbox/socket.hpp b/include/cfbox/socket.hpp new file mode 100644 index 0000000..0a9b446 --- /dev/null +++ b/include/cfbox/socket.hpp @@ -0,0 +1,171 @@ +#pragma once + +// socket.hpp — minimal RAII socket primitives shared by the Phase 3 network +// applets (nc / wget / tftp / netstat / ...). Reuses cfbox::io::unique_fd for +// fd lifetime on purpose (DRY: a socket *is* a file descriptor — we do not +// wrap it again in a SocketFd class). Errors flow through cfbox::base::Result; +// no exceptions/RTTI. Plaintext only — TLS is out of scope for Phase 3. + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include // unique_fd + +namespace cfbox::socket { + +enum class Family : int { IPv4 = AF_INET, IPv6 = AF_INET6 }; +enum class Kind : int { Stream = SOCK_STREAM, Datagram = SOCK_DGRAM }; + +// Create an unbound socket of the requested family/kind. +[[nodiscard]] inline auto make(Family fam, Kind kind) -> base::Result { + int fd = ::socket(static_cast(fam), static_cast(kind), 0); + if (fd < 0) + return std::unexpected(base::Error{errno, "socket() failed"}); + return io::unique_fd{fd}; +} + +// One resolved endpoint. sockaddr_storage holds either v4 or v6. +struct AddrInfo { + sockaddr_storage addr{}; + socklen_t len = 0; +}; + +// Resolve host:port via getaddrinfo (numeric or named hosts). Returns every +// candidate so the caller can try them in order until one connects. +[[nodiscard]] inline auto resolve(std::string_view host, uint16_t port, Kind kind) + -> base::Result> { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = static_cast(kind); + + addrinfo* res = nullptr; + int rc = ::getaddrinfo(std::string{host}.c_str(), std::to_string(port).c_str(), &hints, &res); + if (rc != 0) { + return std::unexpected(base::Error{rc, std::string{"getaddrinfo: "} + gai_strerror(rc)}); + } + std::vector out; + for (addrinfo* p = res; p; p = p->ai_next) { + AddrInfo info; + std::memcpy(&info.addr, p->ai_addr, p->ai_addrlen); + info.len = p->ai_addrlen; + out.push_back(info); + } + ::freeaddrinfo(res); + if (out.empty()) + return std::unexpected(base::Error{0, "no addresses resolved"}); + return out; +} + +[[nodiscard]] inline auto connect_fd(int fd, const sockaddr* addr, socklen_t len) + -> base::Result { + if (::connect(fd, addr, len) < 0) + return std::unexpected(base::Error{errno, "connect() failed"}); + return {}; +} + +[[nodiscard]] inline auto set_reuseaddr(int fd) -> base::Result { + int on = 1; + if (::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0) + return std::unexpected(base::Error{errno, "setsockopt(SO_REUSEADDR) failed"}); + return {}; +} + +// Client convenience: resolve, create a matching socket, connect — trying each +// resolved address until one succeeds (happy-eyeballs-lite). +[[nodiscard]] inline auto dial(std::string_view host, uint16_t port, Kind kind = Kind::Stream) + -> base::Result { + CFBOX_TRY(addrs, resolve(host, port, kind)); + for (const auto& a : *addrs) { + auto fam = (a.addr.ss_family == AF_INET6) ? Family::IPv6 : Family::IPv4; + auto s = make(fam, kind); + if (!s) + continue; + if (connect_fd(s->get(), reinterpret_cast(&a.addr), a.len)) + return s; + } + return std::unexpected(base::Error{ECONNREFUSED, "connection refused"}); +} + +// Server convenience: socket + SO_REUSEADDR + bind + listen. port=0 lets the OS +// pick; retrieve the chosen port with bound_port(). addr may be "0.0.0.0", "::", +// or a literal v4/v6 address. +[[nodiscard]] inline auto listen_on(std::string_view addr, uint16_t port, Kind kind = Kind::Stream) + -> base::Result { + bool v6 = addr.find(':') != std::string_view::npos; + CFBOX_TRY(fd, make(v6 ? Family::IPv6 : Family::IPv4, kind)); + if (auto r = set_reuseaddr(fd->get()); !r) + return std::unexpected(r.error()); + + if (v6) { + sockaddr_in6 sa{}; + sa.sin6_family = AF_INET6; + sa.sin6_port = htons(port); + if (addr != "::") { + if (::inet_pton(AF_INET6, std::string{addr}.c_str(), &sa.sin6_addr) != 1) + return std::unexpected(base::Error{EINVAL, "invalid IPv6 address"}); + } + if (::bind(fd->get(), reinterpret_cast(&sa), sizeof(sa)) < 0) + return std::unexpected(base::Error{errno, "bind() failed"}); + } else { + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_port = htons(port); + if (addr != "0.0.0.0" && !addr.empty()) { + if (::inet_pton(AF_INET, std::string{addr}.c_str(), &sa.sin_addr) != 1) + return std::unexpected(base::Error{EINVAL, "invalid IPv4 address"}); + } + if (::bind(fd->get(), reinterpret_cast(&sa), sizeof(sa)) < 0) + return std::unexpected(base::Error{errno, "bind() failed"}); + } + if (kind == Kind::Stream) { + if (::listen(fd->get(), SOMAXCONN) < 0) + return std::unexpected(base::Error{errno, "listen() failed"}); + } + return fd; +} + +// Read back the OS-assigned port after listen_on(port=0). +[[nodiscard]] inline auto bound_port(int fd) -> base::Result { + sockaddr_storage sa{}; + socklen_t len = sizeof(sa); + if (::getsockname(fd, reinterpret_cast(&sa), &len) < 0) + return std::unexpected(base::Error{errno, "getsockname() failed"}); + if (sa.ss_family == AF_INET6) + return ntohs(reinterpret_cast(&sa)->sin6_port); + return ntohs(reinterpret_cast(&sa)->sin_port); +} + +// Accept one connection on a listening socket. +[[nodiscard]] inline auto accept_one(int listen_fd) -> base::Result { + int c = ::accept(listen_fd, nullptr, nullptr); + if (c < 0) + return std::unexpected(base::Error{errno, "accept() failed"}); + return io::unique_fd{c}; +} + +// Format a sockaddr_storage as "host:port" (inet_ntop — never sprintf). +[[nodiscard]] inline auto format_addr(const sockaddr_storage& ss) -> std::string { + char buf[INET6_ADDRSTRLEN] = {}; + uint16_t port = 0; + if (ss.ss_family == AF_INET6) { + ::inet_ntop(AF_INET6, &reinterpret_cast(&ss)->sin6_addr, buf, + sizeof(buf)); + port = ntohs(reinterpret_cast(&ss)->sin6_port); + } else { + ::inet_ntop(AF_INET, &reinterpret_cast(&ss)->sin_addr, buf, + sizeof(buf)); + port = ntohs(reinterpret_cast(&ss)->sin_port); + } + return std::string{buf} + ':' + std::to_string(port); +} + +} // namespace cfbox::socket diff --git a/src/applets/nc.cpp b/src/applets/nc.cpp new file mode 100644 index 0000000..d3e3bc5 --- /dev/null +++ b/src/applets/nc.cpp @@ -0,0 +1,168 @@ +// nc.cpp — netcat: TCP connect/listen and bidirectional relay. +// +// Two modes (BusyBox nc subset): +// nc [-s ADDR] -l -p PORT listen, accept one connection, relay +// nc [-s ADDR] HOST PORT connect, relay stdin/stdout<->socket +// +// Relay bridges stdin→socket and socket→stdout (two different fds — a common +// mistake is to treat one fd as both read and write; that writes reply data into +// fd 0). Single-process poll(2), no fork, so cfbox process state is not polluted +// (relevant when nc is one applet of many in a single binary). On stdin EOF we +// half-close the socket (SHUT_WR) so a request/response peer can still answer, +// matching traditional netcat semantics. + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "nc", + .version = CFBOX_VERSION_STRING, + .one_line = "TCP connect/listen and relay (netcat)", + .usage = "nc [-l] [-p PORT] [-s ADDR] [HOST] [PORT]", + .options = " -l listen mode (accept one connection)\n" + " -p PORT port to listen on / connect to\n" + " -s ADDR bind address (default 0.0.0.0)", + .extra = "", +}; + +// Write exactly n bytes; returns false if the write end closes mid-way. +auto write_all(int fd, const char* buf, std::size_t n) -> bool { + std::size_t off = 0; + while (off < n) { + ssize_t w = ::write(fd, buf + off, n - off); + if (w <= 0) + return false; + off += static_cast(w); + } + return true; +} + +// Bridge stdin(0)→sock and sock→stdout(1) via poll. Exits when both directions +// close. stdin EOF triggers SHUT_WR on the socket (half-close) so a peer can +// still send its reply. +auto relay(int sock_fd) -> void { + pollfd fds[2] = {{STDIN_FILENO, POLLIN, 0}, {sock_fd, POLLIN, 0}}; + bool in_eof = false; + bool sock_eof = false; + char buf[4096]; + + while (!in_eof || !sock_eof) { + if (::poll(fds, 2, -1) <= 0) + break; + + if (!in_eof && fds[0].revents) { + ssize_t n = ::read(STDIN_FILENO, buf, sizeof(buf)); + if (n <= 0) { + in_eof = true; + fds[0].fd = -1; + ::shutdown(sock_fd, SHUT_WR); + } else { + if (!write_all(sock_fd, buf, static_cast(n))) + sock_eof = true; + } + } + if (!sock_eof && fds[1].revents) { + ssize_t n = ::read(sock_fd, buf, sizeof(buf)); + if (n <= 0) { + sock_eof = true; + fds[1].fd = -1; + } else { + if (!write_all(STDOUT_FILENO, buf, static_cast(n))) + in_eof = true; + } + } + } +} + +} // namespace + +auto nc_main(int argc, char* argv[]) -> int { + using namespace cfbox; + auto parsed = args::parse(argc, argv, + { + args::OptSpec{'l', false, "listen"}, + args::OptSpec{'p', true, "port"}, + args::OptSpec{'s', true, "source"}, + }); + + if (parsed.has_long("help")) { + help::print_help(HELP); + return 0; + } + if (parsed.has_long("version")) { + help::print_version(HELP); + return 0; + } + + std::string bind_addr = "0.0.0.0"; + if (auto s = parsed.get('s')) + bind_addr = std::string{*s}; + + if (parsed.has('l')) { + // Port from -p, else first positional (nc -l PORT). + std::string_view port_str; + if (auto v = parsed.get('p')) + port_str = *v; + else if (!parsed.positional().empty()) + port_str = parsed.positional()[0]; + else { + CFBOX_ERR("nc", "listen mode needs -p PORT"); + return 2; + } + auto pi = args::parse_int(port_str); + if (!pi) { + CFBOX_ERR("nc", "invalid port: %s", pi.error().msg.c_str()); + return 2; + } + if (*pi < 0 || *pi > 65535) { + CFBOX_ERR("nc", "port out of range: %d", *pi); + return 2; + } + auto srv = socket::listen_on(bind_addr, static_cast(*pi)); + if (!srv) { + CFBOX_ERR("nc", "listen failed: %s", srv.error().msg.c_str()); + return 1; + } + auto conn = socket::accept_one(srv->get()); + if (!conn) { + CFBOX_ERR("nc", "accept failed: %s", conn.error().msg.c_str()); + return 1; + } + relay(conn->get()); + return 0; + } + + // connect mode: HOST PORT + auto& pos = parsed.positional(); + if (pos.size() < 2) { + CFBOX_ERR("nc", "usage: nc [-l] [-p PORT] HOST PORT"); + return 2; + } + auto port_i = args::parse_int(pos[1]); + if (!port_i) { + CFBOX_ERR("nc", "invalid port: %s", port_i.error().msg.c_str()); + return 2; + } + if (*port_i < 0 || *port_i > 65535) { + CFBOX_ERR("nc", "port out of range: %d", *port_i); + return 2; + } + auto conn = socket::dial(pos[0], static_cast(*port_i)); + if (!conn) { + CFBOX_ERR("nc", "connect failed: %s", conn.error().msg.c_str()); + return 1; + } + relay(conn->get()); + return 0; +} diff --git a/tests/integration/test_nc.sh b/tests/integration/test_nc.sh new file mode 100755 index 0000000..f8ccf7b --- /dev/null +++ b/tests/integration/test_nc.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# test_nc.sh — nc loopback echo: server listens, client sends one line, +# server writes what it received to a file we then check. +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +PORT=29501 + +# Server: listen, relay stdin (here /dev/null → immediate EOF → SHUT_WR) to the +# accepted socket, and write the socket's data to a file via stdout redirect. +"$CFBOX" nc -l -p "$PORT" "$tmpdir/out" 2>"$tmpdir/serr" & +server=$! +sleep 0.3 + +if ! kill -0 "$server" 2>/dev/null; then + echo "nc: cannot listen on $PORT (busy?), SKIP ($(cat "$tmpdir/serr"))" + exit 0 +fi + +# Client: send one line. +echo "hello-from-nc" | "$CFBOX" nc 127.0.0.1 "$PORT" 2>"$tmpdir/cerr" || true +sleep 0.3 + +if grep -q "hello-from-nc" "$tmpdir/out"; then + ((++pass)) +else + echo "FAIL [nc echo]: out='$(cat "$tmpdir/out")' serr='$(cat "$tmpdir/serr")' cerr='$(cat "$tmpdir/cerr")'" + ((++fail)) +fi + +kill "$server" 2>/dev/null || true +wait "$server" 2>/dev/null || true + +echo "nc: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/unit/test_socket.cpp b/tests/unit/test_socket.cpp new file mode 100644 index 0000000..af77ba0 --- /dev/null +++ b/tests/unit/test_socket.cpp @@ -0,0 +1,63 @@ +#include +#include +#include +#include + +#include +#include + +#include + +#include + +// End-to-end loopback echo. listen_on(port=0) → bound_port → dial lands in the +// accept backlog before accept is called (no thread/race needed), then one round +// trip each way exercises make/bind/listen/connect/accept together. +TEST(SocketTest, LoopbackEcho) { + auto srv = cfbox::socket::listen_on("127.0.0.1", 0); + ASSERT_TRUE(srv.has_value()) << srv.error().msg; + auto port = cfbox::socket::bound_port(srv->get()); + ASSERT_TRUE(port.has_value()) << port.error().msg; + ASSERT_GT(*port, 0); + + auto client = cfbox::socket::dial("127.0.0.1", *port); + ASSERT_TRUE(client.has_value()) << client.error().msg; + + auto conn = cfbox::socket::accept_one(srv->get()); + ASSERT_TRUE(conn.has_value()) << conn.error().msg; + + ASSERT_EQ(::write(client->get(), "ping", 4), 4); + char buf[64] = {}; + ASSERT_EQ(::read(conn->get(), buf, 4), 4); + EXPECT_EQ(std::string(buf, 4), "ping"); + + ASSERT_EQ(::write(conn->get(), "pong", 4), 4); + ASSERT_EQ(::read(client->get(), buf, 4), 4); + EXPECT_EQ(std::string(buf, 4), "pong"); +} + +TEST(SocketTest, ResolveLoopback) { + auto addrs = cfbox::socket::resolve("127.0.0.1", 8080, cfbox::socket::Kind::Stream); + ASSERT_TRUE(addrs.has_value()) << addrs.error().msg; + EXPECT_FALSE(addrs->empty()); +} + +TEST(SocketTest, FormatAddrV4) { + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_port = htons(8080); + ::inet_pton(AF_INET, "127.0.0.1", &sa.sin_addr); + sockaddr_storage ss{}; + std::memcpy(&ss, &sa, sizeof(sa)); + EXPECT_EQ(cfbox::socket::format_addr(ss), "127.0.0.1:8080"); +} + +TEST(SocketTest, FormatAddrV6) { + sockaddr_in6 sa{}; + sa.sin6_family = AF_INET6; + sa.sin6_port = htons(9999); + ::inet_pton(AF_INET6, "::1", &sa.sin6_addr); + sockaddr_storage ss{}; + std::memcpy(&ss, &sa, sizeof(sa)); + EXPECT_EQ(cfbox::socket::format_addr(ss), "::1:9999"); +} From 48a4e67602046aa6396536ed0fad0604bc573d7b Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 09:18:37 +0800 Subject: [PATCH 3/8] docs(plan): record Phase 3 wave 0 (socket + nc) Add Phase 3 batch table to PLAN (batch 1 = socket.hpp + nc, 10f811f), bump top-line baseline to 440 GTest / 451 KB / 124 applet, and leave a batch note covering the three pitfalls hit (CFBOX_TRY in int-returning entry, relay 4-fd bridge, test_nc.sh set -e) plus the SocketFd->unique_fd DRY decision. --- document/ai/PLAN.md | 12 +++++++- document/notes/2026-07-06-phase3-socket-nc.md | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 document/notes/2026-07-06-phase3-socket-nc.md diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index 55f04f2..adbf743 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -3,7 +3,7 @@ > Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 > **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 > ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 -> 🔄 **下一站:Phase 3 网络最小闭环**(socket/http/net_util/icmp 基础设施 + ip/ifconfig/route/netstat/ping/traceroute/nslookup/wget/nc/tftp + hostname 深化)。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 +> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` 基础设施 + `nc` applet)✅ —— 当前基线 **440 GTest / 451 KB / 124 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -56,6 +56,16 @@ > 批级记录见 [notes/2026-07-06-structure-performance.md](../notes/2026-07-06-structure-performance.md)。两批均未增删 applet,GTest 基线沿用 Phase 2 末 436;size-opt 体积 v0.3.0 的 418 KB → 439 KB(+21 KB,主因 io/tar 流式缓冲与 benchmark 链接产物,仍在 ≤ 550 KB 预算内)。 +## 🔄 Phase 3(网络最小闭环)— 进行中 + +> 目标:socket/http/net_util/icmp 基础设施 + ip/ifconfig/route/netstat/ping/traceroute/nslookup/wget/nc/tftp + hostname 深化(11 applet)。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。每批≈一 commit + 完成门。 + +| 批 | 范围 | 状态 | Commit | 测试 | +|----|------|------|--------|------| +| 批1(Wave 0) | `include/cfbox/socket.hpp` 基础设施(复用 `io::unique_fd`:make/resolve/dial/listen_on/accept_one/format_addr,双栈、header-only)+ `nc` applet(connect/listen 模式 + poll 双向 relay stdin→sock/sock→stdout + SHUT_WR 半关) | ✅ | 10f811f | 440/1 | + +> 下一批:Wave 1 `ifconfig`/`ip show` + `hostname` 深化(依赖新建 `net_util.hpp` 读 /proc/net + ioctl)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md)。 + ## OPEN GOTCHAS(跨批陷阱,改前必看) 1. **APPLET_REGISTRY + CMake 开关双改**:新增/裁剪 applet 必须**同时**改 [include/cfbox/applets.hpp](../../include/cfbox/applets.hpp) 注册表与 CMake 的 `CFBOX_ENABLE_*`(生成于 [applet_config.hpp.in](../../include/cfbox/applet_config.hpp.in));漏一处 → 编译期静默跳过或链接错。 diff --git a/document/notes/2026-07-06-phase3-socket-nc.md b/document/notes/2026-07-06-phase3-socket-nc.md new file mode 100644 index 0000000..bc863e5 --- /dev/null +++ b/document/notes/2026-07-06-phase3-socket-nc.md @@ -0,0 +1,30 @@ +# 2026-07-06 — Phase 3 批1:socket.hpp 基础设施 + nc(Wave 0) + +## 背景 +Phase 3 网络最小闭环的第一锹。按 [phase-2-network.md](../todo/phases/phase-2-network.md) 依赖图,`socket.hpp` 是所有 Wave 的地基;选 `nc` 作为第一个消费者形成"基础设施 + 可用 applet"闭环(对应文档 3.2 的 nc echo 测试场景)。 + +## 设计决策 + +- **socket.hpp 复用 `cfbox::io::unique_fd`,不另造 `SocketFd` 类**(DRY,[STRUCTURE-TASTE](../ai/STRUCTURE-TASTE.md) §一.2)。文档 1.1 建议的 `SocketFd` 是 pre-code 设计;落地时 `io::unique_fd` 已是通用 fd RAII,socket 也是 fd,再包一层是死抽象。落成 header-only inline 自由函数(`make/resolve/dial/listen_on/accept_one/bound_port/format_addr`),对齐 io.hpp 风格。 +- **nc relay 单进程 poll(2),不 fork**([PLAN GOTCHA #6](../ai/PLAN.md) 多调用 binary 全局状态)。fork 会污染同进程后续 applet 调用;poll 双向中继无副作用,退出干净。 +- **SHUT_WR 半关**:stdin EOF 后 `shutdown(sock, SHUT_WR)`,让 request/response peer 仍能回复(传统 netcat 语义)。 +- **双栈**:dial 走 `getaddrinfo(AF_UNSPEC)` 自动 v4/v6;listen_on 按 addr 含 `:` 判 v6。format_addr 用 `inet_ntop`(不用 sprintf,过 structure gate 门 1)。 + +## 踩坑(本批 3 个,都修了) + +1. **`CFBOX_TRY` 不能在返回 `int` 的 applet 入口用**:宏展开是 `return std::unexpected(...)`,而 `nc_main` 返回 int → 编译错 "cannot convert unexpected to int"。`CFBOX_TRY` 只在返回 `Result` 的函数里用(如 socket.hpp 内部)。applet 入口手动 `auto x = parse_int(...); if (!x) { CFBOX_ERR(...); return 2; }`,对齐 hostname.cpp。 +2. **relay 初版把 `local` 当既读又写的 fd**:`relay(STDIN_FILENO, conn)` 里读到的 socket 数据 `write(local=fd0)` —— 写到 fd 0(stdin),数据丢失(手动 echo 复测 `out=''`)。netcat 是 stdin(0)→sock、sock→stdout(1) **两个方向四个 fd**。改成 `relay(sock_fd)` 内部固定 stdin/stdout,server 收到的数据正确落到重定向的 out 文件。 +3. **test_nc.sh 的 `kill` 在 `set -e` 下炸**:server 在 client 退出后 relay 自然结束、进程已死,`kill $server` 返回非 0 触发 `set -e`,trap rm 跑了但 `echo`/`[[ ]]` 没跑(exit 1 无输出)。加 `|| true`。 + +## 验证 +- GTest **440/440**(+4:SocketTest LoopbackEcho/ResolveLoopback/FormatAddrV4/FormatAddrV6)。 +- 集成 **55 脚本**全绿(test_nc.sh 新增)。 +- structure gate PASS(0 sprintf/stoi/裸 fopen/新 layering)。 +- size-opt **451 KB**(基线 439 → +12 KB,nc + socket.hpp,≤ 550 KB 预算)。 +- commit: `10f811f` + +## 陷阱(留给后续批/维护者) +- **nc 是最小版**:只支持 `-l/-p/-s`。文档列的 `-w`(timeout)/`-e`(exec)/`-z`(scan)/UDP 模式均未实现(HELP 只列已实现的,避免名实不符)。Wave 3 收尾或后续批补。 +- **socket.hpp 无超时/DNS 缓存/keepalive**:后续 wget/traceroute 按需扩。`dial` 是 happy-eyeballs-lite(逐个试 resolved addr),非并行。 +- **armhf 未验证**:socket raw POSIX 在 32 位可能有 -Wconversion 盲区(`htons`/`socklen_t`/`ssize_t` 算术)。本机 `arm-linux-gnueabihf-g++` 当前未装(只有 aarch64),C 阶段补 armhf 冒烟。 +- **net_util.hpp 还没建**:Wave 1(ifconfig/ip/route/netstat)依赖它读 `/proc/net/*` + ioctl,是下一批的前置。 From 1ab221d8a48238fb3bd0d917c10a7bda772cab28 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 09:29:13 +0800 Subject: [PATCH 4/8] docs(notes): record armhf smoke verification for perf batch and nc Ran the armhf gap that PR#18 left open (compile-only, no qemu run): static build with /opt/arm-gnu-toolchain is clean at -WConversion, and qemu-arm-static runs the five streamed applets (io/md5sum/tar/sed/cmp) plus nc loopback echo end to end. No 32-bit regression. armhf static 1235 KB. --- document/notes/2026-07-06-phase3-socket-nc.md | 2 +- document/notes/2026-07-06-structure-performance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/document/notes/2026-07-06-phase3-socket-nc.md b/document/notes/2026-07-06-phase3-socket-nc.md index bc863e5..f2696a4 100644 --- a/document/notes/2026-07-06-phase3-socket-nc.md +++ b/document/notes/2026-07-06-phase3-socket-nc.md @@ -26,5 +26,5 @@ Phase 3 网络最小闭环的第一锹。按 [phase-2-network.md](../todo/phases ## 陷阱(留给后续批/维护者) - **nc 是最小版**:只支持 `-l/-p/-s`。文档列的 `-w`(timeout)/`-e`(exec)/`-z`(scan)/UDP 模式均未实现(HELP 只列已实现的,避免名实不符)。Wave 3 收尾或后续批补。 - **socket.hpp 无超时/DNS 缓存/keepalive**:后续 wget/traceroute 按需扩。`dial` 是 happy-eyeballs-lite(逐个试 resolved addr),非并行。 -- **armhf 未验证**:socket raw POSIX 在 32 位可能有 -Wconversion 盲区(`htons`/`socklen_t`/`ssize_t` 算术)。本机 `arm-linux-gnueabihf-g++` 当前未装(只有 aarch64),C 阶段补 armhf 冒烟。 +- **armhf 已验证(C 阶段)**:`/opt/arm-gnu-toolchain`(arm-none-linux-gnueabihf gcc 15.2)static 编译,32 位 `-Wconversion` 干净;`qemu-arm-static` 冒烟 nc loopback echo 端到端通过(`armhf-nc-works`,server+client 双进程)。armhf static 体积 1235 KB(v0.3.0 基线 1195 → +40 KB,nc + socket.hpp)。 - **net_util.hpp 还没建**:Wave 1(ifconfig/ip/route/netstat)依赖它读 `/proc/net/*` + ioctl,是下一批的前置。 diff --git a/document/notes/2026-07-06-structure-performance.md b/document/notes/2026-07-06-structure-performance.md index d2e918e..c51da96 100644 --- a/document/notes/2026-07-06-structure-performance.md +++ b/document/notes/2026-07-06-structure-performance.md @@ -33,5 +33,5 @@ ## 陷阱(留给后续批/维护者) - **benchmark 与 GTest 是两套**:google-benchmark 不计入 `ctest` 的 436;perf 回归看 benchmark + timing 脚本,不是看 GTest 数。结构与性能改动**不会**自动反映在 GTest 基线上,验收要换标尺。 - **structure gate 是 CI-only**:本地 `ctest` 不跑 [check_structure_gates.sh](../../tests/check_structure_gates.sh);新增 `using namespace` / 越层 include 在本地绿、CI 红。改公共头后本地最好 `bash tests/check_structure_gates.sh` 先自检。 -- **perf 批未做 armhf 冒烟**:流式化改了公共 IO 路径(for_each_line),armhf 32 位盲区(PLAN GOTCHA #2)在 PR#18 里只覆盖到 `-Wconversion` 编译错,没跑 qemu 直执行冒烟——Phase 3 启动前补(armhf static 交叉编译 + qemu 直执行冒烟,本机基线记在本地 memory `cfbox-armhf-smoke-environment`)。 +- **perf 批 armhf 冒烟已补(C 阶段,2026-07-06)**:PR#18 当初只覆盖到 `-Wconversion` 编译错,没跑 qemu 直执行冒烟。本批用 `/opt/arm-gnu-toolchain` static 编译 armhf 32 位干净(无回归),`qemu-arm-static` 跑 perf 批 5 个流式 applet 全绿(io `for_each_line` 经 grep、`md5sum`、`tar -cf`/`-tf`、`sed s/o/0/g`、`cmp` 早退)——确认 PR#18 流式化在 armhf 32 位无回归。环境基线见本地 memory `cfbox-armhf-smoke-environment`。 - 文档债教训:批级工作必须**当批**写进 PLAN 批次表 + notes,否则 `/resume`、`/status` 漂移(本批就是还这个债)。 From f4279d67dea25b3584209fa75e74c53b40d24ff1 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 09:47:28 +0800 Subject: [PATCH 5/8] feat(ifconfig): net_util interface query + ifconfig display (Phase 3 wave 1a) Wave 1 first slice: a read-only interface layer plus the ifconfig display path. Mutations (addr/up/down/mtu) and ip/route/netstat come later. - include/cfbox/net_util.hpp: InterfaceInfo + read_interfaces() (parses /proc/net/dev for counters, ioctl SIOCGIF* for flags/mtu/hwaddr/ipv4) + format_ifconfig() in BusyBox multi-line style. Hand-rolled field parser avoids . - src/applets/ifconfig.cpp: 'ifconfig' (up only), '-a' (all), 'ifconfig IFACE'. - ipv4_from_ioctl helper memcpy's sockaddr->sockaddr_in instead of casting, sidestepping -Wcast-align on 32-bit ARM (x86-64 is silent; armhf -Werror caught it). - tests/unit/test_net_util.cpp: live /proc/net/dev + format coverage. - tests/integration/test_ifconfig.sh: -a/no-arg/named/bogus-exit. Verified: 446/446 GTest (+6 NetUtilTest), structure gate PASS, size-opt 455 KB (+4), 56 integration scripts green, armhf static clean + qemu ifconfig. --- cmake/Config.cmake | 2 +- include/cfbox/applet_config.hpp.in | 1 + include/cfbox/applets.hpp | 6 + include/cfbox/net_util.hpp | 238 +++++++++++++++++++++++++++++ src/applets/ifconfig.cpp | 71 +++++++++ tests/integration/test_ifconfig.sh | 45 ++++++ tests/unit/test_net_util.cpp | 83 ++++++++++ 7 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 include/cfbox/net_util.hpp create mode 100644 src/applets/ifconfig.cpp create mode 100755 tests/integration/test_ifconfig.sh create mode 100644 tests/unit/test_net_util.cpp diff --git a/cmake/Config.cmake b/cmake/Config.cmake index f09ae1b..b9b504c 100644 --- a/cmake/Config.cmake +++ b/cmake/Config.cmake @@ -29,7 +29,7 @@ set(CFBOX_APPLETS dmesg hexdump more rev cal renice clear which mount mountpoint mdev chmod chown chgrp umount swapoff reboot - nc + nc ifconfig ) foreach(applet IN LISTS CFBOX_APPLETS) diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in index 4a00498..e599fbc 100644 --- a/include/cfbox/applet_config.hpp.in +++ b/include/cfbox/applet_config.hpp.in @@ -125,3 +125,4 @@ #cmakedefine01 CFBOX_ENABLE_SWAPOFF #cmakedefine01 CFBOX_ENABLE_REBOOT #cmakedefine01 CFBOX_ENABLE_NC +#cmakedefine01 CFBOX_ENABLE_IFCONFIG diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index 8c1eb51..d181ad1 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -368,6 +368,9 @@ extern auto poweroff_main(int argc, char* argv[]) -> int; #if CFBOX_ENABLE_NC extern auto nc_main(int argc, char* argv[]) -> int; #endif +#if CFBOX_ENABLE_IFCONFIG +extern auto ifconfig_main(int argc, char* argv[]) -> int; +#endif // registry — one line per applet, conditionally compiled constexpr auto APPLET_REGISTRY = std::to_array({ @@ -737,4 +740,7 @@ constexpr auto APPLET_REGISTRY = std::to_array({ #if CFBOX_ENABLE_NC {"nc", nc_main, "TCP connect/listen and relay"}, #endif +#if CFBOX_ENABLE_IFCONFIG + {"ifconfig", ifconfig_main, "configure a network interface"}, +#endif }); diff --git a/include/cfbox/net_util.hpp b/include/cfbox/net_util.hpp new file mode 100644 index 0000000..6a4190f --- /dev/null +++ b/include/cfbox/net_util.hpp @@ -0,0 +1,238 @@ +#pragma once + +// net_util.hpp — network interface query helpers shared by ifconfig/ip/route/ +// netstat. Data comes from two places: /proc/net/dev for packet counters, and +// ioctl(SIOCGIF*) on a control socket for flags/mtu/hwaddr/ipv4. Errors flow +// through cfbox::base::Result; no exceptions/RTTI. Read-only — mutations +// (setting addr/flags) belong in the applets that need them, not here. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace cfbox::net { + +struct InterfaceInfo { + std::string name; + int flags = 0; // raw ifr_flags (IFF_UP | IFF_BROADCAST | ...) + int mtu = 0; + std::string ipv4_addr; // dotted, empty if unassigned + std::string ipv4_bcast; // dotted, empty if none + std::string netmask; // dotted, empty if none + std::string mac_addr; // "aa:bb:..", empty if loopback/unset + + std::uint64_t rx_bytes = 0, rx_packets = 0, rx_errors = 0, rx_dropped = 0; + std::uint64_t tx_bytes = 0, tx_packets = 0, tx_errors = 0, tx_dropped = 0; + + [[nodiscard]] auto up() const -> bool { return flags & IFF_UP; } + [[nodiscard]] auto loopback() const -> bool { return flags & IFF_LOOPBACK; } +}; + +// Parse the 16 counters after the ':' on a /proc/net/dev line. Hand-rolled to +// avoid pulling in (size cost) — the values fit in uint64. +inline auto parse_dev_fields(std::string_view s) -> std::vector { + std::vector nums; + std::size_t i = 0; + while (i < s.size()) { + while (i < s.size() && std::isspace(static_cast(s[i]))) + ++i; + if (i >= s.size()) + break; + if (!std::isdigit(static_cast(s[i]))) { + ++i; + continue; + } + std::uint64_t n = 0; + while (i < s.size() && std::isdigit(static_cast(s[i]))) { + n = n * 10 + static_cast(s[i] - '0'); + ++i; + } + nums.push_back(n); + } + return nums; +} + +// Copy an ioctl-returned sockaddr into a sockaddr_in via memcpy — a direct +// reinterpret_cast would trip -Wcast-align on 32-bit ARM, where sockaddr_in +// requires stricter alignment than sockaddr. +inline auto ipv4_from_ioctl(const sockaddr* sa) -> std::string { + sockaddr_in in{}; + std::memcpy(&in, sa, sizeof(in)); + char buf[INET_ADDRSTRLEN]; + if (::inet_ntop(AF_INET, &in.sin_addr, buf, sizeof(buf))) + return buf; + return {}; +} + +// Read all interfaces: /proc/net/dev for counters + ioctl for the rest. +[[nodiscard]] inline auto read_interfaces() -> base::Result> { + int ctl = ::socket(AF_INET, SOCK_DGRAM, 0); + if (ctl < 0) + return std::unexpected(base::Error{errno, "socket() for ioctl failed"}); + io::unique_fd ctl_fd{ctl}; + + CFBOX_TRY(content, io::read_all("/proc/net/dev")); + auto lines = io::split_lines(*content); + + std::vector out; + for (std::size_t i = 2; i < lines.size(); ++i) { // skip 2 header lines + const auto& line = lines[i]; + auto colon = line.find(':'); + if (colon == std::string::npos) + continue; + + // name = line[0..colon) with whitespace stripped + std::string name; + for (std::size_t j = 0; j < colon; ++j) { + char c = line[j]; + if (!std::isspace(static_cast(c))) + name += c; + } + if (name.empty()) + continue; + + InterfaceInfo info{}; + info.name = name; + auto nums = parse_dev_fields(std::string_view(line).substr(colon + 1)); + if (nums.size() >= 12) { + info.rx_bytes = nums[0]; + info.rx_packets = nums[1]; + info.rx_errors = nums[2]; + info.rx_dropped = nums[3]; + info.tx_bytes = nums[8]; + info.tx_packets = nums[9]; + info.tx_errors = nums[10]; + info.tx_dropped = nums[11]; + } + + struct ifreq ifr{}; + name.copy(ifr.ifr_name, IFNAMSIZ - 1); // ifr zero-init → guaranteed NUL-terminated + + if (::ioctl(ctl_fd.get(), SIOCGIFFLAGS, &ifr) == 0) + info.flags = ifr.ifr_flags; + if (::ioctl(ctl_fd.get(), SIOCGIFMTU, &ifr) == 0) + info.mtu = ifr.ifr_mtu; + if (::ioctl(ctl_fd.get(), SIOCGIFHWADDR, &ifr) == 0 && !info.loopback()) { + auto* m = reinterpret_cast(ifr.ifr_hwaddr.sa_data); + char buf[18]; + std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", m[0], m[1], m[2], m[3], + m[4], m[5]); + info.mac_addr = buf; + } + if (::ioctl(ctl_fd.get(), SIOCGIFADDR, &ifr) == 0) + info.ipv4_addr = ipv4_from_ioctl(&ifr.ifr_addr); + if (::ioctl(ctl_fd.get(), SIOCGIFNETMASK, &ifr) == 0) + info.netmask = ipv4_from_ioctl(&ifr.ifr_netmask); + if (::ioctl(ctl_fd.get(), SIOCGIFBRDADDR, &ifr) == 0) + info.ipv4_bcast = ipv4_from_ioctl(&ifr.ifr_broadaddr); + out.push_back(std::move(info)); + } + return out; +} + +// Format an interface in the multi-line BusyBox ifconfig style. +inline auto format_ifconfig(const InterfaceInfo& it) -> std::string { + auto pad = [](const std::string& s, std::size_t w) -> std::string { + return s.size() >= w ? s + ' ' : s + std::string(w - s.size(), ' '); + }; + + auto human = [](std::uint64_t b) -> std::string { + constexpr const char* unit[] = {"B", "KB", "MB", "GB", "TB"}; + double d = static_cast(b); + std::size_t u = 0; + while (d >= 1024.0 && u < 4) { + d /= 1024.0; + ++u; + } + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.1f %s", d, unit[u]); + return buf; + }; + + auto flags_string = [](int flags, bool lo) -> std::string { + std::string s; + if (flags & IFF_UP) + s += "UP "; + if (!lo && (flags & IFF_BROADCAST)) + s += "BROADCAST "; + if (lo) + s += "LOOPBACK "; + if (flags & IFF_POINTOPOINT) + s += "POINTOPOINT "; + if (flags & IFF_RUNNING) + s += "RUNNING "; + if (flags & IFF_NOARP) + s += "NOARP "; + if (flags & IFF_PROMISC) + s += "PROMISC "; + if (!lo && (flags & IFF_MULTICAST)) + s += "MULTICAST "; + if (!s.empty()) + s.pop_back(); + return s; + }; + + const std::string indent(10, ' '); + std::string out = pad(it.name, 10); + + // Line 1: Link encap + HWaddr + out += "Link encap:"; + out += it.loopback() ? "Local Loopback" : "Ethernet"; + if (!it.mac_addr.empty()) + out += " HWaddr " + it.mac_addr; + out += '\n'; + + // Line 2: inet addr / Bcast / Mask (loopback has no broadcast) + if (!it.ipv4_addr.empty()) { + out += indent + "inet addr:" + it.ipv4_addr; + if (!it.loopback() && !it.ipv4_bcast.empty()) + out += " Bcast:" + it.ipv4_bcast; + if (!it.netmask.empty()) + out += " Mask:" + it.netmask; + out += '\n'; + } + + // Line 3: flags + MTU + Metric + out += indent + flags_string(it.flags, it.loopback()); + out += " MTU:" + std::to_string(it.mtu); + out += " Metric:1\n"; + + // Line 4/5: RX/TX counters + auto count = [](std::string_view dir, std::uint64_t packets, std::uint64_t errs, + std::uint64_t dropped, std::string_view tail) -> std::string { + char buf[160]; + std::snprintf( + buf, sizeof(buf), "%.*s packets:%llu errors:%llu dropped:%llu overruns:0 %.*s", + static_cast(dir.size()), dir.data(), static_cast(packets), + static_cast(errs), static_cast(dropped), + static_cast(tail.size()), tail.data()); + return buf; + }; + out += indent + count("RX", it.rx_packets, it.rx_errors, it.rx_dropped, "frame:0") + '\n'; + out += indent + count("TX", it.tx_packets, it.tx_errors, it.tx_dropped, "carrier:0") + '\n'; + + // Line 6: collisions + txqueuelen (defaults; full ifconfig queries SIOCGIFTXQLEN) + out += indent + "collisions:0 txqueuelen:1000\n"; + + // Line 7: bytes (human-readable in parens, like BusyBox) + out += indent + "RX bytes:" + std::to_string(it.rx_bytes) + " (" + human(it.rx_bytes) + ") "; + out += "TX bytes:" + std::to_string(it.tx_bytes) + " (" + human(it.tx_bytes) + ")\n"; + + return out; +} + +} // namespace cfbox::net diff --git a/src/applets/ifconfig.cpp b/src/applets/ifconfig.cpp new file mode 100644 index 0000000..e88aae6 --- /dev/null +++ b/src/applets/ifconfig.cpp @@ -0,0 +1,71 @@ +// ifconfig.cpp — display network interfaces (read-only subset for now). +// +// Implemented: `ifconfig` (up interfaces), `ifconfig -a` (all), `ifconfig IFACE` +// (one). Output mirrors the BusyBox multi-line format via net::format_ifconfig. +// Mutations (ADDR/netmask/up/down/mtu) are Wave 1 scope but deferred to a +// follow-up batch — this first cut closes the read path shared with ip/netstat. + +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "ifconfig", + .version = CFBOX_VERSION_STRING, + .one_line = "display network interfaces (read-only)", + .usage = "ifconfig [-a] [IFACE]", + .options = " -a show all interfaces, including down ones", + .extra = "", +}; + +} // namespace + +auto ifconfig_main(int argc, char* argv[]) -> int { + using namespace cfbox; + auto parsed = args::parse(argc, argv, + { + args::OptSpec{'a', false, "all"}, + }); + + if (parsed.has_long("help")) { + help::print_help(HELP); + return 0; + } + if (parsed.has_long("version")) { + help::print_version(HELP); + return 0; + } + + auto interfaces = net::read_interfaces(); + if (!interfaces) { + CFBOX_ERR("ifconfig", "%s", interfaces.error().msg.c_str()); + return 1; + } + + // BusyBox semantics: no args → up interfaces only; -a → all; IFACE → that one + std::string want; + if (!parsed.positional().empty()) + want = std::string{parsed.positional()[0]}; + const bool show_all = parsed.has('a') || !want.empty(); + + bool any = false; + for (const auto& it : *interfaces) { + if (!want.empty() && it.name != want) + continue; + if (!show_all && !it.up()) + continue; + std::fputs(net::format_ifconfig(it).c_str(), stdout); + any = true; + } + if (!any && !want.empty()) { + CFBOX_ERR("ifconfig", "interface '%s' not found", want.c_str()); + return 1; + } + return 0; +} diff --git a/tests/integration/test_ifconfig.sh b/tests/integration/test_ifconfig.sh new file mode 100755 index 0000000..d3bd502 --- /dev/null +++ b/tests/integration/test_ifconfig.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# test_ifconfig.sh — ifconfig display: -a lists lo, named interface works, +# bogus interface exits non-zero. +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 + +# ifconfig -a must list lo (every Linux has it) with the encap line +out=$("$CFBOX" ifconfig -a 2>&1) || true +if echo "$out" | grep -q "lo" && echo "$out" | grep -q "Link encap"; then + ((++pass)) +else + echo "FAIL [ifconfig -a]: missing lo or Link encap" + ((++fail)) +fi + +# no-arg run still shows at least one interface (loopback is up) +out=$("$CFBOX" ifconfig 2>&1) || true +if echo "$out" | grep -q "Link encap"; then + ((++pass)) +else + echo "FAIL [ifconfig no-arg]" + ((++fail)) +fi + +# named interface: lo shows loopback markers +out=$("$CFBOX" ifconfig lo 2>&1) || true +if echo "$out" | grep -q "Local Loopback"; then + ((++pass)) +else + echo "FAIL [ifconfig lo]: $(echo "$out" | head -1)" + ((++fail)) +fi + +# bogus interface → non-zero exit +if ! "$CFBOX" ifconfig no-such-iface-xyz >/dev/null 2>&1; then + ((++pass)) +else + echo "FAIL [ifconfig bogus exit]" + ((++fail)) +fi + +echo "ifconfig: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/unit/test_net_util.cpp b/tests/unit/test_net_util.cpp new file mode 100644 index 0000000..f51cf51 --- /dev/null +++ b/tests/unit/test_net_util.cpp @@ -0,0 +1,83 @@ +#include + +#include + +#include + +#include + +// Live /proc/net/dev read — every Linux has loopback, so we can assert on it +// without mocking the filesystem. +TEST(NetUtilTest, ReadInterfacesHasLoopback) { + auto ifs = cfbox::net::read_interfaces(); + ASSERT_TRUE(ifs.has_value()) << ifs.error().msg; + EXPECT_FALSE(ifs->empty()); + bool has_lo = false; + for (const auto& it : *ifs) { + if (it.loopback()) + has_lo = true; + } + EXPECT_TRUE(has_lo) << "loopback interface missing"; +} + +TEST(NetUtilTest, LoopbackHasLocalAddress) { + auto ifs = cfbox::net::read_interfaces(); + ASSERT_TRUE(ifs.has_value()); + for (const auto& it : *ifs) { + if (it.loopback()) { + EXPECT_EQ(it.ipv4_addr, "127.0.0.1"); + EXPECT_GT(it.mtu, 0); + return; + } + } + FAIL() << "no loopback interface"; +} + +TEST(NetUtilTest, ParseDevFields) { + auto nums = cfbox::net::parse_dev_fields(" 123 456 0 0 junk 789"); + ASSERT_EQ(nums.size(), 5u); + EXPECT_EQ(nums[0], 123u); + EXPECT_EQ(nums[1], 456u); + EXPECT_EQ(nums[2], 0u); + EXPECT_EQ(nums[3], 0u); + EXPECT_EQ(nums[4], 789u); +} + +TEST(NetUtilTest, ParseDevFieldsEmpty) { + auto nums = cfbox::net::parse_dev_fields(" "); + EXPECT_TRUE(nums.empty()); +} + +TEST(NetUtilTest, FormatIfconfigLoopback) { + cfbox::net::InterfaceInfo lo; + lo.name = "lo"; + lo.flags = IFF_UP | IFF_LOOPBACK | IFF_RUNNING; + lo.mtu = 65536; + lo.ipv4_addr = "127.0.0.1"; + lo.netmask = "255.0.0.0"; + lo.rx_bytes = 1024; + lo.tx_bytes = 2048; + auto s = cfbox::net::format_ifconfig(lo); + EXPECT_NE(s.find("lo"), std::string::npos); + EXPECT_NE(s.find("Local Loopback"), std::string::npos); + EXPECT_NE(s.find("127.0.0.1"), std::string::npos); + EXPECT_NE(s.find("LOOPBACK"), std::string::npos); + EXPECT_NE(s.find("MTU:65536"), std::string::npos); +} + +TEST(NetUtilTest, FormatIfconfigEthernet) { + cfbox::net::InterfaceInfo eth; + eth.name = "eth0"; + eth.flags = IFF_UP | IFF_BROADCAST | IFF_RUNNING | IFF_MULTICAST; + eth.mtu = 1500; + eth.ipv4_addr = "10.0.0.1"; + eth.ipv4_bcast = "10.0.0.255"; + eth.netmask = "255.255.255.0"; + eth.mac_addr = "aa:bb:cc:dd:ee:ff"; + auto s = cfbox::net::format_ifconfig(eth); + EXPECT_NE(s.find("Ethernet"), std::string::npos); + EXPECT_NE(s.find("HWaddr aa:bb:cc:dd:ee:ff"), std::string::npos); + EXPECT_NE(s.find("Bcast:10.0.0.255"), std::string::npos); + EXPECT_NE(s.find("BROADCAST"), std::string::npos); + EXPECT_EQ(s.find("LOOPBACK"), std::string::npos); // ethernet line must not say LOOPBACK +} From cc82b511117f1867078ff896c52c61e682a41d80 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 09:49:39 +0800 Subject: [PATCH 6/8] docs(plan): record Phase 3 wave 1a (net_util + ifconfig) Add batch 2 row to Phase 3 table (f4279d6), bump baseline to 446 GTest / 455 KB / 125 applet, and note the armhf -Wcast-align catch (sockaddr -> sockaddr_in memcpy via ipv4_from_ioctl) plus the loopback Bcast and collisions/txqueuelen output fixes. --- document/ai/PLAN.md | 5 ++-- document/notes/2026-07-06-phase3-ifconfig.md | 31 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 document/notes/2026-07-06-phase3-ifconfig.md diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index adbf743..6ba725b 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -3,7 +3,7 @@ > Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 > **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 > ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 -> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` 基础设施 + `nc` applet)✅ —— 当前基线 **440 GTest / 451 KB / 124 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 +> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅ —— 当前基线 **446 GTest / 455 KB / 125 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -63,8 +63,9 @@ | 批 | 范围 | 状态 | Commit | 测试 | |----|------|------|--------|------| | 批1(Wave 0) | `include/cfbox/socket.hpp` 基础设施(复用 `io::unique_fd`:make/resolve/dial/listen_on/accept_one/format_addr,双栈、header-only)+ `nc` applet(connect/listen 模式 + poll 双向 relay stdin→sock/sock→stdout + SHUT_WR 半关) | ✅ | 10f811f | 440/1 | +| 批2(Wave 1a) | `include/cfbox/net_util.hpp` 基础设施(`read_interfaces` 解析 /proc/net/dev + ioctl SIOCGIF* 取 flags/mtu/hwaddr/ipv4;`format_ifconfig` BusyBox 多行格式;`ipv4_from_ioctl` memcpy 解 cast-align)+ `ifconfig` applet(`-a`/`IFACE` 只读显示) | ✅ | f4279d6 | 446/1 | -> 下一批:Wave 1 `ifconfig`/`ip show` + `hostname` 深化(依赖新建 `net_util.hpp` 读 /proc/net + ioctl)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md)。 +> 下一批:Wave 1b `ifconfig` 写操作(ADDR/netmask/up/down/mtu)+ `ip addr/route show` + `hostname` 深化(依赖 `net_util.hpp` 已就绪)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md)。 ## OPEN GOTCHAS(跨批陷阱,改前必看) diff --git a/document/notes/2026-07-06-phase3-ifconfig.md b/document/notes/2026-07-06-phase3-ifconfig.md new file mode 100644 index 0000000..50361af --- /dev/null +++ b/document/notes/2026-07-06-phase3-ifconfig.md @@ -0,0 +1,31 @@ +# 2026-07-06 — Phase 3 批2:net_util.hpp + ifconfig 显示(Wave 1a) + +## 背景 +Phase 3 Wave 1 第一锹。`net_util.hpp` 是 Wave 1(ifconfig/ip/route/netstat)共享的接口查询层;`ifconfig -a` 是第一个 read-only 消费者,闭环可集成测(lo 必有)。 + +## 设计决策 + +- **net_util 复用 `io::read_all` + `io::split_lines`** 解 /proc/net/dev(对齐 proc.hpp 的 /proc 解析风格),手写 `parse_dev_fields` 解析 16 列计数器——避免 `` 撑体积([STRUCTURE-TASTE](../ai/STRUCTURE-TASTE.md) 体积敏感)。 +- **ioctl control socket**:一个 `AF_INET/SOCK_DGRAM` fd(`io::unique_fd` 守护)跑所有 SIOCGIF* 查询(flags/mtu/hwaddr/addr/netmask/brdaddr)。 +- **`format_ifconfig` BusyBox 多行格式**:name 列宽 10、Link encap/HWaddr、inet addr/Bcast/Mask、flags + MTU、RX/TX counters、bytes 人类可读(KB/MB/GB,1024 进制 BusyBox 标签)。 +- **本批只做 read**:`net_util` 只实现 `read_interfaces` + `format_ifconfig`。文档 [phase-2-network.md](../todo/phases/phase-2-network.md) 1.3 设计的 `read_routes`/`read_sockets`/写操作(interface_up/set_addr/add_route)留 B3+。ifconfig 写操作(ADDR/up/down)同批或下批补。 + +## 踩坑 + +1. **`-Wcast-align` armhf 32 位盲区**([memory local-cross-compile-selfcheck]):`reinterpret_cast(&ifr.ifr_addr)` 在 x86-64 静默,armhf `-Werror=cast-align` 报——sockaddr(2 字节对齐)→ sockaddr_in(4 字节对齐)增加对齐。解:`ipv4_from_ioctl` helper 用 `memcpy` 拷到局部 sockaddr_in(无指针 cast,对齐安全)。**这是 memory 警告的 32 位盲区首次真命中**,证明本地 armhf 交叉核查的价值。注意 `sockaddr_storage*` → `sockaddr*`(bound_port/listen_on)方向对齐减小,不报。 +2. **lo 显示 `Bcast:0.0.0.0`**:SIOCGIFBRDADDR 对 loopback 返回 0.0.0.0 而非空。BusyBox ifconfig 对 loopback 不显示 Bcast(语义:loopback 无广播)。`format_ifconfig` 加 `!it.loopback()` 条件。 +3. **collisions 行重复 `Metric:1`**:初版写 `collisions:0 Metric:1`,BusyBox 是 `collisions:0 txqueuelen:1000`。改 `txqueuelen:1000`(默认值;完整需 SIOCGIFTXQLEN,后续批补)。 + +## 验证 +- GTest **446/446**(+6:NetUtilTest ReadInterfacesHasLoopback/LoopbackHasLocalAddress/ParseDevFields/ParseDevFieldsEmpty/FormatIfconfigLoopback/FormatIfconfigEthernet)。 +- 集成 **56 脚本**全绿(test_ifconfig.sh 新增:-a/no-arg/named/bogus-exit)。 +- structure gate PASS。 +- size-opt **455 KB**(基线 451 → +4 KB,ifconfig + net_util.hpp)。 +- armhf static **1243 KB**(B1 后 1235 → +8 KB);qemu-arm-static `ifconfig lo` 输出正确(32 位 ioctl + memcpy 解工作)。 +- commit: `f4279d6` + +## 陷阱(留给后续批/维护者) +- **只读**:`ifconfig ADDR up/down` 等写操作未实现(HELP 只列 `-a`,避免名实不符)。Wave 1b 补 SIOCSIF*(需 root/CAP_NET_ADMIN)。 +- **net_util 未完**:`read_routes`/`read_sockets`(route/netstat 要)未实现,下批按需加。 +- **cast-align 教训**:网络代码里 `sockaddr*` → `sockaddr_in*` 的 reinterpret_cast 一律走 memcpy helper,别直接 cast。后续 ip/route/netstat/wget 若遇同模式,复用 `ipv4_from_ioctl` 或同理。 +- **Metric:1 / txqueuelen:1000 是写死默认**:真实值需额外 ioctl(SIOCGIFMETRIC/SIOCGIFTXQLEN),下批可补,但对显示兼容非阻塞。 From f0ff0db9229724c4ba99714a8c42f9ac4c5b91f0 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 10:18:50 +0800 Subject: [PATCH 7/8] feat(ip,route,hostname): addr show, route -n, hostname -i/-f/-d (Phase 3 wave 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1b: two new display applets and the hostname options, all built on the net_util layer from wave 1a. - include/cfbox/net_util.hpp: RouteEntry + read_routes() (parses /proc/net/route tab-separated hex fields) + format_route_table() (BusyBox route -n layout) + hex_to_ipv4 (host-endian hex → dotted) + prefix_len. hex_to_ipv4 explicitly casts strtoul's unsigned long to uint32_t (-Wshorten-64-to-32 on x86-64). - src/applets/ip.cpp: 'ip addr [show] [IFACE]' — iproute2-style index, flags, mtu, link, inet/prefix/scope. addr subtree only (link/route/add later). - src/applets/route.cpp: 'route [-n]' — prints the table; add/del rejected. - src/applets/hostname.cpp: add -i (resolve_ipv4 via getaddrinfo, memcpy to dodge -Wcast-align), -f (FQDN via AI_CANONNAME), -d (domain part). - tests: NetUtil +3 (hex/prefix/route-table), IpTest +3, test_ip.sh, test_route.sh. Verified: 452/452 GTest (+9), structure gate PASS, size-opt 463 KB (+8), 58 integration scripts green, armhf static clean + qemu ip/route/hostname. --- cmake/Config.cmake | 2 +- include/cfbox/applet_config.hpp.in | 2 + include/cfbox/applets.hpp | 12 +++ include/cfbox/net_util.hpp | 103 +++++++++++++++++++++++ src/applets/hostname.cpp | 130 ++++++++++++++++++++++++----- src/applets/ip.cpp | 87 +++++++++++++++++++ src/applets/route.cpp | 58 +++++++++++++ tests/integration/test_ip.sh | 32 +++++++ tests/integration/test_route.sh | 33 ++++++++ tests/unit/test_ip.cpp | 34 ++++++++ tests/unit/test_net_util.cpp | 29 +++++++ 11 files changed, 502 insertions(+), 20 deletions(-) create mode 100644 src/applets/ip.cpp create mode 100644 src/applets/route.cpp create mode 100755 tests/integration/test_ip.sh create mode 100755 tests/integration/test_route.sh create mode 100644 tests/unit/test_ip.cpp diff --git a/cmake/Config.cmake b/cmake/Config.cmake index b9b504c..134cc58 100644 --- a/cmake/Config.cmake +++ b/cmake/Config.cmake @@ -29,7 +29,7 @@ set(CFBOX_APPLETS dmesg hexdump more rev cal renice clear which mount mountpoint mdev chmod chown chgrp umount swapoff reboot - nc ifconfig + nc ifconfig ip route ) foreach(applet IN LISTS CFBOX_APPLETS) diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in index e599fbc..0ab9af8 100644 --- a/include/cfbox/applet_config.hpp.in +++ b/include/cfbox/applet_config.hpp.in @@ -126,3 +126,5 @@ #cmakedefine01 CFBOX_ENABLE_REBOOT #cmakedefine01 CFBOX_ENABLE_NC #cmakedefine01 CFBOX_ENABLE_IFCONFIG +#cmakedefine01 CFBOX_ENABLE_IP +#cmakedefine01 CFBOX_ENABLE_ROUTE diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index d181ad1..590cd30 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -371,6 +371,12 @@ extern auto nc_main(int argc, char* argv[]) -> int; #if CFBOX_ENABLE_IFCONFIG extern auto ifconfig_main(int argc, char* argv[]) -> int; #endif +#if CFBOX_ENABLE_IP +extern auto ip_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_ROUTE +extern auto route_main(int argc, char* argv[]) -> int; +#endif // registry — one line per applet, conditionally compiled constexpr auto APPLET_REGISTRY = std::to_array({ @@ -743,4 +749,10 @@ constexpr auto APPLET_REGISTRY = std::to_array({ #if CFBOX_ENABLE_IFCONFIG {"ifconfig", ifconfig_main, "configure a network interface"}, #endif +#if CFBOX_ENABLE_IP + {"ip", ip_main, "show / manipulate routing and devices"}, +#endif +#if CFBOX_ENABLE_ROUTE + {"route", route_main, "show the IP routing table"}, +#endif }); diff --git a/include/cfbox/net_util.hpp b/include/cfbox/net_util.hpp index 6a4190f..e49c244 100644 --- a/include/cfbox/net_util.hpp +++ b/include/cfbox/net_util.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -235,4 +236,106 @@ inline auto format_ifconfig(const InterfaceInfo& it) -> std::string { return out; } +// ---- routing table (/proc/net/route) ---- + +struct RouteEntry { + std::string destination; // dotted, "0.0.0.0" for default + std::string gateway; // dotted, "0.0.0.0" if none (on-link) + std::string genmask; // dotted + std::string iface; + int flags = 0; // raw RTF_* bits + int metric = 0; +}; + +// /proc/net/route prints addresses as host-endian hex; assign straight into +// in_addr.s_addr (the targets we ship are little-endian). +inline auto hex_to_ipv4(std::string_view hex) -> std::string { + std::uint32_t v = + static_cast(std::strtoul(std::string{hex}.c_str(), nullptr, 16)); + in_addr a; + a.s_addr = v; + char buf[INET_ADDRSTRLEN]; + return ::inet_ntop(AF_INET, &a, buf, sizeof(buf)) ? buf : std::string{}; +} + +// Bit width of a contiguous-ones netmask (255.255.255.0 -> 24). +inline auto prefix_len(const std::string& mask) -> int { + in_addr a; + if (::inet_pton(AF_INET, mask.c_str(), &a) != 1) + return 0; + std::uint32_t v = ntohl(a.s_addr); + int n = 0; + while (v & 0x80000000u) { + ++n; + v <<= 1; + } + return n; +} + +[[nodiscard]] inline auto read_routes() -> base::Result> { + CFBOX_TRY(content, io::read_all("/proc/net/route")); + auto lines = io::split_lines(*content); + std::vector out; + for (std::size_t i = 1; i < lines.size(); ++i) { // skip header line + const auto& line = lines[i]; + if (line.empty()) + continue; + // tab-separated: Iface Dest GW Flags RefCnt Use Metric Mask MTU Window IRTT + std::vector f; + std::string cur; + for (char c : line) { + if (c == '\t' || c == ' ') { + if (!cur.empty()) { + f.push_back(cur); + cur.clear(); + } + } else { + cur += c; + } + } + if (!cur.empty()) + f.push_back(cur); + if (f.size() < 8) + continue; + + RouteEntry r; + r.iface = f[0]; + r.destination = hex_to_ipv4(f[1]); + r.gateway = (f[2] == "00000000") ? "0.0.0.0" : hex_to_ipv4(f[2]); + r.flags = static_cast(std::strtoul(f[3].c_str(), nullptr, 16)); + r.metric = static_cast(std::strtoul(f[6].c_str(), nullptr, 10)); + r.genmask = hex_to_ipv4(f[7]); + out.push_back(std::move(r)); + } + return out; +} + +inline auto format_route_table(const std::vector& routes) -> std::string { + std::string out = "Kernel IP routing table\n"; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%-16s%-16s%-16s%-6s%-7s%-6s%-8s%s\n", "Destination", "Gateway", + "Genmask", "Flags", "Metric", "Ref", "Use", "Iface"); + out += buf; + for (const auto& r : routes) { + std::string fl; + if (r.flags & 0x1) + fl += 'U'; // RTF_UP + if (r.flags & 0x2) + fl += 'G'; // RTF_GATEWAY + if (r.flags & 0x4) + fl += 'H'; // RTF_HOST + if (r.flags & 0x8) + fl += 'R'; // RTF_REINSTATE + if (r.flags & 0x10) + fl += 'D'; // RTF_DYNAMIC + if (r.flags & 0x20) + fl += 'M'; // RTF_MODIFIED + std::snprintf(buf, sizeof(buf), "%-16s%-16s%-16s%-6s%-7d%-6d%-8d%s\n", + r.destination.c_str(), r.gateway.c_str(), r.genmask.c_str(), fl.c_str(), + r.metric, 0, 0, r.iface.c_str()); + out += buf; + } + return out; +} + } // namespace cfbox::net diff --git a/src/applets/hostname.cpp b/src/applets/hostname.cpp index 13f49b6..a591dc0 100644 --- a/src/applets/hostname.cpp +++ b/src/applets/hostname.cpp @@ -1,45 +1,137 @@ +// hostname.cpp — show the system hostname. BusyBox-compatible options. +// +// -s short (cut at first dot) -i IPv4 address(es) +// -f FQDN (canonical via getaddrinfo) -d DNS domain (FQDN minus short part) +// +// Setting the hostname (hostname NAME) and sethostname(2) are not in this cut. + +#include +#include +#include +#include + #include #include #include -#include +#include #include -#include #include +#include namespace { + constexpr cfbox::help::HelpEntry HELP = { - .name = "hostname", + .name = "hostname", .version = CFBOX_VERSION_STRING, .one_line = "show or set the system host name", - .usage = "hostname [-s]", - .options = " -s short hostname (truncate at first dot)", - .extra = "", + .usage = "hostname [-s|-i|-f|-d]", + .options = " -s short hostname (cut at first dot)\n" + " -i IP address(es) for the host name\n" + " -f long host name (FQDN)\n" + " -d DNS domain name", + .extra = "", }; + +auto get_hostname() -> std::string { + char buf[256] = {}; + if (gethostname(buf, sizeof(buf) - 1) != 0) + return {}; + return buf; +} + +// Resolve name → IPv4 dotted strings. memcpy ai_addr to avoid -Wcast-align. +auto resolve_ipv4(const std::string& name) -> std::vector { + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + if (getaddrinfo(name.c_str(), nullptr, &hints, &res) != 0) + return {}; + std::vector out; + for (addrinfo* p = res; p; p = p->ai_next) { + sockaddr_in sa; + std::memcpy(&sa, p->ai_addr, sizeof(sa)); + char buf[INET_ADDRSTRLEN]; + if (inet_ntop(AF_INET, &sa.sin_addr, buf, sizeof(buf))) + out.push_back(buf); + } + freeaddrinfo(res); + return out; +} + +// Canonical FQDN via getaddrinfo(AI_CANONNAME); falls back to gethostname(). +auto resolve_fqdn(const std::string& name) -> std::string { + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_flags = AI_CANONNAME; + addrinfo* res = nullptr; + if (getaddrinfo(name.c_str(), nullptr, &hints, &res) != 0 || !res) + return name; + std::string fqdn = res->ai_canonname ? res->ai_canonname : name; + freeaddrinfo(res); + return fqdn; +} + } // namespace auto hostname_main(int argc, char* argv[]) -> int { - auto parsed = cfbox::args::parse(argc, argv, { - cfbox::args::OptSpec{'s', false}, - }); + using namespace cfbox; + auto parsed = args::parse(argc, argv, + { + args::OptSpec{'s', false, "short"}, + args::OptSpec{'i', false, "ip-address"}, + args::OptSpec{'f', false, "fqdn"}, + args::OptSpec{'d', false, "domain"}, + }); - if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } - if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + if (parsed.has_long("help")) { + help::print_help(HELP); + return 0; + } + if (parsed.has_long("version")) { + help::print_version(HELP); + return 0; + } - char buf[256]; - if (gethostname(buf, sizeof(buf)) != 0) { + std::string name = get_hostname(); + if (name.empty()) { CFBOX_ERR("hostname", "cannot get hostname"); return 1; } - std::string name{buf}; - if (parsed.has('s')) { - auto dot = name.find('.'); - if (dot != std::string::npos) { - name.erase(dot); + if (parsed.has('i')) { + auto addrs = resolve_ipv4(name); + if (addrs.empty()) { + CFBOX_ERR("hostname", "cannot resolve %s", name.c_str()); + return 1; } + for (std::size_t k = 0; k < addrs.size(); ++k) { + if (k) + std::putchar(' '); + std::fputs(addrs[k].c_str(), stdout); + } + std::putchar('\n'); + return 0; + } + if (parsed.has('f')) { + std::puts(resolve_fqdn(name).c_str()); + return 0; + } + if (parsed.has('d')) { + std::string fqdn = resolve_fqdn(name); + auto dot = fqdn.find('.'); + if (dot != std::string::npos) + std::puts(fqdn.substr(dot + 1).c_str()); + return 0; } - std::puts(name.c_str()); + std::string out = name; + if (parsed.has('s')) { + auto dot = out.find('.'); + if (dot != std::string::npos) + out.erase(dot); + } + std::puts(out.c_str()); return 0; } diff --git a/src/applets/ip.cpp b/src/applets/ip.cpp new file mode 100644 index 0000000..e3ab9bf --- /dev/null +++ b/src/applets/ip.cpp @@ -0,0 +1,87 @@ +// ip.cpp — iproute2-style display (addr show subset). Wave 1b scope: only +// `ip addr [show]`. link/route/add/del subcommands come later. Output mirrors +// the BusyBox/iproute2 "ip addr" shape loosely (index, flags, mtu, link, inet). + +#include + +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "ip", + .version = CFBOX_VERSION_STRING, + .one_line = "show network addresses (ip addr show subset)", + .usage = "ip addr [show] [IFACE]", + .options = " addr show list interfaces and their addresses", + .extra = "", +}; + +auto flags_angle(const cfbox::net::InterfaceInfo& it) -> std::string { + std::string s; + if (it.up()) + s += "UP,"; + if (it.loopback()) + s += "LOOPBACK,"; + if (it.flags & IFF_BROADCAST) + s += "BROADCAST,"; + if (it.flags & IFF_POINTOPOINT) + s += "POINTOPOINT,"; + if (it.flags & IFF_RUNNING) + s += "LOWER_UP,"; + if (it.flags & IFF_MULTICAST) + s += "MULTICAST,"; + if (!s.empty()) + s.pop_back(); + return s; +} + +} // namespace + +auto ip_main(int argc, char* argv[]) -> int { + using namespace cfbox; + // This batch only implements the "addr" subtree. + if (argc < 2 || (std::string{argv[1]} != "addr" && std::string{argv[1]} != "a")) { + CFBOX_ERR("ip", "usage: ip addr [show]"); + return 2; + } + + // optional interface filter as positional after "addr [show]" + std::string want; + for (int i = 2; i < argc; ++i) { + std::string_view a = argv[i]; + if (a == "show") + continue; + want = a; + } + + auto interfaces = net::read_interfaces(); + if (!interfaces) { + CFBOX_ERR("ip", "%s", interfaces.error().msg.c_str()); + return 1; + } + + int idx = 1; + for (const auto& it : *interfaces) { + if (!want.empty() && it.name != want) + continue; + std::printf("%d: %s: <%s> mtu %d state %s\n", idx++, it.name.c_str(), + flags_angle(it).c_str(), it.mtu, it.up() ? "UP" : "DOWN"); + std::printf(" link/%s %s\n", it.loopback() ? "loopback" : "ether", + it.mac_addr.empty() ? "00:00:00:00:00:00" : it.mac_addr.c_str()); + if (!it.ipv4_addr.empty()) { + int prefix = net::prefix_len(it.netmask); + std::printf(" inet %s/%d", it.ipv4_addr.c_str(), prefix); + if (!it.loopback() && !it.ipv4_bcast.empty()) + std::printf(" brd %s", it.ipv4_bcast.c_str()); + std::printf(" scope %s %s\n", it.loopback() ? "host" : "global", it.name.c_str()); + } + } + return 0; +} diff --git a/src/applets/route.cpp b/src/applets/route.cpp new file mode 100644 index 0000000..2ea3d18 --- /dev/null +++ b/src/applets/route.cpp @@ -0,0 +1,58 @@ +// route.cpp — display the kernel IP routing table (route -n style). +// Wave 1b scope: read-only display. add/del (SIOCADDRT/SIOCDELRT) deferred. + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "route", + .version = CFBOX_VERSION_STRING, + .one_line = "show the IP routing table (display only)", + .usage = "route [-n]", + .options = " -n bypass name resolution (always numeric here)", + .extra = "", +}; + +} // namespace + +auto route_main(int argc, char* argv[]) -> int { + using namespace cfbox; + auto parsed = args::parse(argc, argv, + { + args::OptSpec{'n', false, "numeric"}, + }); + + if (parsed.has_long("help")) { + help::print_help(HELP); + return 0; + } + if (parsed.has_long("version")) { + help::print_version(HELP); + return 0; + } + + if (!parsed.positional().empty()) { + auto sub = parsed.positional()[0]; + if (sub == "add" || sub == "del") { + CFBOX_ERR("route", "%.*s not supported in this build", static_cast(sub.size()), + sub.data()); + return 2; + } + } + + auto routes = net::read_routes(); + if (!routes) { + CFBOX_ERR("route", "%s", routes.error().msg.c_str()); + return 1; + } + std::fputs(net::format_route_table(*routes).c_str(), stdout); + return 0; +} diff --git a/tests/integration/test_ip.sh b/tests/integration/test_ip.sh new file mode 100755 index 0000000..8af851a --- /dev/null +++ b/tests/integration/test_ip.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# test_ip.sh — ip addr show lists lo with 127.0.0.1; no-subcommand exits 2. +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 + +out=$("$CFBOX" ip addr show 2>&1) || true +if echo "$out" | grep -q "lo" && echo "$out" | grep -q "127.0.0.1"; then + ((++pass)) +else + echo "FAIL [ip addr show]: $(echo "$out" | head -3)" + ((++fail)) +fi + +out=$("$CFBOX" ip addr 2>&1) || true +if echo "$out" | grep -q "LOOPBACK"; then + ((++pass)) +else + echo "FAIL [ip addr]" + ((++fail)) +fi + +if ! "$CFBOX" ip >/dev/null 2>&1; then + ((++pass)) +else + echo "FAIL [ip no-subcommand should exit non-zero]" + ((++fail)) +fi + +echo "ip: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/integration/test_route.sh b/tests/integration/test_route.sh new file mode 100755 index 0000000..887bc2e --- /dev/null +++ b/tests/integration/test_route.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# test_route.sh — route display prints the table header; add/del rejected. +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 + +out=$("$CFBOX" route -n 2>&1) || true +if echo "$out" | grep -q "Kernel IP routing table" && echo "$out" | grep -q "Destination"; then + ((++pass)) +else + echo "FAIL [route -n]: no header" + ((++fail)) +fi + +out=$("$CFBOX" route 2>&1) || true +if echo "$out" | grep -q "Iface"; then + ((++pass)) +else + echo "FAIL [route bare]" + ((++fail)) +fi + +# add/del are out of scope for this build → non-zero exit +if ! "$CFBOX" route add default gw 1.2.3.4 >/dev/null 2>&1; then + ((++pass)) +else + echo "FAIL [route add should exit non-zero]" + ((++fail)) +fi + +echo "route: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/unit/test_ip.cpp b/tests/unit/test_ip.cpp new file mode 100644 index 0000000..2d9668b --- /dev/null +++ b/tests/unit/test_ip.cpp @@ -0,0 +1,34 @@ +#include +#include + +#include "test_capture.hpp" + +#include + +#if CFBOX_ENABLE_IP + +using namespace cfbox::test; + +TEST(IpTest, AddrShowListsLoopback) { + char a0[] = "ip", a1[] = "addr", a2[] = "show"; + char* argv[] = {a0, a1, a2, nullptr}; + auto out = capture_stdout([&] { return ip_main(3, argv); }); + EXPECT_NE(out.find("lo"), std::string::npos); + EXPECT_NE(out.find("LOOPBACK"), std::string::npos); + EXPECT_NE(out.find("127.0.0.1"), std::string::npos); +} + +TEST(IpTest, AddrAbbrevWorks) { + char a0[] = "ip", a1[] = "a"; + char* argv[] = {a0, a1, nullptr}; + auto out = capture_stdout([&] { return ip_main(2, argv); }); + EXPECT_NE(out.find("link/"), std::string::npos); +} + +TEST(IpTest, NoSubcommandExits2) { + char a0[] = "ip"; + char* argv[] = {a0, nullptr}; + EXPECT_EQ(ip_main(1, argv), 2); +} + +#endif // CFBOX_ENABLE_IP diff --git a/tests/unit/test_net_util.cpp b/tests/unit/test_net_util.cpp index f51cf51..1b7c4d4 100644 --- a/tests/unit/test_net_util.cpp +++ b/tests/unit/test_net_util.cpp @@ -81,3 +81,32 @@ TEST(NetUtilTest, FormatIfconfigEthernet) { EXPECT_NE(s.find("BROADCAST"), std::string::npos); EXPECT_EQ(s.find("LOOPBACK"), std::string::npos); // ethernet line must not say LOOPBACK } + +TEST(NetUtilTest, HexToIpv4RoundTrip) { + // /proc/net/route stores 192.168.2.1 as host-endian hex 0102A8C0 + EXPECT_EQ(cfbox::net::hex_to_ipv4("0102A8C0"), "192.168.2.1"); + EXPECT_EQ(cfbox::net::hex_to_ipv4("00000000"), "0.0.0.0"); + // 255.255.255.0 = bytes FF FF FF 00 → little-endian hex 00FFFFFF + EXPECT_EQ(cfbox::net::hex_to_ipv4("00FFFFFF"), "255.255.255.0"); +} + +TEST(NetUtilTest, PrefixLen) { + EXPECT_EQ(cfbox::net::prefix_len("255.0.0.0"), 8); + EXPECT_EQ(cfbox::net::prefix_len("255.255.255.0"), 24); + EXPECT_EQ(cfbox::net::prefix_len("255.255.255.255"), 32); + EXPECT_EQ(cfbox::net::prefix_len("0.0.0.0"), 0); +} + +TEST(NetUtilTest, FormatRouteTableHasHeader) { + std::vector r(1); + r[0].destination = "0.0.0.0"; + r[0].gateway = "192.168.1.1"; + r[0].genmask = "0.0.0.0"; + r[0].iface = "eth0"; + r[0].flags = 0x3; // UP | GATEWAY + r[0].metric = 0; + auto s = cfbox::net::format_route_table(r); + EXPECT_NE(s.find("Kernel IP routing table"), std::string::npos); + EXPECT_NE(s.find("UG"), std::string::npos); + EXPECT_NE(s.find("192.168.1.1"), std::string::npos); +} From e02f69d74453d36bf0bbed3730073bbd6cb00f0c Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 10:20:51 +0800 Subject: [PATCH 8/8] docs(plan): record Phase 3 wave 1b (ip/route/hostname) Add batch 3 row (f0ff0db), bump baseline to 452 GTest / 463 KB / 127 applet, and note the strtoul-uint32 shorten catch plus the getaddrinfo ai_addr memcpy (reusing the ipv4_from_ioctl cast-align pattern from wave 1a). --- document/ai/PLAN.md | 5 +-- .../2026-07-06-phase3-ip-route-hostname.md | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 document/notes/2026-07-06-phase3-ip-route-hostname.md diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index 6ba725b..6d8f368 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -3,7 +3,7 @@ > Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 > **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 > ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 -> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅ —— 当前基线 **446 GTest / 455 KB / 125 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 +> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅ —— 当前基线 **452 GTest / 463 KB / 127 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -64,8 +64,9 @@ |----|------|------|--------|------| | 批1(Wave 0) | `include/cfbox/socket.hpp` 基础设施(复用 `io::unique_fd`:make/resolve/dial/listen_on/accept_one/format_addr,双栈、header-only)+ `nc` applet(connect/listen 模式 + poll 双向 relay stdin→sock/sock→stdout + SHUT_WR 半关) | ✅ | 10f811f | 440/1 | | 批2(Wave 1a) | `include/cfbox/net_util.hpp` 基础设施(`read_interfaces` 解析 /proc/net/dev + ioctl SIOCGIF* 取 flags/mtu/hwaddr/ipv4;`format_ifconfig` BusyBox 多行格式;`ipv4_from_ioctl` memcpy 解 cast-align)+ `ifconfig` applet(`-a`/`IFACE` 只读显示) | ✅ | f4279d6 | 446/1 | +| 批3(Wave 1b) | `net_util.hpp` 扩展(`read_routes` 解析 /proc/net/route tab-hex + `format_route_table` BusyBox route -n + `hex_to_ipv4`/`prefix_len`)+ `ip addr show`(iproute2 风格 index/flags/mtu/link/inet)+ `route -n`(显示)+ `hostname` 深化(`-i`/`-f`/`-d` getaddrinfo + memcpy 解 cast-align) | ✅ | f0ff0db | 452/2 | -> 下一批:Wave 1b `ifconfig` 写操作(ADDR/netmask/up/down/mtu)+ `ip addr/route show` + `hostname` 深化(依赖 `net_util.hpp` 已就绪)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md)。 +> 下一批:Wave 1c `ifconfig` 写操作(ADDR/netmask/up/down/mtu,SIOCSIF*,需 root)+ `netstat`(依赖 net_util `read_sockets` 待建);或转 Wave 2(`ping`/`traceroute`,需 `icmp.hpp` raw socket)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md)。 ## OPEN GOTCHAS(跨批陷阱,改前必看) diff --git a/document/notes/2026-07-06-phase3-ip-route-hostname.md b/document/notes/2026-07-06-phase3-ip-route-hostname.md new file mode 100644 index 0000000..d549f95 --- /dev/null +++ b/document/notes/2026-07-06-phase3-ip-route-hostname.md @@ -0,0 +1,33 @@ +# 2026-07-06 — Phase 3 批3:ip + route + hostname 深化(Wave 1b) + +## 背景 +Wave 1b:`ip addr show` + `route -n` + `hostname` 深化(`-i`/`-f`/`-d`)。三个都消费 `net_util.hpp`(read_interfaces / read_routes)或 ``。Wave 1 显示路径基本完整;写操作 + netstat 留 1c。 + +## 设计决策 + +- **net_util 扩展 read_routes**:`/proc/net/route` 是 tab 分隔、地址为 host-endian hex。手写 tab-split + `hex_to_ipv4`(strtoul→uint32→直接赋 s_addr,little-endian 目标)。`format_route_table` BusyBox route -n 列布局(Destination/Gateway/Genmask/Flags/Metric/Ref/Use/Iface),flags 位→字母(U/G/H/R/D/M)。 +- **prefix_len** 从 netmask 算(inet_pton → ntohl → 数高位连续 1)给 ip 的 `/24` 用。 +- **ip addr show**:iproute2 风格(`N: name: mtu N` + `link/encap` + `inet addr/prefix [brd] scope name`)。flags_angle 把 IFF_* 转 `UP,LOOPBACK,LOWER_UP,...`。本批只 `addr` 子树(`a` 缩写支持),link/route/add 后续。 +- **route**:只显示(-n 是默认行为,cfbox 永远 numeric 不查 DNS)。add/del 显式拒绝(exit 2 + 报错)。 +- **hostname -i/-f/-d**:`getaddrinfo` 解析本机名得 IPv4 列表(`-i`)/ canonical name(`-f`,`AI_CANONNAME`);`-d` = FQDN 去短名部分。 + +## 踩坑 + +1. **`strtoul` 返回 `unsigned long`→`uint32_t` 截断**(IDE `-Wshorten-64-to-32` 抓到):x86-64 `unsigned long` 64 位,赋 uint32 隐式截断。`hex_to_ipv4` 加 `static_cast`。32 位 armhf 无此问题(unsigned long 32 位)但显式 cast 两端安全。 +2. **getaddrinfo `ai_addr` cast-align**:`sockaddr*`→`sockaddr_in*` 同 [批2 ipv4_from_ioctl](2026-07-06-phase3-ifconfig.md) 的盲区。hostname 的 `resolve_ipv4` 用 `memcpy(&sa, p->ai_addr, sizeof(sa))` 而非 reinterpret_cast,对齐安全。**复用同一模式**。 + +## 验证 +- GTest **452/452**(+9:NetUtil HexToIpv4RoundTrip/PrefixLen/FormatRouteTableHasHeader + IpTest AddrShowListsLoopback/AddrAbbrevWorks/NoSubcommandExits2)。 +- 集成 **58 脚本**全绿(test_ip.sh + test_route.sh 新增)。 +- structure gate PASS。 +- size-opt **463 KB**(455 → +8 KB,hostname 深化 + ip + route + net_util route 扩展)。 +- armhf static **1247 KB**(1243 → +4 KB);qemu-arm-static `ip addr show lo` / `route -n` / `hostname -s` 输出正确。 +- commit: `f0ff0db` + +## 陷阱(留给后续批/维护者) +- **ip 只 addr 子树**:`ip link`/`ip route`/`ip addr add` 未实现(HELP 只列 addr show)。Wave 1c 或后续补。多文件拆分(文档建议 `src/applets/ip/`)目前单文件够用。 +- **route 只读**:add/del(SIOCADDRT/SIOCDELRT)未实现,exit 2。需 root。 +- **ifconfig 写操作仍未做**:Wave 1a 留的 gap,1c 补(SIOCSIFADDR/FLAGS,需 root)。 +- **hostname -i 依赖 /etc/hosts 解析**:WSL 上返回 127.0.1.1(Debian/Ubuntu 习惯),板子上若 /etc/hosts 配置不同会不同。非 bug,行为同 BusyBox。 +- **netstat 未做**:需 net_util `read_sockets`(/proc/net/tcp/udp 解析),Wave 1c。 +- **cast-align/shorten 模式复用**:后续 netstat/wget/ping 遇 `sockaddr*`→`sockaddr_in*` 或长整型→uint32 一律 memcpy + static_cast,别直接 cast。