Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions ods/ods-cli
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
# `._<name>`, which GNU tar lists, and the preset name would otherwise
# resolve to `._<name>` 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
Expand Down
7 changes: 6 additions & 1 deletion ods/scripts/ods-support-bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ods/tests/ci-suite.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions ods/tests/test-preset-macos-appledouble.sh
Original file line number Diff line number Diff line change
@@ -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 `._<name>` ahead of `<name>/`. 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
# `._<name>`, validation could not find `<name>/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 <dir>: 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 <install> <args...>
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 `._<name>` 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 <path> <preset_name>
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 `._<name>` 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 ]]
18 changes: 18 additions & 0 deletions ods/tests/test-support-bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading