diff --git a/.github/workflows/ci.yml b/.github/workflows/erofs-utils-integration.yml similarity index 52% rename from .github/workflows/ci.yml rename to .github/workflows/erofs-utils-integration.yml index 4f18049..7d7c8eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/erofs-utils-integration.yml @@ -1,4 +1,22 @@ -name: CI +# Inherited, and not yet folded into the limen pipeline. +# +# Everything here exists for one reason: the tests that read a real image shell +# out to mkfs.erofs, and erofs-utils is a C project distributed as source — it +# has no release binary aqua could pin, so `just test` under the hermetic PATH +# cannot reach it and every image-backed test skips itself. This workflow builds +# erofs-utils (patched) from source and runs the suite against it, on linux, +# macos, and — via a MinGW cross-compile — windows, plus the fuzz targets. +# +# Retiring it means giving the pinned toolchain an mkfs.erofs, after which these +# jobs become `just` recipes like any other and this file goes away. Until then +# ci.yaml ("ci") is the authority on everything that does NOT need an image, and +# this workflow covers only what it cannot. +# +# Deliberately absent: a lint job. ci.yaml runs `just lint` — the repo's pinned +# golangci-lint (aqua.yaml), its .golangci.yml, once per supported GOOS. The job +# that used to live here ran an action-supplied golangci-lint v2.1 against the +# same code, so the two could disagree about the same tree. +name: erofs-utils integration on: push: @@ -10,22 +28,9 @@ permissions: contents: read env: - EROFS_UTILS_VERSION: v1.9.1 + EROFS_UTILS_VERSION: v1.9.3 jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - with: - go-version: "1.25" - cache: false - - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 - with: - version: v2.1 - build-and-test: name: Build & Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -59,7 +64,11 @@ jobs: patch -p1 < "$p" done ./autogen.sh - ./configure --enable-lz4 + # configure caps the block size at the BUILD host's page size + # (bumped to 16K only when the build CPU is aarch64), so the + # same source yields a different mkfs per runner. Pin it: the + # 16384 leg of TestReadReferenceImage skips itself otherwise. + MAX_BLOCK_SIZE=16384 ./configure --enable-lz4 make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" sudo make install mkfs.erofs -V @@ -93,22 +102,78 @@ jobs: patch -p1 < "$p" done ./autogen.sh - ./configure --enable-lz4 + # configure caps the block size at the BUILD host's page size + # (bumped to 16K only when the build CPU is aarch64), so the + # same source yields a different mkfs per runner. Pin it: the + # 16384 leg of TestReadReferenceImage skips itself otherwise. + MAX_BLOCK_SIZE=16384 ./configure --enable-lz4 make -j"$(nproc)" sudo make install mkfs.erofs -V + # The generated corpus is what makes fuzzing cumulative: each run + # starts from every interesting input earlier runs discovered rather + # than from the seeds. Key on the fuzz test sources so a changed + # target restarts its own corpus; restore-keys keep the rest. + - id: fuzzdir + run: echo "dir=$(go env GOCACHE)/fuzz" >> "$GITHUB_OUTPUT" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.fuzzdir.outputs.dir }} + key: fuzz-corpus-${{ runner.os }}-${{ hashFiles('**/*_fuzz_test.go') }} + restore-keys: | + fuzz-corpus-${{ runner.os }}- + - name: Fuzz run: | fuzz_time=10s - # Build the test binary once to avoid repeated compilation per target. - go test -c -o fuzz.test . - cache_dir=$(go env GOCACHE)/fuzz - for target in $(./fuzz.test -test.list 'Fuzz.*' 2>/dev/null | grep '^Fuzz'); do + # Each target is driven by `go test -fuzz` itself, not a + # prebuilt binary: only the go tool's fuzz build compiles in the + # coverage counters, and without them the engine mutates blind + # ("not built with coverage instrumentation ... may be + # inefficient" — it was random byte-flipping, not fuzzing). The + # per-target rebuild is a cached second or two; the first run + # below warms it. + # + # A real fuzz failure always writes the failing input under + # testdata/fuzz//. The coordinator can also report + # "context deadline exceeded" when a worker is mid-iteration as + # fuzztime expires — that is a shutdown hiccup, not a finding, + # and Wide targets (200-entry ReadDir per iteration) hit it + # most. So the verdict comes from the crasher, not the exit + # code: exit 1 without a new testdata file is retried once + # (a second hiccup in a row is treated as real). + targets=$(go test -list 'Fuzz.*' . 2>/dev/null | grep '^Fuzz') + echo "targets: $(echo "$targets" | wc -w)" + fail=0 + for target in $targets; do echo "::group::$target" - ./fuzz.test -test.fuzz="^${target}\$" -test.fuzztime=$fuzz_time -test.timeout=180s -test.fuzzcachedir="$cache_dir" && echo "PASS: $target" || exit 1 + before=$(find "testdata/fuzz/$target" -type f 2>/dev/null | wc -l) + ok=0 + for attempt in 1 2; do + if go test -fuzz="^${target}\$" -run='^$' -fuzztime=$fuzz_time -timeout=180s . ; then + ok=1; break + fi + after=$(find "testdata/fuzz/$target" -type f 2>/dev/null | wc -l) + if [ "$after" -gt "$before" ]; then + echo "::error::$target: new crasher written to testdata/fuzz/$target" + break + fi + echo "$target: exit without a crasher (attempt $attempt) — coordinator shutdown hiccup, retrying" + done + if [ "$ok" = 1 ]; then echo "PASS: $target"; else fail=1; fi echo "::endgroup::" done + exit $fail + + # Surface crashers as artifacts: the log names the target, but the + # input itself is what reproduces the bug locally. + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: failure() + with: + name: fuzz-crashers + path: testdata/fuzz/ + if-no-files-found: ignore cross-compile-mkfs-windows: name: Cross-compile mkfs.erofs for Windows @@ -153,7 +218,15 @@ jobs: done ./autogen.sh - PKG_CONFIG_PATH=/usr/${MINGW_HOST}/lib/pkgconfig \ + # PKG_CONFIG_LIBDIR (not _PATH): _PATH prepends to the host's + # search dirs, so host .pc files leak into the cross build — + # v1.9.3's libxml2 auto-probe found the runner's libxml-2.0.pc + # and put -lxml2 on a link line no mingw library can satisfy. + # _LIBDIR replaces the search path outright: only the mingw + # sysroot (where the cross-compiled lz4 installs its .pc) is + # visible, and every other auto-probe fails closed. + PKG_CONFIG_LIBDIR=/usr/${MINGW_HOST}/lib/pkgconfig \ + MAX_BLOCK_SIZE=16384 \ ./configure \ --host=${MINGW_HOST} \ --disable-shared \ @@ -164,6 +237,7 @@ jobs: --without-selinux \ --without-uuid \ --without-openssl \ + --without-libxml2 \ --disable-fuse \ --disable-debug \ --disable-dependency-tracking \ @@ -171,8 +245,8 @@ jobs: LDFLAGS="-Wl,-Bstatic -static-libgcc -L/usr/${MINGW_HOST}/lib" \ liblz4_LIBS="/usr/${MINGW_HOST}/lib/liblz4.a" - make -j"$(nproc)" -C lib CPPFLAGS="-include posix_compat.h" - make -j"$(nproc)" -C mkfs CPPFLAGS="-include posix_compat.h" LIBS="-llz4" + make -j"$(nproc)" -C lib CPPFLAGS="-D_GNU_SOURCE -include posix_compat.h" + make -j"$(nproc)" -C mkfs CPPFLAGS="-D_GNU_SOURCE -include posix_compat.h" LIBS="-llz4" ${MINGW_HOST}-strip mkfs/mkfs.erofs.exe diff --git a/.github/workflows/mingw-compat-headers/posix_compat.h b/.github/workflows/mingw-compat-headers/posix_compat.h index 7cb5ed4..4106866 100644 --- a/.github/workflows/mingw-compat-headers/posix_compat.h +++ b/.github/workflows/mingw-compat-headers/posix_compat.h @@ -1,11 +1,33 @@ #ifndef _POSIX_COMPAT_H #define _POSIX_COMPAT_H +/* + * This header is force-included (-include) as the first thing in every + * erofs-utils translation unit built for Windows. It must never change + * the layout of any CRT type: struct stat in particular is defined by + * whichever CRT header runs first in a given TU (sys/stat.h, wchar.h and + * _mingw_stat64.h all reach it), and a typedef that differs between TUs + * gives them different field offsets for the same struct — st_size + * written at one offset and read at another comes back as zero. The + * 64-bit inode identity that mkfs needs and the CRT's 16-bit st_ino + * cannot hold is carried beside the struct instead (see __pc_lstat_id). + */ + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif + #include #include #include #include #include #include +#include +#include +#include /* * MinGW-w64 defines uid_t/gid_t as 'short' (signed 16-bit), which * truncates values >= 32768 and sign-extends them when promoted to @@ -55,7 +77,6 @@ static inline char* strndup(const char* s, size_t n) { if (result) { memcpy(result, s, len); result[len] = 0; } return result; } -static inline int lstat(const char* path, struct stat* buf) { return stat(path, buf); } static inline char* realpath(const char* path, char* resolved) { char* buf = resolved; if (!buf) buf = malloc(260); @@ -81,11 +102,262 @@ static inline ssize_t pwrite(int fd, const void* buf, size_t count, off_t offset return r; } static inline int fsync(int fd) { return _commit(fd); } -static inline ssize_t readlink(const char* path, char* buf, size_t bufsiz) { - fprintf(stderr, "WARNING: readlink() called but not supported on Windows (path=%s)\n", path); - errno = EINVAL; - return -1; + +/* + * Win32-native stat family. + * + * The CRT versions are unusable for mkfs: st_ino is never populated + * (so hardlink dedup collapses the whole tree onto one inode), lstat + * does not exist (MSVCRT stat follows symlinks), and readlink has no + * CRT equivalent at all. Everything below goes straight to Win32: + * st_ino/st_dev come from the NTFS file index + volume serial (the + * same identity Windows hardlinks share, so dedup is actually correct), + * symlinks are detected via the reparse tag, and readlink extracts the + * target from the reparse data. + * + * Mode bits are approximated (0755 dirs, 0644/0444 files, 0777 links): + * NTFS has no POSIX permissions to preserve. + */ + +/* Local mirror of REPARSE_DATA_BUFFER; mingw ships it in ddk/ntifs.h + * which cannot be included alongside user-mode windows.h. */ +typedef struct { + ULONG ReparseTag; + USHORT ReparseDataLength; + USHORT Reserved; + union { + struct { + USHORT SubstituteNameOffset; + USHORT SubstituteNameLength; + USHORT PrintNameOffset; + USHORT PrintNameLength; + ULONG Flags; + WCHAR PathBuffer[1]; + } SymbolicLinkReparseBuffer; + struct { + USHORT SubstituteNameOffset; + USHORT SubstituteNameLength; + USHORT PrintNameOffset; + USHORT PrintNameLength; + WCHAR PathBuffer[1]; + } MountPointReparseBuffer; + } u; +} __pc_reparse_data; + +static inline ssize_t __pc_readlink(const char* path, char* buf, size_t bufsiz) { + HANDLE h = CreateFileA(path, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + if (h == INVALID_HANDLE_VALUE) { errno = ENOENT; return -1; } + char raw[MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; + DWORD got = 0; + BOOL ok = DeviceIoControl(h, FSCTL_GET_REPARSE_POINT, NULL, 0, + raw, sizeof(raw), &got, NULL); + CloseHandle(h); + if (!ok) { errno = EINVAL; return -1; } + __pc_reparse_data* rd = (__pc_reparse_data*)raw; + if (rd->ReparseTag != IO_REPARSE_TAG_SYMLINK) { errno = EINVAL; return -1; } + const WCHAR* base = rd->u.SymbolicLinkReparseBuffer.PathBuffer; + const WCHAR* name; + int wlen; + if (rd->u.SymbolicLinkReparseBuffer.PrintNameLength > 0) { + name = base + rd->u.SymbolicLinkReparseBuffer.PrintNameOffset / sizeof(WCHAR); + wlen = rd->u.SymbolicLinkReparseBuffer.PrintNameLength / sizeof(WCHAR); + } else { + name = base + rd->u.SymbolicLinkReparseBuffer.SubstituteNameOffset / sizeof(WCHAR); + wlen = rd->u.SymbolicLinkReparseBuffer.SubstituteNameLength / sizeof(WCHAR); + if (wlen >= 4 && wcsncmp(name, L"\\??\\", 4) == 0) { name += 4; wlen -= 4; } + } + char tmp[4096]; + int n = WideCharToMultiByte(CP_UTF8, 0, name, wlen, tmp, sizeof(tmp), NULL, NULL); + if (n <= 0) { errno = EINVAL; return -1; } + for (int i = 0; i < n; i++) + if (tmp[i] == '\\') tmp[i] = '/'; + if ((size_t)n > bufsiz) n = (int)bufsiz; + memcpy(buf, tmp, n); + return n; +} + +static inline time_t __pc_ft2unix(const FILETIME* ft) { + unsigned long long v = + ((unsigned long long)ft->dwHighDateTime << 32) | ft->dwLowDateTime; + if (v < 116444736000000000ULL) return 0; + return (time_t)((v - 116444736000000000ULL) / 10000000ULL); +} + +/* + * The stat family below is deliberately split in two. + * + * mingw-w64 has several struct stat layouts (stat, _stat64, _stat64i32, + * ...) and `#define stat _stat64`-style redirects that fire depending on + * _FILE_OFFSET_BITS and on which CRT header happened to run first in a + * translation unit. Naming `struct stat` in a prototype inside this + * header pins the prototype to whichever layout was live *here*, while + * the caller may hold a different one — and then st_size written at one + * offset is read at another and comes back as zero (that was CI's + * "dir/b.txt = \"\"" on the mingw-w64 v11 / msvcrt toolchain; the v12+ + * UCRT toolchains agree with themselves and hid it). + * + * So the layout-independent core fills a POD of our own (__pc_finfo), + * and the stat/lstat/fstat entry points are MACROS that copy it into the + * caller's struct — expanded in the caller's TU, so `->st_size` there is + * the caller's field at the caller's offset, by construction. + */ +typedef struct { + unsigned long long ino; /* NTFS file index: what hard links share */ + unsigned int dev; /* volume serial */ + unsigned int mode; + unsigned int nlink; + long long size; + long long mtime, atime, ctime; +} __pc_finfo; + +static inline int __pc_finfo_from_handle(HANDLE h, __pc_finfo* fi, + int is_symlink, const char* linkpath) { + BY_HANDLE_FILE_INFORMATION bi; + if (!GetFileInformationByHandle(h, &bi)) { errno = EIO; return -1; } + memset(fi, 0, sizeof(*fi)); + fi->ino = ((unsigned long long)bi.nFileIndexHigh << 32) | bi.nFileIndexLow; + fi->dev = (unsigned int)bi.dwVolumeSerialNumber; + fi->nlink = bi.nNumberOfLinks ? bi.nNumberOfLinks : 1; + fi->mtime = __pc_ft2unix(&bi.ftLastWriteTime); + fi->atime = __pc_ft2unix(&bi.ftLastAccessTime); + fi->ctime = __pc_ft2unix(&bi.ftCreationTime); + if (is_symlink) { + char tgt[4096]; + ssize_t n = __pc_readlink(linkpath, tgt, sizeof(tgt)); + fi->mode = S_IFLNK | 0777; + fi->size = n > 0 ? n : 0; + } else if (bi.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + fi->mode = S_IFDIR | 0755; + } else { + fi->mode = S_IFREG | + ((bi.dwFileAttributes & FILE_ATTRIBUTE_READONLY) ? 0444 : 0644); + fi->size = ((long long)bi.nFileSizeHigh << 32) | bi.nFileSizeLow; + } + return 0; +} + +static inline int __pc_finfo_path(const char* path, __pc_finfo* fi, int follow) { + DWORD attrs = GetFileAttributesA(path); + if (attrs == INVALID_FILE_ATTRIBUTES) { errno = ENOENT; return -1; } + int is_symlink = 0; + DWORD flags = FILE_FLAG_BACKUP_SEMANTICS; + if (!follow && (attrs & FILE_ATTRIBUTE_REPARSE_POINT)) { + /* Only a symlink-tagged reparse point is a symlink; any other kind + * (mount point, OneDrive placeholder, ...) is statted through. */ + char probe[8]; + if (__pc_readlink(path, probe, sizeof(probe)) >= 0 || errno != EINVAL) { + is_symlink = 1; + flags |= FILE_FLAG_OPEN_REPARSE_POINT; + } + } + HANDLE h = CreateFileA(path, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, flags, NULL); + if (h == INVALID_HANDLE_VALUE) { errno = ENOENT; return -1; } + int r = __pc_finfo_from_handle(h, fi, is_symlink, path); + CloseHandle(h); + return r; +} + +static inline int __pc_finfo_fd(int fd, __pc_finfo* fi) { + HANDLE h = (HANDLE)_get_osfhandle(fd); + if (h == INVALID_HANDLE_VALUE) { errno = EBADF; return -1; } + if (GetFileType(h) != FILE_TYPE_DISK) { + memset(fi, 0, sizeof(*fi)); + fi->mode = S_IFCHR | 0644; + fi->nlink = 1; + return 0; + } + return __pc_finfo_from_handle(h, fi, 0, NULL); +} + +/* Copy a __pc_finfo into the caller's struct stat, whatever its layout. + * st_ino gets the truncated index (CRT width); the full identity is + * available through lstat_id / __PC_STAT_ID below. */ +#define __PC_FILL_STAT(st, fi) do { \ + memset((st), 0, sizeof(*(st))); \ + (st)->st_dev = (fi).dev; \ + (st)->st_ino = (fi).ino; \ + (st)->st_mode = (fi).mode; \ + (st)->st_nlink = (fi).nlink; \ + (st)->st_size = (fi).size; \ + (st)->st_mtime = (fi).mtime; \ + (st)->st_atime = (fi).atime; \ + (st)->st_ctime = (fi).ctime; \ + } while (0) + +/* Statement-expression form so the entry points can be used as + * expressions (`if (lstat(p, &st))`) while still expanding at the call + * site. GNU C is a hard requirement of erofs-utils anyway. */ +#define __pc_stat_impl(path, st, follow) ({ \ + __pc_finfo __fi; int __r = __pc_finfo_path((path), &__fi, (follow)); \ + if (!__r) __PC_FILL_STAT((st), __fi); \ + __r; }) +#define __pc_stat(path, st) __pc_stat_impl((path), (st), 1) +#define __pc_lstat(path, st) __pc_stat_impl((path), (st), 0) +/* + * lstat plus the file's 64-bit identity: the NTFS file index, which all + * hard links to a file share and which is unique per volume. Together + * with st_dev (volume serial) this is what mkfs keys hardlink detection + * on; the CRT's 16-bit st_ino cannot carry it, so it travels separately. + * The patched erofs_iget_from_local calls this in place of lstat. + */ +#define __pc_lstat_id(path, st, idp) ({ \ + __pc_finfo __fi; int __r = __pc_finfo_path((path), &__fi, 0); \ + if (!__r) { __PC_FILL_STAT((st), __fi); *(idp) = __fi.ino; } \ + __r; }) + +/* + * Wiring the entry points in without disturbing the CRT. + * + * Under _FILE_OFFSET_BITS=64, mingw-w64 (v11, msvcrt) does + * #define stat _stat64 + * #define fstat _fstat64 + * so that BOTH the function name and the type name `struct stat` resolve + * to the 64-bit-size variant, in every TU alike. A function-like + * `#define stat(path, st)` of our own would REPLACE that object-like + * macro: from then on `struct stat st;` in a caller stops being rewritten + * (a 48-byte struct with a 32-bit st_size), while anything parsed before + * the replacement — the CRT prototypes, and this header's own code — saw + * `struct _stat64` (56 bytes, 64-bit st_size). Two layouts for one name + * is exactly the corruption we are fixing. + * + * So the redirect is left alone and the CRT's *target names* are what we + * take over: _stat64 / _fstat64 (and the plain names for good measure). + * The CRT prototypes for _stat64/_fstat64 are declared functions; a + * function-like macro of the same name is legal C and wins at every call + * site, while `struct _stat64` — the type the CRT macro expands + * `struct stat` to — is untouched (types are not macro-expanded via + * function-like macros: `struct _stat64 st;` has no `(` after the name). + */ +static inline int __pc_fstat(int fd, void* st_void) { + __pc_finfo fi; + int r = __pc_finfo_fd(fd, &fi); + if (r) return r; + struct stat* st = (struct stat*)st_void; /* = struct _stat64 here and in every TU */ + __PC_FILL_STAT(st, fi); + return 0; } +#define _stat64(path, st) __pc_stat((path), (st)) +/* fstat: object-like on purpose. erofs_vfops has a member named fstat; + * after the CRT's rewrite it is `_fstat64` in both its declaration and + * every `vf->ops->fstat(...)` use. An object-like macro renames both + * consistently (to __pc_fstat — a harmless member name); a function-like + * one would rename only the uses and break the build. */ +#define _fstat64 __pc_fstat +#ifndef stat +#define stat(path, st) __pc_stat((path), (st)) +#endif +#ifndef fstat +#define fstat __pc_fstat +#endif +#define lstat(path, st) __pc_lstat((path), (st)) +#define readlink __pc_readlink + /* * Linux new_encode_dev/new_decode_dev compatible device number encoding. * erofs-utils uses this scheme in erofs_new_encode_dev() / erofs_new_decode_dev(). diff --git a/.github/workflows/patches/erofs-utils/001-windows.patch b/.github/workflows/patches/erofs-utils/001-windows.patch index 56c5148..189750d 100644 --- a/.github/workflows/patches/erofs-utils/001-windows.patch +++ b/.github/workflows/patches/erofs-utils/001-windows.patch @@ -1,104 +1,176 @@ diff --git a/include/erofs/err.h b/include/erofs/err.h -index 59c8c9c..b493676 100644 +index 28de701..104be85 100644 --- a/include/erofs/err.h +++ b/include/erofs/err.h -@@ -13,6 +13,7 @@ extern "C" - #endif - - #include +@@ -13,6 +13,7 @@ +-#endif +- +-#include +-#include +-#include +- ++#endif ++ ++#include +#include - #include - #include - -@@ -30,7 +31,7 @@ static inline const char *erofs_strerror(int err) - - #define MAX_ERRNO (4095) - #define IS_ERR_VALUE(x) \ ++#include ++#include ++ +@@ -30,7 +31,7 @@ +- +-#define MAX_ERRNO (4095) +-#define IS_ERR_VALUE(x) \ - ((unsigned long)(void *)(x) >= (unsigned long)-MAX_ERRNO) +- +-static inline void *ERR_PTR(long error) +-{ ++ ++#define MAX_ERRNO (4095) ++#define IS_ERR_VALUE(x) \ + ((uintptr_t)(void *)(x) >= (uintptr_t)-MAX_ERRNO) - - static inline void *ERR_PTR(long error) - { -@@ -39,12 +40,12 @@ static inline void *ERR_PTR(long error) - - static inline int IS_ERR(const void *ptr) - { ++ ++static inline void *ERR_PTR(long error) ++{ +@@ -39,12 +40,12 @@ +- +-static inline int IS_ERR(const void *ptr) +-{ - return IS_ERR_VALUE((unsigned long)ptr); -+ return IS_ERR_VALUE((uintptr_t)ptr); - } - - static inline long PTR_ERR(const void *ptr) - { +-} +- +-static inline long PTR_ERR(const void *ptr) +-{ - return (long) ptr; +-} +- +-static inline void * ERR_CAST(const void *ptr) ++ ++static inline int IS_ERR(const void *ptr) ++{ ++ return IS_ERR_VALUE((uintptr_t)ptr); ++} ++ ++static inline long PTR_ERR(const void *ptr) ++{ + return (intptr_t) ptr; - } - - static inline void * ERR_CAST(const void *ptr) ++} ++ ++static inline void * ERR_CAST(const void *ptr) +diff --git a/include/erofs/inode.h b/include/erofs/inode.h +index bf089e8..2de43d5 100644 +--- a/include/erofs/inode.h ++++ b/include/erofs/inode.h +@@ -32,7 +32,11 @@ +-void erofs_inode_manager_init(void); +-void erofs_insert_ihash(struct erofs_inode *inode); +-void erofs_remove_ihash(struct erofs_inode *inode); +-struct erofs_inode *erofs_iget(dev_t dev, ino_t ino); +-unsigned int erofs_iput(struct erofs_inode *inode); +-erofs_nid_t erofs_lookupnid(struct erofs_inode *inode); +-int erofs_iflush(struct erofs_inode *inode); ++void erofs_inode_manager_init(void); ++void erofs_insert_ihash(struct erofs_inode *inode); ++void erofs_remove_ihash(struct erofs_inode *inode); ++#ifdef _WIN32 ++struct erofs_inode *erofs_iget(dev_t dev, unsigned long long ino); ++#else ++struct erofs_inode *erofs_iget(dev_t dev, ino_t ino); ++#endif ++unsigned int erofs_iput(struct erofs_inode *inode); ++erofs_nid_t erofs_lookupnid(struct erofs_inode *inode); ++int erofs_iflush(struct erofs_inode *inode); diff --git a/include/erofs/internal.h b/include/erofs/internal.h -index e741f1c..8322073 100644 +index 2cc9cc8..e1bbdc8 100644 --- a/include/erofs/internal.h +++ b/include/erofs/internal.h -@@ -12,6 +12,7 @@ extern "C" - { - #endif - +@@ -12,6 +12,7 @@ +-{ +-#endif +- +-#include "list.h" +-#include "err.h" +- ++{ ++#endif ++ +#include - #include "list.h" - #include "err.h" - -@@ -344,7 +345,7 @@ static inline unsigned int erofs_inode_datalayout(unsigned int ifmt) - - static inline struct erofs_inode *erofs_parent_inode(struct erofs_inode *inode) - { ++#include "list.h" ++#include "err.h" ++ +@@ -354,7 +355,7 @@ +- +-static inline struct erofs_inode *erofs_parent_inode(struct erofs_inode *inode) +-{ - return (struct erofs_inode *)((unsigned long)inode->i_parent & ~1UL); +-} +- +-#define IS_ROOT(x) ((x) == erofs_parent_inode(x)) ++ ++static inline struct erofs_inode *erofs_parent_inode(struct erofs_inode *inode) ++{ + return (struct erofs_inode *)((uintptr_t)inode->i_parent & ~(uintptr_t)1); - } - - #define IS_ROOT(x) ((x) == erofs_parent_inode(x)) ++} ++ ++#define IS_ROOT(x) ((x) == erofs_parent_inode(x)) diff --git a/lib/compress_hints.c b/lib/compress_hints.c -index 322ec97..3069b19 100644 +index a4ff003..6dc3d07 100644 --- a/lib/compress_hints.c +++ b/lib/compress_hints.c @@ -1,3 +1,6 @@ +-// SPDX-License-Identifier: GPL-2.0+ OR MIT +-/* +- * Copyright (C), 2008-2021, OPPO Mobile Comm Corp., Ltd. +#ifndef REG_NOMATCH +#define REG_NOMATCH 1 +#endif - // SPDX-License-Identifier: GPL-2.0+ OR Apache-2.0 - /* - * Copyright (C), 2008-2021, OPPO Mobile Comm Corp., Ltd. ++// SPDX-License-Identifier: GPL-2.0+ OR MIT ++/* ++ * Copyright (C), 2008-2021, OPPO Mobile Comm Corp., Ltd. diff --git a/lib/diskbuf.c b/lib/diskbuf.c -index 0bf42da..73e919d 100644 +index b32a39a..a1ce1d8 100644 --- a/lib/diskbuf.c +++ b/lib/diskbuf.c -@@ -117,7 +117,11 @@ setupone: - erofs_atomic_set(&strm->count, 1); - if (fstat(strm->fd, &st)) - return -errno; +@@ -117,7 +117,11 @@ +- erofs_atomic_set(&strm->count, 1); +- if (fstat(strm->fd, &st)) +- return -errno; - strm->alignsize = max_t(u32, st.st_blksize, getpagesize()); +- } +- return 0; +-} ++ erofs_atomic_set(&strm->count, 1); ++ if (fstat(strm->fd, &st)) ++ return -errno; +#ifdef _WIN32 + strm->alignsize = (u32)getpagesize(); +#else + strm->alignsize = max_t(u32, st.st_blksize, getpagesize()); +#endif - } - return 0; - } ++ } ++ return 0; ++} diff --git a/lib/exclude.c b/lib/exclude.c -index 5f6107b..4e8a344 100644 +index 6beb46b..38086ea 100644 --- a/lib/exclude.c +++ b/lib/exclude.c @@ -1,3 +1,6 @@ +-// SPDX-License-Identifier: GPL-2.0+ OR MIT +-/* +- * Created by Li Guifu +#ifndef REG_NOMATCH +#define REG_NOMATCH 1 +#endif - // SPDX-License-Identifier: GPL-2.0+ OR Apache-2.0 - /* - * Created by Li Guifu ++// SPDX-License-Identifier: GPL-2.0+ OR MIT ++/* ++ * Created by Li Guifu diff --git a/lib/inode.c b/lib/inode.c -index 4a214f9..d677ed8 100644 +index 0547b60..dc91762 100644 --- a/lib/inode.c +++ b/lib/inode.c @@ -1,3 +1,24 @@ +-// SPDX-License-Identifier: GPL-2.0+ OR MIT +-/* +- * Copyright (C) 2018-2019 HUAWEI, Inc. +#ifdef _WIN32 +#ifndef S_IFLNK +#define S_IFLNK 0xA000 @@ -120,55 +192,164 @@ index 4a214f9..d677ed8 100644 +#define _POSIX_OPEN_MAX 20 +#endif +#endif /* _WIN32 */ - // SPDX-License-Identifier: GPL-2.0+ OR Apache-2.0 - /* - * Copyright (C) 2018-2019 HUAWEI, Inc. -@@ -6,6 +25,7 @@ - * with heavy changes by Gao Xiang - */ - #define _GNU_SOURCE ++// SPDX-License-Identifier: GPL-2.0+ OR MIT ++/* ++ * Copyright (C) 2018-2019 HUAWEI, Inc. +@@ -6,6 +27,7 @@ +- * with heavy changes by Gao Xiang +- */ +-#define _GNU_SOURCE +-#include +-#include +-#include ++ * with heavy changes by Gao Xiang ++ */ ++#define _GNU_SOURCE +#include - #include - #include - #include -@@ -2095,13 +2115,13 @@ static int erofs_mkfs_handle_inode(const struct erofs_mkfs_btctx *ctx, - - static bool erofs_inode_visited(struct erofs_inode *inode) - { ++#include ++#include ++#include +@@ -121,7 +143,11 @@ +-} +- +-/* get the inode from the (source) inode # */ +-struct erofs_inode *erofs_iget(dev_t dev, ino_t ino) +-{ +- u32 nr = (ino ^ dev) % ARRAY_SIZE(erofs_ihash); +- struct list_head *head = &erofs_ihash[nr]; ++} ++ ++/* get the inode from the (source) inode # */ ++#ifdef _WIN32 ++struct erofs_inode *erofs_iget(dev_t dev, unsigned long long ino) ++#else ++struct erofs_inode *erofs_iget(dev_t dev, ino_t ino) ++#endif ++{ ++ u32 nr = (ino ^ dev) % ARRAY_SIZE(erofs_ihash); ++ struct list_head *head = &erofs_ihash[nr]; +@@ -1406,8 +1432,16 @@ +- struct erofs_inode *inode; +- struct stat st; +- int ret; +- +- ret = lstat(path, &st); +- if (ret) +- return ERR_PTR(-errno); +- ++ struct erofs_inode *inode; ++ struct stat st; ++ int ret; ++#ifdef _WIN32 ++ /* The CRT's st_ino is 16 bits and MSVCRT never fills it; the NTFS ++ * file index (what hard links actually share) travels beside the ++ * struct instead. See posix_compat.h. */ ++ unsigned long long win_ino; ++ ++ ret = __pc_lstat_id(path, &st, &win_ino); ++#else ++ ret = lstat(path, &st); ++#endif ++ if (ret) ++ return ERR_PTR(-errno); ++ +@@ -1417,7 +1451,11 @@ +- * since hard-link directory isn't allowed. +- */ +- if (!S_ISDIR(st.st_mode) && !params->hard_dereference) { +- inode = erofs_iget(st.st_dev, st.st_ino); +- if (inode) +- return inode; +- } ++ * since hard-link directory isn't allowed. ++ */ ++ if (!S_ISDIR(st.st_mode) && !params->hard_dereference) { ++#ifdef _WIN32 ++ inode = erofs_iget(st.st_dev, win_ino); ++#else ++ inode = erofs_iget(st.st_dev, st.st_ino); ++#endif ++ if (inode) ++ return inode; ++ } +@@ -1432,6 +1470,13 @@ +- erofs_iput(inode); +- return ERR_PTR(ret); +- } +- inode->datasource = EROFS_INODE_DATA_SOURCE_LOCALPATH; +- return inode; +-} ++ erofs_iput(inode); ++ return ERR_PTR(ret); ++ } ++#ifdef _WIN32 ++ /* erofs_fill_inode keyed the ihash on the truncated st_ino; rekey on ++ * the real identity so later lookups (above) can find it. */ ++ erofs_remove_ihash(inode); ++ inode->i_ino[1] = win_ino; ++ erofs_insert_ihash(inode); ++#endif ++ inode->datasource = EROFS_INODE_DATA_SOURCE_LOCALPATH; ++ return inode; ++} +@@ -2126,13 +2171,13 @@ +- +-static bool erofs_inode_visited(struct erofs_inode *inode) +-{ - return (unsigned long)inode->i_parent & 1UL; -+ return (uintptr_t)inode->i_parent & (uintptr_t)1; - } - - static void erofs_mark_parent_inode(struct erofs_inode *inode, - struct erofs_inode *dir) - { +-} +- +-static void erofs_mark_parent_inode(struct erofs_inode *inode, +- struct erofs_inode *dir) +-{ - inode->i_parent = (void *)((unsigned long)dir | 1); +-} +- +-static int erofs_mkfs_dump_tree(const struct erofs_mkfs_btctx *ctx) ++ ++static bool erofs_inode_visited(struct erofs_inode *inode) ++{ ++ return (uintptr_t)inode->i_parent & (uintptr_t)1; ++} ++ ++static void erofs_mark_parent_inode(struct erofs_inode *inode, ++ struct erofs_inode *dir) ++{ + inode->i_parent = (void *)((uintptr_t)dir | (uintptr_t)1); - } - - static int erofs_mkfs_dump_tree(const struct erofs_mkfs_btctx *ctx) ++} ++ ++static int erofs_mkfs_dump_tree(const struct erofs_mkfs_btctx *ctx) diff --git a/lib/io.c b/lib/io.c -index 0c5eb2c..1a14523 100644 +index 3ba45cc..6756171 100644 --- a/lib/io.c +++ b/lib/io.c -@@ -359,7 +359,11 @@ repeat: - return -errno; - } - } +@@ -359,7 +359,11 @@ +- return -errno; +- } +- } - sbi->devblksz = st.st_blksize; +- break; +- default: +- erofs_err("bad file type (%s, %o).", dev, st.st_mode); ++ return -errno; ++ } ++ } +#ifdef _WIN32 + sbi->devblksz = 4096; +#else + sbi->devblksz = st.st_blksize; +#endif - break; - default: - erofs_err("bad file type (%s, %o).", dev, st.st_mode); ++ break; ++ default: ++ erofs_err("bad file type (%s, %o).", dev, st.st_mode); diff --git a/lib/rebuild.c b/lib/rebuild.c -index f89a17c..f04b456 100644 +index a5308dc..ef8faa4 100644 --- a/lib/rebuild.c +++ b/lib/rebuild.c @@ -1,3 +1,11 @@ +-// SPDX-License-Identifier: GPL-2.0+ OR MIT +-#define _GNU_SOURCE +-#include +#ifdef _WIN32 +#ifndef S_IFLNK +#define S_IFLNK 0xA000 @@ -177,14 +358,17 @@ index f89a17c..f04b456 100644 +#define S_IFSOCK 0xC000 +#endif +#endif /* _WIN32 */ - // SPDX-License-Identifier: GPL-2.0+ OR Apache-2.0 - #define _GNU_SOURCE - #include ++// SPDX-License-Identifier: GPL-2.0+ OR MIT ++#define _GNU_SOURCE ++#include diff --git a/lib/tar.c b/lib/tar.c -index 178f843..31df7e0 100644 +index cf60b02..fa8fee3 100644 --- a/lib/tar.c +++ b/lib/tar.c @@ -1,3 +1,11 @@ +-// SPDX-License-Identifier: GPL-2.0+ OR MIT +-#include +-#include +#ifdef _WIN32 +#ifndef S_IFLNK +#define S_IFLNK 0xA000 @@ -193,18 +377,21 @@ index 178f843..31df7e0 100644 +#define S_IFSOCK 0xC000 +#endif +#endif /* _WIN32 */ - // SPDX-License-Identifier: GPL-2.0+ OR Apache-2.0 - #include - #include ++// SPDX-License-Identifier: GPL-2.0+ OR MIT ++#include ++#include diff --git a/lib/xattr.c b/lib/xattr.c -index d8c7bff..f611d72 100644 +index af45075..7f3a472 100644 --- a/lib/xattr.c +++ b/lib/xattr.c @@ -1,3 +1,7 @@ +-// SPDX-License-Identifier: GPL-2.0+ OR MIT +-/* +- * Copyright (C) 2019 Li Guifu +#include +#ifdef _WIN32 +typedef unsigned int uint; +#endif - // SPDX-License-Identifier: GPL-2.0+ OR Apache-2.0 - /* - * Copyright (C) 2019 Li Guifu ++// SPDX-License-Identifier: GPL-2.0+ OR MIT ++/* ++ * Copyright (C) 2019 Li Guifu diff --git a/.github/workflows/patches/erofs-utils/002-windows-stdin-blocking.patch b/.github/workflows/patches/erofs-utils/002-windows-stdin-blocking.patch index ddf0a7e..d84f332 100644 --- a/.github/workflows/patches/erofs-utils/002-windows-stdin-blocking.patch +++ b/.github/workflows/patches/erofs-utils/002-windows-stdin-blocking.patch @@ -1,27 +1,39 @@ diff --git a/lib/tar.c b/lib/tar.c -index 72c12ed..8e9a1e5 100644 +index fa8fee3..637c686 100644 --- a/lib/tar.c +++ b/lib/tar.c -@@ -7,6 +7,10 @@ - #include - #include - #include +@@ -11,6 +11,10 @@ +-#include +-#include +-#include +-#include "erofs/print.h" +-#include "erofs/diskbuf.h" +-#include "erofs/inode.h" ++#include ++#include ++#include +#ifdef _WIN32 +#include +#include +#endif - #include "erofs/print.h" - #include "erofs/diskbuf.h" - #include "erofs/inode.h" -@@ -71,6 +75,11 @@ int erofs_iostream_open(struct erofs_iostream *ios, int fd, int decoder) - { - s64 fsz; - ++#include "erofs/print.h" ++#include "erofs/diskbuf.h" ++#include "erofs/inode.h" +@@ -89,6 +93,11 @@ +-{ +- s64 fsz; +- +- ios->feof = false; +- ios->tail = ios->head = 0; +- ios->decoder = decoder; ++{ ++ s64 fsz; ++ +#ifdef _WIN32 + /* On Windows, ensure stdin is in blocking binary mode */ + _setmode(fd, _O_BINARY); +#endif + - ios->feof = false; - ios->tail = ios->head = 0; - ios->decoder = decoder; ++ ios->feof = false; ++ ios->tail = ios->head = 0; ++ ios->decoder = decoder; diff --git a/.github/workflows/patches/erofs-utils/003-windows-pipe-lseek.patch b/.github/workflows/patches/erofs-utils/003-windows-pipe-lseek.patch index cef8720..92ea30f 100644 --- a/.github/workflows/patches/erofs-utils/003-windows-pipe-lseek.patch +++ b/.github/workflows/patches/erofs-utils/003-windows-pipe-lseek.patch @@ -1,18 +1,32 @@ diff --git a/lib/tar.c b/lib/tar.c +index 637c686..df4eba7 100644 --- a/lib/tar.c +++ b/lib/tar.c -@@ -12,6 +12,7 @@ - #ifdef _WIN32 - #include - #include +@@ -14,6 +14,7 @@ +-#ifdef _WIN32 +-#include +-#include +-#endif +-#include "erofs/print.h" +-#include "erofs/diskbuf.h" ++#ifdef _WIN32 ++#include ++#include +#include - #endif - #include "erofs/print.h" - #include "erofs/diskbuf.h" -@@ -88,6 +89,23 @@ - s64 fsz; - - #ifdef _WIN32 ++#endif ++#include "erofs/print.h" ++#include "erofs/diskbuf.h" +@@ -93,6 +94,23 @@ +-{ +- s64 fsz; +- +-#ifdef _WIN32 +- /* On Windows, ensure stdin is in blocking binary mode */ +- _setmode(fd, _O_BINARY); ++{ ++ s64 fsz; ++ ++#ifdef _WIN32 + /* On Windows, lseek() on pipes misleadingly "succeeds" - it returns + * the current pipe buffer size for SEEK_END and silently no-ops for + * SEEK_CUR/SEEK_SET. This causes erofs_iostream_lskip() to use @@ -30,13 +44,18 @@ diff --git a/lib/tar.c b/lib/tar.c +#endif + +#ifdef _WIN32 - /* On Windows, ensure stdin is in blocking binary mode */ - _setmode(fd, _O_BINARY); - #endif -@@ -135,6 +153,13 @@ - } else { - ios->vf.fd = fd; - fsz = lseek(fd, 0, SEEK_END); ++ /* On Windows, ensure stdin is in blocking binary mode */ ++ _setmode(fd, _O_BINARY); +@@ -141,6 +159,13 @@ +- } else { +- ios->vf.fd = fd; +- fsz = lseek(fd, 0, SEEK_END); +- if (fsz <= 0) { +- ios->feof = !fsz; +- ios->sz = 0; ++ } else { ++ ios->vf.fd = fd; ++ fsz = lseek(fd, 0, SEEK_END); +#ifdef _WIN32 + /* If this fd is a pipe, ignore the lseek result entirely. + * Pipes are not seekable; treat them the same as Unix does @@ -44,6 +63,6 @@ diff --git a/lib/tar.c b/lib/tar.c + if (is_pipe) + fsz = -1; +#endif - if (fsz <= 0) { - ios->feof = !fsz; - ios->sz = 0; ++ if (fsz <= 0) { ++ ios->feof = !fsz; ++ ios->sz = 0; diff --git a/.github/workflows/patches/erofs-utils/004-windows-gzran-zlib-guard.patch b/.github/workflows/patches/erofs-utils/004-windows-gzran-zlib-guard.patch deleted file mode 100644 index 755e8c2..0000000 --- a/.github/workflows/patches/erofs-utils/004-windows-gzran-zlib-guard.patch +++ /dev/null @@ -1,14 +0,0 @@ -diff --git a/lib/gzran.c b/lib/gzran.c ---- a/lib/gzran.c -+++ b/lib/gzran.c -@@ -5,8 +5,10 @@ - #include "erofs/list.h" - #include "erofs/err.h" - #include "liberofs_gzran.h" - #include -+#ifdef HAVE_ZLIB - #include -+#endif - - #ifdef HAVE_ZLIB - struct erofs_gzran_cutpoint { diff --git a/.github/workflows/patches/erofs-utils/005-windows-tar-uid-gid.patch b/.github/workflows/patches/erofs-utils/005-windows-tar-uid-gid.patch index a17e4bd..678e78e 100644 --- a/.github/workflows/patches/erofs-utils/005-windows-tar-uid-gid.patch +++ b/.github/workflows/patches/erofs-utils/005-windows-tar-uid-gid.patch @@ -1,56 +1,96 @@ ---- a/include/erofs/tar.h 2026-03-03 16:00:00.000000000 +0000 -+++ b/include/erofs/tar.h 2026-04-16 10:48:34.347497841 +0100 +diff --git a/include/erofs/tar.h b/include/erofs/tar.h +index a816633..731e1ba 100644 +--- a/include/erofs/tar.h ++++ b/include/erofs/tar.h @@ -14,6 +14,8 @@ - struct erofs_pax_header { - struct stat st; - struct list_head xattrs; +-struct erofs_pax_header { +- struct stat st; +- struct list_head xattrs; +- bool use_mtime; +- bool use_size; +- bool use_uid; ++struct erofs_pax_header { ++ struct stat st; ++ struct list_head xattrs; + u32 tar_uid; + u32 tar_gid; - bool use_mtime; - bool use_size; - bool use_uid; ---- a/lib/tar.c 2026-04-16 10:48:34.326257577 +0100 -+++ b/lib/tar.c 2026-04-16 10:48:34.355993947 +0100 -@@ -592,6 +592,7 @@ - goto out; - } - eh->st.st_uid = lln; ++ bool use_mtime; ++ bool use_size; ++ bool use_uid; +diff --git a/lib/tar.c b/lib/tar.c +index df4eba7..bee2507 100644 +--- a/lib/tar.c ++++ b/lib/tar.c +@@ -644,6 +644,7 @@ +- goto out; +- } +- eh->st.st_uid = lln; +- eh->use_uid = true; +- } else if (!strncmp(kv, "gid=", sizeof("gid=") - 1)) { +- if (!*value) { ++ goto out; ++ } ++ eh->st.st_uid = lln; + eh->tar_uid = (u32)lln; - eh->use_uid = true; - } else if (!strncmp(kv, "gid=", sizeof("gid=") - 1)) { - ret = sscanf(value, "%lld %n", &lln, &n); -@@ -600,6 +601,7 @@ - goto out; - } - eh->st.st_gid = lln; ++ eh->use_uid = true; ++ } else if (!strncmp(kv, "gid=", sizeof("gid=") - 1)) { ++ if (!*value) { +@@ -656,6 +657,7 @@ +- goto out; +- } +- eh->st.st_gid = lln; +- eh->use_gid = true; +- } else if (!strncmp(kv, "SCHILY.xattr.", +- sizeof("SCHILY.xattr.") - 1)) { ++ goto out; ++ } ++ eh->st.st_gid = lln; + eh->tar_gid = (u32)lln; - eh->use_gid = true; - } else if (!strncmp(kv, "SCHILY.xattr.", - sizeof("SCHILY.xattr.") - 1)) { -@@ -960,7 +962,8 @@ - if (eh.use_uid) { - st.st_uid = eh.st.st_uid; - } else { ++ eh->use_gid = true; ++ } else if (!strncmp(kv, "SCHILY.xattr.", ++ sizeof("SCHILY.xattr.") - 1)) { +@@ -1047,7 +1049,8 @@ +- if (eh.use_uid) { +- st.st_uid = eh.st.st_uid; +- } else { - st.st_uid = tarerofs_parsenum(th->uid, sizeof(th->uid)); +- if (errno) +- goto invalid_tar; +- } ++ if (eh.use_uid) { ++ st.st_uid = eh.st.st_uid; ++ } else { + eh.tar_uid = tarerofs_parsenum(th->uid, sizeof(th->uid)); + st.st_uid = eh.tar_uid; - if (errno) - goto invalid_tar; - } -@@ -968,7 +971,8 @@ - if (eh.use_gid) { - st.st_gid = eh.st.st_gid; - } else { ++ if (errno) ++ goto invalid_tar; ++ } +@@ -1055,7 +1058,8 @@ +- if (eh.use_gid) { +- st.st_gid = eh.st.st_gid; +- } else { - st.st_gid = tarerofs_parsenum(th->gid, sizeof(th->gid)); +- if (errno) +- goto invalid_tar; +- } ++ if (eh.use_gid) { ++ st.st_gid = eh.st.st_gid; ++ } else { + eh.tar_gid = tarerofs_parsenum(th->gid, sizeof(th->gid)); + st.st_gid = eh.tar_gid; - if (errno) - goto invalid_tar; - } -@@ -1125,6 +1129,13 @@ - ret = __erofs_fill_inode(im, inode, &st, eh.path); - if (ret) - goto out; ++ if (errno) ++ goto invalid_tar; ++ } +@@ -1216,6 +1220,13 @@ +- ret = __erofs_fill_inode(im, inode, &st, eh.path); +- if (ret) +- goto out; +- inode->i_size = st.st_size; +- +- if (!S_ISDIR(inode->i_mode)) { ++ ret = __erofs_fill_inode(im, inode, &st, eh.path); ++ if (ret) ++ goto out; +#ifdef _WIN32 + /* MinGW struct stat truncates uid_t/gid_t to 16-bit. */ + if (im->params->fixed_uid == -1) @@ -58,6 +98,6 @@ + if (im->params->fixed_gid == -1) + inode->i_gid = eh.tar_gid + im->params->gid_offset; +#endif - inode->i_size = st.st_size; - - if (!S_ISDIR(inode->i_mode)) { ++ inode->i_size = st.st_size; ++ ++ if (!S_ISDIR(inode->i_mode)) { diff --git a/.github/workflows/update-aqua-checksum.yaml b/.github/workflows/update-aqua-checksum.yaml index 9a3eb0e..15a0aa5 100644 --- a/.github/workflows/update-aqua-checksum.yaml +++ b/.github/workflows/update-aqua-checksum.yaml @@ -25,13 +25,23 @@ # code the branch controls. `aqua update-checksum` only downloads and # hashes declared artifacts; the converge step executes exactly one # binary, the checksum-pinned limen release the branch declares. -# - The push step runs only git, with the token scoped to that single step. +# - The commit step runs only runner-provided tools (git, jq, base64, +# curl) — no branch-pinned binary ever touches the token, which is +# scoped to that single step. # - The branch name reaches the shell via env, never template interpolation # (script-injection hygiene). -# - No loop: a push made with the default GITHUB_TOKEN triggers no further -# workflows — and the no-change early exit terminates recursion +# - No loop: a commit made with the default GITHUB_TOKEN triggers no +# further workflows — and the no-change early exit terminates recursion # regardless. # +# The commit is created through the GraphQL createCommitOnBranch mutation, +# never a local `git commit` + push: GitHub signs the mutation's commits, and +# the `limen:main` ruleset requires signatures — an unsigned fix-up commit +# made every aqua-bump PR unmergeable (see the commit step for the full +# argument). Consequence of the mutation: the commit's author IS the token's +# identity, always — with the default token that is github-actions[bot], +# already listed in the canonical renovate.json5's gitIgnoredAuthors. +# # Known trade of the default token: GitHub suppresses workflow runs for # commits it pushes, so the PR's CI does not re-run on the checksum commit. # To get CI on the final state of Renovate PRs, register a GitHub App — @@ -42,6 +52,10 @@ # long-lived broad credential and nothing that expires on a calendar. A # fine-grained PAT with contents:write as UPDATE_AQUA_CHECKSUM_TOKEN is the # drop-in alternative; the token preference order is App, PAT, default. +# With an App or PAT the commit is authored as that identity (the mutation +# offers no override), so it must be added to gitIgnoredAuthors in +# renovate.json5 — or Renovate treats the branch as human-modified and +# stops rebasing it. name: update-aqua-checksum on: @@ -111,27 +125,72 @@ jobs: # happens to hold now or later. permission-contents: write - - name: Push the update, if any + - name: Commit the update through the API, if any env: BRANCH: ${{ github.ref_name }} TOKEN: ${{ steps.app-token.outputs.token || secrets.UPDATE_AQUA_CHECKSUM_TOKEN || github.token }} run: | [ -z "$(git status --porcelain)" ] && { echo "checksums and baseline already in sync"; exit 0; } - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # `--all` is deliberate, not sloppiness. The tree is fully accounted - # for: a fresh checkout, aqua rooted outside the workspace, and - # exactly two writers between checkout and here — update-checksum - # and the pinned limen's fix. Whatever is dirty IS the payload. An - # enumerated path list would be wrong: the converge step's job is to - # commit whatever the NEW limen's baseline says, and a list baked - # into the older running workflow cannot know that surface (the - # updated workflow arrives in the very commit being built). Nor - # would a list add safety — the only writer that could plant a file - # is limen fix itself, and .limen/.github would be on any list. - git add --all + # The commit is made through GraphQL createCommitOnBranch, not a + # local `git commit` + push, because commits made through that + # mutation are signed by GitHub — and the `limen:main` ruleset + # requires signatures on everything landing on the default branch. + # A plain git push is unsigned, and one unsigned commit on the + # branch made every aqua-bump PR unmergeable: a merge commit would + # land it, GitHub disables rebase while signatures are required, + # and squash of a bot-authored PR is refused to everyone but the + # bot (see limen's book/github.md). + # + # Enumerating the whole dirty tree is deliberate, not sloppiness. + # The tree is fully accounted for: a fresh checkout, aqua rooted + # outside the workspace, and exactly two writers between checkout + # and here — update-checksum and the pinned limen's fix. Whatever + # is dirty IS the payload. An enumerated path list would be wrong: + # the converge step's job is to commit whatever the NEW limen's + # baseline says, and a list baked into the older running workflow + # cannot know that surface (the updated workflow arrives in the + # very commit being built). Nor would a list add safety — the only + # writer that could plant a file is limen fix itself, and + # .limen/.github would be on any list. + # + # Limit of the mutation, accepted: FileAddition carries no file + # mode, so an executable bit cannot travel — both writers here + # only ever produce plain configuration files. + additions="[]" + deletions="[]" + while IFS= read -r -d '' entry; do + path="${entry:3}" + if [ -f "${path}" ]; then + additions="$(jq --arg path "${path}" --arg contents "$(base64 -w0 "${path}")" \ + '. + [{path: $path, contents: $contents}]' <<<"${additions}")" + else + deletions="$(jq --arg path "${path}" '. + [{path: $path}]' <<<"${deletions}")" + fi + done < <(git status --porcelain -z) # Signed-off-by: `just do lint commits` enforces DCO on the PR range, # bot commits included. - git commit -m "chore: update aqua checksums and converge the limen baseline" \ - -m "Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" - git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${BRANCH}" + payload="$(jq -n \ + --arg query 'mutation ($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }' \ + --arg repository "${GITHUB_REPOSITORY}" \ + --arg branch "${BRANCH}" \ + --arg head "$(git rev-parse HEAD)" \ + --argjson additions "${additions}" \ + --argjson deletions "${deletions}" \ + '{query: $query, variables: {input: { + branch: {repositoryNameWithOwner: $repository, branchName: $branch}, + expectedHeadOid: $head, + message: { + headline: "chore: update aqua checksums and converge the limen baseline", + body: "Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + }, + fileChanges: {additions: $additions, deletions: $deletions}}}}')" + # GraphQL reports failure in-body over HTTP 200, so success is the + # presence of the new commit oid, never the HTTP status. + response="$(curl -sS -X POST -H "Authorization: bearer ${TOKEN}" \ + -d "${payload}" https://api.github.com/graphql)" + oid="$(jq -r '.data.createCommitOnBranch.commit.oid // empty' <<<"${response}")" + if [ -z "${oid}" ]; then + echo "${response}" >&2 + exit 1 + fi + echo "pushed ${oid}" diff --git a/.gitignore b/.gitignore index 61ed41a..22d7a34 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ Desktop.ini # Build output and Go artifacts /build /tmp +# `go test -c` drops .test next to the package; one of these (7 MB) was +# committed by accident, which is what made a rebase of that commit conflict. +*.test +*.out # Scratch / work-in-progress files # *.local diff --git a/Justfile b/Justfile index 46821ec..094114a 100644 --- a/Justfile +++ b/Justfile @@ -6,4 +6,19 @@ import '.limen/just/main.just' # The FIRST recipe defined here becomes `just`'s default. lint: do::lint::go::default do::lint::default fix: do::fix::go::default do::fix::default -test: +test: mkfs-info do::test::go::unit do::test::go::race +bench: do::test::go::bench + +# The tests that read a real image shell out to mkfs.erofs and skip themselves +# when it is absent, so a run without erofs-utils silently covers far less than +# one with it. Say which it was: a green run that skipped every image test is +# not evidence about the image reader. Diagnostic only — never fails. +[doc('Report whether mkfs.erofs is available to the image-backed tests')] +mkfs-info: + #!/usr/bin/env bash + set -euo pipefail + if command -v mkfs.erofs > /dev/null 2>&1; then + echo "mkfs.erofs: $(mkfs.erofs -V 2>&1 | head -n 1) — image-backed tests will run" + else + echo "mkfs.erofs: NOT FOUND — every image-backed test will skip itself" + fi diff --git a/aqua-checksums.json b/aqua-checksums.json index d40eca2..04d5fc8 100644 --- a/aqua-checksums.json +++ b/aqua-checksums.json @@ -46,28 +46,28 @@ "algorithm": "sha256" }, { - "id": "github_release/github.com/farcloser/limen/v0.0.10/limen_0.0.10_darwin_arm64.tar.gz", - "checksum": "C5F7CB59CA8313A96D80597E32A2F5CEBDD2D094D6E30A9FC62F5AE0CA661584", + "id": "github_release/github.com/farcloser/limen/v0.0.12/limen_0.0.12_darwin_arm64.tar.gz", + "checksum": "EDCA1AF957C0F14178F6B29D473EC899F479BB3AF2A240020FAD5965F044AC99", "algorithm": "sha256" }, { - "id": "github_release/github.com/farcloser/limen/v0.0.10/limen_0.0.10_linux_amd64.tar.gz", - "checksum": "F4D005A929CDA0678946054FC11FF79862A96653818F0E8C1813E583FC2F9626", + "id": "github_release/github.com/farcloser/limen/v0.0.12/limen_0.0.12_linux_amd64.tar.gz", + "checksum": "551BC67781A4FD18F1941A9BE68320DB730B982DA62BBF41988F88E721B5EE65", "algorithm": "sha256" }, { - "id": "github_release/github.com/farcloser/limen/v0.0.10/limen_0.0.10_linux_arm64.tar.gz", - "checksum": "7F1090310543E9AB9D83A4AEFC6F47C41CA047B080BAC2F9F18873A1C79A294C", + "id": "github_release/github.com/farcloser/limen/v0.0.12/limen_0.0.12_linux_arm64.tar.gz", + "checksum": "13C508CC4ECE232D033AA4E336DA96EFAE35E6C247F7BFE52F8E38824EC502B1", "algorithm": "sha256" }, { - "id": "github_release/github.com/farcloser/limen/v0.0.10/limen_0.0.10_windows_amd64.tar.gz", - "checksum": "3F78DB94E076C7AE6E102AF70B60691D5C79C29948469390BEE8326758A09957", + "id": "github_release/github.com/farcloser/limen/v0.0.12/limen_0.0.12_windows_amd64.tar.gz", + "checksum": "3CFE919FF7CBD49C0DACA0397B5E76330861C96CE4575E997B9D3C4833AE05E0", "algorithm": "sha256" }, { - "id": "github_release/github.com/farcloser/limen/v0.0.10/limen_0.0.10_windows_arm64.tar.gz", - "checksum": "DD5A8AA4DB208D88F7BE93CE92A6833F5A74C416599177CE3744821C108FEAC5", + "id": "github_release/github.com/farcloser/limen/v0.0.12/limen_0.0.12_windows_arm64.tar.gz", + "checksum": "8DA23959424BABC09D0AED2798F1E3393A7DC2F594AE4BC8A098BADDAB03F210", "algorithm": "sha256" }, { diff --git a/aqua.yaml b/aqua.yaml index 1c110ad..9ea8106 100644 --- a/aqua.yaml +++ b/aqua.yaml @@ -32,7 +32,7 @@ packages: - name: github.com/farcloser/godolint/cmd/godolint@v0.1.0 registry: local # --- farcloser tools (local registry; standard once registered upstream) --- - - name: farcloser/limen@v0.0.10 # renovate: depName=farcloser/limen + - name: farcloser/limen@v0.0.12 # renovate: depName=farcloser/limen registry: local # --- toolchain + binary-release tools (standard registry, aqua-verified) --- - name: golang/go@go1.26.5 diff --git a/mkfs.go b/mkfs.go index e360055..a343207 100644 --- a/mkfs.go +++ b/mkfs.go @@ -2,6 +2,7 @@ package erofs import ( "cmp" + "errors" "fmt" "io" "io/fs" @@ -470,6 +471,107 @@ func (fsys *Writer) SetNlink(name string, nlink uint32) error { return nil } +// Remove removes the named file, empty directory, symlink, device or +// hardlink name. Removing one name of a hard-linked file leaves the other +// names — and the shared inode — intact, as unlink(2) does. Removing a +// non-empty directory fails with ErrDirNotEmpty; use RemoveAll for that. +// Removing "/" or a missing path is an error (fs.ErrInvalid, fs.ErrNotExist). +// +// Together with CopyFrom this supports merging layers programmatically, +// without expressing deletions as AUFS whiteout files in a source tree. +func (fsys *Writer) Remove(name string) error { + if fsys.wErr != nil { + return fsys.wErr + } + name = cleanPath(name) + if name == "/" { + return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrInvalid} + } + // Deliberately not lookup(): that resolves a hardlink name to the + // shared inode, which is right for metadata and wrong here — Remove + // takes away exactly the name it was given. + e, ok := fsys.byPath[name] + if !ok { + return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist} + } + if err := fsys.checkNotOpen(e, "remove"); err != nil { + return err + } + if e.mode&disk.StatTypeMask == disk.StatTypeDir { + for _, c := range e.children { + if !c.removed { + return &fs.PathError{Op: "remove", Path: name, Err: ErrDirNotEmpty} + } + } + } + fsys.unlinkEntry(e) + + return nil +} + +// RemoveAll removes the named path and any children it has. Unlike Remove +// it succeeds when the path does not exist and never complains about a +// non-empty directory. Removing "/" is refused (fs.ErrInvalid): empty an +// image by removing root's children. A path that would traverse a +// non-directory fails with ErrNotDirectory. +func (fsys *Writer) RemoveAll(name string) error { + if fsys.wErr != nil { + return fsys.wErr + } + name = cleanPath(name) + if name == "/" { + return &fs.PathError{Op: "removeall", Path: name, Err: fs.ErrInvalid} + } + e, ok := fsys.byPath[name] + if !ok { + // Missing is fine, but only if every existing ancestor is a + // directory — RemoveAll("/file/child") is a caller bug, not a no-op. + for dir := path.Dir(name); dir != "/"; dir = path.Dir(dir) { + if anc, ok := fsys.byPath[dir]; ok { + if anc.mode&disk.StatTypeMask != disk.StatTypeDir { + return &fs.PathError{Op: "removeall", Path: name, Err: ErrNotDirectory} + } + + break + } + } + + return nil + } + if fsys.openFile != nil && fsys.openFile.entry.inSubtreeOf(e) { + return fsys.checkNotOpen(fsys.openFile.entry, "remove") + } + fsys.remove(name) + + return nil +} + +// ErrDirNotEmpty is returned (wrapped in an *fs.PathError) by Remove when +// the named directory still has children. Use RemoveAll to remove a +// directory together with its contents. +var ErrDirNotEmpty = errors.New("directory not empty") + +// checkNotOpen refuses to act on the file currently open from Create. +func (fsys *Writer) checkNotOpen(e *fsEntry, action string) error { + if fsys.openFile != nil && fsys.openFile.entry == e { + return fmt.Errorf("mkfs: %q is still open for writing; close it before you %s it", + e.path, action) + } + + return nil +} + +// inSubtreeOf reports whether e is d or lies beneath it. +func (e *fsEntry) inSubtreeOf(d *fsEntry) bool { + for cur := e; cur != nil; cur = cur.parent { + if cur == d { + return true + } + } + + return false +} + // --- Writer bulk copy --- // CopyFrom walks an fs.FS and adds all entries. @@ -1381,11 +1483,7 @@ func (fsys *Writer) remove(p string) { if !ok { return } - e.removed = true - delete(fsys.byPath, p) - if e.linkTo != nil { - e.linkTo.extraLinks-- - } + fsys.unlinkEntry(e) if e.mode&disk.StatTypeMask == disk.StatTypeDir { fsys.removeSubtree(e) } @@ -1406,11 +1504,7 @@ func (fsys *Writer) removeChildren(dir string) { func (fsys *Writer) removeSubtree(e *fsEntry) { for _, c := range e.children { if !c.removed { - c.removed = true - delete(fsys.byPath, c.path) - if c.linkTo != nil { - c.linkTo.extraLinks-- - } + fsys.unlinkEntry(c) if c.mode&disk.StatTypeMask == disk.StatTypeDir { fsys.removeSubtree(c) } @@ -1418,6 +1512,77 @@ func (fsys *Writer) removeSubtree(e *fsEntry) { } } +// unlinkEntry drops one name. It is the single place an entry becomes +// removed, so the hardlink bookkeeping in both directions lives here: +// +// - removing an alias decrements its target's live-alias count; +// - removing a target that still has live aliases must not orphan the +// inode — the aliases still name it, exactly as unlink(2) leaves a +// multiply-linked file alive. One surviving alias is promoted to +// carry the inode (data, metadata, chunks) and the rest are +// repointed at it, so nothing downstream ever sees a dangling linkTo. +func (fsys *Writer) unlinkEntry(e *fsEntry) { + e.removed = true + delete(fsys.byPath, e.path) + if e.linkTo != nil { + e.linkTo.extraLinks-- + + return + } + if e.extraLinks > 0 { + fsys.promoteAlias(e) + } +} + +// promoteAlias hands e's inode to one of its surviving aliases. Aliases are +// found by scanning the tree: they are rare, and keeping a back-reference +// list on every entry would tax the common case for the exceptional one. +// The lowest path wins so the outcome — and thus the image — is +// deterministic regardless of link creation order. +func (fsys *Writer) promoteAlias(target *fsEntry) { + var aliases []*fsEntry + var walk func(*fsEntry) + walk = func(d *fsEntry) { + for _, c := range d.children { + if c.removed { + continue + } + if c.linkTo == target { + aliases = append(aliases, c) + } + if c.mode&disk.StatTypeMask == disk.StatTypeDir { + walk(c) + } + } + } + walk(fsys.root) + if len(aliases) == 0 { + target.extraLinks = 0 + + return + } + slices.SortFunc(aliases, func(a, b *fsEntry) int { + return cmp.Compare(a.path, b.path) + }) + heir := aliases[0] + + // The heir takes over the inode wholesale, keeping only its own name + // and place in the tree. + path, parent := heir.path, heir.parent + *heir = *target + heir.path, heir.parent = path, parent + heir.linkTo = nil + heir.removed = false + heir.extraLinks = uint32(len(aliases) - 1) + for _, a := range aliases[1:] { + a.linkTo = heir + } + // The old target is now a dead husk: nothing points at it and it owns + // nothing. Zero the link count so a stale reference cannot double-count. + target.extraLinks = 0 + target.children = nil +} + // buildErofsTree converts the fsEntry tree into an erofsEntry tree via BFS. // Children are sorted for deterministic output. The Writer is consumed. func (fsys *Writer) buildErofsTree() *erofsEntry { diff --git a/mkfs_remove_test.go b/mkfs_remove_test.go new file mode 100644 index 0000000..a5b9904 --- /dev/null +++ b/mkfs_remove_test.go @@ -0,0 +1,584 @@ +package erofs_test + +import ( + "bytes" + "errors" + "io/fs" + "testing" + "testing/fstest" + + erofs "github.com/forkcloser/erofs" + "github.com/forkcloser/erofs/internal/erofstest" +) + +// TestWriterRemoveFile verifies Remove deletes a regular file and the +// resulting image contains no trace of it. +func TestWriterRemoveFile(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/keep.txt") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("keep\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + f2, err := w.Create("/drop.txt") + if err != nil { + t.Fatal(err) + } + if _, err := f2.Write([]byte("drop\n")); err != nil { + t.Fatal(err) + } + if err := f2.Close(); err != nil { + t.Fatal(err) + } + + if err := w.Remove("/drop.txt"); err != nil { + t.Fatal("Remove:", err) + } + + if err := w.Close(); err != nil { + t.Fatal("Close:", err) + } + + erofstest.FsckErofsBytes(t, buf.Bytes()) + + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal("Open:", err) + } + erofstest.CheckFile(t, efs, "keep.txt", "keep\n") + erofstest.CheckDirEntries(t, efs, ".", []string{"keep.txt"}) +} + +// TestWriterRemoveEmptyDir verifies that an empty directory can be removed. +func TestWriterRemoveEmptyDir(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + if err := w.Mkdir("/a", 0o755); err != nil { + t.Fatal(err) + } + if err := w.Mkdir("/b", 0o755); err != nil { + t.Fatal(err) + } + if err := w.Remove("/b"); err != nil { + t.Fatal("Remove empty dir:", err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckDirEntries(t, efs, ".", []string{"a"}) +} + +// TestWriterRemoveNonEmptyDirFails verifies that Remove returns an error +// for a directory that still has children. +func TestWriterRemoveNonEmptyDirFails(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + if err := w.Mkdir("/dir", 0o755); err != nil { + t.Fatal(err) + } + f, err := w.Create("/dir/child") + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + err = w.Remove("/dir") + if err == nil { + t.Fatal("expected error removing non-empty directory") + } + var pe *fs.PathError + if !errors.As(err, &pe) { + t.Fatalf("error is not *fs.PathError: %T %v", err, err) + } + if !errors.Is(err, erofs.ErrDirNotEmpty) { + t.Fatalf("error is not ErrDirNotEmpty: %v", err) + } +} + +// TestWriterRemoveMissingReturnsErrNotExist verifies Remove signals +// fs.ErrNotExist for a path that was never added. +func TestWriterRemoveMissingReturnsErrNotExist(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + err := w.Remove("/missing") + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Remove missing: got %v, want fs.ErrNotExist", err) + } +} + +// TestWriterRemoveRootFails verifies Remove cannot delete "/". +func TestWriterRemoveRootFails(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + if err := w.Remove("/"); err == nil { + t.Fatal("expected error removing root") + } +} + +// TestWriterRemoveSymlink verifies a symlink can be removed. +func TestWriterRemoveSymlink(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/target") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("x\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.Symlink("target", "/link"); err != nil { + t.Fatal(err) + } + if err := w.Remove("/link"); err != nil { + t.Fatal("Remove symlink:", err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckDirEntries(t, efs, ".", []string{"target"}) +} + +// TestWriterRemoveHardlinkAlias verifies that removing an alias leaves the +// canonical path intact with the correct nlink. +func TestWriterRemoveHardlinkAlias(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/orig") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("hardlink payload\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.Link("/orig", "/alias1"); err != nil { + t.Fatal(err) + } + if err := w.Link("/orig", "/alias2"); err != nil { + t.Fatal(err) + } + // Remove one alias. + if err := w.Remove("/alias1"); err != nil { + t.Fatal("Remove alias:", err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckFile(t, efs, "orig", "hardlink payload\n") + erofstest.CheckFile(t, efs, "alias2", "hardlink payload\n") + erofstest.CheckDirEntries(t, efs, ".", []string{"alias2", "orig"}) + + stOrig := erofstest.Stat(t, efs, "orig") + stAlias := erofstest.Stat(t, efs, "alias2") + if stOrig.Ino != stAlias.Ino { + t.Errorf("Ino mismatch after alias remove: orig=%d alias2=%d", stOrig.Ino, stAlias.Ino) + } + if stOrig.Nlink != 2 { + t.Errorf("orig nlink after alias remove: got %d, want 2", stOrig.Nlink) + } +} + +// TestWriterRemoveHardlinkCanonicalPromotes verifies that removing the +// canonical path of a hardlink group promotes the first surviving alias +// to canonical (POSIX unlink semantics). +func TestWriterRemoveHardlinkCanonicalPromotes(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/orig") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("promote me\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.Link("/orig", "/alias1"); err != nil { + t.Fatal(err) + } + if err := w.Link("/orig", "/alias2"); err != nil { + t.Fatal(err) + } + + // Remove the canonical entry; data should survive via the aliases. + if err := w.Remove("/orig"); err != nil { + t.Fatal("Remove canonical:", err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckFile(t, efs, "alias1", "promote me\n") + erofstest.CheckFile(t, efs, "alias2", "promote me\n") + erofstest.CheckDirEntries(t, efs, ".", []string{"alias1", "alias2"}) + + st1 := erofstest.Stat(t, efs, "alias1") + st2 := erofstest.Stat(t, efs, "alias2") + if st1.Ino != st2.Ino { + t.Errorf("Ino mismatch after canonical remove: alias1=%d alias2=%d", st1.Ino, st2.Ino) + } + if st1.Nlink != 2 { + t.Errorf("alias1 nlink: got %d, want 2", st1.Nlink) + } +} + +// TestWriterRemoveHardlinkAllAliases verifies removing every alias of a +// pair drops both paths and the data along with them. +func TestWriterRemoveHardlinkAllAliases(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/orig") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("doomed\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.Link("/orig", "/alias"); err != nil { + t.Fatal(err) + } + if err := w.Remove("/orig"); err != nil { + t.Fatal(err) + } + if err := w.Remove("/alias"); err != nil { + t.Fatal(err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckDirEntries(t, efs, ".", []string{}) +} + +// TestWriterRemoveAllRecursive verifies RemoveAll deletes a directory and +// all of its descendants, leaving unrelated paths untouched. +func TestWriterRemoveAllRecursive(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + if err := w.Mkdir("/dir", 0o755); err != nil { + t.Fatal(err) + } + if err := w.Mkdir("/dir/sub", 0o755); err != nil { + t.Fatal(err) + } + for _, p := range []string{"/dir/a", "/dir/b", "/dir/sub/c"} { + f, err := w.Create(p) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte(p + "\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + } + + if err := w.Mkdir("/other", 0o755); err != nil { + t.Fatal(err) + } + f, err := w.Create("/other/keep") + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + if err := w.RemoveAll("/dir"); err != nil { + t.Fatal("RemoveAll:", err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckDirEntries(t, efs, ".", []string{"other"}) + erofstest.CheckDirEntries(t, efs, "other", []string{"keep"}) +} + +// TestWriterRemoveAllMissing verifies RemoveAll is a no-op (returns nil) on +// a path that does not exist. +func TestWriterRemoveAllMissing(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + if err := w.RemoveAll("/does/not/exist"); err != nil { + t.Fatalf("RemoveAll missing: got %v, want nil", err) + } +} + +// TestWriterRemoveAllNonDirectoryAncestor verifies RemoveAll returns +// ErrNotDirectory (not nil) when a path component along the way is an +// existing non-directory, matching Writer.Remove instead of silently +// treating it as a missing path. +func TestWriterRemoveAllNonDirectoryAncestor(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/file") + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + err = w.RemoveAll("/file/child") + if !errors.Is(err, erofs.ErrNotDirectory) { + t.Fatalf("RemoveAll through non-directory: got %v, want ErrNotDirectory", err) + } +} + +// TestWriterRemoveAllRoot verifies RemoveAll cannot delete "/". +func TestWriterRemoveAllRoot(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + if err := w.RemoveAll("/"); err == nil { + t.Fatal("expected error removing root") + } +} + +// TestWriterRemoveAllFile verifies RemoveAll works on a single regular +// file, just like Remove. +func TestWriterRemoveAllFile(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/drop.txt") + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.RemoveAll("/drop.txt"); err != nil { + t.Fatal("RemoveAll file:", err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckDirEntries(t, efs, ".", []string{}) +} + +// TestWriterRemoveAllHardlinkInside verifies that RemoveAll over a subtree +// containing a hardlink alias correctly updates the canonical entry's link +// count when the alias is removed. +func TestWriterRemoveAllHardlinkInside(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + if err := w.Mkdir("/keep", 0o755); err != nil { + t.Fatal(err) + } + f, err := w.Create("/keep/orig") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("payload\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.Mkdir("/scratch", 0o755); err != nil { + t.Fatal(err) + } + if err := w.Link("/keep/orig", "/scratch/alias"); err != nil { + t.Fatal(err) + } + + if err := w.RemoveAll("/scratch"); err != nil { + t.Fatal(err) + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckFile(t, efs, "keep/orig", "payload\n") + st := erofstest.Stat(t, efs, "keep/orig") + if st.Nlink != 1 { + t.Errorf("keep/orig nlink after scratch removal: got %d, want 1", st.Nlink) + } +} + +// TestMergeWhiteoutHardlinkTarget covers the case that motivated +// hardlink-aware removal: an overlay layer whites out one name of a +// multiply-linked file. Before, the target's inode had no owner left to +// serialize and Close failed; now the surviving name carries the inode +// (unlink(2) semantics) and the merge succeeds. +func TestMergeWhiteoutHardlinkTarget(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + + f, err := w.Create("/orig") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("linked\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.Link("/orig", "/dir/alias"); err != nil { + t.Fatal(err) + } + + overlay := fstest.MapFS{ + ".wh.orig": {Data: []byte{}, Mode: 0o644}, + } + if err := w.CopyFrom(overlay, erofs.Merge()); err != nil { + t.Fatal("CopyFrom overlay:", err) + } + if err := w.Close(); err != nil { + t.Fatal("Close:", err) + } + + erofstest.FsckErofsBytes(t, buf.Bytes()) + efs, err := erofs.Open(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + erofstest.CheckNotExists(t, efs, "orig") + erofstest.CheckFile(t, efs, "dir/alias", "linked\n") + if st := erofstest.Stat(t, efs, "dir/alias"); st.Nlink != 1 { + t.Errorf("nlink after whiteout of target: got %d, want 1", st.Nlink) + } +} + +// TestWriterRemoveHardlinkPromotionDeterministic: which alias inherits the +// inode must not depend on link creation order, or identical inputs would +// yield different images. +func TestWriterRemoveHardlinkPromotionDeterministic(t *testing.T) { + build := func(order []string) []byte { + var buf testBuffer + w := erofs.Create(&buf, erofs.WithBuildTime(0, 0)) + f, err := w.Create("/orig") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("x")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + for _, l := range order { + if err := w.Link("/orig", l); err != nil { + t.Fatal(err) + } + } + if err := w.Remove("/orig"); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return append([]byte(nil), buf.Bytes()...) + } + a := build([]string{"/b", "/c", "/d/e"}) + b := build([]string{"/d/e", "/c", "/b"}) + if !bytes.Equal(a, b) { + t.Error("alias promotion produced order-dependent images") + } +} + +// TestWriterRemoveOpenFile: the file currently open from Create cannot be +// removed out from under its writer, directly or via an ancestor. +func TestWriterRemoveOpenFile(t *testing.T) { + var buf testBuffer + w := erofs.Create(&buf) + f, err := w.Create("/dir/open") + if err != nil { + t.Fatal(err) + } + if err := w.Remove("/dir/open"); err == nil { + t.Error("Remove of open file succeeded") + } + if err := w.RemoveAll("/dir"); err == nil { + t.Error("RemoveAll over open file succeeded") + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := w.RemoveAll("/dir"); err != nil { + t.Fatal("RemoveAll after close:", err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } +}