| 1 | """Tests for Safari binary cookie extraction.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import struct |
| 6 | import sys |
| 7 | from pathlib import Path |
| 8 | from unittest.mock import patch |
| 9 | |
| 10 | import pytest |
| 11 | |
| 12 | # Import the internal parser directly for testability (avoids platform check) |
| 13 | from lib.safari_cookies import ( |
| 14 | _parse_binary_cookies, |
| 15 | extract_safari_cookies_macos, |
| 16 | ) |
| 17 | |
| 18 | |
| 19 | def _build_cookie_record(url: str, name: str, value: str, path: str = "/") -> bytes: |
| 20 | """Build a single binary cookie record.""" |
| 21 | # Fixed header: size(4) + flags(4) + padding(8) + url_off(4) + name_off(4) + path_off(4) + value_off(4) + comment(8) + expiry(8) + creation(8) |
| 22 | # Total fixed = 4 + 4 + 8 + 4 + 4 + 4 + 4 + 8 + 8 + 8 = 56 bytes |
| 23 | # String data starts at offset 56 |
| 24 | |
| 25 | url_b = url.encode("utf-8") + b"\x00" |
| 26 | name_b = name.encode("utf-8") + b"\x00" |
| 27 | path_b = path.encode("utf-8") + b"\x00" |
| 28 | value_b = value.encode("utf-8") + b"\x00" |
| 29 | |
| 30 | str_offset_base = 56 |
| 31 | url_offset = str_offset_base |
| 32 | name_offset = url_offset + len(url_b) |
| 33 | path_offset = name_offset + len(name_b) |
| 34 | value_offset = path_offset + len(path_b) |
| 35 | |
| 36 | total_size = value_offset + len(value_b) |
| 37 | |
| 38 | record = struct.pack("<I", total_size) # size |
| 39 | record += struct.pack("<I", 0) # flags |
| 40 | record += b"\x00" * 8 # padding/unknown |
| 41 | record += struct.pack("<I", url_offset) # url offset |
| 42 | record += struct.pack("<I", name_offset) # name offset |
| 43 | record += struct.pack("<I", path_offset) # path offset |
| 44 | record += struct.pack("<I", value_offset) # value offset |
| 45 | record += b"\x00" * 8 # comment (unused) |
| 46 | record += struct.pack("<d", 700000000.0) # expiry (Mac epoch) |
| 47 | record += struct.pack("<d", 690000000.0) # creation (Mac epoch) |
| 48 | record += url_b + name_b + path_b + value_b |
| 49 | |
| 50 | return record |
| 51 | |
| 52 | |
| 53 | def _build_page(cookie_records: list[bytes]) -> bytes: |
| 54 | """Build a binary cookies page from a list of cookie records.""" |
| 55 | num_cookies = len(cookie_records) |
| 56 | |
| 57 | # Page header: 4-byte marker + 4-byte cookie count + offset array |
| 58 | header_size = 4 + 4 + num_cookies * 4 |
| 59 | # Also add 4 bytes for end-of-page marker |
| 60 | offsets_start = header_size |
| 61 | |
| 62 | # Calculate offsets for each cookie record |
| 63 | offsets = [] |
| 64 | current_offset = offsets_start |
| 65 | for rec in cookie_records: |
| 66 | offsets.append(current_offset) |
| 67 | current_offset += len(rec) |
| 68 | |
| 69 | page = b"\x00\x00\x01\x00" # page header marker |
| 70 | page += struct.pack("<I", num_cookies) |
| 71 | for off in offsets: |
| 72 | page += struct.pack("<I", off) |
| 73 | for rec in cookie_records: |
| 74 | page += rec |
| 75 | |
| 76 | return page |
| 77 | |
| 78 | |
| 79 | def _build_binary_cookies_file(pages: list[bytes]) -> bytes: |
| 80 | """Build a complete Cookies.binarycookies file from pages.""" |
| 81 | num_pages = len(pages) |
| 82 | |
| 83 | data = b"cook" # magic |
| 84 | data += struct.pack(">I", num_pages) # page count (big-endian) |
| 85 | |
| 86 | # Page sizes (big-endian) |
| 87 | for page in pages: |
| 88 | data += struct.pack(">I", len(page)) |
| 89 | |
| 90 | # Page data |
| 91 | for page in pages: |
| 92 | data += page |
| 93 | |
| 94 | return data |
| 95 | |
| 96 | @pytest.fixture |
| 97 | |
| 98 | |
| 99 | def x_cookies_file() -> bytes: |
| 100 | """Build a minimal valid binary cookies file with .x.com cookies.""" |
| 101 | rec1 = _build_cookie_record(".x.com", "auth_token", "test_auth_abc123") |
| 102 | rec2 = _build_cookie_record(".x.com", "ct0", "test_ct0_xyz789") |
| 103 | rec3 = _build_cookie_record(".google.com", "NID", "google_nid_value") |
| 104 | page = _build_page([rec1, rec2, rec3]) |
| 105 | return _build_binary_cookies_file([page]) |
| 106 | |
| 107 | |
| 108 | class TestParseValidCookies: |
| 109 | def test_extracts_matching_cookies(self, x_cookies_file: bytes): |
| 110 | result = _parse_binary_cookies(x_cookies_file, "x.com", ["auth_token", "ct0"]) |
| 111 | assert result is not None |
| 112 | assert result["auth_token"] == "test_auth_abc123" |
| 113 | assert result["ct0"] == "test_ct0_xyz789" |
| 114 | |
| 115 | def test_ignores_other_domains(self, x_cookies_file: bytes): |
| 116 | result = _parse_binary_cookies(x_cookies_file, "google.com", ["auth_token"]) |
| 117 | assert result is None |
| 118 | |
| 119 | def test_partial_match_returns_found_only(self, x_cookies_file: bytes): |
| 120 | result = _parse_binary_cookies( |
| 121 | x_cookies_file, "x.com", ["auth_token", "nonexistent"] |
| 122 | ) |
| 123 | assert result is not None |
| 124 | assert result["auth_token"] == "test_auth_abc123" |
| 125 | assert "nonexistent" not in result |
| 126 | |
| 127 | def test_no_matching_cookie_names(self, x_cookies_file: bytes): |
| 128 | result = _parse_binary_cookies(x_cookies_file, "x.com", ["bogus"]) |
| 129 | assert result is None |
| 130 | |
| 131 | def test_domain_substring_match_with_leading_dot(self, x_cookies_file: bytes): |
| 132 | """Domain '.x.com' in cookie should match search for 'x.com'.""" |
| 133 | result = _parse_binary_cookies(x_cookies_file, "x.com", ["auth_token"]) |
| 134 | assert result is not None |
| 135 | assert result["auth_token"] == "test_auth_abc123" |
| 136 | |
| 137 | |
| 138 | class TestMultiplePages: |
| 139 | def test_cookies_across_pages(self): |
| 140 | rec1 = _build_cookie_record(".x.com", "auth_token", "page1_auth") |
| 141 | rec2 = _build_cookie_record(".x.com", "ct0", "page2_ct0") |
| 142 | page1 = _build_page([rec1]) |
| 143 | page2 = _build_page([rec2]) |
| 144 | data = _build_binary_cookies_file([page1, page2]) |
| 145 | |
| 146 | result = _parse_binary_cookies(data, "x.com", ["auth_token", "ct0"]) |
| 147 | assert result is not None |
| 148 | assert result["auth_token"] == "page1_auth" |
| 149 | assert result["ct0"] == "page2_ct0" |
| 150 | |
| 151 | |
| 152 | class TestErrorPaths: |
| 153 | def test_file_not_found(self, tmp_path: Path): |
| 154 | with patch( |
| 155 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 156 | ), patch("lib.safari_cookies.sys") as mock_sys: |
| 157 | mock_sys.platform = "darwin" |
| 158 | mock_sys.stderr = sys.stderr |
| 159 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 160 | assert result is None |
| 161 | |
| 162 | def test_prefers_sandboxed_safari_cookie_path( |
| 163 | self, tmp_path: Path, x_cookies_file: bytes |
| 164 | ): |
| 165 | sandbox_dir = ( |
| 166 | tmp_path |
| 167 | / "Library" |
| 168 | / "Containers" |
| 169 | / "com.apple.Safari" |
| 170 | / "Data" |
| 171 | / "Library" |
| 172 | / "Cookies" |
| 173 | ) |
| 174 | sandbox_dir.mkdir(parents=True) |
| 175 | (sandbox_dir / "Cookies.binarycookies").write_bytes(x_cookies_file) |
| 176 | |
| 177 | legacy_dir = tmp_path / "Library" / "Cookies" |
| 178 | legacy_dir.mkdir(parents=True) |
| 179 | legacy_data = _build_binary_cookies_file( |
| 180 | [_build_page([_build_cookie_record(".x.com", "auth_token", "legacy")])] |
| 181 | ) |
| 182 | (legacy_dir / "Cookies.binarycookies").write_bytes(legacy_data) |
| 183 | |
| 184 | with patch( |
| 185 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 186 | ), patch("lib.safari_cookies.sys") as mock_sys: |
| 187 | mock_sys.platform = "darwin" |
| 188 | mock_sys.stderr = sys.stderr |
| 189 | result = extract_safari_cookies_macos("x.com", ["auth_token", "ct0"]) |
| 190 | |
| 191 | assert result is not None |
| 192 | assert result["auth_token"] == "test_auth_abc123" |
| 193 | assert result["ct0"] == "test_ct0_xyz789" |
| 194 | |
| 195 | def test_falls_back_to_legacy_safari_cookie_path(self, tmp_path: Path): |
| 196 | # Sandboxed path is intentionally NOT created — only the legacy path exists. |
| 197 | legacy_dir = tmp_path / "Library" / "Cookies" |
| 198 | legacy_dir.mkdir(parents=True) |
| 199 | legacy_data = _build_binary_cookies_file( |
| 200 | [_build_page([_build_cookie_record(".x.com", "auth_token", "legacy_auth")])] |
| 201 | ) |
| 202 | (legacy_dir / "Cookies.binarycookies").write_bytes(legacy_data) |
| 203 | |
| 204 | sandbox_path = ( |
| 205 | tmp_path |
| 206 | / "Library" |
| 207 | / "Containers" |
| 208 | / "com.apple.Safari" |
| 209 | / "Data" |
| 210 | / "Library" |
| 211 | / "Cookies" |
| 212 | / "Cookies.binarycookies" |
| 213 | ) |
| 214 | assert not sandbox_path.exists() |
| 215 | |
| 216 | with patch( |
| 217 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 218 | ), patch("lib.safari_cookies.sys") as mock_sys: |
| 219 | mock_sys.platform = "darwin" |
| 220 | mock_sys.stderr = sys.stderr |
| 221 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 222 | |
| 223 | assert result is not None |
| 224 | assert result["auth_token"] == "legacy_auth" |
| 225 | |
| 226 | def test_permission_denied(self, tmp_path: Path, capsys): |
| 227 | cookie_dir = ( |
| 228 | tmp_path |
| 229 | / "Library" |
| 230 | / "Containers" |
| 231 | / "com.apple.Safari" |
| 232 | / "Data" |
| 233 | / "Library" |
| 234 | / "Cookies" |
| 235 | ) |
| 236 | cookie_dir.mkdir(parents=True) |
| 237 | cookie_file = cookie_dir / "Cookies.binarycookies" |
| 238 | cookie_file.write_bytes(b"cook") |
| 239 | |
| 240 | with patch( |
| 241 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 242 | ), patch("lib.safari_cookies.sys") as mock_sys, patch.object( |
| 243 | Path, "read_bytes", side_effect=PermissionError |
| 244 | ): |
| 245 | mock_sys.platform = "darwin" |
| 246 | mock_sys.stderr = sys.stderr |
| 247 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 248 | assert result is None |
| 249 | captured = capsys.readouterr() |
| 250 | assert "Full Disk Access" in captured.err |
| 251 | |
| 252 | def test_truncated_magic_only(self): |
| 253 | result = _parse_binary_cookies(b"cook", "x.com", ["auth_token"]) |
| 254 | assert result is None |
| 255 | |
| 256 | def test_empty_file(self): |
| 257 | result = _parse_binary_cookies(b"", "x.com", ["auth_token"]) |
| 258 | assert result is None |
| 259 | |
| 260 | def test_wrong_magic(self): |
| 261 | result = _parse_binary_cookies(b"notcook!", "x.com", ["auth_token"]) |
| 262 | assert result is None |
| 263 | |
| 264 | def test_truncated_page_sizes(self): |
| 265 | # Header says 5 pages but data is too short |
| 266 | data = b"cook" + struct.pack(">I", 5) + b"\x00" * 4 |
| 267 | result = _parse_binary_cookies(data, "x.com", ["auth_token"]) |
| 268 | assert result is None |
| 269 | |
| 270 | def test_truncated_page_data(self): |
| 271 | # Valid header with 1 page of size 1000, but no actual page data |
| 272 | data = b"cook" + struct.pack(">I", 1) + struct.pack(">I", 1000) |
| 273 | result = _parse_binary_cookies(data, "x.com", ["auth_token"]) |
| 274 | assert result is None |
| 275 | |
| 276 | def test_non_darwin_returns_none(self): |
| 277 | with patch("lib.safari_cookies.sys") as mock_sys: |
| 278 | mock_sys.platform = "linux" |
| 279 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 280 | assert result is None |
| 281 | |
| 282 | def test_garbage_data_no_crash(self): |
| 283 | """Random bytes after valid magic should not crash.""" |
| 284 | import os |
| 285 | |
| 286 | data = b"cook" + os.urandom(200) |
| 287 | # Should not raise — may return None or a dict |
| 288 | result = _parse_binary_cookies(data, "x.com", ["auth_token"]) |
| 289 | # Just verify no exception; result is either None or dict |
| 290 | assert result is None or isinstance(result, dict) |
| 291 |