diff --git a/.github/workflows/TestingCI.yml b/.github/workflows/TestingCI.yml index 78be1836d..9b547801b 100644 --- a/.github/workflows/TestingCI.yml +++ b/.github/workflows/TestingCI.yml @@ -17,7 +17,7 @@ env: # The crates that build and test on Windows. Utilities are ported a whole # crate at a time, because `cargo test -p` builds every binary in the # crate; add a crate here once all of its binaries and tests are portable. - WINDOWS_CRATES: -p gettext-rs -p plib -p posixutils-xform -p posixutils-text + WINDOWS_CRATES: -p gettext-rs -p plib -p posixutils-xform -p posixutils-text -p posixutils-datetime -p posixutils-calc jobs: lint: diff --git a/Cargo.lock b/Cargo.lock index 053003442..2427684e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -865,6 +865,7 @@ version = "0.9.0" dependencies = [ "cc", "cfg-if", + "chrono", "errno", "gettext-rs", "libc", diff --git a/README.md b/README.md index 35004a58e..fddfc37a3 100644 --- a/README.md +++ b/README.md @@ -98,42 +98,11 @@ Note that `cargo install` copies the declared binaries and nothing else, so the ### Windows -Two crates build and are tested on Windows, natively with MSVC (no MinGW or -Cygwin runtime): `xform` (`cksum`, `compress`, `uuencode`, `uudecode`) and -`text` (`asa`, `comm`, `csplit`, `cut`, `diff`, `expand`, `fold`, `grep`, -`head`, `join`, `nl`, `paste`, `patch`, `pr`, `sed`, `sort`, `tail`, `tr`, -`tsort`, `unexpand`, `uniq`, `wc`). - -```sh -cargo build --release -p posixutils-xform -p posixutils-text -``` - -Building the whole workspace on Windows does not work: most other utilities -are inherently Unix (users, terminals, signals, file modes and ownership). -What behaves differently on Windows: - -- a file's POSIX mode is its read-only attribute, read as the owner-write bit - (`0444` or `0644`); setting a mode without owner write makes it read-only; -- `compress` restores permissions and times but not ownership, and does not - warn about hard links; the `zcat` and `uncompress` aliases do not exist - (use `compress -c -d` and `compress -d`); -- `LC_ALL`, `LC_*` and `LANG` set to `C` or `POSIX` select the C locale: - ASCII-only character classes and case mapping, one byte per character, and - byte-order collation; `C.UTF-8` (or any `C`/`POSIX` name with a codeset) - collates in byte order but reads UTF-8 and classifies and case-maps by - Unicode; any other value, or none, is the user's locale with UTF-8 input and - Unicode characters; -- POSIX regular expressions are musl's (vendored in `plib/vendor/musl-regex`) - and do not support characters above U+FFFF; -- `sort -n` always takes `.` as the decimal point, with no thousands - separator; -- `diff` reports anything that is neither a file nor a directory as a - special file, and recognises a directory loop by its canonical path; -- `pr -p` and `patch` prompt on the console; `csplit` removes its files on - Ctrl-C, Ctrl-Break and termination, Windows having no hangup or quit - signal. - -[WINDOWS.md](WINDOWS.md) is the guide to porting another crate. +Some crates build and are tested on Windows, natively with MSVC (no MinGW or +Cygwin runtime); building the whole workspace there does not work, as most +other utilities are inherently Unix. [WINDOWS.md](WINDOWS.md) lists the +crates and utilities that build there, how they behave differently, and how +another crate is ported. ### Container image diff --git a/WINDOWS.md b/WINDOWS.md index aabb2ec50..65d3bd82b 100644 --- a/WINDOWS.md +++ b/WINDOWS.md @@ -1,10 +1,64 @@ -# Porting a crate to Windows +# Windows -How a workspace crate is made to build, and its tests to pass, on Windows -(`x86_64-pc-windows-msvc`), and how it then joins CI. Follow it in order; each -step names what to check before going on. +What builds on Windows (`x86_64-pc-windows-msvc`) and how it behaves there, +then how another workspace crate is ported. -## Rules +## Using the utilities on Windows + +These crates build and are tested on Windows, natively with MSVC (no MinGW or +Cygwin runtime): + +| Crate | Utilities | +|---|---| +| `calc` | `bc`, `expr` | +| `datetime` | `cal`, `date`, `sleep`, `time` | +| `text` | `asa`, `comm`, `csplit`, `cut`, `diff`, `expand`, `fold`, `grep`, `head`, `join`, `nl`, `paste`, `patch`, `pr`, `sed`, `sort`, `tail`, `tr`, `tsort`, `unexpand`, `uniq`, `wc` | +| `xform` | `cksum`, `compress`, `uuencode`, `uudecode` | + +```sh +cargo build --release -p posixutils-calc -p posixutils-datetime -p posixutils-text -p posixutils-xform +``` + +What behaves differently on Windows: + +- a file's POSIX mode is its read-only attribute, read as the owner-write bit + (`0444` or `0644`); setting a mode without owner write makes it read-only; +- `compress` restores permissions and times but not ownership, and does not + warn about hard links; the `zcat` and `uncompress` aliases do not exist + (use `compress -c -d` and `compress -d`); +- `LC_ALL`, `LC_*` and `LANG` set to `C` or `POSIX` select the C locale: + ASCII-only character classes and case mapping, one byte per character, and + byte-order collation; `C.UTF-8` (or any `C`/`POSIX` name with a codeset) + collates in byte order but reads UTF-8 and classifies and case-maps by + Unicode; any other value, or none, is the user's locale with UTF-8 input and + Unicode characters; +- POSIX regular expressions are musl's (vendored in `plib/vendor/musl-regex`) + and do not support characters above U+FFFF; +- `sort -n` always takes `.` as the decimal point, with no thousands + separator; +- `diff` reports anything that is neither a file nor a directory as a + special file, and recognises a directory loop by its canonical path; +- `pr -p` and `patch` prompt on the console; `csplit` removes its files on + Ctrl-C, Ctrl-Break and termination, Windows having no hangup or quit + signal; +- `date` and `cal` name months and days as the POSIX locale does, whatever + `LC_TIME` says; `TZ` takes the C runtime's forms (`UTC0`, `EST5EDT`: a + three-letter name, an offset, an optional daylight name) and not zoneinfo + names such as `America/New_York`; unset, it is the system time zone; +- `date` sets the clock only with the system-time privilege, and reads the + local time it is given in the system time zone, not `TZ`; +- `time` reports its own CPU time plus the utility's, Windows keeping no + totals for a process's descendants; +- `expr` operands are text: a Windows command line cannot carry bytes that + are not UTF-8. + +## Porting a crate + +How a workspace crate is made to build, and its tests to pass, on Windows, +and how it then joins CI. Follow it in order; each step names what to check +before going on. + +### Rules - **A whole crate at a time.** `cargo test -p ` builds every binary in the crate, so a crate is ported when *all* of its binaries and tests compile @@ -24,7 +78,7 @@ step names what to check before going on. (`mode_of`, `set_mode`, `remove_file`, `link_count`), called from shared code. Never scatter `cfg` through a function body twice for the same rule. -## Windows meaning of Unix concepts +### Windows meaning of Unix concepts | Unix | Windows | |---|---| @@ -52,10 +106,15 @@ step names what to check before going on. | FIFOs, devices, sockets | none: neither a file nor a directory is "special" | | `SIGQUIT` | `SIGBREAK` (Ctrl-Break) where a quit key is meant | | absolute paths | a root (`\x`), a drive prefix (`C:x`) or `..` must all be refused where only relative names are allowed | +| `localtime_r`, `gmtime_r`, `TZ` | the C runtime's `localtime_s` / `gmtime_s`, which read `TZ` in its own forms (no zoneinfo); the zone name is its `strftime("%Z")` | +| `strftime`, `LC_TIME` | `plib::timefmt`: the POSIX locale's names and formats, `LC_TIME` ignored | +| `clock_settime` | `SetSystemTime` (needs the system-time privilege) | +| `times()` children's CPU | `GetProcessTimes` on the waited-for child's handle, added to the process's own | +| argv as bytes (`OsStrExt::as_bytes`) | `OsStr::as_encoded_bytes`, portable: the same bytes on Unix, UTF-8 on Windows | -## Steps +### Steps -### 1. Survey +#### 1. Survey ```sh cargo check --target x86_64-pc-windows-msvc -p --all-targets 2>&1 | grep -E '^error' @@ -67,18 +126,18 @@ Sort the errors into: missing `plib` modules (step 2), the crate's own sources Windows meaning; a binary that cannot be ported keeps the whole crate off Windows (or the crate is split), never stubbed. -### 2. plib +#### 2. plib `plib` is the base of every crate. Its modules with no Windows meaning are `#[cfg(unix)]` in `plib/src/lib.rs`; a crate that needs one ports that module (or the part it uses) first, as its own commit, with the module's unit tests running on Windows. Ported so far: `diag`, `io` (including the terminal for -prompts), `locale` (characters and case), `lzw`, `regex`, `testing`, +prompts), `locale` (characters, case and `strftime`), `lzw`, `regex`, `testing`, `archive`, `cscan`, `linediff`. Still Unix-only: `curuser`, `exec`, `group`, `modestr`, `platform`, `priority`, `projectdir`, `sccsfile`, `syslog`, `test_expr`, `tmp`, `tty`, `user`, `utmpx`. -### 3. The crate's sources +#### 3. The crate's sources Apply the table above through small cfg'd helpers. Prefer a portable std API that serves both platforms when it is exactly equivalent on Unix. Read every @@ -86,7 +145,7 @@ that serves both platforms when it is exactly equivalent on Unix. Read every not the ones that compile and mean something else (a path with `/dev/stdout`, a `:`-separated list). -### 4. Tests +#### 4. Tests - Integration tests find binaries with `plib::testing::get_binary_path`, which already handles `.exe` and `--target` layouts. @@ -101,7 +160,7 @@ a `:`-separated list). - Windows will not delete a read-only file under Wine: clear the attribute before cleanup. -### 5. Verify +#### 5. Verify Every commit, never two cargo commands at once: @@ -128,17 +187,24 @@ A change to `plib::testing` or anything every crate uses gets the full Wine's limits: the `-gnu` target links `msvcrt`, which refuses the UTF-8 locale, so UTF-8 multibyte regex behaviour is exercised only by CI's MSVC build; and Wine refuses to delete a read-only file even where current Windows -does, which is the stricter behaviour to be correct against. +does, which is the stricter behaviour to be correct against. Three +`datetime` tests fail under Wine 9 and pass only on Windows: its `msvcrt` +parses `TZ` wrongly (`TZ=UTC` names the zone `UT`), failing `date`'s +`test_tz_utc` and `test_default_format_utc`; and its `GetProcessTimes` +answers the caller's own times for another process, failing `time`'s +`cpu_bound_child_reports_nonzero_cpu_time`. Wine maps `/dev/full` to the +Linux device, so the tests that write to it run there and not on Windows. -### 6. CI and docs +#### 6. CI and docs Add `-p ` to `WINDOWS_CRATES` in `.github/workflows/TestingCI.yml`; the `windows` job, the lint job's Windows clippy and the `msrv` job's Windows -check all read it. List the crate's utilities in README's "Windows" section, -with anything that behaves differently there, and add any new Unix→Windows -meaning to the table above. Ported so far: `xform`, `text`. +check all read it. Add the crate and its utilities to the table under "Using +the utilities on Windows" and to its build command, anything that behaves +differently to the list there, and any new Unix→Windows meaning to the table +above. README only points here: it is not edited per crate. -### 7. Commits +#### 7. Commits A bisectable series, each commit building and passing clippy on both targets on its own: plib modules first, then the crate's sources, then its tests, diff --git a/calc/bc.rs b/calc/bc.rs index 1a04d93f8..13bef3e5e 100644 --- a/calc/bc.rs +++ b/calc/bc.rs @@ -79,7 +79,7 @@ fn main() { } Err(e) => { diag::init("bc"); - diag::error(&format!("{}", e)); + diag::error(&diag::io_error_text(&e)); std::process::exit(1); } } @@ -92,6 +92,11 @@ fn report(e: impl std::fmt::Display) -> bool { true } +/// Report a failed write as the system names it. Returns true, as [`report`]. +fn report_io(e: std::io::Error) -> bool { + report(diag::io_error_text(&e)) +} + /// Report each diagnostic of a parse failure at its own position. fn report_parse_error(e: &ParseError) -> bool { for (line, col, message) in e.diagnostics() { @@ -164,14 +169,14 @@ fn run() { had_error |= report(e); } if let Err(e) = out.flush() { - had_error |= report(e); + had_error |= report_io(e); } } Err(e) => had_error |= report_parse_error(&e), } if interpreter.has_quit() { if let Err(e) = out.flush() { - had_error |= report(e); + had_error |= report_io(e); } std::process::exit(if had_error { 1 } else { 0 }); } @@ -180,7 +185,7 @@ fn run() { let mut repl = match DefaultEditor::new() { Ok(repl) => repl, Err(e) => { - diag::error(&format!("{}", e)); + diag::error(&diag::error_text(&e)); std::process::exit(1); } }; @@ -201,7 +206,7 @@ fn run() { failed = report(e); } if let Err(e) = out.flush() { - failed = report(e); + failed = report_io(e); } had_error |= failed && !interactive; line_buffer.clear(); @@ -218,7 +223,7 @@ fn run() { // End of input (Ctrl-D) or interrupt (Ctrl-C): exit silently. Err(ReadlineError::Eof) | Err(ReadlineError::Interrupted) => break, Err(e) => { - had_error |= report(format!("{:?}", e)); + had_error |= report(diag::error_text(&e)); break; } } @@ -234,7 +239,7 @@ fn run() { } if let Err(e) = out.flush() { - had_error |= report(e); + had_error |= report_io(e); } std::process::exit(if had_error { 1 } else { 0 }); } diff --git a/calc/bc_util/interpreter.rs b/calc/bc_util/interpreter.rs index 7cdafd01c..5707e84e6 100644 --- a/calc/bc_util/interpreter.rs +++ b/calc/bc_util/interpreter.rs @@ -65,7 +65,7 @@ impl From<&'static str> for ExecutionError { impl From for ExecutionError { fn from(e: io::Error) -> Self { Self { - message: format!("cannot write output: {e}"), + message: format!("cannot write output: {}", plib::diag::io_error_text(&e)), call_stack: Vec::new(), } } diff --git a/calc/expr.rs b/calc/expr.rs index 0fe71fbc9..057fd5dac 100644 --- a/calc/expr.rs +++ b/calc/expr.rs @@ -152,13 +152,13 @@ fn parse_token(arg: Vec) -> Token { // tokenize the command line arguments, all in a single pass fn tokenize() -> Vec { - use std::os::unix::ffi::OsStrExt; - // POSIX operands are byte strings: a pathname or a compared string need - // not be text, and decoding argv as UTF-8 aborts on one that is not. + // not be text, and decoding argv as UTF-8 aborts on one that is not. On + // Unix these are the argument's own bytes; Windows arguments are UTF-16, + // read as UTF-8. let mut args: Vec> = std::env::args_os() .skip(1) - .map(|arg| arg.as_bytes().to_vec()) + .map(|arg| arg.as_encoded_bytes().to_vec()) .collect(); // POSIX / XBD 12.2 Guideline 10: a leading "--" delimits the end of diff --git a/calc/tests/bc/mod.rs b/calc/tests/bc/mod.rs index a07cd2770..2e1f31347 100644 --- a/calc/tests/bc/mod.rs +++ b/calc/tests/bc/mod.rs @@ -79,7 +79,10 @@ fn test_bc_missing_file() { args: vec!["/nonexistent-bc-file.bc".to_string()], stdin_data: String::new(), expected_out: String::new(), - expected_err: String::from("bc: /nonexistent-bc-file.bc: No such file or directory\n"), + expected_err: format!( + "bc: /nonexistent-bc-file.bc: {}\n", + crate::open_error("/nonexistent-bc-file.bc") + ), expected_exit_code: 1, }); } @@ -273,13 +276,32 @@ fn test_bc_sparse_array() { test_bc("a[16777215]=7\na[16777215]\na[5]\nquit\n", "7\n0\n"); } +/// A fresh directory for one test's files, removed when dropped. +struct TestDir(std::path::PathBuf); + +impl TestDir { + fn new(tag: &str) -> Self { + let path = std::env::temp_dir().join(format!("posixutils-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + TestDir(path) + } + + fn path(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// A file that exists but is not text is not an access failure. #[test] fn test_bc_non_text_file() { - let dir = plib::tmp::Builder::new() - .prefix("bc-nontext") - .tempdir() - .unwrap(); + let dir = TestDir::new("bc-nontext"); let path = dir.path().join("binary.bc"); std::fs::write(&path, b"1+1\n\xff\xfe\n").unwrap(); let output = plib::testing::run_test_base("bc", &[path.to_string_lossy().to_string()], b""); @@ -317,10 +339,7 @@ fn test_bc_incomplete_input_at_eof() { /// operand or on standard input. #[test] fn test_bc_exit_status_is_consistent() { - let dir = plib::tmp::Builder::new() - .prefix("bc-status") - .tempdir() - .unwrap(); + let dir = TestDir::new("bc-status"); for program in ["1/0\n", "1+\n"] { let path = dir.path().join("program.bc"); std::fs::write(&path, program).unwrap(); @@ -344,10 +363,7 @@ fn test_bc_write_error_is_reported() { Ok(file) => file, Err(_) => return, // no /dev/full on this host }; - let dir = plib::tmp::Builder::new() - .prefix("bc-write") - .tempdir() - .unwrap(); + let dir = TestDir::new("bc-write"); let path = dir.path().join("program.bc"); // Enough output to leave the buffer and reach the device. std::fs::write(&path, "for(i=0;i<5000;++i) i\n").unwrap(); @@ -374,9 +390,13 @@ fn test_bc_write_error_is_reported() { output.status.code() ); assert!( - stderr.contains("No space left on device"), + stderr.contains(&crate::full_device_error()), "expected the write failure to be named, got {stderr:?}" ); + assert!( + !stderr.contains("(os error"), + "Rust's errno parenthetical must not reach the user: {stderr:?}" + ); } /// The math library carries guard digits through its argument reductions, so diff --git a/calc/tests/calc-tests.rs b/calc/tests/calc-tests.rs index ad4fe7eb3..49fd4d20c 100644 --- a/calc/tests/calc-tests.rs +++ b/calc/tests/calc-tests.rs @@ -9,3 +9,19 @@ mod bc; mod expr; + +/// The system's own text for a failure to open `path`, as a utility reports it. +fn open_error(path: &str) -> String { + plib::diag::io_error_text(&std::fs::File::open(path).unwrap_err()) +} + +/// The system's own text for a write to `/dev/full`, as a utility reports it. +fn full_device_error() -> String { + use std::io::Write; + let mut full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + let e = full.write_all(b"x").unwrap_err(); + plib::diag::io_error_text(&e) +} diff --git a/calc/tests/expr/mod.rs b/calc/tests/expr/mod.rs index 9f8ab3bb8..07c78355a 100644 --- a/calc/tests/expr/mod.rs +++ b/calc/tests/expr/mod.rs @@ -7,10 +7,12 @@ // SPDX-License-Identifier: MIT // +#[cfg(unix)] +use plib::testing::os_bytes; use plib::testing::{ - locale_matching, os_bytes, run_test, run_test_os, run_test_with_env, utf8_locale, TestPlan, - TestPlanOs, + locale_matching, run_test, run_test_os, run_test_with_env, utf8_locale, TestPlan, TestPlanOs, }; +use std::ffi::OsString; // success: result is neither null nor zero (exit 0) fn expr_test(args: &[&str], expected_output: &str) { @@ -222,7 +224,9 @@ fn expr_integer_out_of_range() { } /// POSIX operands are byte strings and need not be text; decoding argv as -/// UTF-8 aborted the process on one that was not. +/// UTF-8 aborted the process on one that was not. Unix only: a Windows +/// command line is UTF-16 and cannot carry bytes that are not UTF-8. +#[cfg(unix)] #[test] fn expr_non_utf8_operands() { // Printed back unchanged. @@ -261,14 +265,18 @@ fn expr_non_utf8_operands() { expected_err: Vec::new(), expected_exit_code: 0, }); - // A capture that lands inside a multibyte character returns those bytes, - // rather than aborting or substituting a replacement character. +} + +/// A capture that lands inside a multibyte character returns those bytes, +/// rather than aborting or substituting a replacement character. +#[test] +fn expr_capture_inside_a_multibyte_character() { run_test_os(TestPlanOs { cmd: String::from("expr"), args: vec![ - os_bytes("日本語".as_bytes()), - os_bytes(b":"), - os_bytes(b"\\(..\\)"), + OsString::from("日本語"), + OsString::from(":"), + OsString::from("\\(..\\)"), ], stdin_data: Vec::new(), expected_out: b"\xe6\x97\n".to_vec(), @@ -335,7 +343,7 @@ fn expr_write_error_is_reported() { output.status.code() ); assert!( - stderr.contains("No space left on device"), + stderr.contains(&crate::full_device_error()), "expected the write failure to be named, got {stderr:?}" ); } diff --git a/datetime/date.rs b/datetime/date.rs index a4bff87b8..ddb036788 100644 --- a/datetime/date.rs +++ b/datetime/date.rs @@ -11,8 +11,10 @@ use chrono::{DateTime, Datelike, Local, LocalResult, TimeZone, Utc}; use clap::Parser; use gettextrs::gettext; use plib::diag; +#[cfg(unix)] use std::ffi::CString; use std::io::{self, Write}; +#[cfg(unix)] use std::mem::MaybeUninit; use std::process; @@ -20,6 +22,7 @@ const DEF_TIMESTR: &str = "%a %b %e %H:%M:%S %Z %Y"; /// Upper bound for the `strftime` output buffer. A zero return at this size is /// treated as a legitimately-empty conversion rather than a buffer overflow. +#[cfg(unix)] const STRFTIME_BUF_MAX: usize = 64 * 1024; /// Map a 2-digit year to a full year per POSIX: values in [00,68] refer to @@ -59,19 +62,32 @@ fn show_time(utc: bool, formatstr: &str) { return; } - let c_format = match CString::new(formatstr) { - Ok(s) => s, - Err(_) => { - diag::error(&gettext("format string contains NUL byte")); - process::exit(1); - } - }; - let now = unsafe { libc::time(std::ptr::null_mut()) }; if now == -1 { diag::error(&gettext("failed to get current time")); process::exit(1); } + + match format_time(now, utc, formatstr) { + Ok(text) => { + // Write the raw bytes so non-UTF-8 locale output is preserved. + let mut out = io::stdout().lock(); + let _ = out.write_all(&text); + let _ = out.write_all(b"\n"); + } + Err(msg) => { + diag::error(&gettext(msg)); + process::exit(1); + } + } +} + +/// `now` formatted by `formatstr` with the C library's `strftime`, in UTC or +/// local time. +#[cfg(unix)] +fn format_time(now: libc::time_t, utc: bool, formatstr: &str) -> Result, &'static str> { + let c_format = CString::new(formatstr).map_err(|_| "format string contains NUL byte")?; + let mut tm = MaybeUninit::::uninit(); let tm_ptr = unsafe { @@ -83,8 +99,7 @@ fn show_time(utc: bool, formatstr: &str) { }; if tm_ptr.is_null() { - diag::error(&gettext("failed to get current time")); - process::exit(1); + return Err("failed to get current time"); } let tm = unsafe { tm.assume_init() }; @@ -109,27 +124,30 @@ fn show_time(utc: bool, formatstr: &str) { ) }; if len > 0 { - // Write the raw bytes so non-UTF-8 locale output is preserved. - let mut out = io::stdout().lock(); - let _ = out.write_all(&buf[..len]); - let _ = out.write_all(b"\n"); - return; + buf.truncate(len); + return Ok(buf); } if buf[0] == 0 { - // Empty-but-valid conversion: a shall always be appended. - let _ = io::stdout().write_all(b"\n"); - return; + // Empty-but-valid conversion: a shall still be appended. + return Ok(Vec::new()); } // Output did not fit; grow and retry, capped to guard against a // pathologically large format silently allocating unbounded memory. if buf_size >= STRFTIME_BUF_MAX { - diag::error(&gettext("formatted output exceeds internal buffer limit")); - process::exit(1); + return Err("formatted output exceeds internal buffer limit"); } buf_size *= 2; } } +/// `now` formatted by `formatstr` in UTC or local time; see `plib::timefmt`. +#[cfg(windows)] +fn format_time(now: libc::time_t, utc: bool, formatstr: &str) -> Result, &'static str> { + plib::timefmt::format_time(formatstr, now, utc) + .map(String::into_bytes) + .map_err(|_| "failed to format the time") +} + fn set_time(utc: bool, timestr: &str) -> Result<(), &'static str> { for ch in timestr.chars() { if !ch.is_ascii_digit() { @@ -195,12 +213,17 @@ fn set_time(utc: bool, timestr: &str) -> Result<(), &'static str> { } }; + set_clock(new_time) +} + +/// Set the system clock to `secs` seconds since the Epoch. +#[cfg(unix)] +fn set_clock(secs: i64) -> Result<(), &'static str> { let new_time = libc::timespec { - tv_sec: new_time, + tv_sec: secs, tv_nsec: 0, }; - // set system time unsafe { if libc::clock_settime(libc::CLOCK_REALTIME, &new_time) != 0 { return Err("failed to set time"); @@ -210,6 +233,48 @@ fn set_time(utc: bool, timestr: &str) -> Result<(), &'static str> { Ok(()) } +/// Set the system clock to `secs` seconds since the Epoch: `SetSystemTime`, +/// which needs the system-time privilege. +#[cfg(windows)] +fn set_clock(secs: i64) -> Result<(), &'static str> { + use chrono::Timelike; + + #[repr(C)] + struct SystemTime { + year: u16, + month: u16, + day_of_week: u16, + day: u16, + hour: u16, + minute: u16, + second: u16, + milliseconds: u16, + } + + #[link(name = "kernel32")] + extern "system" { + fn SetSystemTime(time: *const SystemTime) -> i32; + } + + let t = DateTime::from_timestamp(secs, 0).ok_or("invalid date")?; + let field = |v: u32| u16::try_from(v).map_err(|_| "invalid date"); + let new_time = SystemTime { + year: u16::try_from(t.year()).map_err(|_| "invalid date")?, + month: field(t.month())?, + day_of_week: field(t.weekday().num_days_from_sunday())?, + day: field(t.day())?, + hour: field(t.hour())?, + minute: field(t.minute())?, + second: field(t.second())?, + milliseconds: 0, + }; + // SAFETY: new_time is a valid SYSTEMTIME for the duration of the call. + if unsafe { SetSystemTime(&new_time) } == 0 { + return Err("failed to set time"); + } + Ok(()) +} + fn main() { diag::init_locale("date"); diff --git a/datetime/sleep.rs b/datetime/sleep.rs index c0f2d7b3f..e8ad736e5 100644 --- a/datetime/sleep.rs +++ b/datetime/sleep.rs @@ -27,8 +27,9 @@ fn main() { let args = Args::parse(); + // Ignore the SIGALRM signal (Windows has none). + #[cfg(unix)] unsafe { - // Ignore the SIGALRM signal libc::signal(libc::SIGALRM, libc::SIG_IGN); } diff --git a/datetime/tests/date/mod.rs b/datetime/tests/date/mod.rs index b074abd37..8eac32e0b 100644 --- a/datetime/tests/date/mod.rs +++ b/datetime/tests/date/mod.rs @@ -41,6 +41,9 @@ fn test_tz_utc_flag() { }); } +// Unix only: a zoneinfo name needs the tz database, which the Windows C +// runtime does not read. +#[cfg(unix)] #[test] fn test_tz_named() { run_test_with_checker_and_env( @@ -104,7 +107,9 @@ fn test_default_format_utc() { // Regression for the #D2 follow-up (Copilot): a format whose output exceeds // the internal 64 KiB strftime buffer must be reported as an error, not -// silently truncated to a bare newline. +// silently truncated to a bare newline. Unix only: the buffer is the C +// library's strftime's, and a Windows command line cannot hold such a format. +#[cfg(unix)] #[test] fn test_format_exceeds_buffer_errors() { let huge = format!("+{}", "x".repeat(70_000)); @@ -208,7 +213,9 @@ fn test_format_newline_tab_and_embedded_conversion() { // #D4: the `-u` *set* form. Setting the clock needs privilege, so an // unprivileged run must fail cleanly with a diagnostic and a non-zero status — // not panic, and not silently succeed. This exercises the set-time branch, -// which no test reached before. +// which no test reached before. Unix only: Windows CI runs as an +// administrator, where the test would really set the clock. +#[cfg(unix)] #[test] fn test_utc_set_form_fails_cleanly_without_privilege() { // Root would actually set the system clock; never do that in a test. diff --git a/datetime/tests/time/mod.rs b/datetime/tests/time/mod.rs index 533378eb8..49ee5cdcd 100644 --- a/datetime/tests/time/mod.rs +++ b/datetime/tests/time/mod.rs @@ -9,23 +9,26 @@ use std::process::Output; -use plib::testing::{run_test_base, TestPlan}; +use plib::testing::{get_binary_path, run_test_base, TestPlan}; + +/// A utility to time: this crate's own `date`, which every platform has. +fn date_path() -> String { + get_binary_path("date").to_string_lossy().into_owned() +} fn get_output(plan: TestPlan) -> Output { run_test_base(&plan.cmd, &plan.args, plan.stdin_data.as_bytes()) } fn run_test_time( - args: &[&str], + args: &[String], expected_output: &str, expected_error: &str, expected_exit_code: i32, ) { - let str_args: Vec = args.iter().map(|s| String::from(*s)).collect(); - let output = get_output(TestPlan { cmd: String::from("time"), - args: str_args, + args: args.to_vec(), stdin_data: String::new(), expected_out: String::from(expected_output), expected_err: String::from(expected_error), @@ -39,12 +42,17 @@ fn run_test_time( #[test] fn simple_test() { - run_test_time(&["--", "ls", "-l"], "", "User time", 0); + run_test_time(&["--".into(), date_path(), "-u".into()], "", "User time", 0); } #[test] fn p_test() { - run_test_time(&["-p", "--", "ls", "-l"], "", "user", 0); + run_test_time( + &["-p".into(), "--".into(), date_path(), "-u".into()], + "", + "user", + 0, + ); } #[test] @@ -54,7 +62,12 @@ fn parse_error_test() { #[test] fn command_error_test() { - run_test_time(&["-s", "ls", "-l"], "", "unexpected argument '-s' found", 0); + run_test_time( + &["-s".into(), date_path(), "-u".into()], + "", + "unexpected argument '-s' found", + 0, + ); } /// Parse the `user`/`sys` seconds out of `time -p` output on stderr. @@ -74,20 +87,34 @@ fn parse_p_user_sys(stderr: &str) -> (f64, f64) { ) } +/// Not a test of its own: the CPU-bound child of +/// `cpu_bound_child_reports_nonzero_cpu_time`, which runs this test binary +/// with `--ignored --exact`. +#[test] +#[ignore = "run as a child by cpu_bound_child_reports_nonzero_cpu_time"] +fn busy_child() { + let start = std::time::Instant::now(); + let mut n: u64 = 0; + while start.elapsed() < std::time::Duration::from_millis(300) { + n = std::hint::black_box(n.wrapping_add(1)); + } +} + // Regression for #T1/#T2: a CPU-bound child must report non-zero CPU time. // The pre-fix code never refilled tms_end and read the parent's own counters, // so user/sys were always ~0 regardless of the child's work. #[test] fn cpu_bound_child_reports_nonzero_cpu_time() { - let busy = "i=0; while [ $i -lt 3000000 ]; do i=$((i+1)); done"; + let this_test = std::env::current_exe().unwrap(); let output = get_output(TestPlan { cmd: String::from("time"), args: vec![ String::from("-p"), String::from("--"), - String::from("sh"), - String::from("-c"), - String::from(busy), + this_test.to_string_lossy().into_owned(), + String::from("--ignored"), + String::from("--exact"), + String::from("time::busy_child"), ], stdin_data: String::new(), expected_out: String::new(), @@ -96,6 +123,11 @@ fn cpu_bound_child_reports_nonzero_cpu_time() { }); assert!(output.status.success(), "time of busy child should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("1 passed"), + "the busy child did not run: {stdout}" + ); let stderr = String::from_utf8_lossy(&output.stderr); let (user, sys) = parse_p_user_sys(&stderr); assert!( @@ -110,21 +142,21 @@ fn cpu_bound_child_reports_nonzero_cpu_time() { fn propagates_child_exit_status() { let output = get_output(TestPlan { cmd: String::from("time"), + // `sleep` rejects a non-numeric operand with status 2. args: vec![ String::from("--"), - String::from("sh"), - String::from("-c"), - String::from("exit 7"), + get_binary_path("sleep").to_string_lossy().into_owned(), + String::from("abc"), ], stdin_data: String::new(), expected_out: String::new(), expected_err: String::new(), - expected_exit_code: 7, + expected_exit_code: 2, }); assert_eq!( output.status.code(), - Some(7), + Some(2), "time should exit with the utility's exit status" ); // Timing statistics are still written even when the utility fails. @@ -148,16 +180,7 @@ fn reports_127_when_the_utility_is_not_found() { fn reports_126_when_the_utility_cannot_be_invoked() { // A regular, non-executable file: found, but not invocable. let path = std::env::temp_dir().join(format!("posixutils_time_noexec_{}", std::process::id())); - { - use std::os::unix::fs::OpenOptionsExt; - let _f = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .mode(0o644) - .open(&path) - .unwrap(); - } + std::fs::write(&path, b"").unwrap(); let output = run_test_base("time", &[path.to_string_lossy().into_owned()], b""); assert_eq!(output.status.code(), Some(126)); @@ -170,16 +193,12 @@ fn reports_126_when_the_utility_cannot_be_invoked() { // `allow_hyphen_values`, so clap tried to parse the utility's first hyphenated // argument as one of time's own and rejected it: `time ls -l` and // `time sh -c '...'` both failed. See `#C4` in the process/ audit — env, nice -// and timeout had the identical defect. +// and timeout had the identical defect. The timed utility here is `date -u`. #[test] fn utility_arguments_may_start_with_a_hyphen() { let output = run_test_base( "time", - &[ - "sh".to_string(), - "-c".to_string(), - "echo passed-through".to_string(), - ], + &[date_path(), "-u".to_string(), "+passed-through".to_string()], b"", ); @@ -203,9 +222,9 @@ fn own_p_option_still_parses_before_the_utility() { "time", &[ "-p".to_string(), - "sh".to_string(), - "-c".to_string(), - "echo ok".to_string(), + date_path(), + "-u".to_string(), + "+ok".to_string(), ], b"", ); diff --git a/datetime/time.rs b/datetime/time.rs index 6f9ef14ce..75d232bc3 100644 --- a/datetime/time.rs +++ b/datetime/time.rs @@ -8,8 +8,7 @@ // use std::io::{self, Write}; -use std::os::unix::process::ExitStatusExt; -use std::process::{Command, Stdio}; +use std::process::{Child, Command, ExitStatus, Stdio}; use std::time::Instant; use clap::Parser; @@ -65,18 +64,7 @@ enum TimeError { /// status, per POSIX EXIT STATUS). fn time(args: Args) -> Result { let start_time = Instant::now(); - // SAFETY: std::mem::zeroed() is used to create an instance of libc::tms with all fields set to zero. - // This is safe here because libc::tms is a Plain Old Data type, and zero is a valid value for all its fields. - let mut tms_start: libc::tms = unsafe { std::mem::zeroed() }; - // SAFETY: sysconf is a POSIX function that returns the number of clock ticks per second. - // It is safe to call because it does not modify any memory and has no side effects. - let clock_ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) as f64 }; - - // Snapshot the process's accumulated CPU times *before* spawning the child. - // SAFETY: times is a POSIX function that fills the provided tms structure with time-accounting information. - // It is safe to call because we have correctly allocated and initialized tms_start, and the function - // only writes to this structure. - unsafe { libc::times(&mut tms_start) }; + let cpu_start = CpuStart::now(); let mut child = Command::new(&args.utility) .args(args.arguments) @@ -91,22 +79,7 @@ fn time(args: Args) -> Result { let status = child.wait().map_err(|_| TimeError::ExecTime)?; let elapsed = start_time.elapsed(); - - // Snapshot again *after* the child has been waited for, so the child's CPU - // usage has been folded into this process's tms_cutime/tms_cstime fields. - // SAFETY: same invariant as the tms_start call above. - let mut tms_end: libc::tms = unsafe { std::mem::zeroed() }; - unsafe { libc::times(&mut tms_end) }; - - // POSIX: User CPU time is the sum of tms_utime and tms_cutime, System CPU - // time the sum of tms_stime and tms_cstime, for the process in which the - // utility is executed. The child's usage is in the c* fields after wait(). - let user_ticks = - (tms_end.tms_utime + tms_end.tms_cutime) - (tms_start.tms_utime + tms_start.tms_cutime); - let system_ticks = - (tms_end.tms_stime + tms_end.tms_cstime) - (tms_start.tms_stime + tms_start.tms_cstime); - let user_time = user_ticks as f64 / clock_ticks_per_second; - let system_time = system_ticks as f64 / clock_ticks_per_second; + let (user_time, system_time) = cpu_start.used(&child); if args.posix { writeln!( @@ -129,12 +102,116 @@ fn time(args: Args) -> Result { } // EXIT STATUS: the exit status of time shall be the exit status of utility. - // A child terminated by a signal is reported as 128 + signal number. - let code = match status.code() { + Ok(exit_code(status)) +} + +/// This process's CPU times before the utility ran: `times(2)`, whose +/// children's fields take in the utility's usage once it has been waited for. +#[cfg(unix)] +struct CpuStart(libc::tms); + +#[cfg(unix)] +impl CpuStart { + fn now() -> Self { + // SAFETY: tms is plain data, and times only writes to it. + let mut tms: libc::tms = unsafe { std::mem::zeroed() }; + unsafe { libc::times(&mut tms) }; + CpuStart(tms) + } + + /// User and system CPU seconds used since `now`, by this process and the + /// waited-for utility. + fn used(&self, _child: &Child) -> (f64, f64) { + let start = &self.0; + let end = CpuStart::now().0; + // SAFETY: sysconf has no side effects. + let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) as f64 }; + + // POSIX: User CPU time is the sum of tms_utime and tms_cutime, System + // CPU time the sum of tms_stime and tms_cstime, for the process in + // which the utility is executed. + let user = (end.tms_utime + end.tms_cutime) - (start.tms_utime + start.tms_cutime); + let system = (end.tms_stime + end.tms_cstime) - (start.tms_stime + start.tms_cstime); + ( + user as f64 / ticks_per_second, + system as f64 / ticks_per_second, + ) + } +} + +/// Process CPU times, user then kernel, in 100-nanosecond units. +#[cfg(windows)] +fn process_times(process: std::os::windows::io::RawHandle) -> (u64, u64) { + #[link(name = "kernel32")] + extern "system" { + fn GetProcessTimes( + process: std::os::windows::io::RawHandle, + creation: *mut u64, + exit: *mut u64, + kernel: *mut u64, + user: *mut u64, + ) -> i32; + } + let (mut creation, mut exit, mut kernel, mut user) = (0, 0, 0, 0); + // SAFETY: each pointer is a FILETIME-sized out parameter; a failed call + // leaves them zero. + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; + (user, kernel) +} + +/// This process's CPU times before the utility ran. Windows keeps no +/// children's totals, so the utility's own times are read from its handle. +#[cfg(windows)] +struct CpuStart((u64, u64)); + +#[cfg(windows)] +impl CpuStart { + fn now() -> Self { + CpuStart(process_times(Self::current_process())) + } + + fn current_process() -> std::os::windows::io::RawHandle { + #[link(name = "kernel32")] + extern "system" { + fn GetCurrentProcess() -> std::os::windows::io::RawHandle; + } + // SAFETY: returns a pseudo-handle; nothing to release. + unsafe { GetCurrentProcess() } + } + + /// User and system CPU seconds used since `now`, by this process and the + /// waited-for utility. + fn used(&self, child: &Child) -> (f64, f64) { + use std::os::windows::io::AsRawHandle; + const TICKS_PER_SECOND: f64 = 10_000_000.0; + + let (start_user, start_kernel) = self.0; + let (end_user, end_kernel) = process_times(Self::current_process()); + let (child_user, child_kernel) = process_times(child.as_raw_handle()); + let user = end_user - start_user + child_user; + let system = end_kernel - start_kernel + child_kernel; + ( + user as f64 / TICKS_PER_SECOND, + system as f64 / TICKS_PER_SECOND, + ) + } +} + +/// The utility's exit status; one terminated by a signal is reported as +/// 128 + the signal number. +#[cfg(unix)] +fn exit_code(status: ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt; + match status.code() { Some(code) => code, None => 128 + status.signal().unwrap_or(0), - }; - Ok(code) + } +} + +/// The utility's exit status; a Windows process always has an exit code. +#[cfg(windows)] +fn exit_code(status: ExitStatus) -> i32 { + status.code().unwrap_or(1) } enum Status { diff --git a/plib/Cargo.toml b/plib/Cargo.toml index f54a661e6..f10f493b0 100644 --- a/plib/Cargo.toml +++ b/plib/Cargo.toml @@ -13,6 +13,10 @@ libc.workspace = true errno.workspace = true gettext-rs.workspace = true +# Formats times on Windows, which has no strftime(3) of its own (timefmt.rs). +[target.'cfg(windows)'.dependencies] +chrono.workspace = true + # Compiles the vendored musl regex for a Windows target (see build.rs). Not # under [target.'cfg(windows)'.build-dependencies]: Cargo matches that against # the host, so a Linux host cross-building for Windows would not get it. diff --git a/plib/src/lib.rs b/plib/src/lib.rs index b19e54f4c..81376ff56 100644 --- a/plib/src/lib.rs +++ b/plib/src/lib.rs @@ -36,6 +36,8 @@ pub mod syslog; #[cfg(unix)] pub mod test_expr; pub mod testing; +#[cfg(windows)] +pub mod timefmt; #[cfg(unix)] pub mod tmp; #[cfg(unix)] diff --git a/plib/src/locale.rs b/plib/src/locale.rs index c8e64fa49..221934db3 100644 --- a/plib/src/locale.rs +++ b/plib/src/locale.rs @@ -46,8 +46,8 @@ //! //! Every public function splits ASCII from the rest the same way on both //! platforms; only the private helpers that answer each half are per-platform. -//! [`strftime`] stays Unix-only: it needs `localtime_r` and `LC_TIME`, and -//! plib carries no date library to replace them. +//! [`strftime`] formats with [`crate::timefmt`]: `TZ` as the C runtime reads +//! it, and the POSIX locale's names, `LC_TIME` having no Windows meaning. use std::ffi::CString; #[cfg(unix)] @@ -684,6 +684,12 @@ pub fn strftime(fmt: &str, epoch_secs: i64) -> io::Result { } } +/// Format a unix epoch timestamp in local time; see [`crate::timefmt`]. +#[cfg(windows)] +pub fn strftime(fmt: &str, epoch_secs: i64) -> std::io::Result { + crate::timefmt::format_time(fmt, epoch_secs, false) +} + /// Return `true` if `response` is an affirmative answer under the current `LC_MESSAGES`. /// /// Uses the locale's `YESEXPR` extended regular expression (via libc `nl_langinfo(3)`); if that is diff --git a/plib/src/timefmt.rs b/plib/src/timefmt.rs new file mode 100644 index 000000000..cba064b9a --- /dev/null +++ b/plib/src/timefmt.rs @@ -0,0 +1,276 @@ +// +// Copyright (c) 2026 Jeff Garzik +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +//! `strftime(3)` on Windows. +//! +//! The C runtime converts a time to its broken-down local form and names its +//! zone, honouring `TZ` in the forms it understands (`UTC0`, `EST5EDT`; no +//! zoneinfo names) or the system time zone when `TZ` is unset. Every other +//! conversion is done here, in the POSIX +//! locale's names (`LC_TIME` has no Windows meaning): the CRT's own +//! `strftime` lacks conversions in older runtimes and aborts the process on an +//! unknown one. +//! +//! Supported: the POSIX conversions `aAbBcCdDeFgGhHIjmMnprRStTuUVwWxXyYzZ%`. +//! An `E` or `O` modifier is ignored, as the POSIX locale defines no +//! alternative forms. Any other conversion, including flags and field widths, +//! is copied to the output as written, as glibc does. + +use chrono::format::{Item, StrftimeItems}; +use chrono::{FixedOffset, NaiveDate, NaiveDateTime, TimeZone}; +use std::io; + +/// A broken-down time, its zone abbreviation and its offset east of UTC. +struct BrokenDown { + tm: libc::tm, + zone: String, + gmtoff: i32, +} + +fn invalid(msg: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, msg.to_string()) +} + +/// `epoch` in UTC: zone `GMT`, as glibc's `gmtime_r` names it. +fn universal_time(epoch: libc::time_t) -> io::Result { + // SAFETY: tm is plain data; gmtime_s fills it or reports failure. + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + if unsafe { libc::gmtime_s(&mut tm, &epoch) } != 0 { + return Err(invalid("timestamp out of range")); + } + Ok(BrokenDown { + tm, + zone: String::from("GMT"), + gmtoff: 0, + }) +} + +// The C runtime's strftime, which the libc crate does not bind on Windows. +// Called only with "%Z", which every runtime supports. +extern "C" { + fn strftime( + buf: *mut libc::c_char, + size: libc::size_t, + format: *const libc::c_char, + tm: *const libc::tm, + ) -> libc::size_t; +} + +/// The runtime's name for the zone `tm` is in (`tm_isdst` selects standard or +/// daylight time). +fn zone_name(tm: &libc::tm) -> String { + let mut name = [0u8; 128]; + // SAFETY: the buffer's size is passed with it; the format is a NUL- + // terminated literal and tm a valid broken-down time. + let len = unsafe { + strftime( + name.as_mut_ptr() as *mut libc::c_char, + name.len(), + c"%Z".as_ptr(), + tm, + ) + }; + String::from_utf8_lossy(&name[..len]).into_owned() +} + +/// The date and time `tm`'s fields name, without a zone. +fn naive_fields(tm: &libc::tm) -> Option { + let date = NaiveDate::from_ymd_opt( + tm.tm_year + 1900, + u32::try_from(tm.tm_mon + 1).ok()?, + u32::try_from(tm.tm_mday).ok()?, + )?; + date.and_hms_opt( + u32::try_from(tm.tm_hour).ok()?, + u32::try_from(tm.tm_min).ok()?, + u32::try_from(tm.tm_sec).ok()?, + ) +} + +/// `epoch` in the local time zone, which `TZ` selects. +fn local_time(epoch: libc::time_t) -> io::Result { + // SAFETY: tzset only reads TZ; tm is plain data that localtime_s fills or + // reports failure. + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + if unsafe { + libc::tzset(); + libc::localtime_s(&mut tm, &epoch) + } != 0 + { + return Err(invalid("timestamp out of range")); + } + // The offset is how far the local fields run ahead of the instant. + let gmtoff = naive_fields(&tm) + .and_then(|local| i32::try_from(local.and_utc().timestamp() - epoch).ok()) + .ok_or_else(|| invalid("timestamp out of range"))?; + Ok(BrokenDown { + zone: zone_name(&tm), + tm, + gmtoff, + }) +} + +/// Rewrite a POSIX format into chrono's: `%Z` becomes the zone name, an `E` or +/// `O` modifier is dropped, and an unsupported conversion becomes literal text. +fn chrono_format(fmt: &str, zone: &str) -> String { + const SUPPORTED: &str = "aAbBcCdDeFgGhHIjmMnprRStTuUVwWxXyYz%"; + let mut out = String::with_capacity(fmt.len()); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + if let Some('E' | 'O') = chars.peek() { + let modifier = chars.next().unwrap(); + match chars.peek() { + Some(&next) if next == 'Z' || SUPPORTED.contains(next) => {} + _ => { + out.push_str("%%"); + out.push(modifier); + continue; + } + } + } + match chars.next() { + Some('Z') => out.push_str(&zone.replace('%', "%%")), + Some(conv) if SUPPORTED.contains(conv) => { + out.push('%'); + out.push(conv); + } + Some(other) => { + out.push_str("%%"); + out.push(other); + } + None => out.push_str("%%"), + } + } + out +} + +/// Format a broken-down time. +fn format_broken_down(fmt: &str, t: &BrokenDown) -> io::Result { + let naive = naive_fields(&t.tm).ok_or_else(|| invalid("time out of range"))?; + let offset = FixedOffset::east_opt(t.gmtoff).ok_or_else(|| invalid("bad zone offset"))?; + let datetime = offset + .from_local_datetime(&naive) + .single() + .ok_or_else(|| invalid("time out of range"))?; + + let format = chrono_format(fmt, &t.zone); + let items: Vec = StrftimeItems::new(&format).collect(); + if items.iter().any(|item| matches!(item, Item::Error)) { + return Err(invalid("invalid time format")); + } + Ok(datetime.format_with_items(items.into_iter()).to_string()) +} + +/// Format `epoch` (seconds since the Epoch) by the strftime conversion string +/// `fmt`, in local time or, when `utc` is set, in UTC. +pub fn format_time(fmt: &str, epoch: i64, utc: bool) -> io::Result { + let epoch: libc::time_t = epoch; + let t = if utc { + universal_time(epoch)? + } else { + local_time(epoch)? + }; + format_broken_down(fmt, &t) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 2026-01-04 05:06:07 UTC, a Sunday. + const EPOCH: i64 = 1_767_503_167; + + fn utc(fmt: &str) -> String { + format_time(fmt, EPOCH, true).unwrap() + } + + #[test] + fn every_posix_conversion_in_the_c_locale() { + let cases = [ + ("%a", "Sun"), + ("%A", "Sunday"), + ("%b", "Jan"), + ("%B", "January"), + ("%c", "Sun Jan 4 05:06:07 2026"), + ("%C", "20"), + ("%d", "04"), + ("%D", "01/04/26"), + ("%e", " 4"), + ("%F", "2026-01-04"), + ("%g", "26"), + ("%G", "2026"), + ("%h", "Jan"), + ("%H", "05"), + ("%I", "05"), + ("%j", "004"), + ("%m", "01"), + ("%M", "06"), + ("%n", "\n"), + ("%p", "AM"), + ("%r", "05:06:07 AM"), + ("%R", "05:06"), + ("%S", "07"), + ("%t", "\t"), + ("%T", "05:06:07"), + ("%u", "7"), + ("%U", "01"), + ("%V", "01"), + ("%w", "0"), + ("%W", "00"), + ("%x", "01/04/26"), + ("%X", "05:06:07"), + ("%y", "26"), + ("%Y", "2026"), + ("%z", "+0000"), + ("%Z", "GMT"), + ("%%", "%"), + ]; + for (fmt, want) in cases { + assert_eq!(utc(fmt), want, "conversion {fmt}"); + } + } + + #[test] + fn iso_week_year_differs_from_the_calendar_year() { + // 2027-01-01 is a Friday: ISO week 53 of 2026. + let new_year_2027 = 1_798_761_600; + assert_eq!( + format_time("%G-W%V %g %U %W", new_year_2027, true).unwrap(), + "2026-W53 26 00 00" + ); + } + + #[test] + fn modifiers_are_ignored_and_unknown_conversions_are_literal() { + assert_eq!(utc("%Ey %OH %EZ"), "26 05 GMT"); + assert_eq!(utc("%q %-d %Ek"), "%q %-d %Ek"); + assert_eq!(utc("100%"), "100%"); + assert_eq!(utc("literal text"), "literal text"); + assert_eq!(utc(""), ""); + } + + #[test] + fn zone_name_and_offset_are_used_as_given() { + let mut t = universal_time(EPOCH).unwrap(); + t.zone = String::from("A%Z"); + t.gmtoff = -(4 * 3600 + 30 * 60); + assert_eq!(format_broken_down("%Z %z", &t).unwrap(), "A%Z -0430"); + } + + #[test] + fn local_time_formats() { + // Whatever the zone, local time formats and names a zone. + let s = format_time("%Y %z", EPOCH, false).unwrap(); + assert!(s.starts_with("2026 ") || s.starts_with("2025 "), "{s}"); + } +}