diff --git a/ods/ods-cli b/ods/ods-cli index fc1b97d414..2da6f2eb5d 100755 --- a/ods/ods-cli +++ b/ods/ods-cli @@ -3800,7 +3800,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 @@ -3827,7 +3829,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 dcf36f82c6..7becd4fdaa 100755 --- a/ods/scripts/ods-support-bundle.sh +++ b/ods/scripts/ods-support-bundle.sh @@ -868,7 +868,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 c315a28274..08290ddcf1 100644 --- a/ods/tests/ci-suite.txt +++ b/ods/tests/ci-suite.txt @@ -184,6 +184,7 @@ tests/test-preset-compatibility.sh tests/test-preset-diff.sh tests/test-preset-import-export.sh tests/test-preset-import-transaction.sh +tests/test-preset-macos-appledouble.sh tests/test-preview-inspection.mjs tests/test-resolve-compose-nondict-service.sh tests/test-resolve-compose-symlinked-install.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