download: never extract a symlink from a downloaded VM

Bumping ZIPFoundation to 0.9.20 brought in its symlink containment guard,
but that guard is not sufficient here. `URL.isContained(in:)` collapses
repeated separators in a single pass, so a link target ending in
`<package>///../elsewhere` is accepted while the filesystem resolves it to
`<package>/../elsewhere`. A crafted archive could pair such a link with a
later regular file underneath it, whose own path passes the containment
check, and write outside the package again.

Nothing in UTM ever stores a symlink inside a package, so reject them
outright instead of trying to validate the target.

Also remove the destination on failure. Extraction created the .utm
directory up front and a mid-loop error left the partial contents in
place, where the library would list them as a real virtual machine that
survives a relaunch.

Assisted-by: Claude:claude-fable-5-1
This commit is contained in:
osy
2026-09-01 12:30:12 -07:00
parent a22bac70ef
commit 34d48fec66
+18 -7
View File
@@ -85,13 +85,24 @@ class UTMDownloadVMTask: UTMDownloadTask {
let destinationURL = destinationFolder.appendingPathComponent(destinationUtmDirectory, isDirectory: true)
/// create the .utm directory
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: false)
/// get and extract all files contained in the UTM directory, except the `__MACOSX` folder
let containedFiles = archive.filter({ $0.path.hasPrefix(utmFolderInZip.path) && !$0.path.hasSuffix(utmDirectoryEnding) && !$0.path.contains("__MACOSX") })
for file in containedFiles {
let relativePath = String(file.path.dropFirst(utmFolderInZip.path.count))
let isDirectory = file.path.hasSuffix("/")
let fileURL = try containedDestination(for: relativePath, in: destinationURL, isDirectory: isDirectory)
_ = try archive.extract(file, to: fileURL, skipCRC32: true)
do {
/// get and extract all files contained in the UTM directory, except the `__MACOSX` folder
let containedFiles = archive.filter({ $0.path.hasPrefix(utmFolderInZip.path) && !$0.path.hasSuffix(utmDirectoryEnding) && !$0.path.contains("__MACOSX") })
for file in containedFiles {
/// we never store a symlink in a package, and a link target that survives a containment
/// check can still be resolved outside of the package when it is written through later
guard file.type != .symlink else {
throw UnzipUnsafePathError()
}
let relativePath = String(file.path.dropFirst(utmFolderInZip.path.count))
let isDirectory = file.path.hasSuffix("/")
let fileURL = try containedDestination(for: relativePath, in: destinationURL, isDirectory: isDirectory)
_ = try archive.extract(file, to: fileURL, skipCRC32: true)
}
} catch {
/// a partially extracted package would still be picked up as a VM by the library
try? fileManager.removeItem(at: destinationURL)
throw error
}
return destinationURL
} else {