Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/chirp/audio_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
winsound = None # type: ignore[assignment]


MAX_AUDIO_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5MB


class AudioFeedback:
def __init__(
self,
Expand Down Expand Up @@ -130,6 +133,20 @@ def _play_sound(self, asset_name: str, override_path: Optional[str]) -> None:
self._logger.exception("Failed to play sound %s: %s", asset_name, exc)

def _load_and_cache(self, path: Path, key: str) -> Any:
try:
file_size = path.stat().st_size
if file_size > MAX_AUDIO_FILE_SIZE_BYTES:
self._logger.warning(
"Audio file %s is too large (%d bytes). Max allowed: %d bytes.",
path,
file_size,
MAX_AUDIO_FILE_SIZE_BYTES,
)
return None
except OSError as exc:
self._logger.warning("Could not stat audio file %s: %s", path, exc)
return None

if self._use_sounddevice:
# Load as numpy array for volume-controlled playback via sounddevice
with wave.open(str(path), "rb") as wf:
Expand Down
12 changes: 10 additions & 2 deletions tests/test_audio_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,17 @@ def test_play_with_sounddevice_called(self, mock_sd):
af._load_and_cache.assert_called_once()
af._play_cached.assert_called_once_with("data")

@patch("pathlib.Path.stat")
@patch("chirp.audio_feedback.sd")
@patch("chirp.audio_feedback.np")
@patch("chirp.audio_feedback.wave")
def test_load_and_cache_sounddevice(self, mock_wave, mock_np, mock_sd):
def test_load_and_cache_sounddevice(self, mock_wave, mock_np, mock_sd, mock_stat):
"""_load_and_cache should read WAV and cache data."""
af = AudioFeedback(logger=self.mock_logger, enabled=True)

# Mock valid file size
mock_stat.return_value.st_size = 1024

# Setup wave mock
mock_wf = MagicMock()
mock_wave.open.return_value.__enter__.return_value = mock_wf
Expand Down Expand Up @@ -81,14 +85,18 @@ def test_play_cached_sounddevice(self, mock_sd):
af._play_cached(mock_data)
mock_sd.play.assert_called_with(mock_data[0], 44100)

@patch("pathlib.Path.stat")
@patch("chirp.audio_feedback.sd")
@patch("chirp.audio_feedback.np")
@patch("chirp.audio_feedback.wave")
@patch("chirp.audio_feedback.winsound", None)
def test_load_and_cache_with_volume_scaling(self, mock_wave, mock_np, mock_sd):
def test_load_and_cache_with_volume_scaling(self, mock_wave, mock_np, mock_sd, mock_stat):
"""_load_and_cache should scale audio when volume < 1.0."""
import numpy as np

# Mock valid file size
mock_stat.return_value.st_size = 1024

# Create real numpy array for testing
mock_np.int16 = np.int16
mock_np.float32 = np.float32
Expand Down
52 changes: 52 additions & 0 deletions tests/test_audio_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import logging
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch

# Need to patch sys.modules before importing chirp.audio_feedback to mock imports if needed,
# but since the module handles ImportError, we can just patch the imported names.

from chirp.audio_feedback import AudioFeedback

class TestAudioFeedbackSecurity(unittest.TestCase):
def setUp(self):
self.mock_logger = MagicMock(spec=logging.Logger)

@patch("chirp.audio_feedback.np")
@patch("chirp.audio_feedback.sd", new=MagicMock())
@patch("chirp.audio_feedback.winsound", None)
@patch("pathlib.Path.stat")
def test_load_large_file_fails(self, mock_stat, mock_np):
"""_load_and_cache should return None if file is too large (>5MB)."""
af = AudioFeedback(logger=self.mock_logger, enabled=True)

# Mock file size to be slightly larger than 5MB (5 * 1024 * 1024 + 1)
mock_stat.return_value.st_size = 5 * 1024 * 1024 + 1

# We need to mock wave.open so it doesn't actually try to open a file
# if the check fails (or if it doesn't fail yet)
with patch("chirp.audio_feedback.wave") as mock_wave:
# Setup mock to behave "normally" if called
mock_wf = MagicMock()
mock_wave.open.return_value.__enter__.return_value = mock_wf
mock_wf.getframerate.return_value = 44100
mock_wf.getnchannels.return_value = 1
mock_wf.readframes.return_value = b"\x00" * 100
mock_wf.getnframes.return_value = 50

# Mock numpy behavior to avoid errors if the code proceeds
mock_np.frombuffer.return_value = MagicMock()

result = af._load_and_cache(Path("/fake/large_file.wav"), "key")

# This assertion should fail until the fix is implemented
self.assertIsNone(result, "Should return None for files larger than 5MB")

# Verify a warning was logged
self.mock_logger.warning.assert_called()
# Check if the warning message contains expected text
args, _ = self.mock_logger.warning.call_args
self.assertIn("too large", args[0])

if __name__ == "__main__":
unittest.main()