Move SSD storage path checks to typed Kotlin

ober

206ab4e1d6708634f2a04d1731daeb9290cbba69

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 764c718..aeab9e0 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -1630,7 +1630,7 @@
                 TruthIndexEntry-truthTime betterTruthIndex
                 fileExists fileIsDirectory fileIsRegular fileIsSymbolicLink
                 fileSizeBytes
-                pathIsSymbolicLink safeLocalRegularFile
+                pathIsSymbolicLink pathNotSymbolicLink safeLocalRegularFile
                 destinationNewFileDelta
                 storageStatsExceedsQuota storageCapacityWithinQuota
                 byteCountWithinLimit storageEntryBytesAllowed
@@ -1653,8 +1653,9 @@
                 remotePinAllowedForProtocol remoteConfigHasPin
                 remoteRelativePathSafe remoteOriginMatches
                 shouldCompareMoreCompleteTruth shouldWriteRemoteTruth
+                localFileMissing localTextMatchesRemote
                 shouldReplaceLocalByTimes
-                zipEntryIsDirectory zipEntryDeclaresExcessiveSize)
+                zipEntryIsDirectory zipEntryIsFile zipEntryDeclaresExcessiveSize)
         (type File)
         (type Path)
         (type URL)
@@ -1723,6 +1724,8 @@
           (fileLength file))
         (def (pathIsSymbolicLink (path : Path)) : Bool
           (pathIsSymbolicLinkRaw path))
+        (def (pathNotSymbolicLink (path : Path)) : Bool
+          (not (pathIsSymbolicLink path)))
         (def (fileIsSymbolicLink (file : File)) : Bool
           (pathIsSymbolicLink (fileToPath file)))
         (def (safeLocalRegularFile (file : File)) : Bool
@@ -1730,6 +1733,8 @@
                (not (fileIsSymbolicLink file))))
         (def (destinationNewFileDelta (destination : File)) : Int32
           (if (fileExists destination) (int32 0) (int32 1)))
+        (def (localFileMissing (file : File)) : Bool
+          (not (fileExists file)))
         (def (oldDestinationBytes (destination : File)) : Int
           (if (fileIsFile destination) (fileLength destination) (int 0)))
         (def (storageStatsExceedsQuota (files : Int32)
@@ -1886,8 +1891,13 @@
             (>= (nullable-get remoteGenerated) (nullable-get localGenerated))
             (and (> remoteModified (int 0))
                  (>= remoteModified localModified))))
+        (def (localTextMatchesRemote (localText : String)
+                                     (remoteText : String)) : Bool
+          (equal? localText remoteText))
         (def (zipEntryIsDirectory (entry : ZipEntry)) : Bool
           (zipEntryIsDirectoryRaw entry))
+        (def (zipEntryIsFile (entry : ZipEntry)) : Bool
+          (not (zipEntryIsDirectory entry)))
         (def (zipEntryDeclaresExcessiveSize (entry : ZipEntry)
                                             (entryLimit : Int)
                                             (compressedLimit : Int)) : Bool
@@ -5603,7 +5613,9 @@
 
     (typed-kotlin-file "com/sfb/ssdreview/PathSafety.kt"
       (typed-library (com sfb ssdreview)
-        (export safePathComponent safeLeaf safeZipEntryName validSourceKey
+        (export safePathComponent safePathComponentInvalid
+                safeLeaf safeLeafInvalid
+                safeZipEntryName zipEntryNameUnsafe validSourceKey
                 validatedSourceKey treeFileMime
                 safeTreePathParts treePathLeaf treePathDirectoryParts
                 safeImportLeafEntryName zipEntryRootName zipEntryLeafName
@@ -5620,10 +5632,14 @@
                  (and (not (equal? name "."))
                       (and (not (equal? name ".."))
                            (string-matches-regex? name "[A-Za-z0-9][A-Za-z0-9._-]*"))))))
+        (def (safePathComponentInvalid (name : String)) : Bool
+          (not (safePathComponent name)))
         (def (safeLeaf (name : String)) : Bool
           (and (safePathComponent name)
                (or (string-ends-with? name ".json")
                    (string-ends-with? name ".jsonl"))))
+        (def (safeLeafInvalid (name : String)) : Bool
+          (not (safeLeaf name)))
         (def (jsonLinesFileName (name : String)) : Bool
           (string-ends-with? name ".jsonl"))
         (def (truthJsonFileName (name : String)) : Bool
@@ -5661,6 +5677,8 @@
                                (string-matches-regex?
                                  name
                                  "^(ground_truth|learned|events|reviews)/[A-Za-z0-9][A-Za-z0-9._-]*\\.(json|jsonl)$")))))))
+        (def (zipEntryNameUnsafe (name : String)) : Bool
+          (not (safeZipEntryName name)))
         (def (safeImportLeafEntryName (name : String)) : Bool
           (and (safeZipEntryName name)
                (not (string-ends-with? name "/"))))
@@ -8332,7 +8350,7 @@
        "    private fun containedLeaf(base: File, leaf: String): File {"
        "        require(safeLeaf(leaf)) { \"Unsafe local leaf\" }"
        "        val basePath = base.toPath().toRealPath(LinkOption.NOFOLLOW_LINKS)"
-       "        require(!pathIsSymbolicLink(basePath)) { \"Symlinked storage root rejected\" }"
+       "        require(pathNotSymbolicLink(basePath)) { \"Symlinked storage root rejected\" }"
        "        val target = basePath.resolve(leaf).normalize()"
        "        require(target.parent == basePath) { \"Local path escaped storage root\" }"
        "        return target.toFile()"
@@ -8372,10 +8390,10 @@
        "        ensureStorageCapacity(destination, byteArraySizeBytes(bytes))"
        "        val parent = destination.parentFile ?: throw IllegalArgumentException(\"Destination has no parent\")"
        "        val parentPath = parent.toPath().toRealPath(LinkOption.NOFOLLOW_LINKS)"
-       "        require(!pathIsSymbolicLink(parentPath)) { \"Symlinked destination parent rejected\" }"
+       "        require(pathNotSymbolicLink(parentPath)) { \"Symlinked destination parent rejected\" }"
        "        val target = parentPath.resolve(destination.name).normalize()"
        "        require(target.parent == parentPath) { \"Destination escaped parent\" }"
-       "        require(!pathIsSymbolicLink(target)) { \"Symlinked destination rejected\" }"
+       "        require(pathNotSymbolicLink(target)) { \"Symlinked destination rejected\" }"
        "        val temporary = Files.createTempFile(parentPath, \".ssd-review-\", \".part\")"
        "        try {"
        "            Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)"
@@ -8616,10 +8634,10 @@
        "                    budget.entries += 1"
        "                    if (zipEntryCountExceeded(budget.entries, MAX_ZIP_ENTRIES)) throw IllegalStateException(zipEntryCountLimitExceededMessage(budget.entries, MAX_ZIP_ENTRIES))"
        "                    val name = entry.name"
-       "                    if (!safeZipEntryName(name)) {"
+       "                    if (zipEntryNameUnsafe(name)) {"
        "                        throw IllegalStateException(\"Unsafe ZIP entry\")"
        "                    }"
-       "                    if (!zipEntryIsDirectory(entry)) {"
+       "                    if (zipEntryIsFile(entry)) {"
        "                        if (zipEntryDeclaresExcessiveSize(entry, MAX_ZIP_ENTRY_BYTES, MAX_REMOTE_ZIP_BYTES)) {"
        "                            throw IllegalStateException(\"ZIP entry declares excessive size\")"
        "                        }"
@@ -8851,11 +8869,11 @@
        "            if (zipEntryCountExceeded(budget.entries, MAX_ZIP_ENTRIES)) throw IllegalStateException(\"Import tree exceeds entry limit\")"
        "            if (documentFileIsDirectory(child)) {"
        "                val directoryName = child.name ?: return@forEach"
-       "                if (!safePathComponent(directoryName)) throw IllegalStateException(\"Unsafe import directory\")"
+       "                if (safePathComponentInvalid(directoryName)) throw IllegalStateException(\"Unsafe import directory\")"
        "                count += importTreeDir(child, File(localDir, directoryName), depth + 1, budget)"
        "            } else if (documentFileIsRegular(child)) {"
        "                val name = child.name ?: return@forEach"
-       "                if (!safeLeaf(name)) return@forEach"
+       "                if (safeLeafInvalid(name)) return@forEach"
        "                val remaining = MAX_ZIP_TOTAL_BYTES - budget.expandedBytes"
        "                if (!positiveByteCount(remaining)) throw IllegalStateException(\"Import tree exceeds byte limit\")"
        "                val bytes = context.contentResolver.openInputStream(child.uri)?.use {"
@@ -8918,9 +8936,9 @@
        "    }"
        ""
        "    private fun shouldReplaceLocal(local: File, remoteText: String, remoteModified: Long): Boolean {"
-       "        if (!fileExists(local)) return true"
+       "        if (localFileMissing(local)) return true"
        "        val localText = readLocalText(local)"
-       "        if (localText == remoteText) return false"
+       "        if (localTextMatchesRemote(localText, remoteText)) return false"
        "        val localGenerated = jsonTime(localText)"
        "        val remoteGenerated = jsonTime(remoteText)"
        "        return shouldReplaceLocalByTimes(localGenerated, remoteGenerated, remoteModified, local.lastModified())"