返回 last30days-skill
test_safari_cookies.py
根目录 / tests / test_safari_cookies.py
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
348 lines PYTHON