From b87778e3f005dcb56832cda70c872eb0bae1877c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 7 Aug 2026 16:01:32 -0500 Subject: [PATCH 1/2] Preserve non-UTF-8 paths in NAR streams --- .gitignore | 1 + hnix-store-nar/CHANGELOG.md | 1 + hnix-store-nar/README.md | 3 + hnix-store-nar/benchmarks/NarStream.hs | 66 ++++++++++++++ hnix-store-nar/benchmarks/README.md | 57 +++++++++++++ .../benchmarks/prepare-hyperfine.sh | 85 +++++++++++++++++++ hnix-store-nar/benchmarks/run-hyperfine.sh | 83 ++++++++++++++++++ hnix-store-nar/benchmarks/shell.nix | 13 +++ hnix-store-nar/hnix-store-nar.cabal | 17 ++++ hnix-store-nar/src/System/Nix/Nar/Streamer.hs | 51 ++++++----- hnix-store-nar/tests/NarFormat.hs | 45 ++++++++++ 11 files changed, 400 insertions(+), 22 deletions(-) create mode 100644 hnix-store-nar/benchmarks/NarStream.hs create mode 100644 hnix-store-nar/benchmarks/README.md create mode 100755 hnix-store-nar/benchmarks/prepare-hyperfine.sh create mode 100755 hnix-store-nar/benchmarks/run-hyperfine.sh create mode 100644 hnix-store-nar/benchmarks/shell.nix diff --git a/.gitignore b/.gitignore index 05986bdc..79a9a75c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ TAGS tags result result-* +benchmark-results/ *.actual diff --git a/hnix-store-nar/CHANGELOG.md b/hnix-store-nar/CHANGELOG.md index e93609ee..e2086814 100644 --- a/hnix-store-nar/CHANGELOG.md +++ b/hnix-store-nar/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased * Switch from `cryptonite` to `crypton` +* Preserve non-UTF-8 filename and symlink target bytes when streaming NARs # [0.1.1.0](https://github.com/haskell-nix/hnix-store/compare/nar-0.1.0.0...nar-0.1.1.0) 2024-10-09 diff --git a/hnix-store-nar/README.md b/hnix-store-nar/README.md index b660fca0..296fdece 100644 --- a/hnix-store-nar/README.md +++ b/hnix-store-nar/README.md @@ -4,5 +4,8 @@ For a description of the NAR format, see [`Eelco's thesis`](https://nixos.org/~eelco/pubs/phd-thesis.pdf). +The [NAR streaming benchmark](./benchmarks/README.md) provides a reproducible +Hyperfine comparison between the working tree and a baseline Git revision. + [System.Nix.Nar]: ./src/System/Nix/Nar.hs [System.Nix.Nar.Effects]: ./src/System/Nix/Nar/Effects.hs diff --git a/hnix-store-nar/benchmarks/NarStream.hs b/hnix-store-nar/benchmarks/NarStream.hs new file mode 100644 index 00000000..73e34f45 --- /dev/null +++ b/hnix-store-nar/benchmarks/NarStream.hs @@ -0,0 +1,66 @@ +{-# LANGUAGE ImportQualifiedPost #-} + +module Main (main) where + +import Control.Monad (forM_, replicateM_, unless) +import Data.ByteString qualified as Bytes +import Data.IORef (modifyIORef', newIORef, readIORef) +import System.Directory qualified as Directory +import System.Environment (getArgs) +import System.Exit (die) +import System.FilePath (()) +import Text.Printf (printf) +import Text.Read (readMaybe) + +import System.Nix.Nar.Streamer (dumpPath) + +main :: IO () +main = do + args <- getArgs + case args of + ["prepare", fixturePath, fileCountText] -> do + fileCount <- readPositive "file count" fileCountText + prepareFixture fixturePath fileCount + ["run", fixturePath, iterationsText] -> do + iterations <- readPositive "iteration count" iterationsText + runBenchmark fixturePath iterations + [] -> putStrLn usage + _ -> die usage + +usage :: String +usage = unlines + [ "Usage: nar-stream prepare FIXTURE_PATH FILE_COUNT" + , " nar-stream run FIXTURE_PATH ITERATIONS" + ] + +readPositive :: String -> String -> IO Int +readPositive label input = + case readMaybe input of + Just value | value > 0 -> pure value + _ -> die $ label <> " must be a positive integer" + +prepareFixture :: FilePath -> Int -> IO () +prepareFixture fixturePath fileCount = do + exists <- Directory.doesPathExist fixturePath + if exists + then die $ "fixture path already exists: " <> fixturePath + else Directory.createDirectory fixturePath + + forM_ [1 .. fileCount] $ \index -> + Bytes.writeFile (fixturePath fileName index) Bytes.empty + where + fileName :: Int -> FilePath + fileName = printf "file-%08d" + +runBenchmark :: FilePath -> Int -> IO () +runBenchmark fixturePath iterations = do + exists <- Directory.doesDirectoryExist fixturePath + unless exists $ die $ "fixture directory does not exist: " <> fixturePath + + byteCount <- newIORef (0 :: Int) + replicateM_ iterations $ + dumpPath fixturePath $ \chunk -> + modifyIORef' byteCount (+ Bytes.length chunk) + + streamedBytes <- readIORef byteCount + unless (streamedBytes > 0) $ die "NAR streamer produced no output" diff --git a/hnix-store-nar/benchmarks/README.md b/hnix-store-nar/benchmarks/README.md new file mode 100644 index 00000000..118299d1 --- /dev/null +++ b/hnix-store-nar/benchmarks/README.md @@ -0,0 +1,57 @@ +# NAR streaming wall-time benchmark + +This benchmark compares the working tree's NAR streamer with a baseline Git +revision. Both binaries archive the same pre-created wide directory, so fixture +creation, compilation, and cleanup are excluded from Hyperfine's measurements. + +## 1. Prepare + +From the repository root: + +```console +nix-shell hnix-store-nar/benchmarks/shell.nix --run \ + 'hnix-store-nar/benchmarks/prepare-hyperfine.sh' +``` + +The default baseline is `HEAD`. This is appropriate while the fix is still an +uncommitted working-tree change. After committing the fix, select its parent or +another known revision explicitly: + +```console +HNIX_NAR_BASELINE_REF=HEAD^ \ + nix-shell hnix-store-nar/benchmarks/shell.nix --run \ + 'hnix-store-nar/benchmarks/prepare-hyperfine.sh' +``` + +Preparation creates ignored state under `benchmark-results/nar-stream-state`: +the two prebuilt runners, a shared 5,000-file fixture, build metadata, +and the working-tree patch. It does not run Hyperfine. + +## 2. Measure when the machine is idle + +```console +nix-shell hnix-store-nar/benchmarks/shell.nix --run \ + 'hnix-store-nar/benchmarks/run-hyperfine.sh' +``` + +The runner refuses to start when one-minute load divided by the CPU count is +above `0.5`. It pins both binaries to the same CPU, performs five warmups and 30 +measurements, then repeats the comparison in reverse order to expose drift. +JSON and Markdown reports are written below the prepared state's `results/` +directory. + +Useful overrides: + +```console +HNIX_NAR_FILES=10000 # preparation only +HNIX_NAR_ITERATIONS=10 # dumps per measured process +HNIX_NAR_WARMUPS=5 +HNIX_NAR_RUNS=30 +HNIX_NAR_CPU=14 +HNIX_NAR_MAX_LOAD_PER_CPU=0.5 +HNIX_NAR_STATE_DIR=/some/path +``` + +This is a warm-cache benchmark of NAR traversal and encoding. Do not clear the +machine's page cache between runs; doing so needs elevated privileges, disrupts +other work, and measures a different workload. diff --git a/hnix-store-nar/benchmarks/prepare-hyperfine.sh b/hnix-store-nar/benchmarks/prepare-hyperfine.sh new file mode 100755 index 00000000..cf74a825 --- /dev/null +++ b/hnix-store-nar/benchmarks/prepare-hyperfine.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +benchmark_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(git -C "$benchmark_dir" rev-parse --show-toplevel) +state_dir=${HNIX_NAR_STATE_DIR:-"$repo_root/benchmark-results/nar-stream-state"} +baseline_ref=${HNIX_NAR_BASELINE_REF:-HEAD} +file_count=${HNIX_NAR_FILES:-5000} + +if [[ -e "$state_dir" ]]; then + echo "Benchmark state already exists: $state_dir" >&2 + echo "Set HNIX_NAR_STATE_DIR to a new path or remove the old state first." >&2 + exit 1 +fi + +scratch_dir=$(mktemp -d "${TMPDIR:-/tmp}/hnix-nar-prepare.XXXXXX") +baseline_tree="$scratch_dir/baseline" +baseline_added=false + +cleanup() { + if [[ "$baseline_added" == true ]]; then + git -C "$repo_root" worktree remove --force "$baseline_tree" >/dev/null 2>&1 || true + fi + rm -rf -- "$scratch_dir" +} +trap cleanup EXIT + +mkdir -p "$state_dir/bin" + +build_runner() { + local source_tree=$1 + local label=$2 + local build_dir="$scratch_dir/dist-$label" + local object_dir="$scratch_dir/objects-$label" + + mkdir -p "$object_dir" + ( + cd "$source_tree" + cabal build hnix-store-nar \ + --builddir="$build_dir" \ + --disable-tests \ + --disable-benchmarks \ + -j1 + cabal exec --builddir="$build_dir" -- \ + ghc -O2 -threaded -rtsopts "-with-rtsopts=-N1" \ + -odir "$object_dir" \ + -hidir "$object_dir" \ + -package bytestring \ + -package directory \ + -package filepath \ + -package hnix-store-nar \ + "$benchmark_dir/NarStream.hs" \ + -o "$state_dir/bin/$label" + ) +} + +echo "Building fixed benchmark runner..." +build_runner "$repo_root" fixed + +echo "Creating baseline worktree at $baseline_ref..." +git -C "$repo_root" worktree add --detach "$baseline_tree" "$baseline_ref" +baseline_added=true + +echo "Building baseline benchmark runner..." +build_runner "$baseline_tree" baseline + +echo "Creating the shared $file_count-file fixture..." +"$state_dir/bin/fixed" prepare "$state_dir/fixture" "$file_count" + +baseline_commit=$(git -C "$baseline_tree" rev-parse HEAD) +fixed_commit=$(git -C "$repo_root" rev-parse HEAD) +{ + printf 'baseline_ref=%s\n' "$baseline_ref" + printf 'baseline_commit=%s\n' "$baseline_commit" + printf 'fixed_commit=%s\n' "$fixed_commit" + printf 'file_count=%s\n' "$file_count" + printf 'ghc=%s\n' "$(ghc --numeric-version)" + printf 'cabal=%s\n' "$(cabal --numeric-version)" +} > "$state_dir/metadata.txt" +git -C "$repo_root" diff --binary > "$state_dir/fixed.patch" + +echo +echo "Benchmark state is ready: $state_dir" +echo "When the machine is idle, run:" +echo " nix-shell $benchmark_dir/shell.nix --run '$benchmark_dir/run-hyperfine.sh'" diff --git a/hnix-store-nar/benchmarks/run-hyperfine.sh b/hnix-store-nar/benchmarks/run-hyperfine.sh new file mode 100755 index 00000000..d6a84d8e --- /dev/null +++ b/hnix-store-nar/benchmarks/run-hyperfine.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +benchmark_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(git -C "$benchmark_dir" rev-parse --show-toplevel) +state_dir=${HNIX_NAR_STATE_DIR:-"$repo_root/benchmark-results/nar-stream-state"} +iterations=${HNIX_NAR_ITERATIONS:-7} +warmups=${HNIX_NAR_WARMUPS:-5} +runs=${HNIX_NAR_RUNS:-30} +max_load_per_cpu=${HNIX_NAR_MAX_LOAD_PER_CPU:-0.5} +cpu_count=$(nproc) +default_cpu=$((cpu_count > 1 ? cpu_count - 2 : 0)) +benchmark_cpu=${HNIX_NAR_CPU:-$default_cpu} + +baseline_bin="$state_dir/bin/baseline" +fixed_bin="$state_dir/bin/fixed" +fixture="$state_dir/fixture" + +for required_path in "$baseline_bin" "$fixed_bin" "$fixture" "$state_dir/metadata.txt"; do + if [[ ! -e "$required_path" ]]; then + echo "Missing benchmark state: $required_path" >&2 + echo "Run prepare-hyperfine.sh first." >&2 + exit 1 + fi +done + +check_load() { + local load_one + load_one=$(awk '{ print $1 }' /proc/loadavg) + if ! awk \ + -v current_load="$load_one" \ + -v cpus="$cpu_count" \ + -v maximum="$max_load_per_cpu" \ + 'BEGIN { exit !((current_load / cpus) <= maximum) }' + then + echo "Refusing to benchmark: load is $load_one across $cpu_count CPUs." >&2 + echo "Wait for load/CPU <= $max_load_per_cpu, or override HNIX_NAR_MAX_LOAD_PER_CPU." >&2 + exit 75 + fi +} + +if ! taskset -c "$benchmark_cpu" true; then + echo "CPU $benchmark_cpu is not available to taskset." >&2 + exit 1 +fi + +check_load + +timestamp=$(date -u +%Y%m%dT%H%M%SZ) +results_dir="$state_dir/results/$timestamp" +mkdir -p "$results_dir" + +baseline_command="taskset -c $benchmark_cpu $baseline_bin run $fixture $iterations +RTS -N1 -A64m -RTS" +fixed_command="taskset -c $benchmark_cpu $fixed_bin run $fixture $iterations +RTS -N1 -A64m -RTS" + +common_options=( + --warmup "$warmups" + --runs "$runs" + --shell=none +) + +echo "Benchmarking baseline first on CPU $benchmark_cpu..." +hyperfine \ + "${common_options[@]}" \ + --export-json "$results_dir/baseline-first.json" \ + --export-markdown "$results_dir/baseline-first.md" \ + --command-name baseline "$baseline_command" \ + --command-name fixed "$fixed_command" + +check_load + +echo "Benchmarking fixed first on CPU $benchmark_cpu..." +hyperfine \ + "${common_options[@]}" \ + --export-json "$results_dir/fixed-first.json" \ + --export-markdown "$results_dir/fixed-first.md" \ + --command-name fixed "$fixed_command" \ + --command-name baseline "$baseline_command" + +cp "$state_dir/metadata.txt" "$results_dir/metadata.txt" + +echo +echo "Benchmark results: $results_dir" diff --git a/hnix-store-nar/benchmarks/shell.nix b/hnix-store-nar/benchmarks/shell.nix new file mode 100644 index 00000000..4ff16eae --- /dev/null +++ b/hnix-store-nar/benchmarks/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? (import ../../default.nix {}).pkgs }: + +let + narShell = (import ../../shell.nix { inherit pkgs; }).hnix-store-nar; +in +pkgs.mkShell { + inputsFrom = [ narShell ]; + packages = [ + pkgs.cabal-install + pkgs.hyperfine + pkgs.util-linux + ]; +} diff --git a/hnix-store-nar/hnix-store-nar.cabal b/hnix-store-nar/hnix-store-nar.cabal index 3da74158..4f7d814a 100644 --- a/hnix-store-nar/hnix-store-nar.cabal +++ b/hnix-store-nar/hnix-store-nar.cabal @@ -16,6 +16,10 @@ extra-doc-files: CHANGELOG.md extra-source-files: README.md + , benchmarks/README.md + , benchmarks/prepare-hyperfine.sh + , benchmarks/run-hyperfine.sh + , benchmarks/shell.nix , tests/fixtures/case-conflict.nar flag bounded_memory @@ -116,3 +120,16 @@ test-suite nar , tasty-quickcheck , text , unix + +benchmark nar-stream + import: commons + type: exitcode-stdio-1.0 + main-is: NarStream.hs + hs-source-dirs: benchmarks + ghc-options: -O2 -threaded -rtsopts "-with-rtsopts -N1" + build-depends: + base + , bytestring + , directory + , filepath + , hnix-store-nar diff --git a/hnix-store-nar/src/System/Nix/Nar/Streamer.hs b/hnix-store-nar/src/System/Nix/Nar/Streamer.hs index 19dd43c1..883b0609 100644 --- a/hnix-store-nar/src/System/Nix/Nar/Streamer.hs +++ b/hnix-store-nar/src/System/Nix/Nar/Streamer.hs @@ -14,7 +14,8 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Map.Strict qualified as Map -import Control.Monad ( forM_ +import Control.Monad ( forM + , forM_ , when ) import Control.Monad.IO.Class qualified as IO @@ -23,9 +24,11 @@ import Data.ByteString.Lazy qualified as Bytes.Lazy import Data.Foldable qualified import Data.List qualified import Data.Serialize qualified as Serial -import Data.Text qualified as T (pack, unpack) -import Data.Text.Encoding qualified as TE (encodeUtf8) +import Data.Text qualified as T (unpack) +import GHC.Foreign qualified as Foreign +import GHC.IO.Encoding qualified as Encoding import System.FilePath (()) +import System.IO (TextEncoding) import System.Nix.Nar.Effects qualified as Nar import System.Nix.Nar.Options qualified as Nar @@ -78,38 +81,41 @@ streamNarIOWithOptions -> FilePath -> NarSource m streamNarIOWithOptions opts effs basePath yield = do + fileSystemEncoding <- IO.liftIO Encoding.getFileSystemEncoding yield $ str "nix-archive-1" - parens $ go basePath + parens $ go fileSystemEncoding basePath where - go :: FilePath -> m () - go path = do + go :: TextEncoding -> FilePath -> m () + go fileSystemEncoding path = do isSymLink <- IO.liftIO $ Nar.narIsSymLink effs path if isSymLink then do target <- IO.liftIO $ Nar.narReadLink effs path + targetBytes <- IO.liftIO $ filePathToBS fileSystemEncoding target yield $ - strs ["type", "symlink", "target", filePathToBS target] + strs ["type", "symlink", "target", targetBytes] else do isDir <- IO.liftIO $ Nar.narIsDir effs path if isDir then do fs <- IO.liftIO (Nar.narListDir effs path) + names <- IO.liftIO $ forM fs $ \f -> do + let name = + if Nar.optUseCaseHack opts + then undoCaseHack f + else f + nameBytes <- filePathToBS fileSystemEncoding name + pure (nameBytes, name, f) let entries = - foldr (\f acc -> - let - name = - if Nar.optUseCaseHack opts - then undoCaseHack f - else f - in - case Map.insertLookupWithKey (\_ n _ -> n) name f acc of + foldr (\(nameBytes, name, original) acc -> + case Map.insertLookupWithKey (\_ n _ -> n) nameBytes (name, original) acc of (Nothing, newMap) -> newMap - (Just conflict, _) -> error $ "File name collision between " ++ (path name) ++ " and " ++ (path conflict) - ) Map.empty fs + (Just (conflict, _), _) -> error $ "File name collision between " ++ (path name) ++ " and " ++ (path conflict) + ) Map.empty names yield $ strs ["type", "directory"] - forM_ (Map.toAscList entries) $ \(unhacked, original) -> do + forM_ (Map.toAscList entries) $ \(nameBytes, (_, original)) -> do yield $ str "entry" parens $ do - yield $ strs ["name", filePathToBS unhacked, "node"] - parens $ go (path original) + yield $ strs ["name", nameBytes, "node"] + parens $ go fileSystemEncoding (path original) else do isExec <- IO.liftIO $ Nar.narIsExec effs path yield $ strs ["type", "regular"] @@ -151,8 +157,9 @@ padBS strSize bs = bs <> Bytes.replicate (padLen strSize) 0 strs :: [ByteString] -> ByteString strs xs = Bytes.concat $ str <$> xs -filePathToBS :: FilePath -> ByteString -filePathToBS = TE.encodeUtf8 . T.pack +filePathToBS :: TextEncoding -> FilePath -> IO ByteString +filePathToBS encoding filePath = + Foreign.withCStringLen encoding filePath Bytes.packCStringLen undoCaseHack :: FilePath -> FilePath undoCaseHack f = diff --git a/hnix-store-nar/tests/NarFormat.hs b/hnix-store-nar/tests/NarFormat.hs index 57b3662f..e5e5bd4b 100644 --- a/hnix-store-nar/tests/NarFormat.hs +++ b/hnix-store-nar/tests/NarFormat.hs @@ -37,6 +37,8 @@ import System.FilePath ((<.>), ()) import System.IO qualified as IO import System.IO.Temp qualified as Temp import System.Posix.Files qualified as Unix +import System.Posix.Files.ByteString qualified as UnixFilesBS +import System.Posix.IO.ByteString qualified as UnixBS import System.Posix.Process qualified as Unix import System.Process qualified as P import Test.Tasty as T @@ -144,6 +146,49 @@ unit_nixStoreDirectory = filesystemNixStore "directory" (Nar sampleDirectory) unit_nixStoreDirectory' :: HU.Assertion unit_nixStoreDirectory' = filesystemNixStore "directory'" (Nar sampleDirectory') +unit_nixStoreNonUtf8FilePaths :: HU.Assertion +unit_nixStoreNonUtf8FilePaths = + Temp.withSystemTempDirectory "hnix-store-non-utf8" $ \baseDir -> do + let rawBaseDir = BSC.pack baseDir + rawFileNames = + [ "NetLock_Arany_=Class_Gold=_F" <> BS.pack [0xf5] + <> "tan" <> BS.pack [0xfa, 0x73, 0xed, 0x74, 0x76, 0xe1] + <> "ny.crt" + -- These two names sort in the opposite order after the invalid byte + -- is decoded to a surrogate, so they also check raw-byte ordering. + , "sort-" <> BS.pack [0x80] + , "sort-" <> BS.pack [0xc2, 0x80] + ] + rawLinkTarget = "broken-target-" <> BS.pack [0xff] + rawLinkName = "link" + + forM_ rawFileNames $ \rawFileName -> do + fd <- UnixBS.createFile + (rawBaseDir <> "/" <> rawFileName) + Unix.ownerReadMode + UnixBS.closeFd fd + UnixFilesBS.createSymbolicLink + rawLinkTarget + (rawBaseDir <> "/" <> rawLinkName) + + hnixNar <- Temp.withSystemTempFile "hnix-store-non-utf8.nar" $ \narPath h -> do + buildNarIO narEffectsIO baseDir h + IO.hClose h + BS.readFile narPath + + forM_ (rawLinkTarget : rawFileNames) $ \rawPath -> + rawPath `BS.isInfixOf` hnixNar `shouldBe` True + + ver <- try (P.readProcess "nix-store" ["--version"] "") + case ver of + Left (_ :: SomeException) -> print ("No nix-store on system" :: String) + Right _ -> do + nixStoreNar <- BSL.toStrict <$> getNixStoreDump baseDir + HU.assertEqual + "non-UTF-8 paths serialize the same between hnix-store and nix-store" + nixStoreNar + hnixNar + -- | Test that the executable permissions are handled correctly in app bundles on macOS. -- In this case, access() returns false for a file under this specific path, even when the executable bit is set. -- NAR implementations should avoid this syscall on macOS. From 76fbab98949b320a9b3f361287bcf459e467e501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 7 Aug 2026 17:12:55 -0500 Subject: [PATCH 2/2] Skip non-UTF-8 filename fixture on macOS --- hnix-store-nar/tests/NarFormat.hs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/hnix-store-nar/tests/NarFormat.hs b/hnix-store-nar/tests/NarFormat.hs index e5e5bd4b..c613ad3f 100644 --- a/hnix-store-nar/tests/NarFormat.hs +++ b/hnix-store-nar/tests/NarFormat.hs @@ -34,6 +34,7 @@ import System.Directory ( doesDirectoryExist import System.Directory qualified as Directory import System.Environment (getEnv) import System.FilePath ((<.>), ()) +import System.Info qualified as Info import System.IO qualified as IO import System.IO.Temp qualified as Temp import System.Posix.Files qualified as Unix @@ -147,8 +148,11 @@ unit_nixStoreDirectory' :: HU.Assertion unit_nixStoreDirectory' = filesystemNixStore "directory'" (Nar sampleDirectory') unit_nixStoreNonUtf8FilePaths :: HU.Assertion -unit_nixStoreNonUtf8FilePaths = - Temp.withSystemTempDirectory "hnix-store-non-utf8" $ \baseDir -> do +unit_nixStoreNonUtf8FilePaths + -- APFS only permits valid UTF-8 filenames, so the fixture cannot be + -- constructed on macOS. Linux still exercises the filesystem integration. + | Info.os == "darwin" = pure () + | otherwise = Temp.withSystemTempDirectory "hnix-store-non-utf8" $ \baseDir -> do let rawBaseDir = BSC.pack baseDir rawFileNames = [ "NetLock_Arany_=Class_Gold=_F" <> BS.pack [0xf5]