fix(android): support document uploads and bump version to 1.0.4
This commit is contained in:
@@ -5,7 +5,9 @@ import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
@@ -18,6 +20,13 @@ import org.mozilla.geckoview.*
|
||||
import org.mozilla.geckoview.GeckoSession.PromptDelegate.*
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.UUID
|
||||
|
||||
private object BrowserUploads {
|
||||
val executor = Executors.newCachedThreadPool()
|
||||
private var cache: UploadCache? = null
|
||||
@Synchronized fun get(context: Context): UploadCache = cache ?: UploadCache(context.cacheDir).also { cache = it }
|
||||
}
|
||||
|
||||
private object BrowserRuntime {
|
||||
private var instance: GeckoRuntime? = null
|
||||
@@ -46,6 +55,7 @@ internal class GeckoPlatformView(
|
||||
private val prompts = mutableMapOf<BasePrompt, () -> Unit>()
|
||||
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
private var uploadSession = UUID.randomUUID().toString()
|
||||
private val downloads = java.util.Collections.synchronizedSet(mutableSetOf<DownloadNotifications>())
|
||||
@Volatile private var disposed = false
|
||||
private var loaded = false
|
||||
@@ -54,6 +64,8 @@ internal class GeckoPlatformView(
|
||||
private var loadFailed = false
|
||||
|
||||
init {
|
||||
val appContext = activity.applicationContext
|
||||
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(appContext) } }
|
||||
session.progressDelegate = object : GeckoSession.ProgressDelegate {
|
||||
override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") }
|
||||
override fun onPageStop(session: GeckoSession, success: Boolean) {
|
||||
@@ -122,6 +134,13 @@ internal class GeckoPlatformView(
|
||||
cancelDownloads()
|
||||
prompts.values.toList().forEach { it() }
|
||||
emit("onLoadError", "网页进程已停止,请重试")
|
||||
closeUploads()
|
||||
}
|
||||
|
||||
private fun closeUploads() {
|
||||
val previous = uploadSession
|
||||
uploadSession = UUID.randomUUID().toString()
|
||||
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).closeSession(previous) } }
|
||||
}
|
||||
|
||||
private fun chooseOptions(prompt: ChoicePrompt): GeckoResult<PromptResponse> {
|
||||
@@ -195,15 +214,29 @@ internal class GeckoPlatformView(
|
||||
|
||||
private fun chooseFiles(prompt: FilePrompt): GeckoResult<PromptResponse> {
|
||||
val result = GeckoResult<PromptResponse>()
|
||||
fun finish(uris: Array<Uri>?) {
|
||||
val cancellation = UploadCancellation()
|
||||
val signal = CancellationSignal()
|
||||
val unregisterSignal = cancellation.onCancel { signal.cancel() }
|
||||
val selectionSession = uploadSession
|
||||
var finished = false
|
||||
fun discard(batch: UploadCache.Batch) {
|
||||
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).discard(batch) } }
|
||||
}
|
||||
fun finish(uris: Array<Uri>?, batch: UploadCache.Batch? = null) {
|
||||
if (finished) { batch?.let { discard(it) }; return }
|
||||
finished = true
|
||||
cancellation.cancel()
|
||||
var confirmed = false
|
||||
if (!prompt.isComplete) {
|
||||
try {
|
||||
result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
|
||||
confirmed = !uris.isNullOrEmpty()
|
||||
} catch (error: Exception) {
|
||||
if (!prompt.isComplete) result.complete(prompt.dismiss())
|
||||
Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
|
||||
if (!disposed) Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
if (!confirmed) batch?.let { discard(it) }
|
||||
prompts.remove(prompt)
|
||||
prompt.setDelegate(null)
|
||||
}
|
||||
@@ -223,12 +256,59 @@ internal class GeckoPlatformView(
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
try {
|
||||
activity.launchDocument(this, intent) { code, data ->
|
||||
if (code != Activity.RESULT_OK || data == null) finish(null)
|
||||
if (finished || disposed || code != Activity.RESULT_OK || data == null) finish(null)
|
||||
else {
|
||||
val uris = mutableListOf<Uri>()
|
||||
data.clipData?.let { clip -> for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) }
|
||||
if (uris.isEmpty()) data.data?.let { uris.add(it) }
|
||||
finish((if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)).toTypedArray())
|
||||
val selected = if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)
|
||||
// Gecko's FOLDER traversal is separate; copying files cannot preserve directory semantics.
|
||||
if (folder || selected.isEmpty()) finish(selected.toTypedArray())
|
||||
else {
|
||||
Toast.makeText(activity, "正在准备上传文件…", Toast.LENGTH_SHORT).show()
|
||||
val appContext = activity.applicationContext
|
||||
BrowserUploads.executor.execute {
|
||||
try {
|
||||
cancellation.check()
|
||||
val resolver = appContext.contentResolver
|
||||
val sources = selected.map { uri ->
|
||||
cancellation.check()
|
||||
val name = runCatching {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null, signal)?.use { cursor ->
|
||||
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment
|
||||
cancellation.check()
|
||||
UploadCache.Source(name) {
|
||||
cancellation.check()
|
||||
val descriptor = resolver.openAssetFileDescriptor(uri, "r", signal)
|
||||
?: throw java.io.IOException("无法打开所选文件")
|
||||
try { descriptor.createInputStream() } catch (error: Exception) {
|
||||
descriptor.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
val batch = BrowserUploads.get(appContext).copy(selectionSession, sources, cancellation)
|
||||
activity.runOnUiThread {
|
||||
if (disposed || finished || prompt.isComplete) {
|
||||
discard(batch)
|
||||
finish(null)
|
||||
} else finish(batch.files.map { Uri.fromFile(it) }.toTypedArray(), batch)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
activity.runOnUiThread {
|
||||
if (!finished && !disposed) {
|
||||
Toast.makeText(activity, "无法准备上传文件,请检查可用空间或重新选择:" + error.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
finish(null)
|
||||
}
|
||||
} finally {
|
||||
unregisterSignal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) { finish(null); Toast.makeText(activity, "无法打开文件选择器:" + error.message, Toast.LENGTH_LONG).show() }
|
||||
@@ -335,6 +415,7 @@ internal class GeckoPlatformView(
|
||||
view.releaseSession()
|
||||
session.close()
|
||||
}
|
||||
closeUploads()
|
||||
session.progressDelegate = null
|
||||
session.navigationDelegate = null
|
||||
session.contentDelegate = null
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
internal class UploadCancellation {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private val actions = mutableSetOf<() -> Unit>()
|
||||
fun cancel() {
|
||||
val pending = synchronized(actions) {
|
||||
if (!cancelled.compareAndSet(false, true)) return
|
||||
actions.toList().also { actions.clear() }
|
||||
}
|
||||
pending.forEach { action -> closer.execute { runCatching(action) } }
|
||||
}
|
||||
fun onCancel(action: () -> Unit): () -> Unit {
|
||||
synchronized(actions) {
|
||||
if (cancelled.get()) closer.execute { runCatching(action) } else actions.add(action)
|
||||
}
|
||||
return { synchronized(actions) { actions.remove(action) }; Unit }
|
||||
}
|
||||
fun check() { if (cancelled.get()) throw CancellationException("Upload cancelled") }
|
||||
|
||||
companion object {
|
||||
// Provider cancellation and close can themselves block; neither may run on the UI thread.
|
||||
private val closer = Executors.newCachedThreadPool { task ->
|
||||
Thread(task, "upload-cancel").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Never hold the cache monitor while calling a provider.
|
||||
internal class UploadCache(
|
||||
cacheDir: File,
|
||||
private val openOutput: (File) -> java.io.OutputStream = { it.outputStream() }
|
||||
) {
|
||||
private val root = File(cacheDir.canonicalFile, "gecko-upload-v1")
|
||||
private val active = mutableMapOf<File, UploadCancellation>()
|
||||
private val closedSessions = mutableSetOf<String>()
|
||||
|
||||
init {
|
||||
require(root.canonicalFile == root) { "Upload cache must not be a symlink" }
|
||||
remove(root)
|
||||
if (!root.mkdirs()) throw IOException("Cannot create upload cache")
|
||||
}
|
||||
|
||||
data class Source(val name: String?, val open: () -> InputStream)
|
||||
data class Batch(val directory: File, val files: List<File>)
|
||||
|
||||
fun copy(session: String, sources: List<Source>, cancellation: UploadCancellation): Batch {
|
||||
require(UUID.fromString(session).toString() == session)
|
||||
val directory = File(File(root, session), UUID.randomUUID().toString())
|
||||
synchronized(this) {
|
||||
cancellation.check()
|
||||
if (session in closedSessions) throw CancellationException("Upload session closed")
|
||||
if (!directory.mkdirs()) throw IOException("Cannot create upload directory")
|
||||
active[directory] = cancellation
|
||||
}
|
||||
var completed = false
|
||||
try {
|
||||
val files = sources.mapIndexed { index, source ->
|
||||
cancellation.check()
|
||||
val parent = File(directory, index.toString())
|
||||
if (!parent.mkdir()) throw IOException("Cannot create upload directory")
|
||||
val target = File(parent, safeName(source.name))
|
||||
source.open().use { input ->
|
||||
val unregister = cancellation.onCancel { input.close() }
|
||||
try {
|
||||
cancellation.check()
|
||||
openOutput(target).use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
cancellation.check()
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
cancellation.check()
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
} finally { unregister() }
|
||||
}
|
||||
target
|
||||
}
|
||||
return synchronized(this) {
|
||||
cancellation.check()
|
||||
completed = true
|
||||
Batch(directory, files)
|
||||
}
|
||||
} finally {
|
||||
synchronized(this) {
|
||||
active.remove(directory)
|
||||
if (!completed || session in closedSessions) remove(directory)
|
||||
if (session in closedSessions && active.keys.none { it.parentFile == directory.parentFile }) {
|
||||
deleteOwned(directory.parentFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun discard(batch: Batch) {
|
||||
require(batch.directory !in active)
|
||||
deleteOwned(batch.directory)
|
||||
}
|
||||
@Synchronized fun closeSession(session: String) {
|
||||
require(UUID.fromString(session).toString() == session)
|
||||
closedSessions.add(session)
|
||||
val directory = File(root, session)
|
||||
val live = active.filterKeys { it.parentFile == directory }
|
||||
live.values.forEach { it.cancel() }
|
||||
if (live.isEmpty()) deleteOwned(directory)
|
||||
else directory.listFiles()?.filter { it !in live }?.forEach { deleteOwned(it) }
|
||||
}
|
||||
|
||||
private fun deleteOwned(file: File) {
|
||||
require(file.toPath().normalize().startsWith(root.toPath()) && file != root)
|
||||
require(root.canonicalFile == root && file.canonicalFile == file)
|
||||
remove(file)
|
||||
}
|
||||
|
||||
private fun remove(file: File) {
|
||||
if (!Files.exists(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
|
||||
Files.walk(file.toPath()).use { paths ->
|
||||
paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun safeName(name: String?): String {
|
||||
val cleaned = name.orEmpty().map { if (it == '/' || it.code == 92 || it.isISOControl()) '_' else it }.joinToString("")
|
||||
if (cleaned.isBlank() || cleaned == "." || cleaned == "..") return "upload"
|
||||
if (cleaned.toByteArray(Charsets.UTF_8).size <= 240) return cleaned
|
||||
val extension = cleaned.substringAfterLast('.', "").takeIf { it.length in 1..20 }?.let { ".$it" }.orEmpty()
|
||||
var stem = cleaned.removeSuffix(extension)
|
||||
while ((stem + extension).toByteArray(Charsets.UTF_8).size > 240) stem = stem.dropLast(1)
|
||||
return stem + extension
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UploadCacheTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
private fun session() = UUID.randomUUID().toString()
|
||||
private fun source(name: String = "报告.txt", value: String = "content") =
|
||||
UploadCache.Source(name) { value.byteInputStream() }
|
||||
|
||||
@Test fun preservesNamesAndSeparatesDuplicatesAndSelections() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val id = session()
|
||||
val first = cache.copy(id, listOf(source(), source(value = "second")), UploadCancellation())
|
||||
val second = cache.copy(id, listOf(source()), UploadCancellation())
|
||||
assertEquals(listOf("报告.txt", "报告.txt"), first.files.map { it.name })
|
||||
assertEquals(listOf("content", "second"), first.files.map { it.readText() })
|
||||
assertNotEquals(first.files[0], first.files[1])
|
||||
cache.discard(second)
|
||||
assertTrue(first.files.all { it.exists() })
|
||||
cache.closeSession(id)
|
||||
assertTrue(first.files.none { it.exists() })
|
||||
}
|
||||
|
||||
@Test fun sanitizesTraversalAndKeepsExtensions() {
|
||||
assertEquals(".._.._report.pdf", UploadCache.safeName("../../report.pdf"))
|
||||
assertEquals("a_b.txt", UploadCache.safeName("a" + 92.toChar() + "b.txt"))
|
||||
assertEquals("a_b.txt", UploadCache.safeName("a" + 0.toChar() + "b.txt"))
|
||||
listOf(null, "", " ", ".", "..").forEach { assertEquals("upload", UploadCache.safeName(it)) }
|
||||
val longName = UploadCache.safeName("文".repeat(300) + ".pdf")
|
||||
assertTrue(longName.endsWith(".pdf"))
|
||||
assertTrue(longName.toByteArray().size <= 240)
|
||||
}
|
||||
|
||||
@Test fun streamsLargeInputWithBoundedReadsAndClosesIt() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
var remaining = 10 * 1024 * 1024
|
||||
var closed = false
|
||||
val input = object : InputStream() {
|
||||
override fun read(): Int = error("Must use buffered reads")
|
||||
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
|
||||
assertTrue(length <= 64 * 1024)
|
||||
if (remaining == 0) return -1
|
||||
val count = minOf(length, remaining)
|
||||
bytes.fill(42, offset, offset + count)
|
||||
remaining -= count
|
||||
return count
|
||||
}
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
val batch = cache.copy(session(), listOf(UploadCache.Source("large.bin") { input }), UploadCancellation())
|
||||
assertEquals(10L * 1024 * 1024, batch.files.single().length())
|
||||
assertTrue(closed)
|
||||
}
|
||||
|
||||
@Test fun cancellationBeforeCopyDoesNotOpenInput() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val cancel = UploadCancellation().apply { cancel() }
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(session(), listOf(UploadCache.Source("file") { error("Opened after cancellation") }), cancel)
|
||||
}
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun cancellationDuringCopyClosesInputAndRemovesWholeBatch() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val cancel = UploadCancellation()
|
||||
var closed = false
|
||||
val input = object : InputStream() {
|
||||
override fun read(): Int { cancel.cancel(); return 1 }
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(session(), listOf(source(), UploadCache.Source("cancel.bin") { input }), cancel)
|
||||
}
|
||||
assertTrue(closed)
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun providerFailureRemovesEarlierFilesAndPartialFile() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val input = object : InputStream() {
|
||||
var reads = 0
|
||||
override fun read(): Int { if (++reads > 70000) throw IOException("Provider failed"); return 1 }
|
||||
}
|
||||
assertThrows(IOException::class.java) {
|
||||
cache.copy(session(), listOf(source(), UploadCache.Source("failed") { input }), UploadCancellation())
|
||||
}
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun externalCancellationClosesBlockedReadOffCallerThread() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val cancellation = UploadCancellation()
|
||||
val reading = CountDownLatch(1)
|
||||
val closed = CountDownLatch(1)
|
||||
val caller = Thread.currentThread()
|
||||
val worker = Executors.newSingleThreadExecutor()
|
||||
val input = object : InputStream() {
|
||||
override fun read(): Int {
|
||||
reading.countDown()
|
||||
check(closed.await(5, TimeUnit.SECONDS))
|
||||
throw IOException("Closed")
|
||||
}
|
||||
override fun close() {
|
||||
assertNotEquals(caller, Thread.currentThread())
|
||||
closed.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val copy = worker.submit {
|
||||
assertThrows(IOException::class.java) {
|
||||
cache.copy(session(), listOf(UploadCache.Source("blocked") { input }), cancellation)
|
||||
}
|
||||
}
|
||||
assertTrue(reading.await(5, TimeUnit.SECONDS))
|
||||
cancellation.cancel()
|
||||
assertTrue(closed.await(5, TimeUnit.SECONDS))
|
||||
copy.get(5, TimeUnit.SECONDS)
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
} finally { closed.countDown(); worker.shutdownNow() }
|
||||
}
|
||||
|
||||
@Test fun uncooperativeOpenDoesNotBlockOtherCopiesOrDeleteLiveBatch() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val id = session()
|
||||
val opening = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
val workers = Executors.newCachedThreadPool()
|
||||
try {
|
||||
val blocked = workers.submit {
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(id, listOf(UploadCache.Source("blocked") {
|
||||
opening.countDown()
|
||||
check(release.await(5, TimeUnit.SECONDS))
|
||||
"late".byteInputStream()
|
||||
}), UploadCancellation())
|
||||
}
|
||||
}
|
||||
assertTrue(opening.await(5, TimeUnit.SECONDS))
|
||||
val directory = File(temporary.root, "gecko-upload-v1/$id")
|
||||
val live = directory.listFiles()!!.single()
|
||||
workers.submit { cache.closeSession(id) }.get(5, TimeUnit.SECONDS)
|
||||
assertTrue(live.exists())
|
||||
val other = workers.submit<UploadCache.Batch> {
|
||||
cache.copy(session(), listOf(source()), UploadCancellation())
|
||||
}.get(5, TimeUnit.SECONDS)
|
||||
assertEquals("content", other.files.single().readText())
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(id, listOf(source()), UploadCancellation())
|
||||
}
|
||||
release.countDown()
|
||||
blocked.get(5, TimeUnit.SECONDS)
|
||||
assertFalse(directory.exists())
|
||||
assertTrue(other.files.single().exists())
|
||||
cache.discard(other)
|
||||
cache.discard(other)
|
||||
} finally { release.countDown(); workers.shutdownNow() }
|
||||
}
|
||||
|
||||
@Test fun blockedCancellationCallbackDoesNotBlockCallerOrOtherCallbacks() {
|
||||
val cancellation = UploadCancellation()
|
||||
val release = CountDownLatch(1)
|
||||
val started = CountDownLatch(1)
|
||||
val other = CountDownLatch(1)
|
||||
cancellation.onCancel { started.countDown(); release.await(5, TimeUnit.SECONDS) }
|
||||
cancellation.onCancel { other.countDown() }
|
||||
try {
|
||||
cancellation.cancel()
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(other.await(5, TimeUnit.SECONDS))
|
||||
val late = CountDownLatch(1)
|
||||
cancellation.onCancel { late.countDown() }
|
||||
assertTrue(late.await(5, TimeUnit.SECONDS))
|
||||
} finally { release.countDown() }
|
||||
}
|
||||
|
||||
@Test fun diskFullClosesStreamsAndDeletesPartialBatch() {
|
||||
var inputClosed = false
|
||||
var outputClosed = false
|
||||
val cache = UploadCache(temporary.root) { file ->
|
||||
val output = file.outputStream()
|
||||
object : java.io.OutputStream() {
|
||||
override fun write(value: Int) { output.write(value); throw IOException("No space left on device") }
|
||||
override fun close() { outputClosed = true; output.close() }
|
||||
}
|
||||
}
|
||||
val input = object : java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3)) {
|
||||
override fun close() { inputClosed = true; super.close() }
|
||||
}
|
||||
assertThrows(IOException::class.java) {
|
||||
cache.copy(session(), listOf(UploadCache.Source("full.bin") { input }), UploadCancellation())
|
||||
}
|
||||
assertTrue(inputClosed)
|
||||
assertTrue(outputClosed)
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun restartCleansOnlyOwnedCacheAndSessionCleanupIsIsolated() {
|
||||
val other = temporary.newFile("unrelated")
|
||||
val cache = UploadCache(temporary.root)
|
||||
val firstId = session()
|
||||
val first = cache.copy(firstId, listOf(source()), UploadCancellation())
|
||||
val second = cache.copy(session(), listOf(source()), UploadCancellation())
|
||||
cache.closeSession(firstId)
|
||||
assertFalse(first.files.single().exists())
|
||||
assertTrue(second.files.single().exists())
|
||||
UploadCache(temporary.root)
|
||||
assertFalse(second.files.single().exists())
|
||||
assertTrue(other.exists())
|
||||
assertThrows(IllegalArgumentException::class.java) { cache.closeSession("../unrelated") }
|
||||
assertThrows(IllegalArgumentException::class.java) { cache.discard(UploadCache.Batch(other, emptyList())) }
|
||||
}
|
||||
|
||||
@Test fun rejectsSymlinkRootAndDoesNotFollowNestedSymlinks() {
|
||||
val outside = temporary.newFolder("outside")
|
||||
val retained = File(outside, "retained").apply { writeText("keep") }
|
||||
val parent = temporary.newFolder("cache")
|
||||
val root = File(parent, "gecko-upload-v1")
|
||||
Files.createSymbolicLink(root.toPath(), outside.toPath())
|
||||
assertThrows(IllegalArgumentException::class.java) { UploadCache(parent) }
|
||||
Files.delete(root.toPath())
|
||||
root.mkdir()
|
||||
Files.createSymbolicLink(File(root, "link").toPath(), outside.toPath())
|
||||
UploadCache(parent)
|
||||
assertTrue(retained.exists())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user