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.
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?d<e>f|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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user