Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Check text out with LF on every platform: the test fixtures are compared
# byte for byte, and a Windows checkout would otherwise convert them to CRLF.
* text=auto eol=lf

# Fixtures whose line endings are part of what they test: never converted.
text/tests/diff/** -text
display/test.mdoc -text
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,5 @@ When working on utilities, be aware of their maturity stage:
## References

- POSIX specification: https://pubs.opengroup.org/onlinepubs/9699919799/
- Minimum Rust version: 1.84.0
- Minimum Rust version: 1.88.0
- License: MIT
52 changes: 52 additions & 0 deletions .github/workflows/TestingCI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ permissions:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# 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

jobs:
lint:
Expand All @@ -24,6 +28,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --all -- --check
Expand All @@ -33,6 +38,31 @@ jobs:
cargo clippy --all-targets 2>&1 || true
# Second pass: fail if any warnings exist
cargo clippy --all-targets -- -D warnings
# The Windows code paths, linted here without a Windows runner.
- name: Clippy (Windows target)
run: cargo clippy --target x86_64-pc-windows-msvc $WINDOWS_CRATES --all-targets -- -D warnings

# The declared minimum Rust (Cargo.toml's rust-version) has to build the
# workspace. The other jobs use the latest stable, so a dependency or a
# language feature that raises the floor would otherwise go unnoticed.
msrv:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Read rust-version
id: msrv
run: echo "version=$(sed -n 's/^rust-version = "\(.*\)"$/\1/p' Cargo.toml)" >> "$GITHUB_OUTPUT"
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ steps.msrv.outputs.version }}
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
- name: Check the workspace
run: cargo check --workspace --all-targets
- name: Check the Windows crates
run: cargo check --target x86_64-pc-windows-msvc $WINDOWS_CRATES --all-targets

linux-ubuntu:
needs: lint
Expand Down Expand Up @@ -117,3 +147,25 @@ jobs:
run: |
echo "=== Re-running cc tests with more detail ==="
cargo test --release -p posixutils-cc --verbose -- --nocapture --test-threads=1 2>&1 | head -500

# Windows: the crates in WINDOWS_CRATES, built and tested with MSVC.
windows:
needs: lint
runs-on: windows-latest
timeout-minutes: 45
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: System info
run: |
echo "=== Toolchain ==="
rustc --version --verbose
cargo --version
- name: Build
run: cargo build --release --verbose $WINDOWS_CRATES
- name: Run tests
run: cargo test --release --verbose $WINDOWS_CRATES
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

posixutils-rs: Rust-native POSIX utilities (cp, mv, awk, sh, cc, make, vi, etc.) targeting POSIX.2024. Goal: clean, race-free, POSIX-compliant utilities.

**Rust**: 1.84.0+ | **License**: MIT | **Platforms**: Linux, macOS
**Rust**: 1.88.0+ | **License**: MIT | **Platforms**: Linux, macOS; Windows for the crates in `WINDOWS_CRATES` (README "Windows")

## Commands

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ members = [
repository = "https://github.com/rustcoreutils/posixutils-rs"
license = "MIT"
edition = "2021"
rust-version = "1.84.0"
rust-version = "1.88.0"

[workspace.dependencies]
clap = { version = "4", default-features = false, features = ["std", "derive", "help", "usage", "error-context", "cargo"] }
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,45 @@ The standard `cargo install` should work, for those interested in testing. Care

Note that `cargo install` copies the declared binaries and nothing else, so the six `argv[0]` symlinks above — `tar`, `cpio`, `ex`, `zcat`, `uncompress` and `[` — are *not* installed by it. They are created in `target/<profile>` by the crates' build scripts, and delivered by the container image.

### 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.

### Container image

A multi-architecture image is published to the GitHub container registry on
Expand Down
146 changes: 146 additions & 0 deletions WINDOWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Porting a crate to 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.

## Rules

- **A whole crate at a time.** `cargo test -p <crate>` builds every binary in
the crate, so a crate is ported when *all* of its binaries and tests compile
and pass. A crate joins `WINDOWS_CRATES` in `.github/workflows/TestingCI.yml`
only then.
- **Gate, never stub.** Code with no Windows meaning is compiled out with
`#[cfg(unix)]`. Nothing is replaced by a stand-in that pretends to work: a
binary that exists on Windows does its job there.
- **Unix behaviour does not change.** Every Unix code path stays as it was,
unless one portable std API now serves both platforms (then it is one rule
for both, and the Unix suite proves nothing moved).
- **The Windows meaning of the same thing.** Where Unix has a concept Windows
expresses differently, implement that meaning rather than dropping the
feature (see the table below).
- **One rule, one helper.** A platform difference lives in one small
`#[cfg(unix)]` / `#[cfg(windows)]` pair of helpers named for what they mean
(`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

| Unix | Windows |
|---|---|
| permission bits | the read-only attribute is the owner-write bit: a file reads as `0444` or `0644`; setting a mode without owner write sets read-only |
| umask | none; a new file is `0644` |
| uid, gid, `chown` | none: keep Unix-only |
| `access(W_OK)` | "not read-only" |
| `nlink` | not exposed by stable Rust: a file counts as its only link |
| `pathconf(_PC_NAME_MAX)`, `PATH_MAX` | 255 (NTFS component), no path-length check |
| `utimensat` | `File::set_times`, portable (set through an open write handle: a reopen can be refused) |
| `isatty` | `std::io::IsTerminal`, portable |
| `SIGPIPE` | none: a write to a closed pipe is an error; `restore_sigpipe` does nothing |
| `SIGINT`, `SIGTERM` handlers | the C runtime's `signal()` has both; `SIGHUP`, `SIGQUIT` do not exist |
| `strerror_r` | Rust's own error text (already the system's) |
| `LC_MESSAGES` | none: `LC_ALL` |
| path lists (`:`) | `std::env::split_paths` (`;` on Windows) |
| argv[0] symlink aliases (`zcat`, `[`) | not created (`build.rs` is Unix-only); use the main name's flags |
| deleting a read-only file | refused under Wine and older Windows: clear the attribute first, restore it on failure |
| `/dev/tty` | the console, `CONIN$` / `CONOUT$`: `plib::io::open_terminal_input` / `open_terminal_output` |
| `LC_ALL`, `LC_*`, `LANG` | read per category by `plib::diag::init_locale`: `C` or `POSIX` selects the C locale (the C runtime's `"C"`, and ASCII-only, byte-per-character `plib::locale`); `C.UTF-8` and other `C`/`POSIX` names with a codeset select the C runtime's `"C"` except for `LC_CTYPE`, which stays UTF-8; anything else, or unset, the user's locale in UTF-8 |
| characters, case, multibyte | `plib::locale`: outside the C locale, Rust's Unicode rules with input decoded as UTF-8; before `init_locale`, the C locale |
| POSIX regex | vendored musl regex (`plib::regex`); no characters above U+FFFF |
| `localeconv` (decimal point, grouping) | `.` and no grouping |
| `dev`/`ino` file identity | the canonical path (stable Rust has no file ID) |
| 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 |

## Steps

### 1. Survey

```sh
cargo check --target x86_64-pc-windows-msvc -p <crate> --all-targets 2>&1 | grep -E '^error'
grep -rnE 'os::unix|os::fd|libc::' <crate> | grep -v '^<crate>/tests'
```

Sort the errors into: missing `plib` modules (step 2), the crate's own sources
(step 3), and its tests (step 4). Decide per binary whether every feature has a
Windows meaning; a binary that cannot be ported keeps the whole crate off
Windows (or the crate is split), never stubbed.

### 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`,
`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

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
`libc::` and `std::os::unix` use; `cargo check --target ...` finds them, but
not the ones that compile and mean something else (a path with `/dev/stdout`,
a `:`-separated list).

### 4. Tests

- Integration tests find binaries with `plib::testing::get_binary_path`, which
already handles `.exe` and `--target` layouts.
- Gate a test `#[cfg(unix)]` only when its subject is Unix (modes, umask,
`utimensat`, root, signals, the argv[0] aliases, `plib::tmp`), and say why in
a comment. A test of portable behaviour that merely used a Unix helper is
rewritten to run everywhere (pick the Windows equivalent, or a portable
helper), not gated.
- Fixtures are compared byte for byte; `.gitattributes` keeps them LF on a
Windows checkout. A fixture whose CRLF is the point is listed there as
`-text`.
- Windows will not delete a read-only file under Wine: clear the attribute
before cleanup.

### 5. Verify

Every commit, never two cargo commands at once:

```sh
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings # Linux, zero
cargo clippy --target x86_64-pc-windows-msvc <crates> --all-targets -- -D warnings
cargo test --release -p <crate> # Unix unchanged
cargo +<rust-version> check --target x86_64-pc-windows-msvc <crates> --all-targets
```

and run the Windows tests on Linux under Wine (needs the `mingw-w64` and `wine`
packages; MinGW is not MSVC, so CI has the last word):

```sh
rustup target add x86_64-pc-windows-gnu
WINEDEBUG=-all CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine \
cargo test --release --target x86_64-pc-windows-gnu <crates>
```

A change to `plib::testing` or anything every crate uses gets the full
`cargo test --release` on Linux.

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.

### 6. CI and docs

Add `-p <crate>` 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`.

### 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,
then CI, then docs. A fix to a bug a commit in the series introduced is
folded into that commit, not appended.
2 changes: 1 addition & 1 deletion cc/ir/linearize_atomic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ impl Linearizer<'_> {

let bits = self.types.size_bits(typ);
(is_scalar || is_aggregate)
&& bits % 8 == 0
&& bits.is_multiple_of(8)
&& crate::target::atomic_is_lock_free(u64::from(bits / 8))
}

Expand Down
2 changes: 1 addition & 1 deletion cc/ir/loadfwd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ fn narrowing(
/// Which byte of the store `s` the one-byte access `b` is, counting from
/// the store's lowest address; `None` unless `s` writes all of `b`.
fn byte_index(s: &MemLoc, b: &MemLoc) -> Option<u32> {
if s.base != b.base || s.size % 8 != 0 {
if s.base != b.base || !s.size.is_multiple_of(8) {
return None;
}
let (start, end) = s.byte_extent()?;
Expand Down
2 changes: 1 addition & 1 deletion cc/ir/memexpand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ pub(crate) fn block_chunks(bytes: i64) -> impl Iterator<Item = (i64, Chunk)> {
/// when the object ends a page.
pub(crate) fn overlapping_halves(bits: u32) -> Option<(i64, i64)> {
let bytes = i64::from(bits / 8);
if bits % 8 != 0 || bytes == 0 || bytes > 8 || bytes.count_ones() == 1 {
if !bits.is_multiple_of(8) || bytes == 0 || bytes > 8 || bytes.count_ones() == 1 {
return None;
}
let width = 1i64 << (63 - bytes.leading_zeros() as i64);
Expand Down
13 changes: 7 additions & 6 deletions cc/parse/declaration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,13 @@ impl<'a> Parser<'a> {

while let Some(name_id) = self.current_ident() {
let pos = self.current_pos();
// Every spelling of `const`, `volatile` and `restrict`, from the
// one shared answer.
if let Some(m) = super::cv_qualifier_modifier(name_id) {
self.advance();
modifiers |= m;
continue;
}
match name_id {
// An attribute can sit anywhere among the specifiers and goes
// to the pending slots; a type-name has set the enclosing
Expand All @@ -644,12 +651,6 @@ impl<'a> Parser<'a> {
self.skip_extensions();
continue;
}
// Every spelling of `const`, `volatile` and `restrict`,
// from the one shared answer.
_ if let Some(m) = super::cv_qualifier_modifier(name_id) => {
self.advance();
modifiers |= m;
}
crate::kw::STATIC
| crate::kw::EXTERN
| crate::kw::REGISTER
Expand Down
10 changes: 7 additions & 3 deletions cc/parse/declarator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ impl Parser<'_> {
pub(super) fn parse_pointer_qualifiers(&mut self) -> TypeModifiers {
let mut modifiers = TypeModifiers::empty();
while let Some(name_id) = self.current_ident() {
// Every spelling of the three CV qualifiers, from the one shared
// answer.
if let Some(m) = super::cv_qualifier_modifier(name_id) {
modifiers |= m;
self.advance();
continue;
}
match name_id {
crate::kw::ATOMIC => modifiers |= TypeModifiers::ATOMIC,
// Every spelling of the three CV qualifiers, from the one
// shared answer.
_ if let Some(m) = super::cv_qualifier_modifier(name_id) => modifiers |= m,
_ if super::is_nullability_qualifier(name_id) => {}
// An attribute may sit between two `*`s -- `int *
// __attribute__((aligned(16))) *p;` -- where it qualifies the
Expand Down
2 changes: 1 addition & 1 deletion clippy.toml
Original file line number Diff line number Diff line change
@@ -1 +1 @@
msrv = "1.84.0"
msrv = "1.88.0"
Loading
Loading