diff --git a/Dockerfile b/Dockerfile index 7c2e82f..74006e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,9 @@ RUN flutter config --android-sdk "${ANDROID_HOME}" \ # 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本 # 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。 + +# Java/Gradle 不自动读取 http_proxy;仅构建时注入 JVM 代理参数,不固化到镜像 ENV。 +ARG JAVA_TOOL_OPTIONS RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \ && cd /tmp/warmup \ && flutter build apk --release \ diff --git a/README.md b/README.md index 2e286d1..db86e4a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie - 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制) - 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪 - 文件上传:`` 走系统文件选择器(工作区上传、备份导入) +- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。 - 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie - 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。 - 返回键:优先回退页面历史,无历史时退出应用 @@ -17,6 +18,8 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie 前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。 +Java/Gradle 不自动使用上述代理变量。需要时另外设置 `JAVA_TOOL_OPTIONS`,例如 `-Dhttp.proxyHost=代理主机 -Dhttp.proxyPort=端口 -Dhttps.proxyHost=代理主机 -Dhttps.proxyPort=端口`;`build.sh` 会将其传入镜像构建和 APK 构建容器。代理必须能从容器访问,不要把宿主机的 `127.0.0.1` 直接当成容器代理地址。Java 会输出此变量,切勿在其中放入密码或其他秘密。 + ```sh ./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release ``` @@ -64,6 +67,7 @@ CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD` - 登录后重启应用,确认站点的持久登录状态及本地存储保留。 - 工作区单文件/多文件上传、选择器取消、配置备份导入。 +- 从下载目录、内部存储及云盘提供程序选择文件;验证中文名、同名多选、空文件、大文件、复制失败及复制中退出。目录上传仍走 Gecko 原有目录 URI 处理路径,需单独验收。 - 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。 - 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。 - JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。 diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt index 952520b..bd3abb8 100644 --- a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt +++ b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt @@ -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 Unit>() private val streams = java.util.Collections.synchronizedSet(mutableSetOf()) private val io = Executors.newSingleThreadExecutor() + private var uploadSession = UUID.randomUUID().toString() private val downloads = java.util.Collections.synchronizedSet(mutableSetOf()) @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 { @@ -195,15 +214,29 @@ internal class GeckoPlatformView( private fun chooseFiles(prompt: FilePrompt): GeckoResult { val result = GeckoResult() - fun finish(uris: Array?) { + 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?, 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() 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 diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/UploadCache.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/UploadCache.kt new file mode 100644 index 0000000..bbf20ad --- /dev/null +++ b/android/app/src/main/kotlin/ren/misaka/dshapp/UploadCache.kt @@ -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() + private val closedSessions = mutableSetOf() + + 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) + + fun copy(session: String, sources: List, 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 + } + } +} diff --git a/android/app/src/test/kotlin/ren/misaka/dshapp/UploadCacheTest.kt b/android/app/src/test/kotlin/ren/misaka/dshapp/UploadCacheTest.kt new file mode 100644 index 0000000..4a5deed --- /dev/null +++ b/android/app/src/test/kotlin/ren/misaka/dshapp/UploadCacheTest.kt @@ -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 { + 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()) + } +} diff --git a/build.sh b/build.sh index 8cffb06..8db72af 100755 --- a/build.sh +++ b/build.sh @@ -69,7 +69,7 @@ if [ "$REBUILD" = true ] || ! docker image inspect "$IMAGE" > /dev/null 2>&1; th echo "==> 构建镜像 $IMAGE(首次较慢,需下载 Flutter/Android SDK)" BUILD_ARGS=() if [ "$REBUILD" = true ]; then BUILD_ARGS+=(--no-cache); fi - docker build --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy "${BUILD_ARGS[@]}" -t "$IMAGE" . + docker build --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy --build-arg JAVA_TOOL_OPTIONS "${BUILD_ARGS[@]}" -t "$IMAGE" . fi echo "==> 在容器内构建 release APK" @@ -80,6 +80,7 @@ docker run --rm \ -e DSH_KEYSTORE=/run/dsh-signing/release.keystore \ -e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \ -e http_proxy -e https_proxy -e no_proxy \ + -e JAVA_TOOL_OPTIONS \ -e PUB_CACHE=/opt/pub-cache \ -e DSH_SITE_URL="$DSH_SITE_URL" \ "$IMAGE" \ diff --git a/pubspec.yaml b/pubspec.yaml index 20403e5..f87376e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.3+4 +version: 1.0.4+5 environment: sdk: ^3.13.5