diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9bdb64a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# The build context is sent to the daemon in full before anything runs, and this repo is +# ~20 GB of test corpora and vendored trees. Only docker/sol9-cross/ ships a file (the +# sysroot), and it builds from its own directory, so exclude the heavy trees for anyone +# who builds with `.` as the context. +regression-tests/ +rb-cli-vintage/ +rb-cli-ppc/ +rb-cli-sol9/vendor/ +target/ +dist/ +.git/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48047f88..e23d97b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ on: type: boolean default: true sol9: - description: 'Build rb-cli for Solaris 9 SPARC (needs the SOL9_SYSROOT_URL secret)' + description: 'Build rb-cli for Solaris 9 SPARC (needs the SOL9_SPARC64_SYSROOT_URL seed secret)' type: boolean default: true runner_image: @@ -1513,70 +1513,116 @@ jobs: run: sudo chmod -R a+rX buildroot/dl buildroot/ccache 2>/dev/null || true build-sol9-sparc: - name: Build rb-cli for Solaris 9 (SPARC) - # Only runs where the sysroot secret exists. A Solaris 9 sysroot is a copy of a - # licensed install's headers and libraries, so it cannot live in the repo or a - # public registry -- SOL9_SYSROOT_URL points at a private copy. Without it the - # job skips rather than failing, so forks still get a green pipeline. + name: Build rb-cli for Solaris 9 (${{ matrix.arch }}) + # Only runs where the seed secret exists. The seed carries Sun's headers and libraries, + # so it cannot live in the repo or a public registry -- SOL9_SPARC64_SYSROOT_URL points + # at a private copy. Without it the job skips rather than failing, so forks still get a + # green pipeline. if: ${{ github.event_name != 'workflow_dispatch' || inputs.sol9 }} needs: generate-version runs-on: ubuntu-latest - # Non-fatal like the other vintage-media jobs: this one also depends on a - # self-hosted download and a ~40 minute toolchain build. - continue-on-error: true + # Deliberately NOT continue-on-error. It was, back when this job built GCC from source + # and a mirror hiccup could redden a release for reasons unrelated to the code. It now + # downloads a prebuilt seed, and a missing secret already skips the job -- so a failure + # here means the Solaris build is genuinely broken, and saying so is the point. + strategy: + # One entry today. Solaris also ran on x86, and the secret is already named per-arch, + # so a second target is a matrix row rather than a second job. + fail-fast: false + matrix: + include: + - arch: sparc64 + target: sparcv9-sun-solaris2.9 env: VER: ${{ needs.generate-version.outputs.version }} - SOL9_SYSROOT_URL: ${{ secrets.SOL9_SYSROOT_URL }} + SOL9_SPARC64_SYSROOT_URL: ${{ secrets.SOL9_SPARC64_SYSROOT_URL }} + SOL9_TARGET: ${{ matrix.target }} + # Where scripts/pack-sol9-seed.sh stages things. GCC bakes its sysroot path at + # configure time, so the tree has to land where it was configured. + SOL9_BIN: /opt/sol9/bin + SOL9_SYSROOT: /opt/sol9/sysroot + SOL9_LIBGCC: /opt/sol9/${{ matrix.target }}/lib/sparcv9/libgcc_s.so.1 + MRUSTC_DIR: /opt/mrustc + # The seed unpacks at / as root, so /opt/mrustc is not writable by the job user. + # Only the two output directories are ever written; the stdlibs there are read-only. + # Send them to the workspace instead of trying to write inside the seed's tree. + SOL9_OUT: ${{ github.workspace }}/.sol9-out + HOSTC_OUT: ${{ github.workspace }}/.sol9-hostc steps: - uses: actions/checkout@v6 - - name: Check the sysroot secret is configured + # `cargo vendor` only. Nothing here is built with it -- mrustc compiles the engine, + # and its language mode is pinned to 1.74. Ubuntu's packaged cargo is too old: a + # dependency in the graph needs edition2024. + - uses: dtolnay/rust-toolchain@stable + + - name: Check the seed secret is configured id: gate run: | - if [ -z "$SOL9_SYSROOT_URL" ]; then - echo "SOL9_SYSROOT_URL is not set; skipping the Solaris 9 build." >&2 + if [ -z "$SOL9_SPARC64_SYSROOT_URL" ]; then + echo "SOL9_SPARC64_SYSROOT_URL is not set; skipping the Solaris 9 build." >&2 echo "run=false" >> "$GITHUB_OUTPUT" else echo "run=true" >> "$GITHUB_OUTPUT" fi - # GCC 4.9.4 + binutils 2.35.2 against the sysroot. Cache it: the toolchain - # only changes when docker/sol9-cross does, and rebuilding costs ~40 minutes. - - name: Restore the cross-toolchain image + # libgmp/libmpfr/libmpc/zlib are what GCC 4.9.4 links; python3 runs + # apply-vendor-patches.py, and patch/make/file/rsync are used by build-sol9.sh. + # Everything else it needs is in the seed. Building the toolchain here instead would + # cost ~40 minutes and put us at the mercy of whichever Debian release just went EOL. + - name: Install what the cross GCC links against if: steps.gate.outputs.run == 'true' - id: toolchain-cache - uses: actions/cache@v4 - with: - path: /tmp/mrustc-sol9-cross.tar - key: sol9-cross-${{ hashFiles('docker/sol9-cross/Dockerfile') }} - - - name: Build the cross-toolchain image - if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' run: | - sh docker/sol9-cross/build.sh - docker save mrustc-sol9-cross -o /tmp/mrustc-sol9-cross.tar - - - name: Load the cached toolchain image - if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit == 'true' - run: docker load -i /tmp/mrustc-sol9-cross.tar + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + libgmp10 libmpfr6 libmpc3 zlib1g file rsync python3 patch make - - name: Build rb-cli for sparcv9-sun-solaris2.9 + # The seed is the cross toolchain, its sysroot, mrustc and both prebuilt standard + # libraries -- about 43 MB, against 7 GB for the equivalent container image. + - name: Fetch and unpack the build seed if: steps.gate.outputs.run == 'true' run: | - docker build -t rb-sol9 - < docker/sol9.Dockerfile - docker run --rm -v "$PWD":/src -e RELEASE_VERSION="$VER" rb-sol9 + curl -fsSL "$SOL9_SPARC64_SYSROOT_URL" -o /tmp/sol9-seed.tar.gz + gzip -t /tmp/sol9-seed.tar.gz \ + || { echo "the SOL9_SPARC64_SYSROOT_URL download is not valid gzip" >&2; exit 1; } + listing="$(tar tzf /tmp/sol9-seed.tar.gz)" + if ! printf '%s\n' "$listing" | grep -qE '^(\./)?opt/sol9/bin/'; then + echo "error: that artifact is not a seed built by scripts/pack-sol9-seed.sh." >&2 + echo " its top-level entries are:" >&2 + printf '%s\n' "$listing" | sed 's|^\./||' | awk -F/ '{print $1"/"$2}' | sort -u | head -10 >&2 + exit 1 + fi + sudo tar xzf /tmp/sol9-seed.tar.gz -C / + "$SOL9_BIN/$SOL9_TARGET-gcc" --version | head -1 + + # minicargo can schedule one crate's build script twice at once -- seen as + # `serde_core (build)` listed twice in its own progress line -- and the second worker + # then execs the binary while the first is still linking it, before the linker has + # set the executable bit. That surfaces as "Unable to run process ... Permission + # denied" partway through the graph. The build is incremental, so a retry resumes + # from the completed crates and gets past it. This is a mitigation, not a cure: the + # race is upstream, and docs/solaris9-open-items.md tracks it. + - name: Build rb-cli for ${{ matrix.target }} + if: steps.gate.outputs.run == 'true' + run: | + RELEASE_VERSION="$VER" scripts/build-sol9.sh vendor + if ! RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9; then + echo "::warning::sol9 stage failed; retrying once (minicargo build-script race)" + RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9 + fi + RELEASE_VERSION="$VER" scripts/build-sol9.sh dist ls -lh dist/rb-cli-sol9.tar.gz - name: Name the artifact for the release if: steps.gate.outputs.run == 'true' - run: cp dist/rb-cli-sol9.tar.gz "dist/rb-cli-sol9-${VER}.tar.gz" + run: cp dist/rb-cli-sol9.tar.gz "dist/rb-cli-solaris9-${{ matrix.arch }}-${VER}.tar.gz" - name: Upload the Solaris 9 artifact if: steps.gate.outputs.run == 'true' uses: actions/upload-artifact@v7 with: - name: rb-cli-sol9 - path: dist/rb-cli-sol9-*.tar.gz + name: rb-cli-solaris9-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: dist/rb-cli-solaris9-${{ matrix.arch }}-*.tar.gz build-cb-dos: name: Build cb-dos media (FreeDOS floppy + CD) diff --git a/.gitignore b/.gitignore index d5ab6b9c..fd8f5702 100644 --- a/.gitignore +++ b/.gitignore @@ -99,5 +99,7 @@ regression-tests/**/*.local.toml # tree means a coverage change shows up in a diff. Regenerate rather than edit. # Solaris 9 sysroot: a licensed install's headers and libraries, never committed. -docker/sol9-cross/sysroot.tar.gz +# Licensed Sun headers and libraries, and the CI seed built from them; never commit one. +sol9-seed.tar.gz +sol9-sysroot.tar.gz docker/sol9-cross/sysroot.tar.gz.tmp diff --git a/Cargo.toml b/Cargo.toml index 53526755..f522a0d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -277,7 +277,9 @@ lzma-rs = "0.3.0" lzfse_rust = "0.2.1" des = { version = "0.9.0", optional = true } -[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] +# Solaris is built through rb-cli-sol9, which declares libc itself; naming it here too lets +# `cargo check --target sparcv9-sun-solaris` type-check src/os/solaris.rs without mrustc. +[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "solaris"))'.dependencies] libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/docker/README.md b/docker/README.md index 39926eaf..a9199727 100644 --- a/docker/README.md +++ b/docker/README.md @@ -12,22 +12,19 @@ builds**, so a build is a one-liner. Linux cross artifacts land in | `cross-i586-musl.Dockerfile` | `rb-cli` i586 | **static** (musl) | Pentium+ Linux | ✅ runs | | `cross-i486.Dockerfile` | `rb-cli` i486 | dynamic (glibc) | 486+ Linux¹ | ✅ builds | | `cb-dos.Dockerfile` | DOS `.exe`s | — | DOS (486+) | ✅ runs (DOSBox-X) | -| `sol9.Dockerfile` | `rb-cli` sparcv9 | dynamic (Solaris) | Solaris 9+ SPARC² | ✅ runs (Blade 2500) | ¹ i486 *codegen* (no CMPXCHG8B), but Debian's 32-bit glibc is i686-baseline, so the binary needs an **i486 rootfs** (Buildroot/musl) to actually run on bare 486 hardware — see below. -² Solaris 9 is the one **vintage** target that cross-builds end to end on Linux, -so unlike the PowerPC Mac build it can run unattended in the release pipeline. -Its base image is the exception to the one-liner above: it needs a Solaris 9 -sysroot, which Sun does not permit redistributing, so the image cannot be -published and has to be built once from a Solaris 9 sysroot (`docker/sol9-cross/`, -which CI fetches via the `SOL9_SYSROOT_URL` secret; `Notes/SolarisSysroot.md` in the -mrustc tree builds one from scratch). Full recipe in -`docker/sol9.Dockerfile` and [`docs/build-sol9-mrustc.md`](../docs/build-sol9-mrustc.md). -Verified on a Sun Blade 2500 (SunOS 5.9, sun4u): both parity gates agree with -the desktop build byte for byte, and the ratatui TUI runs. +**Solaris 9 SPARC is no longer here.** It used to build through a container. GCC 4.9.4 is the newest compiler +that can target it, and it builds fine on a current host, so the toolchain is built +natively by [`scripts/build-sol9-toolchain.sh`](../scripts/build-sol9-toolchain.sh) and +packed for CI by [`scripts/pack-sol9-seed.sh`](../scripts/pack-sol9-seed.sh) -- a 47 MB +seed against the 7 GB image the container approach needed, because it carries only the +toolchain, the sysroot, mrustc and the two prebuilt standard libraries. The release +pipeline unpacks that seed and runs `scripts/build-sol9.sh`; it installs no compiler and +builds no image. See [`docs/build-sol9-mrustc.md`](../docs/build-sol9-mrustc.md). ```sh # build the toolchain image, then build the artifact (default CMD): diff --git a/docker/sol9-cross/Dockerfile b/docker/sol9-cross/Dockerfile deleted file mode 100644 index f1de8e96..00000000 --- a/docker/sol9-cross/Dockerfile +++ /dev/null @@ -1,110 +0,0 @@ -# Cross toolchain: x86_64 Linux -> 64-bit SPARC Solaris 9 (sparcv9-sun-solaris2.9) -# -# Why 4.9.4, and why an old base image: -# GCC obsoleted Solaris 9 in 4.9 (buildable only with --enable-obsolete) and deleted -# the port in the next release, which shipped as GCC 5. So 4.9.4 is the newest GCC -# that can target Solaris 9 at all. GCC 4.9's own sources predate C++11-strict compilers -# and need -std=gnu++98 -fpermissive; with those it does build under a current host GCC -# (verified on 13.3), so the Debian 11 (GCC 10) pin is for reproducibility, not necessity. -# -# GCC 4.9 has but not __builtin_{add,sub,mul}_overflow, which arrived in -# GCC 5. mrustc covers that gap by emitting its own helpers -- see the -# `emulate-overflow-intrinsics` target flag used by the sparcv9-sun-solaris2.9 target. -# -# The sysroot is not downloadable: Sun's headers and libraries are not redistributable. -# Supply sysroot.tar.gz in the build context, as ./build.sh does. See README.md. - -ARG BASE=debian:11-slim - -FROM ${BASE} AS builder - -ARG BINUTILS_VERSION=2.35.2 -ARG GCC_VERSION=4.9.4 -ARG TARGET=sparcv9-sun-solaris2.9 -ARG PREFIX=/opt/sol9 - -ENV SYSROOT=${PREFIX}/sysroot - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential ca-certificates curl xz-utils bzip2 file \ - libgmp-dev libmpfr-dev libmpc-dev m4 texinfo \ - && rm -rf /var/lib/apt/lists/* - -# Solaris 9 ships no crt1.o/crti.o/crtn.o -- on Solaris those come with the compiler, -# not the OS -- so only headers and libraries are needed here. GCC builds its own crt -# files from libgcc/config/sparc/sol2-c1.S. -COPY sysroot.tar.gz /tmp/ -RUN mkdir -p "${SYSROOT}" \ - && tar xzf /tmp/sysroot.tar.gz -C "${SYSROOT}" \ - && rm /tmp/sysroot.tar.gz \ - && ln -sfn usr/lib "${SYSROOT}/lib" - -WORKDIR /src -RUN curl -fsSLO "https://ftp.gnu.org/gnu/binutils/binutils-${BINUTILS_VERSION}.tar.xz" \ - && tar xJf "binutils-${BINUTILS_VERSION}.tar.xz" \ - && rm "binutils-${BINUTILS_VERSION}.tar.xz" -RUN curl -fsSLO "https://ftp.gnu.org/gnu/gcc/gcc-${GCC_VERSION}/gcc-${GCC_VERSION}.tar.bz2" \ - && tar xjf "gcc-${GCC_VERSION}.tar.bz2" \ - && rm "gcc-${GCC_VERSION}.tar.bz2" - -# GNU as/ld: Solaris' own are not available to a cross build, and mrustc's target spec -# already avoids the GNU-only linker options that Solaris ld lacks. -RUN mkdir -p /build/binutils && cd /build/binutils \ - && "/src/binutils-${BINUTILS_VERSION}/configure" \ - --target="${TARGET}" --prefix="${PREFIX}" --with-sysroot="${SYSROOT}" \ - --disable-nls --disable-werror \ - && make -j"$(nproc)" && make install-strip \ - && cd / && rm -rf /build/binutils - -ENV PATH=${PREFIX}/bin:${PATH} - -# -std=gnu++98 -fpermissive: GCC 4.9's own sources do not compile under a C++11-or-later -# default. libsanitizer is disabled because it does not build against modern glibc headers -# and is of no use to a Rust codegen backend. -RUN mkdir -p /build/gcc && cd /build/gcc \ - && "/src/gcc-${GCC_VERSION}/configure" \ - --target="${TARGET}" --prefix="${PREFIX}" \ - --with-sysroot="${SYSROOT}" --with-build-sysroot="${SYSROOT}" \ - --enable-obsolete \ - --enable-languages=c \ - --enable-threads=posix \ - --with-gnu-as --with-gnu-ld \ - --with-as="${PREFIX}/bin/${TARGET}-as" --with-ld="${PREFIX}/bin/${TARGET}-ld" \ - --disable-nls --disable-libssp --disable-libgomp --disable-libatomic \ - --disable-libitm --disable-libsanitizer --disable-libquadmath --disable-libvtv \ - --disable-libcilkrts \ - MAKEINFO=missing \ - CFLAGS="-O2 -w" CXXFLAGS="-O2 -w -std=gnu++98 -fpermissive" \ - && make -j"$(nproc)" && make install-strip \ - && cd / && rm -rf /build/gcc /src - -# Solaris 9's defines INTPTR_MAX and UINTPTR_MAX as *empty* -# macros -- pre-C99 they were existence flags ("a pointer fits in an integral -# type"), and C99 later gave them values. GCC copies the header into include-fixed -# without fixing this, so C99 code doing `#if UINTPTR_MAX == ...` fails with -# "operator '==' has no left operand" (mbedTLS, among others). GCC's own -# __INTPTR_MAX__/__UINTPTR_MAX__ are per-multilib, so this is right for both. -RUN H="${PREFIX}/lib/gcc/${TARGET}/${GCC_VERSION}/include-fixed/sys/int_limits.h" \ - && sed -i -e 's|^#define[ \t]*INTPTR_MAX[ \t]*$|#define\tINTPTR_MAX\t__INTPTR_MAX__|' \ - -e 's|^#define[ \t]*UINTPTR_MAX[ \t]*$|#define\tUINTPTR_MAX\t__UINTPTR_MAX__|' "$H" \ - && grep -q '__UINTPTR_MAX__' "$H" - - -FROM ${BASE} - -ARG TARGET=sparcv9-sun-solaris2.9 -ARG PREFIX=/opt/sol9 - -# build-essential/python3/curl/patch are mrustc's own build and run dependencies, so this -# one image can build mrustc and then cross-compile its C output. -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential ca-certificates curl git patch python3 zlib1g-dev file \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=builder ${PREFIX} ${PREFIX} - -ENV PATH=${PREFIX}/bin:${PATH} -# mrustc looks for `-gcc`, overridable via CC_ -ENV CC_sparcv9_sun_solaris2_9=${TARGET}-gcc - -WORKDIR /work diff --git a/docker/sol9-cross/README.md b/docker/sol9-cross/README.md deleted file mode 100644 index 0b2aa4af..00000000 --- a/docker/sol9-cross/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# `sparcv9-sun-solaris2.9` cross toolchain - -A container holding a GCC that can compile mrustc's C output for **64-bit SPARC -Solaris 9**, plus mrustc's own build dependencies, so one image does both halves of a -cross build. - -## Why this exists - -Solaris 9 sits in a gap that no package can fill: - -| | | -|---|---| -| Newest GCC in any OpenCSW Solaris 9 catalog | 4.6.4 | -| Newest GCC that supports Solaris 9 at all | 4.9.x, and only with `--enable-obsolete` | -| Needed for `` in mrustc's output | 4.9 | -| Needed for `__builtin_{add,sub,mul}_overflow` | 5.0 | - -GCC obsoleted Solaris 9 in 4.9 and deleted the port in the next release, which shipped -as GCC 5 — so 4.9.4 is the ceiling, and it is missing the overflow builtins. mrustc -closes that gap from its side: the `sparcv9-sun-solaris2.9` target sets -`emulate-overflow-intrinsics`, and `src/trans/codegen_c.cpp` emits type-suffixed -`__builtin_*_overflow_u32`-style helpers instead of the type-generic builtins. - -GCC 4.9 needs `-std=gnu++98 -fpermissive` to build at all; with those it builds under a -current host GCC too (verified on 13.3), so the Debian 11 (GCC 10) pin is for -reproducibility rather than necessity. Building on the Blade itself was never an option — -every translation unit would compile on a 1.6GHz UltraSPARC IIIi. - -## The sysroot - -No sysroot ships here: it is a copy of a licensed Solaris 9 install's headers and -libraries, and Solaris 9 predates OpenSolaris by three years so no free substitute -exists. Whether a copy may be redistributed depends on the licence yours came -under. Building one from scratch: `Notes/SolarisSysroot.md` in the mrustc tree. Supply -one from a live Solaris 9 SPARC install: - -```sh -SOL9_HOST=user@192.168.99.176 ./build.sh -``` - -or drop your own `sysroot.tar.gz` (rooted at `/`, containing `usr/include`, `usr/lib` -and `usr/ccs/lib`) next to this file and run `./build.sh`. - -The image also patches one header. Solaris 9's `` defines -`INTPTR_MAX` and `UINTPTR_MAX` as *empty* macros: before C99 they were existence -flags rather than values, and GCC copies the header into `include-fixed` without -fixing it. Any C99 code doing `#if UINTPTR_MAX == ...` then fails with "operator -'==' has no left operand" — mbedTLS does exactly that. - -Solaris 9 ships **no `crt1.o`/`crti.o`/`crtn.o`** — on Solaris those come with the -compiler rather than the OS, and the package database has no entry for them. That is -expected and harmless: GCC builds its own from `libgcc/config/sparc/sol2-c1.S`. - -Pull the sysroot as root. Parts of `/usr/lib` are not world-readable, and a partial -sysroot fails much later and far less clearly. - -## Using it - -```sh -docker run --rm -v "$PWD:/work" -w /work mrustc-sol9-cross \ - make -f minicargo.mk LIBS \ - MRUSTC_TARGET=sparcv9-sun-solaris2.9 \ - OVERRIDE_SUFFIX=-solaris \ - STD_ENV_ARCH=sparc64 -``` - -mrustc invokes `-gcc`, so `sparcv9-sun-solaris2.9-gcc` is what it picks up off -`PATH`. `CC_sparcv9_sun_solaris2_9` overrides that (`-` becomes `_`), as does `CC`. - -`STD_ENV_ARCH=sparc64` is needed because `std::env::consts::ARCH` is otherwise derived -from the first component of the target triple, which is `sparcv9`. - -## Deploying to the target - -Solaris 9 has no `libgcc_s.so.1` of its own, and Rust's `unwind` crate asks for it by name -(`#[link(name = "gcc_s")]`), which defeats `-static-libgcc`. Install the toolchain's copy -on the target once: - -```sh -scp /opt/sol9/sparcv9-sun-solaris2.9/lib/sparcv9/libgcc_s.so.1 HOST:/tmp/ -ssh HOST 'sudo cp /tmp/libgcc_s.so.1 /usr/lib/sparcv9/ && sudo chmod 755 /usr/lib/sparcv9/libgcc_s.so.1' -``` - -The 64-bit library lives under `lib/sparcv9/`; `lib/` holds the 32-bit one, following the -Solaris multilib layout rather than GCC's usual one. - -## Notes - -- The compiler defaults to 64-bit: `sparcv9-*-solaris2*` selects `sparc/default-64.h`. - 32-bit remains available through the usual multilib, and the target spec passes - `-m64 -mcpu=v9` explicitly anyway — 32-bit SPARC has no `__int128`. -- GNU `as`/`ld` are used, since Solaris' own are not available to a cross build. The - target spec already avoids the GNU-only linker options Solaris `ld` lacks, so nothing - regresses when linking natively on the target instead. diff --git a/docker/sol9-cross/build.sh b/docker/sol9-cross/build.sh deleted file mode 100755 index dbd3e759..00000000 --- a/docker/sol9-cross/build.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh -# Build the sparcv9-sun-solaris2.9 cross-toolchain image (mrustc-sol9-cross). -# -# Needs a Solaris 9 sysroot, which is the one part of this that cannot be fetched from a -# public source: it is a copy of a licensed Solaris 9 install's headers and libraries, and -# Solaris 9 predates OpenSolaris by three years so no free substitute exists. Supply one of: -# -# ./mksysroot-from-iso.sh DVD.iso build one from the install DVD -- no Solaris box needed -# SOL9_SYSROOT_URL=https://... download a prepared tarball (what CI uses; keep it private) -# SOL9_HOST=user@host pull one off a live Solaris 9 SPARC install -# ./sysroot.tar.gz drop your own next to this script -# -# Building one from scratch: Notes/SolarisSysroot.md in the mrustc tree. -# The tarball is rooted at /, holding usr/include, usr/ccs/lib and usr/lib. -set -eu - -SOL9_HOST="${SOL9_HOST:-}" -SOL9_SYSROOT_URL="${SOL9_SYSROOT_URL:-}" -IMAGE="${IMAGE:-mrustc-sol9-cross}" -TAR="${TAR:-/opt/csw/bin/gtar}" -cd "$(dirname "$0")" - -if [ ! -f sysroot.tar.gz ]; then - if [ -n "$SOL9_SYSROOT_URL" ]; then - echo "==> Downloading sysroot" - curl -fsSL "$SOL9_SYSROOT_URL" -o sysroot.tar.gz.tmp - mv sysroot.tar.gz.tmp sysroot.tar.gz - elif [ -n "$SOL9_HOST" ]; then - echo "==> Pulling sysroot from $SOL9_HOST" - # sudo: parts of /usr/lib are not world-readable, and a partial sysroot fails late. - ssh "$SOL9_HOST" "sudo $TAR czf - -C / usr/include usr/ccs/lib usr/lib" > sysroot.tar.gz.tmp - mv sysroot.tar.gz.tmp sysroot.tar.gz - else - echo "error: no sysroot.tar.gz, and neither SOL9_SYSROOT_URL nor SOL9_HOST is set." >&2 - exit 1 - fi -fi - -# A truncated or HTML-error download fails deep inside the GCC build otherwise. -gzip -t sysroot.tar.gz || { echo "error: sysroot.tar.gz is not a valid gzip file" >&2; exit 1; } -tar tzf sysroot.tar.gz usr/include/stdio.h >/dev/null 2>&1 \ - || { echo "error: sysroot.tar.gz has no usr/include/stdio.h; is it rooted at / ?" >&2; exit 1; } - -echo "==> Building $IMAGE" -exec docker build -t "$IMAGE" "$@" . diff --git a/docker/sol9.Dockerfile b/docker/sol9.Dockerfile deleted file mode 100644 index bedf22ca..00000000 --- a/docker/sol9.Dockerfile +++ /dev/null @@ -1,73 +0,0 @@ -# Build rb-cli (CLI + TUI) for Solaris 9 on SPARC, via mrustc. -# See docs/build-sol9-mrustc.md and rb-cli-sol9/. -# -# This is the one vintage target that cross-builds end to end on Linux, so -# unlike the PowerPC Mac build (which needs real PowerPC hardware to compile its -# C) it can run in the release pipeline unattended. That is the whole point of -# this image. -# -# PREREQUISITE: the base image. It carries a GCC 4.9.4 that can target Solaris 9 -# plus a Solaris 9 sysroot, and it CANNOT be published -- Sun's headers and -# libraries are not redistributable. Build it once from the mrustc tree: -# -# cd ~/repos/mrustc/docker/sol9-cross && SOL9_HOST=user@ ./build.sh -# -# Then build this image (bakes in mrustc + the Solaris stdlib, so each later -# build is only the engine), and build the artifact with the repo bind-mounted: -# -# docker build -t rb-sol9 - < docker/sol9.Dockerfile -# docker run --rm -v "$PWD":/src rb-sol9 -# # -> ./dist/rb-cli-sol9.tar.gz -# -# Override the stage to do less than the default full run: -# docker run --rm -v "$PWD":/src rb-sol9 scripts/build-sol9.sh check - -ARG BASE=mrustc-sol9-cross -FROM ${BASE} - -ARG MRUSTC_REPO=https://github.com/danifunker/mrustc -# The Solaris 9 target lives on this branch: the target spec itself, the -# emitted-overflow-helper support GCC 4.9 needs, and the CC_${TRIPLE} name -# sanitisation that a triple with dots in it requires. -ARG MRUSTC_BRANCH=sparc-solaris-10 -ARG RUSTC_VERSION=1.74.0 -ARG TARGET=sparcv9-sun-solaris2.9 - -# A modern cargo, for `cargo vendor` only -- nothing here is built with it. The -# engine is compiled by mrustc, whose language mode is pinned to 1.74. -RUN apt-get update && apt-get install -y --no-install-recommends \ - cargo rsync \ - && rm -rf /var/lib/apt/lists/* - -# mrustc, and the rustc source it bootstraps its standard library from. -RUN git clone --branch "${MRUSTC_BRANCH}" --single-branch "${MRUSTC_REPO}" /opt/mrustc -WORKDIR /opt/mrustc -RUN make -j"$(nproc)" && make -f minicargo.mk bin/minicargo RUSTC_VERSION="${RUSTC_VERSION}" -RUN make RUSTCSRC RUSTC_VERSION="${RUSTC_VERSION}" - -# The host standard library, then the Solaris 9 one. Baking both in is what -# makes a pipeline run cost only the engine transpile. -# -# OVERRIDE_SUFFIX is picked from the *host* OS by minicargo.mk, so a cross build -# from Linux would silently take the -linux build-script overrides. -# STD_ENV_ARCH is needed because std::env::consts::ARCH is otherwise derived -# from the first triple component, which is `sparcv9` rather than `sparc64`. -RUN make -f minicargo.mk LIBS RUSTC_VERSION="${RUSTC_VERSION}" -j"$(nproc)" -RUN make -f minicargo.mk LIBS \ - RUSTC_VERSION="${RUSTC_VERSION}" \ - MRUSTC_TARGET="${TARGET}" \ - OVERRIDE_SUFFIX=-solaris \ - STD_ENV_ARCH=sparc64 \ - PARLEVEL="$(nproc)" \ - && ls "output-${RUSTC_VERSION}-${TARGET}/libstd.rlib" - -# scripts/build-sol9.sh reads every path from the environment, so the image only -# has to say where things ended up. -ENV MRUSTC_DIR=/opt/mrustc \ - RB_DIR=/src \ - SOL9_BIN=/opt/sol9/bin \ - SOL9_SYSROOT=/opt/sol9/sysroot \ - RUSTC_VERSION=1.74.0 - -WORKDIR /src -CMD ["scripts/build-sol9.sh"] diff --git a/docs/build-sol9-mrustc.md b/docs/build-sol9-mrustc.md index fafcb91b..f1b96d5b 100644 --- a/docs/build-sol9-mrustc.md +++ b/docs/build-sol9-mrustc.md @@ -681,10 +681,17 @@ are **done** as of 2026-09-07; 8 (doc sync) is the remainder. ## Putting it in the pipeline -The build is already containerised - `docker/sol9.Dockerfile` clones mrustc, -builds it, fetches the rustc source, builds the host and target stdlibs and -runs `scripts/build-sol9.sh`. Everything in that chain is fetched from source -at build time **except one file**, and there is one blocker. +CI does not build a toolchain. It downloads a **seed** -- the cross toolchain, its +sysroot, mrustc and both prebuilt standard libraries, about 47 MB -- unpacks it at `/`, +and runs `scripts/build-sol9.sh`. No container, no compiler build, no package repo beyond +eight Ubuntu packages. + +That is a deliberate reversal. The pipeline used to build GCC 4.9.4 from source in a +Debian image on every cache miss, roughly 40 minutes, and then rebuild mrustc and both +standard libraries on *every* run, roughly 40 more. All of it reproduced work that already +existed on a developer machine. It also broke twice in one evening for reasons that had +nothing to do with this project: Debian 11 reached EOL mid-flight, first through an +expired `Release` file and then through its security packages 404ing out of the pool. ### What it needs @@ -692,44 +699,49 @@ at build time **except one file**, and there is one blocker. |---|---|---| | `rb-cli-sol9/` manifest + `shim/sol9-compat.c` | this repo | yes, committed | | `scripts/build-sol9.sh`, vendor patches | this repo | yes, committed | -| `docker/sol9.Dockerfile` | this repo | yes, committed | -| mrustc fork, branch `sparc-solaris-10` | `danifunker/mrustc` | **blocked - see below** | -| Base image `mrustc-sol9-cross` | mrustc's `docker/sol9-cross/` | all but the sysroot | -| binutils 2.35.2, gcc 4.9.4 | ftp.gnu.org, at build time | yes | -| rustc 1.74.0 source | fetched by `make RUSTCSRC` | yes | +| `scripts/build-sol9-toolchain.sh` (builds the toolchain natively) | this repo | yes, committed | +| `scripts/pack-sol9-seed.sh` (packs the seed) | this repo | yes, committed | +| binutils 2.35.2, gcc 4.9.4 | ftp.gnu.org, when building a toolchain | yes | +| rustc 1.74.0 source | `make RUSTCSRC`, when building stdlibs | yes | | Crate sources | `cargo vendor`, at build time | yes (needs crates.io) | -| **Solaris 9 sysroot** (`sysroot.tar.gz`, 109 MB) | `docker/sol9-cross/`, **gitignored** | **no - not redistributable** | +| **The seed** (toolchain + sysroot + mrustc + stdlibs) | `SOL9_SPARC64_SYSROOT_URL` | **no - carries Sun's files** | -Two things on this machine are *not* needed and should not be mistaken for -dependencies: `~/sol9-deps/prefix` (nothing references it - zstd and zlib are -compiled from source by cc-rs for the target), and the Blade itself, which is -needed only for the parity gates, never for the build. +### Building the toolchain, and packing a seed -### The mrustc branch + scripts/mksysroot-solaris.sh disk DISK.img # sysroot from a Solaris disk image + scripts/build-sol9-toolchain.sh sysroot.tar.gz # binutils + gcc 4.9.4, ~40 min + scripts/build-sol9.sh sol9libs # the Solaris standard library + scripts/pack-sol9-seed.sh sol9-seed.tar.gz # -> 47 MB, host this privately -`docker/sol9.Dockerfile` clones `danifunker/mrustc` at branch -`sparc-solaris-10`, so that branch must carry the Solaris target for the image -to build. **It does, as of 2026-09-07** (`71910c7c`) - the branch was pushed -that day. If a container build ever fails at -`make -f minicargo.mk LIBS MRUSTC_TARGET=...` with an unknown target, check -that ref first; the whole image depends on it. +GCC bakes its sysroot path in at configure time, so the seed stages absolute paths at +`/opt/sol9` and `/opt/mrustc` and unpacks with `sudo tar xzf sol9-seed.tar.gz -C /`. Build +the toolchain with `--prefix=/opt/sol9` if you intend to pack one. The tarball stores root +ownership rather than whoever packed it -- it unpacks at `/` on another machine, and +preserving a local uid hands `/opt/mrustc` to whichever user happens to hold that number +there. -### The sysroot +The toolchain is built on the same OS that consumes it -- GitHub's `ubuntu-latest` and a +current desktop are both Ubuntu 24.04 with GCC 13.3, and GCC 4.9.4 builds under that given +`-std=gnu++98 -fpermissive`. A GCC 4.9.4 built on Debian 12 also runs unmodified on Ubuntu +24.04; glibc is forward compatible. -Sun does not permit redistributing Solaris 9, so `sysroot.tar.gz` cannot go in -a public image or the repo (it is gitignored for that reason). For CI it has to -arrive out of band - a private registry holding the pre-built -`mrustc-sol9-cross`, or the tarball as a secret artifact restored before -`docker build`. Building the base image is a one-off; only the layer above it -needs to re-run per commit. +### Two traps in the seed + +`libstd.rlib` is a **0-byte marker**. mrustc keeps the crate metadata in `libstd.rlib.hir` +and the code in `libstd.rlib.o`, so a packer that strips `*.hir` as an intermediate +produces a seed where every dependent crate dies with "Unable to deserialise crate +metadata". The emitted `*.rlib.c` really is regenerable, and dropping it is most of the +size win. + +Ubuntu's packaged `cargo` is too old for `cargo vendor` here: a crate in the graph needs +`edition2024`. The job uses `dtolnay/rust-toolchain@stable`, as the rest of the workflow +does. ### Cost -The engine transpile dominates: a from-scratch container build is tens of -minutes and wants ~25 GB of disk (mrustc's tree plus the rustc source plus the -generated C). Cache the base image and the `output-1.74.0-` stdlib and -a normal commit rebuilds only the engine and the link. Cap parallelism at 4 - -see `docs/build-memory-crashes.md`, which applies to this build too. +About 20-25 minutes: 30 s of apt, 10 s for the seed, 2 min to vendor 217 crates, and +15-20 min to transpile, cross-compile and link 219. Cap parallelism at 4 - see +`docs/build-memory-crashes.md`, which applies to this build too. ### Gating a release on the hardware @@ -766,7 +778,7 @@ depends on the last: |---|---|---| | g | `73c570f3` | `emulate-overflow-intrinsics`, so a pre-GCC-5 compiler can build mrustc's output. Needs (e) | | h | `400e373a` | The `sparcv9-sun-solaris` target (Solaris 10) | -| i | `6421bfef`, `b3aa8ae6` | `sparcv9-sun-solaris2.9`, `emulate-c99-math`, `emulate-posix2001`, and the `docker/sol9-cross` toolchain container | +| i | `6421bfef`, `b3aa8ae6` | `sparcv9-sun-solaris2.9`, `emulate-c99-math`, `emulate-posix2001`, and the toolchain container that later became `scripts/build-sol9-toolchain.sh` | | j | **not yet written** | Finding 10: mrustc lowers a fieldless `#[repr(u32)]` enum to a one-field struct and passes it **by value** in `extern "C"` signatures, where the callee expects a scalar. On any 64-bit big-endian target the value lands in the wrong half of the register. Should emit the underlying integer in extern signatures and at call sites | Two practical notes. The branch is **not linear** - it contains a merge commit diff --git a/docs/solaris-raw-devices.md b/docs/solaris-raw-devices.md new file mode 100644 index 00000000..18f4a2ff --- /dev/null +++ b/docs/solaris-raw-devices.md @@ -0,0 +1,82 @@ +# Raw devices on Solaris + +How `rb-cli` sees disks on Solaris, why enumeration works the way it does, and what to do +when a USB device wedges the driver. + +## Device naming, and the difference between SPARC and x86 + +Solaris exposes every disk twice. `/dev/dsk/...` is the buffered block device; `/dev/rdsk/...` +is the raw character device. Only the raw node gives unbuffered access, and it insists that +every read and write be a whole multiple of the sector size -- an unaligned one fails +`EINVAL` rather than returning a short count. That is why `SectorAlignedWriter` is mandatory +here rather than an optimisation. + +The whole-disk node is spelled differently per architecture: + +| | Whole disk | Partitions | +|---|---|---| +| **SPARC** | `cXtYdZs2` -- slice 2 is the whole disk by SMI/VTOC convention | `s0`-`s7` | +| **x86** | `cXtYdZp0` -- the fdisk table | `p1`-`p4`, with `s0`-`s7` inside the Solaris one | + +`src/os/solaris.rs` probes `p0` then `s2`, so one binary handles both. This matters because +Oracle's own USB documentation shows `p0` throughout -- those examples are from x86 systems. +On SPARC **every** device gets `s0`-`s7`, including USB sticks; there are no `p` nodes at all. + +## Enumeration never opens a device + +A wedged driver blocks in `open(2)` and **ignores `O_NONBLOCK`**. Measured on a Sun Blade 2500 +running Solaris 9: a USB stick behind `scsa2usb` left processes stuck in the kernel that +`kill -9` could not touch. Solaris' own tools have the same problem -- + + format(1M) scans by opening /dev/rdsk/*s2 -> hangs + rmformat(1) the documented removable tool -> hangs + iostat -E reads published kstats -> returns instantly + +-- so listing disks by opening them is not merely slow, it is a hang waiting for one bad +device. Oracle explicitly warns never to point `format` at a USB drive. + +`rb-cli` therefore reads what the driver already publishes: + +1. `/dev/rdsk/{p0,s2}` is a symlink into `/devices` -- `readlink`, no open. +2. `/etc/path_to_inst` maps that physical path to a driver instance (`sd`, instance 32). +3. `libkstat` reads `err:` for `Size`, `Vendor` and `Product`. +4. `/etc/mnttab` supplies mount points. + +A device that has stopped answering publishes `Size 0`, which is how it is excluded without +anything touching it. `libdiskmgt` -- the API `format` and the installer use -- is **32-bit +only on Solaris 9**, so it is unavailable to a `sparcv9` binary. + +The one ioctl still used is `DKIOCREMOVABLE`, and only for disks whose kstat already reports +a real size, so the device is known to be answering first. + +## USB mass storage is beta + +USB support on Solaris 9 is **beta** and depends more on the device than on the OS. The +`scsa2usb` driver issues a full SCSI command set that many cheap flash bridges do not +implement; those devices then wedge on open, exactly as above. Symptoms, all visible without +touching the device: + + iostat -En Size: 0.00GB <0 bytes>, high Transport Errors + kstat -p sderr::: err::Size 0 + +`scripts/solaris-usb-unblock.sh` reports this state and prints the documented remedies. + +The supported fix is to tell the driver to use the reduced command set, per +. In +`/kernel/drv/scsa2usb.conf`: + + attribute-override-list = "vid=* reduced-cmd-support=true"; + +then `update_drv -f scsa2usb`. This edits kernel driver configuration and affects **all** USB +storage on the machine, so it is not something rb-cli does for you. Note that the driver +cannot reload while processes are still stuck holding it open, and that a malformed `.conf` +will not be noticed until the next boot -- which matters on a machine whose `auto-boot?` is +false. + +Once wedged, the only reliable recovery is to unplug the device; the stuck processes clear +when it goes away. + +## What is still open + +`docs/solaris9-open-items.md` tracks the rest: raw-device writing, free-space reporting, the +CD-ROM removable flag, and whether Solaris 10 needs a build of its own. diff --git a/docs/solaris9-open-items.md b/docs/solaris9-open-items.md new file mode 100644 index 00000000..d7104197 --- /dev/null +++ b/docs/solaris9-open-items.md @@ -0,0 +1,73 @@ +# Solaris 9 SPARC: what is still open + +Tracking file for the Solaris 9 port. Everything here is known-incomplete or known-wrong, as +distinct from untested -- where something has not been verified, that is said outright. + +Working today: cross build through mrustc, the release pipeline leg, disk-image operations, +the TUI, and read-only device enumeration. See `docs/build-sol9-mrustc.md` for the build and +`docs/solaris-raw-devices.md` for how devices are handled. + +## Raw devices + +- [ ] **Writing to a raw device is refused.** `open_target_for_writing_inner` in + `src/os/mod.rs` still falls into the catch-all arm for Solaris and bails. Enumeration + and reading are done; writing needs a device that is not the root disk to develop + against, and Solaris raw nodes reject unaligned I/O with `EINVAL`, so + `SectorAlignedWriter` has to be exercised properly rather than assumed. +- [ ] **Reading an explicit device path is untested.** `open_source_for_reading` is not + gated for Solaris -- it falls through to a plain `File::open` -- so naming a + `/dev/rdsk/...` path should work, but no read of a real disk has been done through it. +- [ ] **A CD-ROM reports `removable: no`.** `DKIOCREMOVABLE` either fails or returns 0 on the + BlueSCSI emulation in the test machine, and `is_removable` treats a failed ioctl as + "not removable". Whether that is the emulator or the ioctl has not been established. + This is a known-wrong value, not a verified one. +- [ ] **Free space is not reported.** The third catch-all arm in `src/os/mod.rs` returns + `None` for Solaris, so `MountedPartition::total_space` and `available_space` are always + zero. `statvfs(2)` is the obvious fix. +- [ ] **Mounted-partition safety is untested.** Solaris will not let a mounted slice be + written through its raw node; `device_safety.rs` should refuse first, with a clear + message, rather than letting the write fail late. + +## USB mass storage (beta) + +- [ ] **`scsa2usb` wedges on non-compliant devices**, blocking in `open(2)` and ignoring + `O_NONBLOCK`, leaving processes that `kill -9` cannot clear. Enumeration already routes + around this by never opening a device, but any *use* of such a disk will hang. + `scripts/solaris-usb-unblock.sh` diagnoses it and prints Oracle's remedy. +- [ ] **The `reduced-cmd-support` override is untried.** The documented fix for + non-compliant devices has not been applied on the test machine, so we do not know + whether it recovers a stick that is already wedged, or only prevents it. +- [ ] **No USB device has been read or written end to end.** Every USB test so far has been + against one stick that wedges, so the USB path is entirely unproven. + +## Solaris 10 + +- [ ] **Deliberately not built.** Support was implemented and then removed: Solaris + guarantees forward binary compatibility, so the Solaris 9 artifact is expected to run + on 10 and 11. **This has never been tested** -- the test machine was reinstalled to 9. + Copying `rb-cli-sol9` onto a Solaris 10 box settles whether a second target is needed + at all. The mrustc target (`sparcv9-sun-solaris`, GCC 11) exists if it is. + +## Build and pipeline + +- [ ] **minicargo races on build scripts.** It can schedule one crate's build script twice + concurrently -- visible as `serde_core (build)` listed twice in its own progress line + -- and the second worker then execs the binary while the first is still linking it, + before the linker has set the executable bit. It surfaces as `Unable to run process + ... Permission denied` partway through the graph, and is intermittent: the same + commit and seed failed once and passed on re-run. The CI job retries the stage once, + which works because the build is incremental, but the race is upstream in minicargo + and that retry is a mitigation rather than a fix. +- [ ] **The seed pins an mrustc commit implicitly.** `scripts/pack-sol9-seed.sh` packs + whatever `bin/mrustc` and the stdlib outputs happen to be, with nothing recording which + commit built them. Stamping that into the seed would make a stale one obvious. +- [ ] **`docs/cli-reference.md` has no `mv` entry.** Unrelated to Solaris, but found here: + the verb shipped without the reference being updated. + +## Upstream mrustc + +- [ ] **The C-calls-Rust direction of the FFI enum fix is unfixed.** libgcc's unwinder calls + `rust_eh_personality` with a plain int while mrustc declares it as a struct, so on + 64-bit big-endian that boundary still mismatches. Pre-existing upstream behaviour, + documented in the commit rather than silently left; fixing it needs a conversion at + function entry. See thepowersgang/mrustc#428. diff --git a/scripts/build-sol9-toolchain.sh b/scripts/build-sol9-toolchain.sh new file mode 100755 index 00000000..0853be49 --- /dev/null +++ b/scripts/build-sol9-toolchain.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# build-sol9-toolchain.sh -- build the sparcv9-sun-solaris2.9 cross toolchain natively. +# +# No container. GitHub's ubuntu-latest runners and this machine are both Ubuntu 24.04 with +# GCC 13.3, and GCC 4.9.4 builds under that given the flags below -- so the toolchain can +# be built on the same OS that will use it, and packed as a seed for CI. +# +# scripts/build-sol9-toolchain.sh SYSROOT.tar.gz [PREFIX] +# +# PREFIX defaults to ~/sol9-toolchain/opt, matching scripts/build-sol9.sh's own default. +# The sysroot tarball is rooted at / and holds usr/include, usr/ccs/lib and usr/lib. +# +# Why 4.9.4: GCC obsoleted Solaris 9 in 4.9 (buildable only with --enable-obsolete) and +# deleted the port in the next release, which shipped as GCC 5. +set -euo pipefail + +SYSROOT_TGZ="${1:?usage: $0 SYSROOT.tar.gz [PREFIX]}" +PREFIX="${2:-$HOME/sol9-toolchain/opt}" +SYSROOT="${SOL9_SYSROOT:-$(dirname "$PREFIX")/sysroot}" + +BINUTILS_VERSION="${BINUTILS_VERSION:-2.35.2}" +GCC_VERSION="${GCC_VERSION:-4.9.4}" +TARGET="${TARGET:-sparcv9-sun-solaris2.9}" +SRC="${SRC:-${TMPDIR:-/tmp}/sol9-toolchain-src}" +JOBS="${JOBS:-$(nproc)}" + +say() { printf '\033[1;36m==> %s\033[0m\n' "$*"; } + +[ -f "$SYSROOT_TGZ" ] || { echo "error: no sysroot tarball at $SYSROOT_TGZ" >&2; exit 1; } +tar tzf "$SYSROOT_TGZ" | grep -qE '^(\./)?usr/include/stdio\.h$' \ + || { echo "error: $SYSROOT_TGZ has no usr/include/stdio.h; is it rooted at / ?" >&2; exit 1; } + +say "Unpacking the sysroot into $SYSROOT" +mkdir -p "$SYSROOT" +tar xzf "$SYSROOT_TGZ" -C "$SYSROOT" +# Solaris 9 keeps its libraries only in /usr/lib; Solaris 10 has the real ones in /lib with +# symlinks in /usr/lib, so linking unconditionally would destroy them. +[ -d "$SYSROOT/lib" ] || ln -sfn usr/lib "$SYSROOT/lib" + +mkdir -p "$SRC"; cd "$SRC" +if [ ! -d "binutils-$BINUTILS_VERSION" ]; then + say "Fetching binutils $BINUTILS_VERSION" + curl -fsSLO "https://ftp.gnu.org/gnu/binutils/binutils-$BINUTILS_VERSION.tar.xz" + tar xJf "binutils-$BINUTILS_VERSION.tar.xz"; rm "binutils-$BINUTILS_VERSION.tar.xz" +fi +if [ ! -d "gcc-$GCC_VERSION" ]; then + say "Fetching gcc $GCC_VERSION" + curl -fsSLO "https://ftp.gnu.org/gnu/gcc/gcc-$GCC_VERSION/gcc-$GCC_VERSION.tar.bz2" + tar xjf "gcc-$GCC_VERSION.tar.bz2"; rm "gcc-$GCC_VERSION.tar.bz2" +fi + +# GNU as/ld: Solaris' own are not available to a cross build, and mrustc's target spec +# already avoids the GNU-only linker options Solaris ld lacks. +say "Building binutils -> $PREFIX" +rm -rf "$SRC/build-binutils"; mkdir -p "$SRC/build-binutils"; cd "$SRC/build-binutils" +"$SRC/binutils-$BINUTILS_VERSION/configure" \ + --target="$TARGET" --prefix="$PREFIX" --with-sysroot="$SYSROOT" \ + --disable-nls --disable-werror >/dev/null +make -j"$JOBS" >/dev/null +make install-strip >/dev/null + +# -std=gnu++98 -fpermissive: GCC 4.9's own sources predate C++11-strict compilers, which +# every current host compiler is. libsanitizer does not build against modern glibc headers +# and is of no use to a Rust codegen backend. +say "Building gcc $GCC_VERSION -> $PREFIX (the slow part, ~30 min)" +rm -rf "$SRC/build-gcc"; mkdir -p "$SRC/build-gcc"; cd "$SRC/build-gcc" +PATH="$PREFIX/bin:$PATH" "$SRC/gcc-$GCC_VERSION/configure" \ + --target="$TARGET" --prefix="$PREFIX" \ + --with-sysroot="$SYSROOT" --with-build-sysroot="$SYSROOT" \ + --enable-obsolete --enable-languages=c --enable-threads=posix \ + --with-gnu-as --with-gnu-ld \ + --with-as="$PREFIX/bin/$TARGET-as" --with-ld="$PREFIX/bin/$TARGET-ld" \ + --disable-nls --disable-libssp --disable-libgomp --disable-libatomic \ + --disable-libitm --disable-libsanitizer --disable-libquadmath --disable-libvtv \ + --disable-libcilkrts \ + MAKEINFO=missing \ + CFLAGS="-O2 -w" CXXFLAGS="-O2 -w -std=gnu++98 -fpermissive" >/dev/null +PATH="$PREFIX/bin:$PATH" make -j"$JOBS" >/dev/null +make install-strip >/dev/null + +# Solaris 9's defines INTPTR_MAX and UINTPTR_MAX as *empty* macros -- +# pre-C99 they were existence flags, and C99 later gave them values. GCC copies the header +# into include-fixed without fixing this, so C99 code testing them fails to compile. +H="$PREFIX/lib/gcc/$TARGET/$GCC_VERSION/include-fixed/sys/int_limits.h" +if [ -f "$H" ] && grep -qE '^#define[[:space:]]*U?INTPTR_MAX[[:space:]]*$' "$H"; then + say "Repairing empty INTPTR_MAX/UINTPTR_MAX in include-fixed" + sed -i -e 's|^#define[ \t]*INTPTR_MAX[ \t]*$|#define\tINTPTR_MAX\t__INTPTR_MAX__|' \ + -e 's|^#define[ \t]*UINTPTR_MAX[ \t]*$|#define\tUINTPTR_MAX\t__UINTPTR_MAX__|' "$H" + grep -q '__UINTPTR_MAX__' "$H" +fi + +say "Checking it works" +echo 'int main(void){return 0;}' > "$SRC/t.c" +"$PREFIX/bin/$TARGET-gcc" -m64 -mcpu=v9 "$SRC/t.c" -o "$SRC/t" +file "$SRC/t" | grep -q 'SPARC V9' || { echo "error: not a SPARC V9 binary" >&2; exit 1; } +rm -rf "$SRC/build-binutils" "$SRC/build-gcc" +say "Toolchain ready: $("$PREFIX/bin/$TARGET-gcc" --version | head -1)" diff --git a/scripts/build-sol9.sh b/scripts/build-sol9.sh index 69c2d74b..5d788b03 100755 --- a/scripts/build-sol9.sh +++ b/scripts/build-sol9.sh @@ -4,7 +4,7 @@ # mrustc and cross-compile it to a Solaris 9 SPARC `rb-cli` + TUI. # # Unlike scripts/build-ppc.sh this is a ONE-machine pipeline. A cross gcc -# targeting Solaris 9 exists (mrustc's docker/sol9-cross, or ~/sol9-toolchain), +# targeting Solaris 9 exists (scripts/build-sol9-toolchain.sh, or the CI seed at /opt/sol9), # so there is no remote compiler, no remote archiver and no split-TU step: # # This machine: Rust --mrustc--> C99 --sparcv9-...-gcc--> SPARC ELF @@ -50,6 +50,8 @@ FEATURES="${FEATURES:-native-zstd,remote,tui,rust173-polyfill}" SOL9_TOOLCHAIN="${SOL9_TOOLCHAIN:-$HOME/sol9-toolchain}" SOL9_BIN="${SOL9_BIN:-$SOL9_TOOLCHAIN/opt/bin}" SOL9_SYSROOT="${SOL9_SYSROOT:-$SOL9_TOOLCHAIN/sysroot}" +# mrustc spawns $SOL9_TARGET-gcc by name, so the cross toolchain has to be on PATH for sol9libs. +if [ -d "$SOL9_BIN" ]; then export PATH="$SOL9_BIN:$PATH"; fi # The C shim must reach the final link line, so default it here rather than in the environment. SOL9_SHIM="${SOL9_SHIM:-$CRATE_DIR/shim/sol9-compat.c}" @@ -260,8 +262,17 @@ libgcc_s.so.1 ships alongside because Solaris 9 has none of its own and Rust's unwinder needs it. rb-cli finds it next to itself, so keep the two together; nothing has to be installed system-wide. -Built with mrustc against a Solaris 9 sysroot. Raw device access is not -available on this platform; disk *images* work normally. +Built with mrustc against a Solaris 9 sysroot. + +Raw devices: `rb-cli show devices` lists local disks, reading what the drivers +publish rather than opening them, so a disk that has stopped answering is +skipped instead of hanging the tool. Run it as root -- /dev/rdsk nodes are not +readable by an ordinary user. + +USB mass storage is BETA. Solaris' scsa2usb driver wedges on devices that do +not implement the full SCSI command set, and once wedged such a device cannot +be recovered without unplugging it. scripts/solaris-usb-unblock.sh diagnoses +that state, and docs/solaris-raw-devices.md explains it. TXT ( cd "$SOL9_OUT/dist" && tar czf "$RB_DIR/dist/rb-cli-sol9.tar.gz" rb-cli-sol9 ) note "bundle at $RB_DIR/dist/rb-cli-sol9.tar.gz ($(du -h "$RB_DIR/dist/rb-cli-sol9.tar.gz" | cut -f1))" diff --git a/docker/sol9-cross/mksysroot.sh b/scripts/mksysroot-solaris.sh similarity index 78% rename from docker/sol9-cross/mksysroot.sh rename to scripts/mksysroot-solaris.sh index 71542be9..8404d922 100755 --- a/docker/sol9-cross/mksysroot.sh +++ b/scripts/mksysroot-solaris.sh @@ -33,12 +33,30 @@ disk) # /lib matters on Solaris 10, where the core libraries live there and /usr/lib holds # symlinks pointing back; on Solaris 9 /lib is itself a symlink to usr/lib and this # copy finds nothing. Take both and let the trailing check decide which happened. + # `get -r` lays a directory source out *under* the destination, so pass the parent: + # naming the directory itself would give usr/include/include. + ERR="$OUT/sysroot-build/get.err"; LINKS="$OUT/sysroot-build/symlinks.tsv"; : > "$LINKS" for d in usr/include usr/lib usr/ccs/lib lib; do echo " $d" - mkdir -p "$ROOT/$(dirname "$d")" - "$RB" get "$SRC@$SLICE" "/$d" "$ROOT/$d" -r >/dev/null 2>&1 || \ + parent="$ROOT/$(dirname "$d")" + mkdir -p "$parent" + "$RB" get "$SRC@$SLICE" "/$d" "$parent" -r >/dev/null 2>"$ERR" || \ echo " (skipped $d -- not present on this install)" >&2 + # rb-cli writes a symlink as a text file holding its target, and names each one it + # did that to; collect them so they can be turned back into links below. + sed -n 's/^ symlink as text: \(.*\) -> \(.*\) (use platform tools.*$/\1\t\2/p' \ + "$ERR" >> "$LINKS" done + + # Without this every symlink is a plain file, so `-lc` finds a 12-byte text libc.so + # and the link fails far from the cause. + if [ -s "$LINKS" ]; then + echo "==> Recreating $(wc -l < "$LINKS" | tr -d " ") symlinks" + while IFS="$(printf "\t")" read -r dst target; do + [ -n "$dst" ] && [ -n "$target" ] || continue + rm -f "$dst" && ln -s "$target" "$dst" + done < "$LINKS" + fi ;; media9) command -v xorriso >/dev/null || { echo "error: xorriso is required" >&2; exit 1; } diff --git a/scripts/pack-sol9-seed.sh b/scripts/pack-sol9-seed.sh new file mode 100755 index 00000000..b45679c3 --- /dev/null +++ b/scripts/pack-sol9-seed.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# pack-sol9-seed.sh -- pack a prebuilt Solaris 9 build environment for CI. +# +# CI has no business rebuilding a 2016 GCC on every run, and a container image carries a +# whole Debian plus the rustc source purely to redo work already done here. This packs only +# what scripts/build-sol9.sh actually reads: the cross toolchain and its sysroot, mrustc's +# two binaries, and the two prebuilt standard libraries. +# +# scripts/pack-sol9-seed.sh [OUT.tar.gz] +# +# Paths are staged absolute, at /opt/sol9 and /opt/mrustc, because GCC bakes its sysroot +# path in at configure time -- the tree has to land where it was configured. Unpack with +# `sudo tar xzf sol9-seed.tar.gz -C /`. +# +# TOOLCHAIN_SRC is a toolchain prefix holding bin/, sysroot/ and /, as +# scripts/build-sol9-toolchain.sh produces. It defaults to /opt/sol9, which is where the +# seed unpacks, so re-packing an already-seeded host needs no arguments. +# +# The result contains Sun's headers and libraries, so it is NOT redistributable -- host it +# privately, exactly as for the sysroot it is built from. +set -euo pipefail + +OUT="${1:-$PWD/sol9-seed.tar.gz}" +MRUSTC_DIR="${MRUSTC_DIR:-$HOME/repos/mrustc}" +RUSTC_VERSION="${RUSTC_VERSION:-1.74.0}" +TARGET="${SOL9_TARGET:-sparcv9-sun-solaris2.9}" +TOOLCHAIN_SRC="${TOOLCHAIN_SRC:-/opt/sol9}" + +say() { printf '\033[1;36m==> %s\033[0m\n' "$*"; } +need() { [ -e "$1" ] || { echo "error: missing $1 -- $2" >&2; exit 1; }; } + +need "$MRUSTC_DIR/bin/mrustc" "build mrustc first" +need "$MRUSTC_DIR/bin/minicargo" "build minicargo first" +need "$MRUSTC_DIR/output-$RUSTC_VERSION/libstd.rlib" "build the host stdlib first" +need "$MRUSTC_DIR/output-$RUSTC_VERSION-$TARGET/libstd.rlib" "run build-sol9.sh sol9libs first" + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "$STAGE/opt" + +say "Taking the toolchain from $TOOLCHAIN_SRC" +need "$TOOLCHAIN_SRC/bin/$TARGET-gcc" "not a toolchain prefix -- run scripts/build-sol9-toolchain.sh" +cp -a "$TOOLCHAIN_SRC" "$STAGE/opt/sol9" +need "$STAGE/opt/sol9/sysroot/usr/include/stdio.h" "the toolchain carries no sysroot" + +say "Staging mrustc and the prebuilt standard libraries" +mkdir -p "$STAGE/opt/mrustc/bin" +cp -a "$MRUSTC_DIR/bin/mrustc" "$MRUSTC_DIR/bin/minicargo" "$STAGE/opt/mrustc/bin/" +cp -a "$MRUSTC_DIR/output-$RUSTC_VERSION" "$STAGE/opt/mrustc/" +cp -a "$MRUSTC_DIR/output-$RUSTC_VERSION-$TARGET" "$STAGE/opt/mrustc/" + +# The emitted C and mrustc's debug dumps are regenerated on demand and are most of the +# weight. Keep .rlib.hir: the .rlib itself is a 0-byte marker, and .hir is where the crate +# metadata actually lives -- without it every dependent crate fails with "Unable to +# deserialise crate metadata". +say "Dropping regenerable intermediates" +find "$STAGE/opt/mrustc" \( -name '*_dbg.txt' -o -name '*.rlib.c' \) -delete + +# Store root ownership rather than whoever packed it: the seed unpacks at / on another +# machine, and preserving a local uid hands /opt/mrustc to whichever user happens to hold +# that number there. +say "Writing $OUT" +tar czf "$OUT" --owner=0 --group=0 --numeric-owner -C "$STAGE" opt +say "Seed ready: $(du -h "$OUT" | cut -f1)" +cat < %s\n' "$*"; } + +say "Disks the drivers are publishing (no device is opened)" +# `kstat -p` is module:instance:name:statistic value, so the value is everything +# after the tab -- splitting on ':' as well would cut a product string containing one. +kstat -p 'sderr:::' 2>/dev/null | awk -F'\t' ' + { n = split($1, k, ":"); stat = k[n]; inst = k[2] } + stat == "Size" { size[inst] = $2 } + stat == "Vendor" { vend[inst] = $2 } + # Some drivers run the product field into the next INQUIRY label; drop the artifact. + stat == "Product" { sub(/ *Revision$/, "", $2); prod[inst] = $2 } + END { + for (i in size) + printf " inst %-4s %-28s %s bytes%s\n", i, vend[i] " " prod[i], size[i], + (size[i] == 0 ? " <-- NOT ANSWERING" : "") + }' | sort + +say "Processes stuck on a raw disk" +# A process wedged in the driver cannot be killed; it clears when the device is unplugged. +stuck=$(ps -ef | grep '[/]dev/rdsk' | awk '{print " pid " $2 " " $8 " " $9}') +if [ -n "$stuck" ]; then + printf '%s\n' "$stuck" + echo " (these are in uninterruptible sleep -- kill -9 will not clear them)" +else + echo " none" +fi + +say "USB mass storage the kernel has attached" +dmesg 2>/dev/null | grep -i 'scsa2usb\|USB-device' | tail -4 | sed 's/^/ /' || echo " none" + +say "scsa2usb command-set override" +if grep -q 'reduced-cmd-support' "$CONF" 2>/dev/null; then + echo " already set in $CONF" +elif [ "$APPLY" -eq 1 ]; then + # Documented remedy for non-compliant devices; see the Oracle link in the docs. + cp "$CONF" "$CONF.bak.$$" + echo "$OVERRIDE" >> "$CONF" + echo " appended to $CONF (backup at $CONF.bak.$$)" + if update_drv -f scsa2usb 2>&1; then + echo " driver reloaded" + else + echo " reload failed -- the driver is busy while processes remain stuck" >&2 + fi +else + echo " not set. To apply the documented remedy for non-compliant devices:" + echo + echo " echo '$OVERRIDE' >> $CONF" + echo " update_drv -f scsa2usb" + echo + echo " Re-run with --apply to do that. It affects ALL USB storage on this machine," + echo " a bad .conf is only noticed at the next boot, and the driver cannot reload" + echo " while processes are still stuck holding it open." +fi + +say "If the device stays wedged" +echo " Unplug it. That is the only reliable recovery, and it clears the stuck processes." diff --git a/src/fs/sfs.rs b/src/fs/sfs.rs index fe64631f..dcac3be8 100644 --- a/src/fs/sfs.rs +++ b/src/fs/sfs.rs @@ -1945,7 +1945,7 @@ impl SfsFilesystem { }); // Full: the parent is full only when every child is. Emptied: the // parent has room again either way. - if (full && all_full) || !full { + if !full || all_full { return self.set_container_full_flag(blk, full); } return Ok(()); diff --git a/src/os/mod.rs b/src/os/mod.rs index 40baeed2..57b0575a 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -24,6 +24,11 @@ pub mod darwin_devices; #[cfg(target_os = "linux")] pub mod linux; +// Compiled under `test` on any unix as well, so the iostat and slice parsers -- pure string +// handling, and the part most likely to regress -- are covered by the normal test run. +#[cfg(any(target_os = "solaris", all(test, unix)))] +pub mod solaris; + #[cfg(target_os = "windows")] pub mod windows; @@ -510,6 +515,48 @@ mod aligned_buffer { /// buffer addresses and sizes must be sector-aligned. This wrapper accumulates /// writes and only flushes complete sectors to the device. /// +/// Commit a target to stable storage, tolerating descriptors that cannot be told to. +/// +/// macOS implements `File::sync_all` as `fcntl(F_FULLFSYNC)`, which a raw `/dev/rdiskN` +/// answers with ENOTTY -- surfacing as "Inappropriate ioctl for device (os error 25)" at the +/// end of an otherwise complete restore. Writes to a raw device are already unbuffered, so +/// there is nothing left to push; fall back to `fsync`, and only treat the sync as done if +/// that is refused the same way. A regular file always accepts `fsync`, so this cannot +/// silently skip flushing one. +fn sync_committed(file: &File) -> io::Result<()> { + const ENOTTY: i32 = 25; + + match file.sync_all() { + Ok(()) => Ok(()), + Err(e) if e.raw_os_error() == Some(ENOTTY) => sync_fallback(file, e), + Err(e) => Err(e), + } +} + +#[cfg(unix)] +fn sync_fallback(file: &File, original: io::Error) -> io::Result<()> { + use std::os::unix::io::AsRawFd; + const ENOTTY: i32 = 25; + + // SAFETY: the descriptor is owned by `file` and stays open for the call. + let rc = unsafe { libc::fsync(file.as_raw_fd()) }; + if rc == 0 { + return Ok(()); + } + let err = io::Error::last_os_error(); + // A device that refuses both has nothing buffered to lose. + if err.raw_os_error() == Some(ENOTTY) { + return Ok(()); + } + let _ = original; + Err(err) +} + +#[cfg(not(unix))] +fn sync_fallback(_file: &File, original: io::Error) -> io::Result<()> { + Err(original) +} + /// `Read` and `Seek` flush the write buffer before delegating to the inner file. #[cfg(not(target_os = "windows"))] pub struct SectorAlignedWriter { @@ -539,7 +586,7 @@ impl SectorAlignedWriter { /// Flush, pad, and push everything to the medium before "complete" is said. pub fn sync_all(&mut self) -> io::Result<()> { self.flush_padded()?; - self.inner.sync_all() + sync_committed(&self.inner) } /// Flush everything, padding the final partial sector with zeros. @@ -649,7 +696,7 @@ impl SectorAlignedWriter { /// Flush, pad, and push everything to the medium before "complete" is said. pub fn sync_all(&mut self) -> io::Result<()> { self.flush_padded()?; - self.inner.sync_all() + sync_committed(&self.inner) } /// Flush everything, padding the final partial sector with zeros. @@ -788,7 +835,16 @@ pub fn enumerate_devices() -> Vec { { windows::enumerate_devices() } - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + #[cfg(target_os = "solaris")] + { + solaris::enumerate_devices() + } + #[cfg(not(any( + target_os = "macos", + target_os = "linux", + target_os = "windows", + target_os = "solaris" + )))] { Vec::new() } @@ -1455,3 +1511,21 @@ mod tests { ); } } + +#[cfg(test)] +mod sync_tests { + use super::sync_committed; + use std::io::Write; + + /// A regular file must still be flushed for real -- the ENOTTY tolerance is only meant + /// to cover raw devices, and must never quietly skip a sync that would have worked. + #[test] + fn a_regular_file_syncs_normally() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("f"); + let mut f = std::fs::File::create(&path).expect("create"); + f.write_all(b"payload").expect("write"); + sync_committed(&f).expect("a regular file must sync"); + assert_eq!(std::fs::read(&path).expect("read"), b"payload"); + } +} diff --git a/src/os/solaris.rs b/src/os/solaris.rs new file mode 100644 index 00000000..49aaf6a5 --- /dev/null +++ b/src/os/solaris.rs @@ -0,0 +1,340 @@ +//! Solaris device enumeration and raw-device access. +//! +//! Solaris exposes disks twice: `/dev/dsk/...` is buffered, `/dev/rdsk/...` is the raw +//! character device. Only the raw node gives unbuffered access, and it demands that every +//! read and write be a whole multiple of the sector size -- an unaligned one fails EINVAL +//! rather than returning a short count, so `SectorAlignedWriter` is not optional here. +//! +//! **Enumeration deliberately opens nothing.** A wedged driver blocks in `open(2)` and +//! ignores `O_NONBLOCK`: measured on a Sun Blade 2500 running Solaris 9, where a USB stick +//! behind `scsa2usb` left processes unkillable in the kernel. Solaris' own tools have the +//! same problem -- `format(1M)` and `rmformat(1)` both hang on that disk -- so listing by +//! opening is not merely slow, it is a hang waiting for one bad device. Sizes therefore come +//! from the kstats the driver publishes, which is what `iostat -E` reads and the only source +//! that keeps working. See docs/solaris-raw-devices.md. +//! +//! This module uses `libc` and `libkstat` alone: `rb-cli-sol9` drops `nix`, which `linux.rs` +//! and `macos.rs` both rely on, so anything reached from here is a raw call. + +#![cfg_attr(not(target_os = "solaris"), allow(dead_code))] + +use crate::device::{DiskDevice, MountedPartition}; +use std::ffi::CString; +use std::fs; +use std::os::raw::{c_char, c_int, c_void}; +use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; + +/// From : DKIOC is (0x04 << 8), and DKIOCREMOVABLE is DKIOC|16. +const DKIOCREMOVABLE: c_int = (0x04 << 8) | 16; + +/// : 30 chars plus NUL. +const KSTAT_STRLEN: usize = 31; +const KSTAT_DATA_CHAR: u8 = 0; +const KSTAT_DATA_UINT64: u8 = 4; + +/// kstat_named_t. The value is a union whose largest arm is `char c[16]`, and +/// it follows a 31-byte name plus a type byte -- so the union lands 8-aligned at offset 32 +/// and the whole struct is 48 bytes. Only this layout is needed: `kstat_t` stays opaque, +/// because `kstat_data_lookup` hands back a pointer into a buffer the library owns. +#[repr(C)] +struct KstatNamed { + name: [c_char; KSTAT_STRLEN], + data_type: u8, + value: [u8; 16], +} + +#[cfg_attr(target_os = "solaris", link(name = "kstat"))] +extern "C" { + fn kstat_open() -> *mut c_void; + fn kstat_close(kc: *mut c_void) -> c_int; + fn kstat_lookup( + kc: *mut c_void, + module: *const c_char, + inst: c_int, + name: *const c_char, + ) -> *mut c_void; + fn kstat_read(kc: *mut c_void, ksp: *mut c_void, buf: *mut c_void) -> c_int; + fn kstat_data_lookup(ksp: *mut c_void, name: *const c_char) -> *mut c_void; +} + +/// An open kstat chain. Solaris publishes one `err` set per disk instance, carrying +/// the size and the inquiry strings without anyone having to touch the device. +struct Kstat(*mut c_void); + +impl Kstat { + fn open() -> Option { + // SAFETY: no arguments, and a null return is the documented failure. + let kc = unsafe { kstat_open() }; + if kc.is_null() { + return None; + } + Some(Kstat(kc)) + } + + /// Read one named statistic out of `err:`. + fn named(&self, driver: &str, instance: i32, field: &str) -> Option { + let module = CString::new(format!("{driver}err")).ok()?; + let field = CString::new(field).ok()?; + // SAFETY: the chain is open, and a null name matches any kstat in the module. + unsafe { + let ksp = kstat_lookup( + self.0, + module.as_ptr(), + instance as c_int, + std::ptr::null::(), + ); + if ksp.is_null() || kstat_read(self.0, ksp, std::ptr::null_mut()) == -1 { + return None; + } + let p = kstat_data_lookup(ksp, field.as_ptr()) as *const KstatNamed; + if p.is_null() { + return None; + } + let n = &*p; + match n.data_type { + KSTAT_DATA_UINT64 => Some(Named::U64(u64::from_ne_bytes( + n.value[..8].try_into().ok()?, + ))), + // A char value is a fixed 16-byte field, NUL-padded rather than terminated. + KSTAT_DATA_CHAR => { + let end = n + .value + .iter() + .position(|&b| b == 0) + .unwrap_or(n.value.len()); + Some(Named::Str( + String::from_utf8_lossy(&n.value[..end]).trim().to_string(), + )) + } + _ => None, + } + } + } + + fn size(&self, driver: &str, instance: i32) -> Option { + match self.named(driver, instance, "Size")? { + Named::U64(v) => Some(v), + Named::Str(_) => None, + } + } + + fn text(&self, driver: &str, instance: i32, field: &str) -> String { + match self.named(driver, instance, field) { + Some(Named::Str(s)) => s, + _ => String::new(), + } + } +} + +impl Drop for Kstat { + fn drop(&mut self) { + // SAFETY: the pointer came from kstat_open and is closed exactly once. + unsafe { kstat_close(self.0) }; + } +} + +enum Named { + U64(u64), + Str(String), +} + +/// The whole-disk node for a disk, which is spelled differently per architecture: SPARC has +/// only SMI slices and calls slice 2 the whole disk, while x86 wraps those in an fdisk table +/// and calls the whole disk `p0`. Both spellings are probed because the Oracle documentation +/// shows `p0` throughout and someone reading it would otherwise "fix" this to match. +fn whole_disk_node(disk: &str) -> Option { + for suffix in ["p0", "s2"] { + let p = PathBuf::from("/dev/rdsk").join(format!("{disk}{suffix}")); + if p.exists() { + return Some(p); + } + } + None +} + +/// Split `c0t0d0s2` or `c1t0d0p0` into the disk name and its slice or partition suffix. +fn split_node(name: &str) -> Option<(&str, &str)> { + let idx = name.rfind(['s', 'p'])?; + let (disk, suffix) = name.split_at(idx); + if disk.is_empty() || suffix.len() < 2 || !suffix[1..].bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some((disk, suffix)) +} + +/// Map each `/dev/rdsk` disk to its driver and instance, which is what a kstat is keyed on. +/// The node is a symlink into /devices; `/etc/path_to_inst` names the instance for that +/// physical path. Both are reads -- no device is opened. +fn instances() -> Vec<(String, String, i32)> { + let table = fs::read_to_string("/etc/path_to_inst").unwrap_or_default(); + let mut by_path = Vec::new(); + for line in table.lines() { + // "/pci@1d,700000/scsi@4/sd@0,0" 2 "sd" + let mut parts = line.split('"'); + let path = match parts.nth(1) { + Some(p) => p, + None => continue, + }; + let rest = parts.next().unwrap_or("").trim(); + let driver = parts.next().unwrap_or(""); + if let Ok(inst) = rest.parse::() { + by_path.push((path.to_string(), driver.to_string(), inst)); + } + } + + let entries = match fs::read_dir("/dev/rdsk") { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + let mut out: Vec<(String, String, i32)> = Vec::new(); + for entry in entries.flatten() { + let name = match entry.file_name().into_string() { + Ok(n) => n, + Err(_) => continue, + }; + let disk = match split_node(&name) { + Some((d, sfx)) if sfx == "s2" || sfx == "p0" => d.to_string(), + _ => continue, + }; + if out.iter().any(|(d, _, _)| *d == disk) { + continue; + } + // The link points at /devices/:; the instance table keys on . + let link = match fs::read_link(entry.path()) { + Ok(l) => l, + Err(_) => continue, + }; + let link = link.to_string_lossy(); + let phys = match link.split("/devices").nth(1) { + Some(p) => p.split(':').next().unwrap_or("").to_string(), + None => continue, + }; + if let Some((_, driver, inst)) = by_path.iter().find(|(p, _, _)| *p == phys) { + out.push((disk, driver.clone(), *inst)); + } + } + out.sort(); + out +} + +/// Mount points keyed by their device name, read from the live mount table. +fn mounts() -> Vec<(String, PathBuf, String)> { + let table = fs::read_to_string("/etc/mnttab").unwrap_or_default(); + let mut out = Vec::new(); + for line in table.lines() { + let mut f = line.split_whitespace(); + let (special, mount_point, fstype) = match (f.next(), f.next(), f.next()) { + (Some(a), Some(b), Some(c)) => (a, b, c), + _ => continue, + }; + if !special.starts_with("/dev/dsk/") { + continue; + } + out.push(( + special.trim_start_matches("/dev/dsk/").to_string(), + PathBuf::from(mount_point), + fstype.to_string(), + )); + } + out +} + +/// Whether the media is removable. Only called for disks whose kstat reports a real size, so +/// the device is known to be answering before anything opens it. +fn is_removable(path: &Path) -> bool { + let file = match fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + { + Ok(f) => f, + Err(_) => return false, + }; + let mut removable: c_int = 0; + // SAFETY: the fd is open for the call and the driver writes a single int. + let rc = unsafe { libc::ioctl(file.as_raw_fd(), DKIOCREMOVABLE as _, &mut removable) }; + rc == 0 && removable != 0 +} + +/// Enumerate disks from the kstats their drivers publish, never opening a device to do it. +pub fn enumerate_devices() -> Vec { + let kstat = match Kstat::open() { + Some(k) => k, + None => return Vec::new(), + }; + let mounted = mounts(); + + let mut devices = Vec::new(); + for (disk, driver, instance) in instances() { + // A zero size means the device is not answering -- an empty drive, or one whose + // driver has wedged. Opening it to find out is exactly what must not happen. + let size_bytes = match kstat.size(&driver, instance) { + Some(s) if s > 0 => s, + _ => continue, + }; + let path = match whole_disk_node(&disk) { + Some(p) => p, + None => continue, + }; + + let vendor = kstat.text(&driver, instance, "Vendor"); + let product = kstat.text(&driver, instance, "Product"); + let media_name = format!("{vendor} {product}").trim().to_string(); + + let partitions: Vec = mounted + .iter() + .filter(|(special, _, _)| split_node(special).map(|(d, _)| d) == Some(&disk[..])) + .map(|(special, mount_point, fstype)| MountedPartition { + name: special.clone(), + mount_point: mount_point.clone(), + filesystem: fstype.clone(), + total_space: 0, + available_space: 0, + }) + .collect(); + + // A disk carrying the root filesystem is the system disk; writing it is refused. + let is_system = partitions.iter().any(|p| p.mount_point == Path::new("/")); + let removable = is_removable(&path); + + devices.push(DiskDevice { + name: disk, + path, + size_bytes, + is_removable: removable, + is_read_only: false, + is_system, + bus_protocol: driver, + media_name, + partitions, + }); + } + devices +} + +#[cfg(test)] +mod tests { + use super::split_node; + + #[test] + fn a_sparc_slice_node_splits_into_disk_and_slice() { + assert_eq!(split_node("c0t0d0s2"), Some(("c0t0d0", "s2"))); + assert_eq!(split_node("c1t3d0s0"), Some(("c1t3d0", "s0"))); + } + + /// x86 wraps the slices in an fdisk table and calls the whole disk p0. + #[test] + fn an_x86_fdisk_node_splits_the_same_way() { + assert_eq!(split_node("c3t0d0p0"), Some(("c3t0d0", "p0"))); + assert_eq!(split_node("c3t0d0p1"), Some(("c3t0d0", "p1"))); + } + + #[test] + fn a_name_that_is_not_a_node_is_rejected() { + assert_eq!(split_node("c0t0d0"), None); + assert_eq!(split_node(""), None); + assert_eq!(split_node("c0t0d0sx"), None); + } +}