-
-
Notifications
You must be signed in to change notification settings - Fork 320
fix(api): validate audiobook metadata writes #2460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| package org.booklore.service.metadata.writer; | ||
|
|
||
| import org.booklore.model.dto.settings.AppSettings; | ||
| import org.booklore.model.dto.settings.MetadataPersistenceSettings; | ||
| import org.booklore.model.entity.BookEntity; | ||
| import org.booklore.model.entity.BookFileEntity; | ||
| import org.booklore.model.entity.BookMetadataEntity; | ||
| import org.booklore.model.enums.BookFileType; | ||
| import org.booklore.service.appsettings.AppSettingService; | ||
| import org.jaudiotagger.audio.AudioFile; | ||
| import org.jaudiotagger.audio.AudioFileIO; | ||
| import org.jaudiotagger.audio.exceptions.CannotReadException; | ||
| import org.jaudiotagger.tag.FieldKey; | ||
| import org.jaudiotagger.tag.Tag; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.extension.ExtendWith; | ||
| import org.junit.jupiter.api.io.TempDir; | ||
| import org.mockito.Mock; | ||
| import org.mockito.MockedStatic; | ||
| import org.mockito.junit.jupiter.MockitoExtension; | ||
|
|
||
| import java.io.File; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.util.Set; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.mockito.ArgumentMatchers.any; | ||
| import static org.mockito.Mockito.*; | ||
|
|
||
| @ExtendWith(MockitoExtension.class) | ||
| class AudiobookMetadataWriterTest { | ||
|
|
||
| @TempDir | ||
| Path tempDir; | ||
|
|
||
| @Mock | ||
| AppSettingService appSettingService; | ||
|
|
||
| private AudiobookMetadataWriter writer; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| MetadataPersistenceSettings.FormatSettings audiobookSettings = | ||
| new MetadataPersistenceSettings.FormatSettings(true, 100); | ||
| MetadataPersistenceSettings.SaveToOriginalFile saveToOriginalFile = | ||
| new MetadataPersistenceSettings.SaveToOriginalFile(); | ||
| saveToOriginalFile.setAudiobook(audiobookSettings); | ||
| MetadataPersistenceSettings persistenceSettings = new MetadataPersistenceSettings(); | ||
| persistenceSettings.setSaveToOriginalFile(saveToOriginalFile); | ||
| AppSettings settings = new AppSettings(); | ||
| settings.setMetadataPersistenceSettings(persistenceSettings); | ||
| when(appSettingService.getAppSettings()).thenReturn(settings); | ||
|
|
||
| writer = new AudiobookMetadataWriter(appSettingService); | ||
| } | ||
|
|
||
| @Test | ||
| void restoresBackupWhenWrittenAudiobookFailsValidation() throws Exception { | ||
| Path path = tempDir.resolve("audiobook.m4b"); | ||
| Files.writeString(path, "original audio"); | ||
| File file = path.toFile(); | ||
|
|
||
| AudioFile audioFile = mock(AudioFile.class); | ||
| Tag tag = mock(Tag.class); | ||
| when(audioFile.getTagOrCreateAndSetDefault()).thenReturn(tag); | ||
| when(tag.getFirst(any(FieldKey.class))).thenReturn(""); | ||
| doAnswer(_ -> { | ||
| Files.writeString(path, "corrupt output"); | ||
| return null; | ||
| }).when(audioFile).commit(); | ||
|
|
||
| BookMetadataEntity metadata = new BookMetadataEntity(); | ||
| metadata.setTitle("Updated title"); | ||
|
|
||
| try (MockedStatic<AudioFileIO> audioFileIO = mockStatic(AudioFileIO.class)) { | ||
| audioFileIO.when(() -> AudioFileIO.read(file)) | ||
| .thenReturn(audioFile) | ||
| .thenThrow(new CannotReadException("missing audio track")); | ||
|
|
||
| writer.saveMetadataToFile(file, metadata, null, null); | ||
| } | ||
|
|
||
| assertThat(path).hasContent("original audio"); | ||
| assertThat(path.resolveSibling("audiobook.m4b.bak")).doesNotExist(); | ||
| verify(audioFile).commit(); | ||
| } | ||
|
|
||
| @Test | ||
| void restoresBackupWhenCoverWriteFailsValidation() throws Exception { | ||
| Path path = tempDir.resolve("cover-update.m4b"); | ||
| Files.writeString(path, "original audio"); | ||
| File file = path.toFile(); | ||
|
|
||
| AudioFile audioFile = mock(AudioFile.class); | ||
| Tag tag = mock(Tag.class); | ||
| when(audioFile.getTagOrCreateAndSetDefault()).thenReturn(tag); | ||
| doAnswer(_ -> { | ||
| Files.writeString(path, "corrupt output"); | ||
| return null; | ||
| }).when(audioFile).commit(); | ||
|
|
||
| BookFileEntity bookFile = mock(BookFileEntity.class); | ||
| when(bookFile.getBookType()).thenReturn(BookFileType.AUDIOBOOK); | ||
| when(bookFile.getFullFilePath()).thenReturn(path); | ||
| BookEntity book = mock(BookEntity.class); | ||
| when(book.getBookFiles()).thenReturn(Set.of(bookFile)); | ||
|
|
||
| try (MockedStatic<AudioFileIO> audioFileIO = mockStatic(AudioFileIO.class)) { | ||
| audioFileIO.when(() -> AudioFileIO.read(file)) | ||
| .thenReturn(audioFile) | ||
| .thenThrow(new CannotReadException("missing audio track")); | ||
|
|
||
| writer.replaceCoverImageFromBytes(book, new byte[]{1, 2, 3, 4}); | ||
| } | ||
|
|
||
| assertThat(path).hasContent("original audio"); | ||
| assertThat(path.resolveSibling("cover-update.m4b.bak")).doesNotExist(); | ||
| verify(audioFile).commit(); | ||
|
Comment on lines
+59
to
+120
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Cover each guarded recovery condition. These tests only make the post-commit 🤖 Prompt for AI AgentsSource: Path instructions |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Do not overwrite a retained recovery backup.
Line 148 replaces an existing
.bakfile. If restoration fails after a corrupted commit, that backup can be the only copy of the original audiobook. A later write replaces it with the corrupted current file. A later failure then cannot restore the original audiobook.The fixed backup name also lets concurrent writes for the same audiobook overwrite or delete each other’s recovery file. Reserve a unique backup per operation, or serialize writes per normalized audiobook path. Preserve an existing recovery backup until an explicit recovery decision removes it.
🧰 Tools
🪛 ast-grep (0.45.2)
[warning] 146-146: Prevent path traversal
Context: new File(originalFile.getParentFile(), originalFile.getName() + ".bak")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'). Security best practice.
(path-traversal-java)
🤖 Prompt for AI Agents