| 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_leading_dot_stored_host_matches_bare_domain(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 | def _single_cookie_file(stored_host: str) -> bytes: |
| 139 | rec = _build_cookie_record(stored_host, "auth_token", "fake_value_for_" + stored_host) |
| 140 | return _build_binary_cookies_file([_build_page([rec])]) |
| 141 | |
| 142 | |
| 143 | class TestDomainMatching: |
| 144 | @pytest.mark.parametrize("wanted", ["x.com", ".x.com"]) |
| 145 | def test_exact_host_match(self, wanted: str): |
| 146 | result = _parse_binary_cookies(_single_cookie_file("x.com"), wanted, ["auth_token"]) |
| 147 | assert result == {"auth_token": "fake_value_for_x.com"} |
| 148 | |
| 149 | @pytest.mark.parametrize("wanted", ["x.com", ".x.com"]) |
| 150 | def test_leading_dot_stored_host_matches(self, wanted: str): |
| 151 | result = _parse_binary_cookies(_single_cookie_file(".x.com"), wanted, ["auth_token"]) |
| 152 | assert result == {"auth_token": "fake_value_for_.x.com"} |
| 153 | |
| 154 | @pytest.mark.parametrize("stored", ["api.x.com", ".api.x.com"]) |
| 155 | @pytest.mark.parametrize("wanted", ["x.com", ".x.com"]) |
| 156 | def test_subdomain_match(self, stored: str, wanted: str): |
| 157 | result = _parse_binary_cookies(_single_cookie_file(stored), wanted, ["auth_token"]) |
| 158 | assert result == {"auth_token": "fake_value_for_" + stored} |
| 159 | |
| 160 | def test_case_insensitive_host(self): |
| 161 | result = _parse_binary_cookies(_single_cookie_file(".X.com"), "x.com", ["auth_token"]) |
| 162 | assert result == {"auth_token": "fake_value_for_.X.com"} |
| 163 | |
| 164 | @pytest.mark.parametrize( |
| 165 | "stored", |
| 166 | [ |
| 167 | "notx.com", |
| 168 | ".notx.com", |
| 169 | "evilx.com", |
| 170 | "x.com.evil.tld", |
| 171 | ".x.com.evil.tld", |
| 172 | "api.x.com.evil.tld", |
| 173 | "xcom", |
| 174 | "com", |
| 175 | ], |
| 176 | ) |
| 177 | @pytest.mark.parametrize("wanted", ["x.com", ".x.com"]) |
| 178 | def test_rejects_hosts_that_merely_contain_domain(self, stored: str, wanted: str): |
| 179 | result = _parse_binary_cookies(_single_cookie_file(stored), wanted, ["auth_token"]) |
| 180 | assert result is None |
| 181 | |
| 182 | def test_rejects_parent_domain_when_subdomain_requested(self): |
| 183 | result = _parse_binary_cookies(_single_cookie_file(".com"), "x.com", ["auth_token"]) |
| 184 | assert result is None |
| 185 | |
| 186 | def test_empty_stored_host_never_matches(self): |
| 187 | result = _parse_binary_cookies(_single_cookie_file(""), "x.com", ["auth_token"]) |
| 188 | assert result is None |
| 189 | |
| 190 | def test_empty_requested_domain_never_matches(self): |
| 191 | result = _parse_binary_cookies(_single_cookie_file(".x.com"), "", ["auth_token"]) |
| 192 | assert result is None |
| 193 | |
| 194 | |
| 195 | class TestMultiplePages: |
| 196 | def test_cookies_across_pages(self): |
| 197 | rec1 = _build_cookie_record(".x.com", "auth_token", "page1_auth") |
| 198 | rec2 = _build_cookie_record(".x.com", "ct0", "page2_ct0") |
| 199 | page1 = _build_page([rec1]) |
| 200 | page2 = _build_page([rec2]) |
| 201 | data = _build_binary_cookies_file([page1, page2]) |
| 202 | |
| 203 | result = _parse_binary_cookies(data, "x.com", ["auth_token", "ct0"]) |
| 204 | assert result is not None |
| 205 | assert result["auth_token"] == "page1_auth" |
| 206 | assert result["ct0"] == "page2_ct0" |
| 207 | |
| 208 | |
| 209 | class TestErrorPaths: |
| 210 | def test_file_not_found(self, tmp_path: Path): |
| 211 | with patch( |
| 212 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 213 | ), patch("lib.safari_cookies.sys") as mock_sys: |
| 214 | mock_sys.platform = "darwin" |
| 215 | mock_sys.stderr = sys.stderr |
| 216 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 217 | assert result is None |
| 218 | |
| 219 | def test_prefers_sandboxed_safari_cookie_path( |
| 220 | self, tmp_path: Path, x_cookies_file: bytes |
| 221 | ): |
| 222 | sandbox_dir = ( |
| 223 | tmp_path |
| 224 | / "Library" |
| 225 | / "Containers" |
| 226 | / "com.apple.Safari" |
| 227 | / "Data" |
| 228 | / "Library" |
| 229 | / "Cookies" |
| 230 | ) |
| 231 | sandbox_dir.mkdir(parents=True) |
| 232 | (sandbox_dir / "Cookies.binarycookies").write_bytes(x_cookies_file) |
| 233 | |
| 234 | legacy_dir = tmp_path / "Library" / "Cookies" |
| 235 | legacy_dir.mkdir(parents=True) |
| 236 | legacy_data = _build_binary_cookies_file( |
| 237 | [_build_page([_build_cookie_record(".x.com", "auth_token", "legacy")])] |
| 238 | ) |
| 239 | (legacy_dir / "Cookies.binarycookies").write_bytes(legacy_data) |
| 240 | |
| 241 | with patch( |
| 242 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 243 | ), patch("lib.safari_cookies.sys") as mock_sys: |
| 244 | mock_sys.platform = "darwin" |
| 245 | mock_sys.stderr = sys.stderr |
| 246 | result = extract_safari_cookies_macos("x.com", ["auth_token", "ct0"]) |
| 247 | |
| 248 | assert result is not None |
| 249 | assert result["auth_token"] == "test_auth_abc123" |
| 250 | assert result["ct0"] == "test_ct0_xyz789" |
| 251 | |
| 252 | def test_falls_back_to_legacy_safari_cookie_path(self, tmp_path: Path): |
| 253 | # Sandboxed path is intentionally NOT created — only the legacy path exists. |
| 254 | legacy_dir = tmp_path / "Library" / "Cookies" |
| 255 | legacy_dir.mkdir(parents=True) |
| 256 | legacy_data = _build_binary_cookies_file( |
| 257 | [_build_page([_build_cookie_record(".x.com", "auth_token", "legacy_auth")])] |
| 258 | ) |
| 259 | (legacy_dir / "Cookies.binarycookies").write_bytes(legacy_data) |
| 260 | |
| 261 | sandbox_path = ( |
| 262 | tmp_path |
| 263 | / "Library" |
| 264 | / "Containers" |
| 265 | / "com.apple.Safari" |
| 266 | / "Data" |
| 267 | / "Library" |
| 268 | / "Cookies" |
| 269 | / "Cookies.binarycookies" |
| 270 | ) |
| 271 | assert not sandbox_path.exists() |
| 272 | |
| 273 | with patch( |
| 274 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 275 | ), patch("lib.safari_cookies.sys") as mock_sys: |
| 276 | mock_sys.platform = "darwin" |
| 277 | mock_sys.stderr = sys.stderr |
| 278 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 279 | |
| 280 | assert result is not None |
| 281 | assert result["auth_token"] == "legacy_auth" |
| 282 | |
| 283 | def test_permission_denied(self, tmp_path: Path, capsys): |
| 284 | cookie_dir = ( |
| 285 | tmp_path |
| 286 | / "Library" |
| 287 | / "Containers" |
| 288 | / "com.apple.Safari" |
| 289 | / "Data" |
| 290 | / "Library" |
| 291 | / "Cookies" |
| 292 | ) |
| 293 | cookie_dir.mkdir(parents=True) |
| 294 | cookie_file = cookie_dir / "Cookies.binarycookies" |
| 295 | cookie_file.write_bytes(b"cook") |
| 296 | |
| 297 | with patch( |
| 298 | "lib.safari_cookies.Path.home", return_value=tmp_path |
| 299 | ), patch("lib.safari_cookies.sys") as mock_sys, patch.object( |
| 300 | Path, "read_bytes", side_effect=PermissionError |
| 301 | ): |
| 302 | mock_sys.platform = "darwin" |
| 303 | mock_sys.stderr = sys.stderr |
| 304 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 305 | assert result is None |
| 306 | captured = capsys.readouterr() |
| 307 | assert "Full Disk Access" in captured.err |
| 308 | |
| 309 | def test_truncated_magic_only(self): |
| 310 | result = _parse_binary_cookies(b"cook", "x.com", ["auth_token"]) |
| 311 | assert result is None |
| 312 | |
| 313 | def test_empty_file(self): |
| 314 | result = _parse_binary_cookies(b"", "x.com", ["auth_token"]) |
| 315 | assert result is None |
| 316 | |
| 317 | def test_wrong_magic(self): |
| 318 | result = _parse_binary_cookies(b"notcook!", "x.com", ["auth_token"]) |
| 319 | assert result is None |
| 320 | |
| 321 | def test_truncated_page_sizes(self): |
| 322 | # Header says 5 pages but data is too short |
| 323 | data = b"cook" + struct.pack(">I", 5) + b"\x00" * 4 |
| 324 | result = _parse_binary_cookies(data, "x.com", ["auth_token"]) |
| 325 | assert result is None |
| 326 | |
| 327 | def test_truncated_page_data(self): |
| 328 | # Valid header with 1 page of size 1000, but no actual page data |
| 329 | data = b"cook" + struct.pack(">I", 1) + struct.pack(">I", 1000) |
| 330 | result = _parse_binary_cookies(data, "x.com", ["auth_token"]) |
| 331 | assert result is None |
| 332 | |
| 333 | def test_non_darwin_returns_none(self): |
| 334 | with patch("lib.safari_cookies.sys") as mock_sys: |
| 335 | mock_sys.platform = "linux" |
| 336 | result = extract_safari_cookies_macos("x.com", ["auth_token"]) |
| 337 | assert result is None |
| 338 | |
| 339 | def test_garbage_data_no_crash(self): |
| 340 | """Random bytes after valid magic should not crash.""" |
| 341 | import os |
| 342 | |
| 343 | data = b"cook" + os.urandom(200) |
| 344 | # Should not raise — may return None or a dict |
| 345 | result = _parse_binary_cookies(data, "x.com", ["auth_token"]) |
| 346 | # Just verify no exception; result is either None or dict |
| 347 | assert result is None or isinstance(result, dict) |
| 348 |