返回 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_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
291 lines PYTHON