From 77947bb0b60f0653e1e9b944f93b1afeb654d5d3 Mon Sep 17 00:00:00 2001 From: Roshan Kumar Gupta <31376345+roshangupta00750@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:54:34 +0200 Subject: [PATCH] fix(macos): omit AppleDouble members from preset and support-bundle archives macOS bsdtar adds a ._ AppleDouble member for every file with extended attributes and hides it from its own listing. A preset exported on macOS therefore led with ._, preset import took that as the preset name, and the import on Linux failed with "missing meta.txt" after extracting the real preset. The support bundle carried the same binary companions next to every file. Run both archive writers under COPYFILE_DISABLE=1, as ods-backup.sh already does, and make preset import skip ._* entries when it takes the preset name so archives exported before this fix still import. Fixes #7341 --- ods/ods-cli | 10 +- ods/scripts/ods-support-bundle.sh | 7 +- ods/tests/ci-suite.txt | 1 + ods/tests/test-preset-macos-appledouble.sh | 161 +++++++++++++++++++++ ods/tests/test-support-bundle.sh | 18 +++ 5 files changed, 194 insertions(+), 3 deletions(-) create mode 100755 ods/tests/test-preset-macos-appledouble.sh diff --git a/ods/ods-cli b/ods/ods-cli index 67f34f4204..ac54b8aae4 100755 --- a/ods/ods-cli +++ b/ods/ods-cli @@ -3530,7 +3530,9 @@ META trap 'exit 143' TERM # Finish privately before replacing an existing public file. chmod 600 "$staged_archive" || exit 1 - tar czf "$staged_archive" "$name" 2>/dev/null || exit 1 + # COPYFILE_DISABLE stops macOS bsdtar adding AppleDouble `._*` + # members, which it hides from its own listing but importers see. + COPYFILE_DISABLE=1 tar czf "$staged_archive" "$name" 2>/dev/null || exit 1 [[ ! -d "$output" ]] || exit 1 mv -f -- "$staged_archive" "$output" ); then @@ -3557,7 +3559,11 @@ META # Validate archive structure local archive_name - archive_name=$(tar tzf "$archive" 2>/dev/null | sed -n '1p' | cut -d/ -f1) + # Take the first real member, skipping AppleDouble `._*` entries: an + # archive exported from macOS before the guard above leads with + # `._`, which GNU tar lists, and the preset name would otherwise + # resolve to `._` and fail validation. + archive_name=$(tar tzf "$archive" 2>/dev/null | awk '!/(^|\/)\._/ { print; exit }' | cut -d/ -f1) [[ -z "$archive_name" ]] && error "Invalid archive structure" # Check if preset already exists diff --git a/ods/scripts/ods-support-bundle.sh b/ods/scripts/ods-support-bundle.sh index ab10acfd23..358e0cabd1 100755 --- a/ods/scripts/ods-support-bundle.sh +++ b/ods/scripts/ods-support-bundle.sh @@ -863,7 +863,12 @@ write_evidence write_manifest chmod -R go-rwx "$BUNDLE_DIR" -(umask 077 && tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" "$BUNDLE_NAME") +# macOS `tar` (bsdtar) embeds AppleDouble `._*` metadata companions for files +# that carry extended attributes. They unpack as real `._*` files wherever the +# bundle is opened and break tooling that decodes bundle files as text. +# COPYFILE_DISABLE tells bsdtar to omit them; GNU tar ignores it. Same guard as +# the backup archive in ods-backup.sh. +(umask 077 && COPYFILE_DISABLE=1 tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" "$BUNDLE_NAME") if [[ "$JSON_OUTPUT" == "true" ]]; then write_summary_json diff --git a/ods/tests/ci-suite.txt b/ods/tests/ci-suite.txt index e0523af52f..31b28974af 100644 --- a/ods/tests/ci-suite.txt +++ b/ods/tests/ci-suite.txt @@ -146,6 +146,7 @@ tests/test-preflight.sh tests/test-preset-compatibility.sh tests/test-preset-diff.sh tests/test-preset-import-export.sh +tests/test-preset-macos-appledouble.sh tests/test-preview-inspection.mjs tests/test-restore-data-transaction-contract.sh tests/test-restore-empty-config.sh diff --git a/ods/tests/test-preset-macos-appledouble.sh b/ods/tests/test-preset-macos-appledouble.sh new file mode 100755 index 0000000000..67f6c9c84e --- /dev/null +++ b/ods/tests/test-preset-macos-appledouble.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Presets exported on macOS must import on every platform. +# +# macOS `tar` (bsdtar) writes AppleDouble `._*` metadata companions for files +# carrying extended attributes, and puts `._` ahead of `/`. bsdtar +# hides those members from its own `tar tzf` listing, so the exporting machine +# sees a clean archive, but GNU tar lists them. `preset import` took the first +# listed member as the preset name, so on Linux the name resolved to +# `._`, validation could not find `/meta.txt`, and the import died +# with "Invalid preset: missing meta.txt" while leaving the real preset +# directory extracted on disk. +# +# Export now sets COPYFILE_DISABLE so new archives carry no such members, and +# import skips them so archives already in circulation still work. +# +# Runs the real CLI against a throwaway install. No Docker is needed. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ODS_CLI="$ROOT_DIR/ods-cli" + +PASS=0 +FAIL=0 +pass() { PASS=$((PASS + 1)); printf '[PASS] %s\n' "$1"; } +fail() { FAIL=$((FAIL + 1)); printf '[FAIL] %s\n' "$1" >&2; } + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +# make_install : the minimal install the CLI accepts (check_install + sr_load) +make_install() { + local dir="$1" + mkdir -p "$dir/lib" "$dir/extensions/services" + cp "$ODS_CLI" "$dir/ods-cli" + cp "$ROOT_DIR"/lib/*.sh "$dir/lib/" + : > "$dir/docker-compose.base.yml" +} + +run_cli() { # run_cli + local install="$1" + shift + ODS_HOME="$install" bash "$install/ods-cli" "$@" +} + +# ── Source contract: export must disable AppleDouble metadata ──────────────── +# Has teeth on every platform, including Linux CI where bsdtar's `._*` behavior +# cannot be reproduced: a future edit that drops the guard fails here. +if grep -Eq 'COPYFILE_DISABLE=[^ ]* +tar +czf' "$ODS_CLI"; then + pass "preset export guards the tar invocation with COPYFILE_DISABLE" +else + fail "preset export must run 'tar czf' under COPYFILE_DISABLE to omit AppleDouble metadata" +fi + +# ── Behavioral: an archive that already carries `._*` members must import ──── +# The archive is synthesized rather than produced by bsdtar, so it is identical +# on every platform. The teeth are on Linux, which is where the bug bites and +# where CI runs: GNU tar lists the `._` member, so before the fix the +# name resolved to `._shared-setup` and this import failed. macOS bsdtar hides +# the member from its own listing, so there the check is a sanity check. +make_appledouble_archive() { # make_appledouble_archive + python3 - "$1" "$2" <<'PY' +import io, sys, tarfile + +archive, name = sys.argv[1], sys.argv[2] + +def add(tar, path, payload): + data = payload.encode() + info = tarfile.TarInfo(path) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + +with tarfile.open(archive, "w:gz") as tar: + # bsdtar emits the AppleDouble companion immediately before what it + # describes, so the top-level `._` lands first — exactly where the + # CLI looked for the preset name. Only that member is modelled: the + # per-file companions play no part in resolving the name, and bsdtar + # would try to apply a synthetic one as real metadata on macOS. + add(tar, "._%s" % name, "Mac OS X AppleDouble payload") + directory = tarfile.TarInfo("%s/" % name) + directory.type = tarfile.DIRTYPE + directory.mode = 0o755 + tar.addfile(directory) + add(tar, "%s/meta.txt" % name, "name=%s\n" % name) + add(tar, "%s/extensions.list" % name, "dashboard\n") +PY +} + +TARGET="$TMP_DIR/target" +make_install "$TARGET" +ARCHIVE="$TMP_DIR/shared-setup.tar.gz" +make_appledouble_archive "$ARCHIVE" "shared-setup" + +if run_cli "$TARGET" preset import "$ARCHIVE" > "$TMP_DIR/import.out" 2>&1; then + if [[ -f "$TARGET/presets/shared-setup/meta.txt" ]]; then + pass "a macOS archive carrying AppleDouble members imports as 'shared-setup'" + else + fail "import reported success but presets/shared-setup/meta.txt is missing" + fi +else + sed 's/^/ /' "$TMP_DIR/import.out" + fail "importing an archive with AppleDouble members failed" +fi + +# The AppleDouble entry must never be mistaken for the preset itself. +if [[ -d "$TARGET/presets/._shared-setup" ]]; then + fail "import created a preset directory from the AppleDouble member" +else + pass "no preset directory is created from the AppleDouble member" +fi + +# ── Regression guard: a clean archive still imports unchanged ──────────────── +CLEAN_SRC="$TMP_DIR/clean" +mkdir -p "$CLEAN_SRC/plain-setup" +printf 'name=plain-setup\n' > "$CLEAN_SRC/plain-setup/meta.txt" +printf 'dashboard\n' > "$CLEAN_SRC/plain-setup/extensions.list" +CLEAN_ARCHIVE="$TMP_DIR/plain-setup.tar.gz" +COPYFILE_DISABLE=1 tar czf "$CLEAN_ARCHIVE" -C "$CLEAN_SRC" plain-setup + +if run_cli "$TARGET" preset import "$CLEAN_ARCHIVE" > "$TMP_DIR/clean.out" 2>&1 \ + && [[ -f "$TARGET/presets/plain-setup/meta.txt" ]]; then + pass "an archive without AppleDouble members imports unchanged" +else + sed 's/^/ /' "$TMP_DIR/clean.out" + fail "importing a clean archive regressed" +fi + +# ── Round trip: export then import through the real CLI ────────────────────── +SOURCE="$TMP_DIR/source" +make_install "$SOURCE" +(umask 077 && printf 'GPU_BACKEND=nvidia\n' > "$SOURCE/.env") +run_cli "$SOURCE" preset save round-trip > "$TMP_DIR/save.out" 2>&1 \ + || { sed 's/^/ /' "$TMP_DIR/save.out"; fail "preset save failed"; } + +ROUND_TRIP="$TMP_DIR/round-trip.tar.gz" +if run_cli "$SOURCE" preset export round-trip "$ROUND_TRIP" > "$TMP_DIR/export.out" 2>&1; then + stray="$(python3 -c ' +import sys, tarfile +names = tarfile.open(sys.argv[1]).getnames() +print("\n".join(n for n in names if n.split("/")[-1].startswith("._"))) +' "$ROUND_TRIP")" + if [[ -n "$stray" ]]; then + echo "$stray" | sed 's/^/ stray member: /' + fail "exported archive contains AppleDouble members" + else + pass "exported archive carries no AppleDouble members" + fi + + if run_cli "$TARGET" preset import "$ROUND_TRIP" > "$TMP_DIR/rt-import.out" 2>&1 \ + && [[ -f "$TARGET/presets/round-trip/meta.txt" ]]; then + pass "exported preset imports on a second install" + else + sed 's/^/ /' "$TMP_DIR/rt-import.out" + fail "exported preset did not import" + fi +else + sed 's/^/ /' "$TMP_DIR/export.out" + fail "preset export failed" +fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/ods/tests/test-support-bundle.sh b/ods/tests/test-support-bundle.sh index 5c55ae47d3..9878e19934 100755 --- a/ods/tests/test-support-bundle.sh +++ b/ods/tests/test-support-bundle.sh @@ -168,6 +168,24 @@ else fail "archive does not contain manifest/evidence.json" fi +# macOS bsdtar embeds AppleDouble `._*` companions unless COPYFILE_DISABLE is +# set; they unpack as binary junk files throughout the bundle. bsdtar hides +# them from its own `tar -tzf` listing, so list the raw members with tarfile. +if python3 -c 'import sys, tarfile; print("\n".join(tarfile.open(sys.argv[1]).getnames()))' "$ARCHIVE_PATH" \ + | grep -qE '(^|/)\._'; then + fail "archive contains AppleDouble (._*) metadata members" +else + pass "archive has no AppleDouble metadata members" +fi + +# Deterministic on every platform, including Linux CI where bsdtar's AppleDouble +# behaviour cannot be reproduced: a future edit dropping the guard fails here. +if grep -Eq 'COPYFILE_DISABLE=[^ ]* +tar +-czf' "$SUPPORT_SCRIPT"; then + pass "archive tar runs under COPYFILE_DISABLE" +else + fail "archive tar must run under COPYFILE_DISABLE to omit AppleDouble metadata" +fi + if [[ -f "$BUNDLE_DIR/config/env.redacted" ]] && grep -q "DASHBOARD_API_KEY=\\[REDACTED\\]" "$BUNDLE_DIR/config/env.redacted"; then pass ".env is included only as redacted env" else