Move SSD storage path checks to typed Kotlin
ober
206ab4e1d6708634f2a04d1731daeb9290cbba69
--- 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())"