Skip to content

Commit 829d86b

Browse files
committed
fix: address PR #16 review comments
1. Close requests.get response with context manager in _fetch_expected_checksum 2. Return (checksum, status) tuple to distinguish fetch_failed vs entry_missing 3. Tailor error messages for each failure mode 4. Document CAPISCIO_REQUIRE_CHECKSUM env var behavior in code comment 5. Move chmod +x after checksum verification (verify before trust) 6. Add unit tests for all checksum paths (match, mismatch, require+fetch_failed, require+entry_missing)
1 parent 66e3d33 commit 829d86b

2 files changed

Lines changed: 215 additions & 29 deletions

File tree

src/capiscio/manager.py

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,28 @@ def get_binary_path(version: str) -> Path:
6969
# For now, let's put it in a versioned folder
7070
return get_cache_dir() / version / filename
7171

72-
def _fetch_expected_checksum(version: str, filename: str) -> Optional[str]:
73-
"""Fetch the expected SHA-256 checksum from the release checksums.txt."""
72+
def _fetch_expected_checksum(version: str, filename: str) -> Tuple[Optional[str], str]:
73+
"""Fetch the expected SHA-256 checksum from the release checksums.txt.
74+
75+
Returns:
76+
A tuple of (checksum, status) where status is one of:
77+
- "ok" — checksum found and returned
78+
- "fetch_failed" — could not download checksums.txt
79+
- "entry_missing" — checksums.txt downloaded but no entry for filename
80+
"""
7481
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/checksums.txt"
7582
try:
76-
resp = requests.get(url, timeout=30)
77-
resp.raise_for_status()
78-
for line in resp.text.strip().splitlines():
79-
parts = line.split()
80-
if len(parts) == 2 and parts[1] == filename:
81-
return parts[0]
82-
logger.warning(f"Binary {filename} not found in checksums.txt")
83-
return None
83+
with requests.get(url, timeout=30) as resp:
84+
resp.raise_for_status()
85+
for line in resp.text.strip().splitlines():
86+
parts = line.split()
87+
if len(parts) == 2 and parts[1] == filename:
88+
return parts[0], "ok"
89+
logger.warning(f"Binary {filename} not found in checksums.txt")
90+
return None, "entry_missing"
8491
except requests.exceptions.RequestException as e:
8592
logger.warning(f"Could not fetch checksums.txt: {e}")
86-
return None
93+
return None, "fetch_failed"
8794

8895
def _verify_checksum(file_path: Path, expected_hash: str) -> bool:
8996
"""Verify SHA-256 checksum of a downloaded file."""
@@ -135,34 +142,52 @@ def download_binary(version: str) -> Path:
135142
f.write(chunk)
136143
progress.update(task, advance=len(chunk))
137144

138-
# Make executable
139-
st = os.stat(target_path)
140-
os.chmod(target_path, st.st_mode | stat.S_IEXEC)
141-
142-
# Verify checksum integrity
145+
# Verify checksum BEFORE making executable (security: validate before trust)
146+
#
147+
# CAPISCIO_REQUIRE_CHECKSUM env var controls strict verification:
148+
# When set to "1", "true", or "yes", the download will fail if
149+
# checksums.txt cannot be fetched OR the binary entry is missing.
150+
# When unset/false, a warning is logged but the binary is still used.
143151
require_checksum = os.environ.get("CAPISCIO_REQUIRE_CHECKSUM", "").lower() in ("1", "true", "yes")
144-
expected_hash = _fetch_expected_checksum(version, filename)
152+
expected_hash, checksum_status = _fetch_expected_checksum(version, target_path.name)
145153
if expected_hash is not None:
146154
if not _verify_checksum(target_path, expected_hash):
147155
target_path.unlink()
148156
raise RuntimeError(
149-
f"Binary integrity check failed for {filename}. "
157+
f"Binary integrity check failed for {target_path.name}. "
150158
"The downloaded file does not match the published checksum. "
151159
"This may indicate a tampered or corrupted download."
152160
)
153-
logger.info(f"Checksum verified for {filename}")
161+
logger.info(f"Checksum verified for {target_path.name}")
154162
elif require_checksum:
155163
target_path.unlink()
156-
raise RuntimeError(
157-
f"Checksum verification required (CAPISCIO_REQUIRE_CHECKSUM=true) "
158-
f"but checksums.txt is not available for v{version}. "
159-
"Cannot verify binary integrity."
160-
)
164+
if checksum_status == "fetch_failed":
165+
raise RuntimeError(
166+
f"Checksum verification required (CAPISCIO_REQUIRE_CHECKSUM=true) "
167+
f"but checksums.txt could not be fetched for v{version}. "
168+
"Cannot verify binary integrity."
169+
)
170+
else:
171+
raise RuntimeError(
172+
f"Checksum verification required (CAPISCIO_REQUIRE_CHECKSUM=true) "
173+
f"but no checksum entry found for {target_path.name} in v{version} checksums.txt. "
174+
"Cannot verify binary integrity."
175+
)
161176
else:
162-
logger.warning(
163-
"Could not verify binary integrity (checksums.txt not available). "
164-
"Set CAPISCIO_REQUIRE_CHECKSUM=true to enforce verification."
165-
)
177+
if checksum_status == "fetch_failed":
178+
logger.warning(
179+
"Could not verify binary integrity (checksums.txt not available). "
180+
"Set CAPISCIO_REQUIRE_CHECKSUM=true to enforce verification."
181+
)
182+
else:
183+
logger.warning(
184+
f"Could not verify binary integrity (no entry for {target_path.name} in checksums.txt). "
185+
"Set CAPISCIO_REQUIRE_CHECKSUM=true to enforce verification."
186+
)
187+
188+
# Make executable only after checksum verification passes
189+
st = os.stat(target_path)
190+
os.chmod(target_path, st.st_mode | stat.S_IEXEC)
166191

167192
console.print(f"[green]Successfully installed CapiscIO Core v{version}[/green]")
168193
return target_path

tests/unit/test_manager.py

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
get_binary_path,
1313
download_binary,
1414
run_core,
15+
_fetch_expected_checksum,
16+
_verify_checksum,
1517
CORE_VERSION,
1618
GITHUB_REPO,
1719
)
@@ -145,15 +147,17 @@ def test_returns_existing_binary(self, mock_get_path):
145147
result = download_binary("1.0.0")
146148
assert result == mock_path
147149

150+
@patch('capiscio.manager._fetch_expected_checksum', return_value=(None, "fetch_failed"))
148151
@patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64'))
149152
@patch('capiscio.manager.get_binary_path')
150153
@patch('capiscio.manager.requests.get')
151154
@patch('capiscio.manager.console')
152-
def test_downloads_binary_on_missing(self, mock_console, mock_requests, mock_get_path, mock_platform):
155+
def test_downloads_binary_on_missing(self, mock_console, mock_requests, mock_get_path, mock_platform, mock_fetch_checksum):
153156
"""Test that binary is downloaded when missing."""
154157
mock_path = MagicMock(spec=Path)
155158
mock_path.exists.return_value = False
156159
mock_path.parent = MagicMock()
160+
mock_path.name = "capiscio-linux-amd64"
157161
mock_get_path.return_value = mock_path
158162

159163
# Mock the response
@@ -194,6 +198,163 @@ def test_cleans_up_on_download_error(self, mock_console, mock_requests, mock_get
194198
mock_path.unlink.assert_called_once()
195199

196200

201+
class TestFetchExpectedChecksum:
202+
"""Tests for _fetch_expected_checksum function."""
203+
204+
@patch('capiscio.manager.requests.get')
205+
def test_returns_checksum_on_match(self, mock_get):
206+
"""Test successful checksum lookup."""
207+
mock_resp = MagicMock()
208+
mock_resp.text = "abc123 capiscio-linux-amd64\ndef456 capiscio-darwin-arm64\n"
209+
mock_resp.raise_for_status = MagicMock()
210+
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
211+
mock_resp.__exit__ = MagicMock(return_value=False)
212+
mock_get.return_value = mock_resp
213+
214+
checksum, status = _fetch_expected_checksum("1.0.0", "capiscio-linux-amd64")
215+
assert checksum == "abc123"
216+
assert status == "ok"
217+
218+
@patch('capiscio.manager.requests.get')
219+
def test_returns_entry_missing_when_not_found(self, mock_get):
220+
"""Test that missing entry returns entry_missing status."""
221+
mock_resp = MagicMock()
222+
mock_resp.text = "abc123 capiscio-linux-amd64\n"
223+
mock_resp.raise_for_status = MagicMock()
224+
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
225+
mock_resp.__exit__ = MagicMock(return_value=False)
226+
mock_get.return_value = mock_resp
227+
228+
checksum, status = _fetch_expected_checksum("1.0.0", "capiscio-darwin-arm64")
229+
assert checksum is None
230+
assert status == "entry_missing"
231+
232+
@patch('capiscio.manager.requests.get')
233+
def test_returns_fetch_failed_on_network_error(self, mock_get):
234+
"""Test that network errors return fetch_failed status."""
235+
import requests.exceptions
236+
mock_get.side_effect = requests.exceptions.ConnectionError("timeout")
237+
238+
checksum, status = _fetch_expected_checksum("1.0.0", "capiscio-linux-amd64")
239+
assert checksum is None
240+
assert status == "fetch_failed"
241+
242+
243+
class TestChecksumVerificationIntegration:
244+
"""Tests for checksum verification during download."""
245+
246+
def _make_download_mocks(self):
247+
"""Helper: set up common mocks for download_binary tests."""
248+
mock_path = MagicMock(spec=Path)
249+
mock_path.exists.return_value = False
250+
mock_path.parent = MagicMock()
251+
mock_path.name = "capiscio-linux-amd64"
252+
return mock_path
253+
254+
@patch('capiscio.manager._fetch_expected_checksum', return_value=("abc123", "ok"))
255+
@patch('capiscio.manager._verify_checksum', return_value=True)
256+
@patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64'))
257+
@patch('capiscio.manager.get_binary_path')
258+
@patch('capiscio.manager.requests.get')
259+
@patch('capiscio.manager.console')
260+
def test_checksum_verified_match(self, mock_console, mock_requests, mock_get_path,
261+
mock_platform, mock_verify, mock_fetch):
262+
"""Test that download succeeds when checksum matches."""
263+
mock_path = self._make_download_mocks()
264+
mock_get_path.return_value = mock_path
265+
266+
mock_response = MagicMock()
267+
mock_response.headers = {'content-length': '1024'}
268+
mock_response.iter_content.return_value = [b'x' * 1024]
269+
mock_response.__enter__ = MagicMock(return_value=mock_response)
270+
mock_response.__exit__ = MagicMock(return_value=False)
271+
mock_requests.return_value = mock_response
272+
273+
with patch('builtins.open', mock_open()):
274+
with patch.object(os, 'stat') as mock_stat:
275+
with patch.object(os, 'chmod'):
276+
mock_stat.return_value = MagicMock(st_mode=0o644)
277+
result = download_binary("1.0.0")
278+
279+
assert result == mock_path
280+
mock_verify.assert_called_once_with(mock_path, "abc123")
281+
282+
@patch('capiscio.manager._fetch_expected_checksum', return_value=("abc123", "ok"))
283+
@patch('capiscio.manager._verify_checksum', return_value=False)
284+
@patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64'))
285+
@patch('capiscio.manager.get_binary_path')
286+
@patch('capiscio.manager.requests.get')
287+
@patch('capiscio.manager.console')
288+
def test_checksum_mismatch_cleans_up(self, mock_console, mock_requests, mock_get_path,
289+
mock_platform, mock_verify, mock_fetch):
290+
"""Test that a checksum mismatch deletes the binary and raises."""
291+
mock_path = self._make_download_mocks()
292+
mock_get_path.return_value = mock_path
293+
294+
mock_response = MagicMock()
295+
mock_response.headers = {'content-length': '1024'}
296+
mock_response.iter_content.return_value = [b'x' * 1024]
297+
mock_response.__enter__ = MagicMock(return_value=mock_response)
298+
mock_response.__exit__ = MagicMock(return_value=False)
299+
mock_requests.return_value = mock_response
300+
301+
with patch('builtins.open', mock_open()):
302+
with pytest.raises(RuntimeError, match="integrity check failed"):
303+
download_binary("1.0.0")
304+
305+
mock_path.unlink.assert_called()
306+
307+
@patch.dict(os.environ, {"CAPISCIO_REQUIRE_CHECKSUM": "true"})
308+
@patch('capiscio.manager._fetch_expected_checksum', return_value=(None, "fetch_failed"))
309+
@patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64'))
310+
@patch('capiscio.manager.get_binary_path')
311+
@patch('capiscio.manager.requests.get')
312+
@patch('capiscio.manager.console')
313+
def test_require_checksum_fails_on_fetch_failed(self, mock_console, mock_requests,
314+
mock_get_path, mock_platform, mock_fetch):
315+
"""Test fail-closed when CAPISCIO_REQUIRE_CHECKSUM=true and fetch fails."""
316+
mock_path = self._make_download_mocks()
317+
mock_get_path.return_value = mock_path
318+
319+
mock_response = MagicMock()
320+
mock_response.headers = {'content-length': '1024'}
321+
mock_response.iter_content.return_value = [b'x' * 1024]
322+
mock_response.__enter__ = MagicMock(return_value=mock_response)
323+
mock_response.__exit__ = MagicMock(return_value=False)
324+
mock_requests.return_value = mock_response
325+
326+
with patch('builtins.open', mock_open()):
327+
with pytest.raises(RuntimeError, match="could not be fetched"):
328+
download_binary("1.0.0")
329+
330+
mock_path.unlink.assert_called()
331+
332+
@patch.dict(os.environ, {"CAPISCIO_REQUIRE_CHECKSUM": "true"})
333+
@patch('capiscio.manager._fetch_expected_checksum', return_value=(None, "entry_missing"))
334+
@patch('capiscio.manager.get_platform_info', return_value=('linux', 'amd64'))
335+
@patch('capiscio.manager.get_binary_path')
336+
@patch('capiscio.manager.requests.get')
337+
@patch('capiscio.manager.console')
338+
def test_require_checksum_fails_on_entry_missing(self, mock_console, mock_requests,
339+
mock_get_path, mock_platform, mock_fetch):
340+
"""Test fail-closed when CAPISCIO_REQUIRE_CHECKSUM=true and entry is missing."""
341+
mock_path = self._make_download_mocks()
342+
mock_get_path.return_value = mock_path
343+
344+
mock_response = MagicMock()
345+
mock_response.headers = {'content-length': '1024'}
346+
mock_response.iter_content.return_value = [b'x' * 1024]
347+
mock_response.__enter__ = MagicMock(return_value=mock_response)
348+
mock_response.__exit__ = MagicMock(return_value=False)
349+
mock_requests.return_value = mock_response
350+
351+
with patch('builtins.open', mock_open()):
352+
with pytest.raises(RuntimeError, match="no checksum entry found"):
353+
download_binary("1.0.0")
354+
355+
mock_path.unlink.assert_called()
356+
357+
197358
class TestRunCore:
198359
"""Tests for run_core function."""
199360

0 commit comments

Comments
 (0)