Skip to content

Commit a6027f8

Browse files
frettclaude
andcommitted
Verify SHA-256 checksum and size when downloading translation files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ef2102a commit a6027f8

3 files changed

Lines changed: 45 additions & 15 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ facebook = "18.2.3"
2121
facebook-flipper = "0.273.0"
2222
firebase-crashlytics = "20.0.6"
2323
firebase-perf = "22.0.5"
24-
godtoolsShared = "1.3.3-SNAPSHOT"
24+
godtoolsShared = "1.4.0-SNAPSHOT"
2525
gtoSupport = "4.5.1-SNAPSHOT"
2626
kotlin = "2.3.21"
2727
kotlinCoroutines = "1.11.0"

library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import kotlinx.coroutines.flow.transformLatest
4343
import kotlinx.coroutines.launch
4444
import kotlinx.coroutines.sync.withLock
4545
import kotlinx.coroutines.withContext
46+
import okio.ByteString.Companion.decodeHex
47+
import okio.HashingSource.Companion.sha256
48+
import okio.buffer
49+
import okio.sink
4650
import org.ccci.gto.android.common.kotlin.coroutines.MutexMap
4751
import org.ccci.gto.android.common.kotlin.coroutines.ReadWriteMutex
4852
import org.ccci.gto.android.common.kotlin.coroutines.flow.EmptyStateFlow
@@ -294,9 +298,10 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
294298
val relatedFiles = manifest.relatedFiles
295299
val completedFiles = AtomicLong(0)
296300
val successful = coroutineScope {
297-
relatedFiles.map {
301+
relatedFiles.map { file ->
298302
async {
299-
downloadTranslationFileIfNecessary(it).also {
303+
val src = file.src ?: return@async true
304+
downloadTranslationFileIfNecessary(src, sha256 = file.checksumSha256, size = file.size).also {
300305
do {
301306
val completed = completedFiles.get()
302307
updateProgress(key, completed + 1, relatedFiles.size.toLong())
@@ -309,22 +314,42 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
309314

310315
// record the translation as downloaded
311316
downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile(translation, manifestFileName))
312-
relatedFiles.forEach { downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile(translation, it)) }
317+
relatedFiles.mapNotNull { it.src }.forEach {
318+
downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile(translation, it))
319+
}
313320
translationsRepository.markTranslationDownloaded(translation.id, true)
314321

315322
return true
316323
}
317324

318-
private suspend fun downloadTranslationFileIfNecessary(fileName: String): Boolean = filesMutex.withLock(fileName) {
325+
private suspend fun downloadTranslationFileIfNecessary(
326+
fileName: String,
327+
sha256: String? = null,
328+
size: Long? = null,
329+
): Boolean = filesMutex.withLock(fileName) {
319330
if (downloadedFilesRepository.findDownloadedFile(fileName) != null) return true
320-
try {
321-
val body = translationsApi.downloadFile(fileName).takeIf { it.isSuccessful }?.body() ?: return false
322-
val downloadedFile = DownloadedFile(fileName)
323-
withContext(Dispatchers.IO) { body.byteStream().copyTo(downloadedFile) }
324-
downloadedFilesRepository.insertOrIgnore(downloadedFile)
325-
return true
326-
} catch (e: IOException) {
327-
return false
331+
332+
return withContext(Dispatchers.IO) {
333+
try {
334+
val body = translationsApi.downloadFile(fileName).takeIf { it.isSuccessful }?.body()
335+
?: return@withContext false
336+
337+
val downloadedFile = DownloadedFile(fileName)
338+
downloadedFile.bufferedSink().use { sink ->
339+
body.source().use { source ->
340+
val digest = sha256(source)
341+
val bytesWritten = sink.writeAll(digest)
342+
343+
if (size != null && size != bytesWritten) return@withContext false
344+
if (sha256 != null && sha256.decodeHex() != digest.hash) return@withContext false
345+
}
346+
}
347+
348+
downloadedFilesRepository.insertOrIgnore(downloadedFile)
349+
true
350+
} catch (_: IOException) {
351+
false
352+
}
328353
}
329354
}
330355

@@ -450,6 +475,8 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
450475
}
451476
// endregion Cleanup
452477

478+
private suspend fun DownloadedFile.bufferedSink() = withContext(Dispatchers.IO) { getFile(fs).sink().buffer() }
479+
453480
private suspend fun InputStream.copyTo(file: DownloadedFile) = withContext(Dispatchers.IO) {
454481
file.getFile(fs).outputStream().use { copyTo(it) }
455482
}

library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ import org.cru.godtools.model.randomTranslation
6060
import org.cru.godtools.shared.tool.parser.ManifestParser
6161
import org.cru.godtools.shared.tool.parser.ParserConfig
6262
import org.cru.godtools.shared.tool.parser.ParserResult
63+
import org.cru.godtools.shared.tool.parser.model.Manifest
64+
import org.cru.godtools.shared.tool.parser.model.Manifest.XmlFile
6365
import retrofit2.Response
6466

6567
private const val TOOL = "tool"
@@ -318,8 +320,9 @@ class GodToolsDownloadManagerTest {
318320
} returns translation
319321
coEvery { translationsRepository.markTranslationDownloaded(any(), any()) } just Runs
320322
val config = slot<ParserConfig>()
321-
coEvery { manifestParser.parseManifest(translation.manifestFileName!!, capture(config)) } returns
322-
ParserResult.Data(mockk { every { relatedFiles } returns setOf("a.txt", "b.txt") })
323+
val manifest = Manifest(pageXmlFiles = listOf(XmlFile("a.txt", "a.txt"), XmlFile("b.txt", "b.txt")))
324+
coEvery { manifestParser.parseManifest(translation.manifestFileName!!, capture(config)) }
325+
.returns(ParserResult.Data(manifest))
323326
coEvery { translationsApi.downloadFile(translation.manifestFileName!!) } returns
324327
Response.success(RealResponseBody(null, 0, Buffer().writeUtf8("manifest")))
325328
coEvery { translationsApi.downloadFile("a.txt") } returns

0 commit comments

Comments
 (0)