Move SSD zip entry validation to typed Kotlin

ober

67e048fd649b38579a7d12934036bac7408f0850

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 50aebe4..4d44d9d 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -1273,7 +1273,7 @@
 
     (typed-kotlin-file "com/sfb/ssdreview/PathSafety.kt"
       (typed-library (com sfb ssdreview)
-        (export safePathComponent safeLeaf validSourceKey validatedSourceKey)
+        (export safePathComponent safeLeaf safeZipEntryName validSourceKey validatedSourceKey)
         (def (safePathComponent (name : String)) : Bool
           (let ((n (string-length name)))
             (and (and (>= n 1) (<= n 128))
@@ -1284,6 +1284,17 @@
           (and (safePathComponent name)
                (or (string-ends-with? name ".json")
                    (string-ends-with? name ".jsonl"))))
+        (def (safeZipEntryName (name : String)) : Bool
+          (let ((n (string-length name)))
+            (and (and (>= n 1) (<= n 240))
+                 (and (not (string-starts-with? name "/"))
+                      (and (not (string-contains? name "\\"))
+                           (or (string-matches-regex?
+                                 name
+                                 "^(ground_truth|learned|events|reviews)/$")
+                               (string-matches-regex?
+                                 name
+                                 "^(ground_truth|learned|events|reviews)/[A-Za-z0-9][A-Za-z0-9._-]*\\.(json|jsonl)$")))))))
         (def (validSourceKey (key : String)) : Bool
           (let ((n (string-length key)))
             (and (and (>= n 1) (<= n 96))
@@ -4942,7 +4953,7 @@
        "                        throw IllegalStateException(\"ZIP export quota exceeded\")"
        "                    }"
        "                    val relative = root.toPath().relativize(file.toPath()).toString()"
-       "                    require(safeEntry(relative)) { \"Unsafe local export path\" }"
+       "                    require(safeZipEntryName(relative)) { \"Unsafe local export path\" }"
        "                    zip.putNextEntry(ZipEntry(relative))"
        "                    file.inputStream().use { input ->"
        "                        LimitedInputStream(input, MAX_ZIP_ENTRY_BYTES).use { it.copyTo(zip, 32 * 1024) }"
@@ -4966,7 +4977,7 @@
        "                    budget.entries += 1"
        "                    if (budget.entries > MAX_ZIP_ENTRIES) throw IllegalStateException(\"ZIP entry-count limit exceeded: ${budget.entries}/$MAX_ZIP_ENTRIES\")"
        "                    val name = entry.name"
-       "                    if (name.length > MAX_ZIP_PATH_CHARS || !safeEntry(name)) {"
+       "                    if (!safeZipEntryName(name)) {"
        "                        throw IllegalStateException(\"Unsafe ZIP entry\")"
        "                    }"
        "                    if (!entry.isDirectory) {"
@@ -5365,18 +5376,8 @@
        "        }"
        "    }"
        ""
-       "    private fun safeEntry(name: String): Boolean {"
-       "        if (name.length !in 1..MAX_ZIP_PATH_CHARS || name.startsWith('/') || name.contains('\\\\')) return false"
-       "        val directory = name.removeSuffix(\"/\")"
-       "        if (name.endsWith(\"/\")) return directory in setOf(\"ground_truth\", \"learned\", \"events\", \"reviews\")"
-       "        val parts = name.split('/')"
-       "        return parts.size == 2 &&"
-       "            parts[0] in setOf(\"ground_truth\", \"learned\", \"events\", \"reviews\") &&"
-       "            safeLeaf(parts[1])"
-       "    }"
-       ""
        "    private fun containedImportPath(name: String): File {"
-       "        require(safeEntry(name) && !name.endsWith('/')) { \"Unsafe import path\" }"
+       "        require(safeZipEntryName(name) && !name.endsWith('/')) { \"Unsafe import path\" }"
        "        val parts = name.split('/')"
        "        val directory = when (parts[0]) {"
        "            \"ground_truth\" -> groundTruthDir"