Skip to content
Merged
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ TypeScript and JavaScript gates are not applicable; the offline report contains
entitlement checks, notarization, stapling, Gatekeeper assessment, privacy scanning, license
collection, exact pre/post-sign native Mach-O license-manifest verification, release-manifest
generation, and checksums before publication is considered.
- Intel macOS resolves the current 64-bit-inode `statfs` ABI through `statfs$INODE64`; the bare
symbol has a legacy layout. Keep the local-volume probe architecture-aware and retain the signed
packaged-helper smoke for both `arm64` and `x86_64`.
- Release packaging must name the reviewed full Git commit, start and finish with a clean worktree,
execute only from a read-only `git archive` of that commit, disable automatic Swift resolution,
rebuild helpers from hash-locked dependencies without trusting caches, and record the source
Expand Down
13 changes: 13 additions & 0 deletions script/build_release_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,10 @@ build_release_for_architecture() {
/bin/cp "$swift_binary" "$app_macos/$EXECUTABLE_NAME"
/usr/bin/strip -S "$app_macos/$EXECUTABLE_NAME"
/bin/chmod 755 "$app_macos/$EXECUTABLE_NAME"
# Cross-compiled Swift executables are unsigned. Give both architectures the
# same valid terminal ad-hoc signature so the pre-sign canonical license hash
# can be compared with the later Developer ID-signed binary.
/usr/bin/codesign --force --sign - "$app_macos/$EXECUTABLE_NAME"
verify_macho_exact_architecture "$app_macos/$EXECUTABLE_NAME" "$architecture"

helper_python="$ROOT_DIR/.build-tools/helper-venv-$architecture/bin/python"
Expand All @@ -391,6 +395,15 @@ build_release_for_architecture() {
/bin/cp "$ROOT_DIR/macos/Bundle/THIRD_PARTY_NOTICES.md" "$app_resources/"
/bin/cp "$ROOT_DIR/LICENSE" "$app_resources/PROJECT_LICENSE.txt"
/bin/cp "$HELPER_INFO_PLIST" "$helper_contents/Info.plist"
# Git release staging deliberately makes tracked inputs read-only. The copied
# bundle resources must be owner-writable so xattr can remove staging metadata
# before the bundle is signed.
/bin/chmod u+w \
"$app_contents/Info.plist" \
"$app_resources/AppIcon.icns" \
"$app_resources/THIRD_PARTY_NOTICES.md" \
"$app_resources/PROJECT_LICENSE.txt" \
"$helper_contents/Info.plist"
/bin/cp "$helper_source/tvtime-helper" "$helper_macos/tvtime-helper"
/bin/chmod 755 "$helper_macos/tvtime-helper"
/usr/bin/ditto "$helper_source/_internal" "$helper_resources/_internal"
Expand Down
29 changes: 29 additions & 0 deletions tests/test_release_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,35 @@ def test_packaging_verifies_native_inventory_after_signing_and_from_dmg(self) ->
mounted_verification,
)

def test_release_builder_makes_copied_source_resources_writable_before_xattr(self) -> None:
contents = (ROOT / "script" / "build_release_app.sh").read_text(encoding="utf-8")
function_contents = contents[contents.index("build_release_for_architecture()") :]
chmod_index = function_contents.index("/bin/chmod u+w")
xattr_index = function_contents.index('/usr/bin/xattr -cr "$app_bundle"')
self.assertLess(chmod_index, xattr_index)
chmod_invocation = function_contents[chmod_index:xattr_index]
for copied_resource in (
'"$app_contents/Info.plist"',
'"$app_resources/AppIcon.icns"',
'"$app_resources/THIRD_PARTY_NOTICES.md"',
'"$app_resources/PROJECT_LICENSE.txt"',
'"$helper_contents/Info.plist"',
):
self.assertIn(copied_resource, chmod_invocation)

def test_release_builder_signs_cross_compiled_swift_before_license_inventory(self) -> None:
contents = (ROOT / "script" / "build_release_app.sh").read_text(encoding="utf-8")
function_contents = contents[contents.index("build_release_for_architecture()") :]
strip_index = function_contents.index('/usr/bin/strip -S "$app_macos/$EXECUTABLE_NAME"')
ad_hoc_sign_index = function_contents.index(
'/usr/bin/codesign --force --sign - "$app_macos/$EXECUTABLE_NAME"'
)
license_inventory_index = function_contents.index(
'"$ROOT_DIR/script/collect_macos_licenses.py"'
)
self.assertLess(strip_index, ad_hoc_sign_index)
self.assertLess(ad_hoc_sign_index, license_inventory_index)


class MacRunScriptContractTests(unittest.TestCase):
def test_telemetry_stream_is_narrow_and_attaches_before_launch(self) -> None:
Expand Down
34 changes: 34 additions & 0 deletions tests/test_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
_WINDOWS_FILE_SHARE_WRITE,
_WINDOWS_GENERIC_READ,
EXTRACTION_DIRECTORY_NAME,
_darwin_volume_is_local,
_linux_volume_is_local,
_windows_close_handle,
_windows_create_file_directory_handle,
Expand Down Expand Up @@ -252,6 +253,39 @@ def test_safe_join_refuses_nested_symbolic_link(self) -> None:


class DestinationSafetyTests(unittest.TestCase):
class _DarwinStatFSFunction:
def __init__(self, flags: int) -> None:
self.flags = flags
self.calls = 0
self.argtypes: object = None
self.restype: object = None

def __call__(self, _path: bytes, filesystem_pointer: object) -> int:
self.calls += 1
filesystem_pointer._obj.f_flags = self.flags # type: ignore[attr-defined]
return 0

def test_darwin_local_volume_prefers_current_inode64_statfs_abi(self) -> None:
legacy = self._DarwinStatFSFunction(0)
current = self._DarwinStatFSFunction(0x00001000)
libc = type("SyntheticDarwinLibC", (), {"statfs": legacy})()
setattr(libc, "statfs$INODE64", current)

with mock.patch("tvtime_extractor.safety.ctypes.CDLL", return_value=libc):
self.assertTrue(_darwin_volume_is_local(Path("/synthetic/private")))

self.assertEqual(current.calls, 1)
self.assertEqual(legacy.calls, 0)

def test_darwin_local_volume_uses_bare_statfs_when_modern_symbol_is_absent(self) -> None:
current = self._DarwinStatFSFunction(0x00001000)
libc = type("SyntheticDarwinLibC", (), {"statfs": current})()

with mock.patch("tvtime_extractor.safety.ctypes.CDLL", return_value=libc):
self.assertTrue(_darwin_volume_is_local(Path("/synthetic/private")))

self.assertEqual(current.calls, 1)

@staticmethod
def _backup(base: Path) -> Path:
backup = base / "backup"
Expand Down
9 changes: 8 additions & 1 deletion tvtime_extractor/safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,14 @@ def is_known_synced_or_shared_path(

def _darwin_volume_is_local(path: Path) -> bool:
libc = ctypes.CDLL(None, use_errno=True)
statfs = getattr(libc, "statfs", None)
# Intel macOS keeps the pre-64-bit-inode ``statfs`` ABI under the bare
# symbol and exposes the current ``struct statfs`` layout through the
# suffixed symbol. Apple silicon has only the current bare symbol. Prefer
# the explicit modern ABI when it exists so an x86_64 helper running under
# Rosetta does not read shifted fields and misclassify local APFS storage.
statfs = getattr(libc, "statfs$INODE64", None)
if statfs is None:
statfs = getattr(libc, "statfs", None)
if statfs is None:
return False
statfs.argtypes = [ctypes.c_char_p, ctypes.POINTER(_DarwinStatFS)]
Expand Down