From 5bdb945406d92c6ea067d1e7f313a3926bf95631 Mon Sep 17 00:00:00 2001 From: Akiaki0324 Date: Mon, 11 May 2026 07:02:11 +0800 Subject: [PATCH] fix: truncate filenames by bytes instead of characters to avoid File name too long (#1933) * fix: truncate filenames by bytes instead of characters to avoid IOException File name too long * add a CHANGELOG.md entry. --- .../nulldev/androidcompat/util/SafePath.kt | 39 +++++++++++++-- CHANGELOG.md | 1 + .../kotlin/suwayomi/tachidesk/SafePathTest.kt | 50 +++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 server/src/test/kotlin/suwayomi/tachidesk/SafePathTest.kt diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/util/SafePath.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/util/SafePath.kt index 7d722b9e..691a4fbf 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/util/SafePath.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/util/SafePath.kt @@ -9,6 +9,9 @@ package xyz.nulldev.androidcompat.util // adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/4cefbce7c34e724b409b6ba127f3c6c5c346ad8d/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt object SafePath { + private const val MAX_FILENAME_CHARS = 240 + private const val MAX_FILENAME_UTF8_BYTES = 240 + /** * Mutate the given filename to make it valid for a FAT filesystem, * replacing any invalid characters with "_". This method doesn't allow hidden files (starting @@ -27,11 +30,41 @@ object SafePath { sb.append('_') } } - // Even though vfat allows 255 UCS-2 chars, we might eventually write to - // ext4 through a FUSE layer, so use that limit minus 15 reserved characters. - return sb.toString().take(240) + + return truncateFilename(sb.toString()) } + private fun truncateFilename(filename: String): String { + // Keep a safety margin under common filesystem limits and satisfy both + // character count and UTF-8 byte-length constraints. + val output = StringBuilder(minOf(filename.length, MAX_FILENAME_CHARS)) + var usedBytes = 0 + var index = 0 + + while (index < filename.length && output.length < MAX_FILENAME_CHARS) { + val codePoint = Character.codePointAt(filename, index) + val codePointBytes = utf8ByteCount(codePoint) + + if (usedBytes + codePointBytes > MAX_FILENAME_UTF8_BYTES) { + break + } + + output.appendCodePoint(codePoint) + usedBytes += codePointBytes + index += Character.charCount(codePoint) + } + + return output.toString() + } + + private fun utf8ByteCount(codePoint: Int): Int = + when { + codePoint <= 0x7f -> 1 + codePoint <= 0x7ff -> 2 + codePoint <= 0xffff -> 3 + else -> 4 + } + /** * Returns true if the given character is a valid filename character, false otherwise. */ diff --git a/CHANGELOG.md b/CHANGELOG.md index b1ffaddd..659e9e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - (CloudFlareInterceptor) Don't send the `cf_clearance` cookie back to Flaresolverr - (WebUI) Handle serving non-default webui with "bundled" - (WebUI) Wait until WebUI is ready to open in browser +- (Downloads) Truncate filenames by byte length to prevent "File name too long" IO errors ## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08 diff --git a/server/src/test/kotlin/suwayomi/tachidesk/SafePathTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/SafePathTest.kt new file mode 100644 index 00000000..a50807f5 --- /dev/null +++ b/server/src/test/kotlin/suwayomi/tachidesk/SafePathTest.kt @@ -0,0 +1,50 @@ +package suwayomi.tachidesk + +import xyz.nulldev.androidcompat.util.SafePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SafePathTest { + @Test + fun invalidCharactersAreReplacedAndEdgesAreTrimmed() { + val input = " .a:b*c?df|g\\h/i. " + + val result = SafePath.buildValidFilename(input) + + assertEquals("a_b_c_d_e_f_g_h_i", result) + } + + @Test + fun emptyAfterTrimReturnsInvalidMarker() { + assertEquals("(invalid)", SafePath.buildValidFilename(" ... . ")) + } + + @Test + fun resultIsTruncatedTo240Characters() { + val input = "a".repeat(300) + + val result = SafePath.buildValidFilename(input) + + assertEquals(240, result.length) + assertEquals("a".repeat(240), result) + } + + @Test + fun mixed256CharactersCanExceed255Utf8Bytes() { + val mixed256 = + buildString { + repeat(128) { + append('a') + append('你') + } + } + + val result = SafePath.buildValidFilename(mixed256) + + assertEquals(120, result.length) + assertEquals(mixed256.take(120), result) + assertEquals(240, result.toByteArray(Charsets.UTF_8).size) + assertTrue(result.toByteArray(Charsets.UTF_8).size == 240) + } +} \ No newline at end of file