Move SSD tree path validation to typed Kotlin

ober

1003f2058176cf5b91c8031c1256307a62348fef

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index a372381..59f1a6b 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -5286,7 +5286,9 @@
     (typed-kotlin-file "com/sfb/ssdreview/PathSafety.kt"
       (typed-library (com sfb ssdreview)
         (export safePathComponent safeLeaf safeZipEntryName validSourceKey
-                validatedSourceKey treeFileMime)
+                validatedSourceKey treeFileMime
+                safeTreePathParts treePathLeaf treePathDirectoryParts)
+        (type Int32)
         (def (safePathComponent (name : String)) : Bool
           (let ((n (string-length name)))
             (and (and (>= n 1) (<= n 128))
@@ -5303,6 +5305,22 @@
             (if (string-ends-with? name ".jsonl")
               "application/x-ndjson"
               "application/octet-stream")))
+        (def (safeTreePathParts (parts : (List String))
+                                (maxDepth : Int32)) : Bool
+          (let ((n (list-size parts)))
+            (and (and (>= n (int32 1)) (<= n maxDepth))
+                 (for/fold ((safe #t))
+                           ((i (in-range (int32 0) n)))
+                   (and safe (safePathComponent (list-ref parts i)))))))
+        (def (treePathLeaf (parts : (List String))) : String
+          (list-ref parts (- (list-size parts) (int32 1))))
+        (def (treePathDirectoryParts (parts : (List String))) : (MutableList String)
+          (for/fold ((out (mutable-list-empty String)))
+                    ((i (in-range (int32 0)
+                                  (- (list-size parts) (int32 1)))))
+            (begin
+              (mutable-list-add! out (list-ref parts i))
+              out)))
         (def (safeZipEntryName (name : String)) : Bool
           (let ((n (string-length name)))
             (and (and (>= n 1) (<= n 240))
@@ -8503,10 +8521,10 @@
        "        require(source.isFile && !Files.isSymbolicLink(source.toPath()))"
        "        require(source.length() <= MAX_ZIP_ENTRY_BYTES) { \"Tree export file exceeds byte limit\" }"
        "        val parts = trimmedNonBlankPathParts(relativeName, File.separatorChar, '/')"
-       "        require(parts.size in 1..MAX_ZIP_DEPTH && parts.all { safePathComponent(it) })"
-       "        val fileName = parts.lastOrNull() ?: return"
+       "        require(safeTreePathParts(parts, MAX_ZIP_DEPTH))"
+       "        val fileName = treePathLeaf(parts)"
        "        var dir = parent"
-       "        parts.dropLast(1).forEach { dir = ensureTreeDir(dir, it) }"
+       "        treePathDirectoryParts(parts).forEach { dir = ensureTreeDir(dir, it) }"
        "        dir.findFile(fileName)?.delete()"
        "        val mime = treeFileMime(fileName)"
        "        val doc = dir.createFile(mime, fileName)"