Skip to content

Windows mvp - #712

Merged
jgarzik merged 17 commits into
mainfrom
windows-mvp
Oct 2, 2026
Merged

jgarzik merged 17 commits into
mainfrom
windows-mvp

Conversation

@jgarzik

@jgarzik jgarzik commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@jgarzik
jgarzik requested a balanced review from Copilot October 1, 2026 18:18
@jgarzik jgarzik self-assigned this Oct 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

It modifies shared plib library code and the workspace-wide get_binary_path test-harness resolution, so its blast radius spans every crate's tests and warrants human verification across build/CI configurations.

Review effort: Balanced
Findings: None

What changed in this PR

This PR introduces a "minimum viable" Windows (MSVC) port for a subset of the workspace — the gettext-rs, plib, and posixutils-xform crates — enabling cksum, compress, uuencode, and uudecode to build and pass tests on Windows. The approach ports whole crates at a time, gating inherently Unix functionality behind #[cfg(unix)] and supplying Windows equivalents (read-only attribute as the POSIX owner-write bit, no umask/chown/hard-link count, no SIGPIPE). It also hardens the shared test harness and adds a dedicated Windows CI job.

Changes:

  • Cross-platform file metadata handling in xform (mode ↔ read-only attribute, FileTimes-based time preservation, link-count and NAME_MAX shims) and #[cfg(unix)] gating across plib modules, locale, io, diag, and gettext-rs.
  • Reworked plib::testing::get_binary_path to locate binaries from the running test executable (current_exe, deps parent, EXE_SUFFIX) instead of reconstructing the target/profile path.
  • New Windows CI job + cross-target clippy lint, WINDOWS_CRATES env, .gitattributes for LF normalization, and Windows docs in README.md/CLAUDE.md.
File Description
xform/​uuencode.rs Split mode formatting into format_mode/mode_of/new_file_mode with Windows read-only-attribute handling
xform/​uudecode.rs Cross-platform is_writable and set_mode (read-only attribute on Windows)
xform/​compress.rs FileMetadata refactor using fs::Permissions/FileTimes, cfg-gated link_count/name_max, reordered apply_to
xform/​tests/​uue/​mod.rs Cross-platform test helpers (running_as_root, set_read_only), cfg-gated mode logic
xform/​tests/​compress/​mod.rs Gated Unix-only symlink/metadata tests; decompress_command helper for Windows
plib/​src/​testing.rs get_binary_path now derived from current_exe; read_until_full/assert_dies_by_sigpipe cfg-gated
plib/​src/​lib.rs #[cfg(unix)] gating of Unix-only modules
plib/​src/​locale.rs Gated libc-backed locale helpers; extracted locale_yesexpr
plib/​src/​io.rs Gated SIGPIPE/atomic-write/std-fd helpers; Windows no-ops
plib/​src/​diag.rs strerror_r path gated to Unix; Windows uses Rust's own message
plib/​tests/​write_atomic_umask.rs Whole-file #![cfg(unix)] gate
gettext-rs/​src/​lib.rs LC_MESSAGES gated to Unix, falls back to LC_ALL on Windows
gettext-rs/​src/​catalog.rs NLSPATH split via env::split_paths for platform separators
.github/​workflows/​TestingCI.yml WINDOWS_CRATES, Windows target clippy lint, Windows build/test job
README.md /​ CLAUDE.md Document Windows subset, behavior, and build/test commands
.gitattributes Force LF checkout for byte-compared fixtures, with CRLF-sensitive exceptions

The implementation is consistently gated, removed imports are correctly localized into their #[cfg(unix)] blocks, the apply_to reordering (times before permissions) is sound, and the is_affirmative/FileMetadata refactors preserve existing semantics. I did not find concrete code defects.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

jgarzik and others added 9 commits October 1, 2026 19:41
The specifier loop and the pointer-qualifier loop each matched
`_ if let Some(m) = super::cv_qualifier_modifier(name_id)`. `if let`
guards were stabilised only recently, and these two were the only
reason the workspace needed a Rust newer than 1.88. Each loop now asks
cv_qualifier_modifier first and continues; the qualifier spellings are
disjoint from every arm before them, and nothing follows the match in
either loop body, so the parse is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.84 was declared but no longer built anything: clap 4.6 and clap_lex
1.1 are edition 2024 and declare rust-version 1.85, and ipp 5.4 (lp's
IPP client) uses let chains, stable from 1.88, without declaring a
rust-version at all. With the cc `if let` guards rewritten, 1.88 checks
the whole workspace, every target, and the Windows crates for
x86_64-pc-windows-msvc. Cargo.toml, clippy.toml's msrv, CLAUDE.md and
the Copilot instructions all say 1.88.0.

Clippy at that msrv asks for the APIs it makes available: 21
`x % n == 0` tests on unsigned integers become `x.is_multiple_of(n)`
(stable 1.87), and dd's byte swap walks `as_chunks_mut::<2>()` (stable
1.88) rather than chunks_exact_mut(2). Each is the same computation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The platform jobs build with the latest stable, so the declared
rust-version had drifted unnoticed to a release that could not build
the workspace at all. A new msrv job reads rust-version from Cargo.toml
-- the one place it is set -- installs that toolchain, and checks every
target of the workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`LC_MESSAGES` is POSIX, not ISO C, and Windows has none: to_libc's
unconditional `libc::LC_MESSAGES` was the first compile error of every
crate in the workspace on x86_64-pc-windows-msvc. It is now Unix-only,
and Windows takes the existing LC_ALL fallback arm.

NLSPATH was split on ':', which on Windows cuts a template at its drive
letter. It is now split as the platform splits PATH
(std::env::split_paths: ':' on Unix, ';' on Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…table

plib is the base of every utility crate, and nothing in it compiled on
x86_64-pc-windows-msvc. The modules with no Windows meaning -- users,
groups, utmpx, syslog, ttys, priorities, umask/mode strings, POSIX
regex, temporary files, exec, the test(1) evaluator, SCCS files -- are
now #[cfg(unix)]. The rest is portable:

- diag::io_error_text: strerror_r is Unix-only; Windows uses Rust's
  own error text, which there is already the system's FormatMessage.
- io: restore_sigpipe does nothing on Windows, which has no SIGPIPE (a
  write to a closed pipe is an error); ensure_std_fds_open does nothing
  there, since handles are not reused by number. write_atomic* and
  SigPipeIgnored, which exist for Unix modes and signals, are Unix-only.
- locale: the wide-character functions (wcwidth, iswprint, towlower,
  mbrtowc -- MSVC has no wcwidth and a 16-bit wchar_t), radix_char and
  strftime are Unix-only. is_affirmative keeps the locale's YESEXPR on
  Unix and the POSIX locale's leading y/Y, its documented fallback, on
  Windows.
- testing: get_binary_path finds the binaries beside the running test
  executable (cargo puts it in <bin dir>/deps) and appends the
  platform's executable suffix, so it holds for --target builds and
  .exe names as well as for the target directory and coverage layouts
  it used to reconstruct from environment variables. get_target_dir and
  get_profile, which did that reconstructing, had no other caller and
  are deleted.

Linux and macOS behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cksum needed nothing beyond a portable plib. The other three spoke Unix
file attributes directly; each now keeps its Unix behaviour and takes
the Windows meaning of the same thing there:

- uuencode: the header mode comes from mode_of, the file's mode on Unix
  and its read-only attribute seen as the owner-write bit (0444 or
  0644) on Windows; with no file, new_file_mode is 0666 less the umask
  on Unix and 0644 on Windows, which has no umask.
- uudecode: set_mode applies the header's bits on Unix and makes the
  file read-only on Windows exactly when the header withholds owner
  write; the access(W_OK) check is "not read-only" on Windows.
- compress: the preserved metadata keeps fs::Permissions, which is
  portable, and the times as fs::FileTimes, set with File::set_times on
  every platform in place of a hand-built utimensat (futimens on Unix,
  still to the nanosecond). They are set through the output's write
  handle before it is dropped -- a reopen would be refused under a
  umask that leaves the output without owner write, and the times
  silently lost -- and before the mode, which comes last. chown and the
  hard-link warning stay Unix-only (stable Rust has no Windows link
  count), and {NAME_MAX} is NTFS's 255 on Windows, which has no
  pathconf.

compress copies the input's read-only attribute onto its output, and
Windows will not delete a read-only file wherever
FILE_DISPOSITION_IGNORE_READONLY_ATTRIBUTE is not honoured (Wine, older
Windows): the input could not be removed, nor the output in the
back-out, leaving both behind. Every removal now goes through one
remove_file, which on Windows clears the attribute first and restores
it if the removal still fails; on Unix it is the plain fs::remove_file.

Two tests pin the rules this changes: the times survive a 0277 umask
under compress and compress -d (Unix), and a read-only input is
compressed and removed (every platform).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The xform test binary did not compile on Windows. What it tests is now
portable except where the subject is Unix-only:

- uue: the expected header digits come from the file's mode on Unix and
  its read-only attribute on Windows, as uuencode writes them; the
  read-only target test sets that attribute through set_read_only,
  which also clears it for cleanup (Windows will not delete a read-only
  file); "running as root" is a Unix question.
- compress: the binary round trips decompress with `uncompress` on
  Unix and `compress -d` on Windows, where xform/build.rs makes no
  argv[0] aliases; the four tests of the zcat and uncompress aliases
  themselves are Unix-only, as are the five that need plib::tmp (the
  -v output and mode/time preservation tests).

62 of the 72 run on Windows (x86_64-pc-windows-gnu under Wine); all 72
still run on Unix.

.gitattributes checks text out with LF on every platform: the fixtures
are compared byte for byte, and a Windows checkout would otherwise turn
them into CRLF. The diff fixtures and display/test.mdoc, committed with
the CRLF they test, are never converted; no committed file changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m for Windows on Linux

A new `windows` job builds and tests, with MSVC, the crates named in
the workflow's WINDOWS_CRATES -- gettext-rs, plib and posixutils-xform
(cksum, compress, uuencode, uudecode). It is required like the other
platform jobs, and runs under bash so the crate list expands to
separate arguments.

The lint job also installs the x86_64-pc-windows-msvc target and runs
clippy over the same crates for it, so code that only compiles on
Windows is linted on every push without waiting on a Windows runner.

The msrv job also checks the same crates for that target with the
declared minimum Rust.

Utilities are ported a whole crate at a time because `cargo test -p`
builds every binary in the crate; a crate joins WINDOWS_CRATES once all
of its binaries and tests are portable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README gains a Windows section: the xform utilities build and are
tested there, the whole workspace does not, what a file mode, compress's
metadata and the zcat/uncompress aliases mean on Windows, how a crate
joins WINDOWS_CRATES (gated with #[cfg(unix)], never stubbed), and how
to run the Windows tests on Linux under Wine. CLAUDE.md's platform line
points at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jgarzik and others added 8 commits October 1, 2026 23:25
The text crate is the next one to build on Windows, and it classifies,
case-maps, measures and decodes characters through plib::locale, whose
character functions existed only on Unix. The MSVC CRT cannot stand in
for the Unix C library: its wchar_t is 16 bits, so a character outside
the Basic Multilingual Plane is not one wide character, and it has no
wcwidth.

On Windows these functions now use Rust's Unicode semantics with input
decoded as UTF-8, keeping each public function's signature and contract
so callers are unchanged:

- ASCII answers exactly as the POSIX locale does. Above ASCII each
  predicate takes the closest Unicode property, documented on the
  function; digit and xdigit stay ASCII-only as POSIX defines them,
  and digits of other scripts count as alpha so alnum stays the union
  of alpha and digit, as glibc does.
- to_lower/to_upper take a one-character Unicode mapping and leave a
  character with a multi-character mapping unchanged.
- wcwidth_char gives 0 for NUL, the combining-only blocks, zero-width
  spaces and variation selectors, -1 for other controls, and 2 for East
  Asian Wide and Fullwidth (Kuhn's ranges plus the main emoji blocks).
- mb_char_slices and MbDecoder decode UTF-8 with the same one-byte
  fallback for an invalid or incomplete sequence; MbDecoder carries a
  split sequence into the next chunk.
- radix_char is ".". strftime stays Unix-only: plib has no date library
  to replace localtime_r and LC_TIME.

Each public function keeps one shared ASCII-versus-wide split, with
per-platform private helpers answering each half. The tests split the
same way: platform-neutral tests (an all-ASCII sweep of every class
against the POSIX definitions, ASCII slicing and decoding, collation)
run everywhere, the libc-locale tests stay Unix-only, and new Windows
tests cover non-ASCII classification, case mapping, widths, invalid
UTF-8 and split sequences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The text crate's grep, sed, nl, csplit and tr use plib::regex, which wraps
the C library's regcomp/regexec. The Windows C runtime has none, so on a
Windows target plib now compiles musl's POSIX regex (TRE) from
plib/vendor/musl-regex with the cc crate and links it under plib_-prefixed
names. Unix keeps the C library's regex, unchanged.

Vendored from musl 1.2.6 (musl-1.2.6.tar.gz, SHA-256
d585fd3b613c66151fc3249e8ed44f77020cb5e6c1e635a616d3f9f82460512a, signature
verified against musl's key): src/regex/{regcomp.c,regexec.c,regerror.c,
tre-mem.c,tre.h} (byte-identical to 1.2.5), src/ctype/iswctype.c, an adapted
include/regex.h, and COPYRIGHT. musl is MIT; the TRE-derived files are
Ville Laurikari's, 2-clause BSD, license text in each file.

Local changes, each marked "plib:" and listed in the vendor README:
- shim headers for musl internals (hidden, CHARCLASS_NAME_MAX, RE_DUP_MAX,
  LCTRANS_CUR) and plib_ renames of every external symbol;
- regoff_t is ptrdiff_t, and ALIGN no longer assumes long is pointer-sized
  (on 64-bit Windows it truncated pointers and under-aligned blocks);
- TRE_CHAR_MAX is capped at a 16-bit WCHAR_MAX;
- [[:class:]] uses musl's wctype/iswctype rather than the runtime's, whose
  msvcrt version has no "blank";
- regexec's pmatch[restrict] is spelled as a pointer for MSVC;
- a musl bug: the backtracking matcher (any pattern with a back-reference)
  assumed the lookahead character was one byte, so after a multibyte
  character back-references compared from the wrong byte. In UTF-8,
  \(a\)\1 did not match "üaa". 226 of 4000 generated patterns disagreed
  with their ASCII transliteration before the fix, none after.

plib/build.rs compiles the C only for a Windows target (CARGO_CFG_TARGET_OS,
since the script runs on the host), and only when a C compiler for it
exists: Cargo does not tell a build script whether it is checking or
building, and the Linux lint job's clippy for x86_64-pc-windows-msvc has no
MSVC compiler. cc is a plain build-dependency, since a target-cfg one is
matched against the host.

plib::regex keeps one wrapper; only the FFI declarations differ by
platform. diag::init_locale now also selects setlocale(LC_ALL, ".UTF-8") on
Windows so the runtime's mbtowc, and so the regex, reads UTF-8.

New tests cover back-references, leftmost-longest, bracket classes,
REG_ICASE, REG_NOTBOL and a UTF-8 pattern. The UTF-8 one returns early
under the -gnu targets (Wine), whose msvcrt has no UTF-8 locale.

plib::regex's UTF-8 test asserts the multibyte back-reference cases
only off Apple platforms: there plib uses Apple's libc regex, which
comes from the same TRE code and keeps the flaw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
io: add open_terminal_input and open_terminal_output, which open what a
prompt reads its answer from and writes its question to. POSIX takes such
answers from /dev/tty rather than standard input (pr -p, patch's
questions), and several utilities spell that path out by hand. Windows has
no /dev/tty; the same thing there is the console, whose input and screen
buffers open as CONIN$ and CONOUT$. One pair of constants holds the
difference, so a caller has one rule on both platforms, and fails the open
the same way when the process has no terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pr: ask std::io::IsTerminal whether standard output is a terminal instead
of libc::isatty, the same question on Unix. The -p pause reads its newline
through plib::io::open_terminal_input, so it waits on the console on
Windows. The SIGINT handler is unchanged: the Windows C runtime has the
same signal, raise and fflush.

patch: the file-name and yes/no prompts share one helper that writes the
question with plib::io::open_terminal_output and reads the answer with
open_terminal_input, rather than opening /dev/tty by hand twice. The
dangerous-name check now refuses any name with a root or a drive prefix
rather than asking is_absolute: identical on Unix, but on Windows
"/etc/passwd" and "C:file" are not is_absolute and would have escaped the
directory being patched.

csplit: the signals that remove created files are a per-platform list.
Windows has no SIGHUP or SIGQUIT; its keyboard quit (Ctrl-Break) arrives
as SIGBREAK, which the libc crate does not declare. It also has no
sigaction, so the handler is installed with the C runtime's signal(),
which resets to the default before the handler runs; the handler
re-raises with the default either way. The handler itself is unchanged
(libc::unlink is the CRT's _unlink there).

sort: numeric_conv reads localeconv on Unix as before. The libc crate has
no localeconv for Windows, and plib takes numbers in the POSIX locale's
form there, so -n uses plib::locale::radix_char and no thousands grouping.

diff: directory-loop detection keys on a per-platform DirId: (dev, ino) on
Unix as before; on Windows, where stable Rust does not expose the volume
serial and file index, the canonical path, which resolves symlinks and
junctions and is unique since Windows has no directory hard links. Special
files are named by a special_kind helper; Windows has no FIFOs or devices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows C runtime reads no locale environment variable:
setlocale(LC_ALL, "") is always the user's regional setting. So LC_ALL=C
had no effect on Windows, and a utility collated, classified and decoded
multibyte input by the regional setting whatever the user asked for --
sort and comm ordered lines by it, and tr's character classes and case
mapping followed Unicode even under LC_ALL=C, unlike Unix.

init_locale now resolves each category's POSIX value (LC_ALL, else
LC_<category>, else LANG, the first set and non-empty) with one helper,
and maps it as glibc would:

  value                 LC_CTYPE                         other categories
  C, POSIX              CRT "C"; ASCII, byte per char    CRT "C"
  C.UTF-8, POSIX.utf8   CRT ".UTF-8"; Unicode, UTF-8     CRT "C" (byte order)
  anything else, unset  CRT ".UTF-8"; Unicode, UTF-8     the user's locale

Any other value keeps the user's locale, since Windows locale names are
not POSIX ones. The LC_CTYPE row also sets a process-wide mode in
plib::locale, which the Windows halves of the predicates,
to_lower/to_upper, wcwidth_char, mb_char_slices and MbDecoder consult: in
the C mode nothing above ASCII is in a class, case-maps or has a width,
and every byte is one character (above ASCII undecodable), as in glibc's
C locale. A program starts in the C mode, as a Unix program starts in
"C". Unix is unchanged.

plib::testing::utf8_locale() returns "C.UTF-8" on Windows without
running `locale -a`, which Windows does not have: plib supports UTF-8
on every Windows system, and C.UTF-8 means there what it means under
glibc. locale_matching() returns None on Windows, since no name other
than a C locale selects the locale it names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The text test binaries did not compile on Windows. What they test is now
portable, except where the subject itself is Unix:

- diff: the fixture expectations spell paths with `/`; diff joins a
  directory operand and an entry with the platform separator, so the
  checker converts them (no fixture contains a `/`), and the per-file
  header test builds its paths with Path::join. Gated to Unix: the three
  symlink tests (a Windows symlink needs a privilege tests do not have),
  the two that make a file unreadable with mode 000 (no Windows meaning),
  and the SIGPIPE test.
- grep, sed: the open-failure diagnostics are the system's own text,
  which differs on Windows; the expectations now come from the same
  failing open in the test, through plib::diag::io_error_text.
- pr, uniq, unexpand: three tests used plib::tmp (Unix-only) only for a
  scratch directory; they write one file under std::env::temp_dir.
- wc: the SIGPIPE test is Unix-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posixutils-text joins WINDOWS_CRATES: all 22 of its utilities build and
their tests pass on Windows, so the windows job builds and tests it with
MSVC, the lint job clippies it for x86_64-pc-windows-msvc, and the msrv
job checks it there with the declared minimum Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a crate

README's Windows section lists both ported crates -- xform and text,
built natively with MSVC -- and everything that behaves differently
there: modes, compress's metadata and aliases, Unicode classification
with the POSIX locale variables, musl regex's U+FFFF limit, sort -n's
decimal point, diff's special files and loop identity, console prompts
and csplit's signals.

WINDOWS.md is the repeatable process for "make crate X build on
Windows": the rules (whole crate, gate never stub, Unix unchanged, one
helper per rule), the table of what each Unix concept means on Windows,
the steps from survey through plib, sources, tests, verification (with
the Wine recipe and its limits), CI wiring and a bisectable commit
series. README points porters to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jgarzik
jgarzik merged commit 4073af0 into main Oct 2, 2026
20 checks passed
@jgarzik
jgarzik deleted the windows-mvp branch October 2, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants