From e61ce71636e7db6a2e33698f73ef72e197b888bf Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:35:41 -0300 Subject: [PATCH 1/2] Keep the cache fallback in the temporary directory out of other users' reach Without HOME or XDG_CACHE_HOME the disk cache used /com.request-dl-nio.Swift.Cache, a path any user can predict, in a directory any user can write to. It now uses /request-dl-nio- when that is a real directory of the process's user that nobody else can read or rename away, and otherwise a directory with an unguessable name that lasts as long as the process. Co-Authored-By: Claude Sonnet 5.5 --- .../Sources/Cache/Data Cache/DataCache.swift | 7 +- .../Extensions/FilePath+.swift | 35 +++- .../Extensions/FilePath+OwnedDirectory.swift | 148 +++++++++++++++ .../InternalsCacheDirectoryTests.swift | 177 +++++++++++++++++- 4 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift diff --git a/Sources/RequestDL/Properties/Sources/Cache/Data Cache/DataCache.swift b/Sources/RequestDL/Properties/Sources/Cache/Data Cache/DataCache.swift index 433e00f6..4ff20a7c 100644 --- a/Sources/RequestDL/Properties/Sources/Cache/Data Cache/DataCache.swift +++ b/Sources/RequestDL/Properties/Sources/Cache/Data Cache/DataCache.swift @@ -540,8 +540,11 @@ public struct DataCache: Sendable, Equatable { /// `FilePath.temporaryDirectory``. Cache entries are meant to survive between requests, and /// the temporary directory offers no such guarantee: the system can purge it at any moment, /// even while the app is running. `cachesDirectory` lands in `Library/Caches` on Darwin, - /// which is only cleared between launches, and falls back to the temporary directory on - /// platforms with no equivalent standard location. + /// which is only cleared between launches. On Linux it is the user's XDG cache directory. + /// Where there is none of those (no `HOME` or `XDG_CACHE_HOME`), it is a directory of the + /// process's user inside the temporary directory, one nobody else created or can rename away, + /// or, if the temporary directory cannot be trusted, a new one that lasts as long as the + /// process. Other platforms use the temporary directory. /// /// `FileSystem.shared.temporaryDirectory` is the NIO one and would otherwise be the obvious /// choice, but it is `async throws` and this call chain cannot suspend: it is reached from diff --git a/Sources/RequestDLInternals/Extensions/FilePath+.swift b/Sources/RequestDLInternals/Extensions/FilePath+.swift index 24d4e840..1b30aa8f 100644 --- a/Sources/RequestDLInternals/Extensions/FilePath+.swift +++ b/Sources/RequestDLInternals/Extensions/FilePath+.swift @@ -14,6 +14,16 @@ import class Foundation.ProcessInfo import class Foundation.FileManager #endif +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#endif + extension FilePath { /// The system temporary directory. @@ -80,9 +90,32 @@ extension FilePath { } #endif - return temporaryDirectory + return sharedCachesDirectory } + /// Where the cache goes when there is no per-user directory for it, which is the temporary + /// directory every user of the machine can write to. Not the temporary directory itself: a + /// directory of this process's user in it, that nobody else made or can rename away + /// (``ownedDirectory(named:under:owner:)``), and, when the place cannot be trusted, one + /// with a name nobody could have guessed, that lasts as long as the process does + /// (``exclusiveDirectory(prefix:under:)``). Where there is no such thing to check (Windows), + /// the temporary directory, as it always was. + /// + /// Resolved once: the fallback must be the same every time it is asked. + package static var sharedCachesDirectory: FilePath { sharedCachesDirectoryOnce } + + private static let sharedCachesDirectoryOnce: FilePath = { + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) + let temporary = temporaryDirectory + + return ownedDirectory(named: "request-dl-nio-\(geteuid())", under: temporary) + ?? exclusiveDirectory(prefix: "request-dl-nio", under: temporary) + ?? temporary + #else + return temporaryDirectory + #endif + }() + /// The per-user cache directory of the XDG Base Directory specification: `$XDG_CACHE_HOME`, /// or `$HOME/.cache` when it is not set. `nil` when neither names a usable absolute path, /// and the caller falls back to the temporary directory. diff --git a/Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift b/Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift new file mode 100644 index 00000000..822801e0 --- /dev/null +++ b/Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift @@ -0,0 +1,148 @@ +// +// See LICENSE for this package's licensing information. +// + +import SystemPackage + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#endif + +#if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) + +extension FilePath { + + /// A directory named `name` under `parent` that only `owner` can change, created owner only + /// when it is not there yet; `nil` when the place cannot be trusted. + /// + /// For the one case where the cache has no per-user directory to go to and lands in the + /// temporary directory, which every user of the machine can write to: a user who gets there + /// first can create the directory the cache is about to use, and then owns it, and can + /// rename it from under the process whenever they like. It is trusted when + /// + /// - what is at `parent/name` is a real directory (a symbolic link is not followed, since + /// anyone can plant one), owned by `owner`, that group and others cannot even read (the + /// names inside it encode the URLs it caches), and + /// - nobody but `owner` or root can rename it away: `parent` is owned by one of them, and it + /// is either not writable by group or others or it has the sticky bit, which limits + /// renaming and removing to the owner of the entry. + /// + /// A directory the check does not pass is left alone, not repaired or removed: it is someone + /// else's. + /// + /// - Parameter owner: The user the directory has to belong to. The effective user of this + /// process, except in a test. + package static func ownedDirectory( + named name: String, + under parent: FilePath, + owner: UInt32 = UInt32(geteuid()) + ) -> FilePath? { + guard isUntamperable(parent: parent, owner: owner) else { + return nil + } + + let path = parent.appending(name) + + switch makeDirectory(atPath: path.string) { + case .created, .alreadyThere: + break + case .failed: + return nil + } + + guard let status = status(of: path) else { + return nil + } + + let isDirectory = status.mode & 0o170000 == 0o040000 + let isOwnerOnly = status.mode & 0o077 == 0 + + guard isDirectory, isOwnerOnly, status.owner == owner else { + return nil + } + + return path + } + + /// A directory under `parent` with a name nobody could have guessed, created owner only. + /// What the cache uses when `ownedDirectory(named:under:owner:)` found nothing it can trust: + /// it does not survive the process, since every process makes its own, but nobody else + /// could have made it first. + package static func exclusiveDirectory( + prefix: String, + under parent: FilePath + ) -> FilePath? { + for _ in 0..<8 { + let path = parent.appending("\(prefix)-\(UInt64.random(in: .min ... .max))") + + switch makeDirectory(atPath: path.string) { + case .created: + return path + case .alreadyThere: + continue + case .failed: + return nil + } + } + + return nil + } + + // MARK: - Private static methods + + private static func isUntamperable(parent: FilePath, owner: UInt32) -> Bool { + guard let status = status(of: parent) else { + return false + } + + guard status.mode & 0o170000 == 0o040000 else { + return false + } + + let isTrustedOwner = status.owner == owner || status.owner == 0 + let isWritableByOthers = status.mode & 0o022 != 0 + let isSticky = status.mode & 0o1000 != 0 + + return isTrustedOwner && (!isWritableByOthers || isSticky) + } + + private static func status(of path: FilePath) -> (mode: UInt32, owner: UInt32)? { + statusOfItem(atPath: path.string) + } +} + +// MARK: - libc + +private enum DirectoryCreation { + case created + case alreadyThere + case failed +} + +/// Outside `FilePath`'s extension because `FilePath` has members called `stat`, which would take +/// the place of the C function inside it. +private func makeDirectory(atPath path: String) -> DirectoryCreation { + if mkdir(path, 0o700) == 0 { + return .created + } + + return errno == EEXIST ? .alreadyThere : .failed +} + +private func statusOfItem(atPath path: String) -> (mode: UInt32, owner: UInt32)? { + var buffer = stat() + + guard lstat(path, &buffer) == 0 else { + return nil + } + + return (UInt32(buffer.st_mode), UInt32(buffer.st_uid)) +} + +#endif diff --git a/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift b/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift index ea49e286..d27eb181 100644 --- a/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift +++ b/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift @@ -55,7 +55,7 @@ struct InternalsCacheDirectoryTests { func cachesDirectory_onLinux_isTheUsersCacheDirectoryWhenThereIsOne() { let expected = FilePath.xdgCacheDirectory(environment: ProcessInfo.processInfo.environment) - ?? FilePath.temporaryDirectory + ?? FilePath.sharedCachesDirectory #expect(FilePath.cachesDirectory == expected) } @@ -89,8 +89,183 @@ struct InternalsCacheDirectoryTests { } } + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) + + // MARK: - Where there is no directory of the user's own + + /// The temporary directory is shared by every user at a path anyone can predict, and a user + /// who gets there first can create the directory the cache is about to use and rename it from + /// under the process. Two users cannot be had in a test, so who owns what is a parameter. + + @Test + func ownedDirectory_whenAbsent_isCreatedOwnerOnly() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + // When + let directory = FilePath.ownedDirectory(named: "cache", under: parent) + + // Then + #expect(directory == parent.appending("cache")) + #expect(try mode(of: parent.appending("cache")) == 0o700) + } + + @Test + func ownedDirectory_whenItIsTheOwnersAlready_isUsed() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + _ = FilePath.ownedDirectory(named: "cache", under: parent) + + // When + let again = FilePath.ownedDirectory(named: "cache", under: parent) + + // Then + #expect(again == parent.appending("cache")) + } + + @Test + func ownedDirectory_whenAnotherUserOwnsIt_isNotUsed() throws { + // Given: the directory is this user's, and the caller asks for a different owner. + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + let someoneElse = UInt32(geteuid()) + 1 + + // When / Then + #expect(FilePath.ownedDirectory(named: "cache", under: parent, owner: someoneElse) == nil) + } + + @Test(arguments: [0o755, 0o750, 0o770, 0o707] as [Int]) + func ownedDirectory_whenOthersCanGetInto_isNotUsed(_ mode: Int) throws { + // Given: a directory of this user's that group or others can read or write. + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + let path = parent.appending("cache") + try FileManager.default.createDirectory(atPath: path.string, withIntermediateDirectories: false) + #expect(chmod(path.string, mode_t(mode)) == 0) + + // When / Then + #expect(FilePath.ownedDirectory(named: "cache", under: parent) == nil) + } + + /// Anyone can plant a symbolic link in the temporary directory, to somewhere that is theirs. + @Test + func ownedDirectory_whenASymbolicLinkIsThere_isNotFollowed() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + let target = parent.appending("target") + try FileManager.default.createDirectory(atPath: target.string, withIntermediateDirectories: false) + try FileManager.default.createSymbolicLink( + atPath: parent.appending("cache").string, + withDestinationPath: target.string + ) + + // When / Then + #expect(FilePath.ownedDirectory(named: "cache", under: parent) == nil) + } + + /// Where every user can write without the sticky bit, any of them can rename the directory + /// away, whoever owns it. + @Test + func ownedDirectory_whenTheParentLetsOthersRenameIt_isNotUsed() throws { + // Given + let parent = try makeParent(mode: 0o777) + defer { remove(parent) } + + // When / Then + #expect(FilePath.ownedDirectory(named: "cache", under: parent) == nil) + } + + /// The temporary directory of an ordinary machine: every user writes in it and the sticky bit + /// keeps them from touching each other's entries. + @Test + func ownedDirectory_whenTheParentIsWorldWritableAndSticky_isUsed() throws { + // Given + let parent = try makeParent(mode: 0o1777) + defer { remove(parent) } + + // When + let directory = FilePath.ownedDirectory(named: "cache", under: parent) + + // Then + #expect(directory == parent.appending("cache")) + } + + @Test + func ownedDirectory_whenTheParentIsMissing_isNotUsed() { + let parent = FilePath.temporaryDirectory.appending("requestdl-missing-\(UUID().uuidString)") + + #expect(FilePath.ownedDirectory(named: "cache", under: parent) == nil) + } + + @Test + func exclusiveDirectory_isNewEveryTime_andOwnerOnly() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + // When + let first = FilePath.exclusiveDirectory(prefix: "cache", under: parent) + let second = FilePath.exclusiveDirectory(prefix: "cache", under: parent) + + // Then + #expect(first != nil) + #expect(second != nil) + #expect(first != second) + #expect(first?.removingLastComponent() == parent) + + for directory in [first, second].compactMap({ $0 }) { + #expect(try mode(of: directory) == 0o700) + } + } + + @Test + func exclusiveDirectory_whenTheParentIsMissing_isNil() { + let parent = FilePath.temporaryDirectory.appending("requestdl-missing-\(UUID().uuidString)") + + #expect(FilePath.exclusiveDirectory(prefix: "cache", under: parent) == nil) + } + + /// The directory the cache falls back to is the process's, not the temporary directory: owner + /// only, and the same every time it is asked. + @Test + func sharedCachesDirectory_isPrivateAndStable() throws { + // Given + let directory = FilePath.sharedCachesDirectory + + // Then + #expect(directory != FilePath.temporaryDirectory) + #expect(directory.string.hasPrefix(FilePath.temporaryDirectory.string)) + #expect(try mode(of: directory) == 0o700) + #expect(FilePath.sharedCachesDirectory == directory) + } + + #endif + // MARK: - Helpers + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) + private func makeParent(mode: Int) throws -> FilePath { + let parent = FilePath.temporaryDirectory.appending("requestdl-owned-\(UUID().uuidString)") + + try FileManager.default.createDirectory(atPath: parent.string, withIntermediateDirectories: false) + #expect(chmod(parent.string, mode_t(mode)) == 0) + + return parent + } + + private func remove(_ path: FilePath) { + _ = chmod(path.string, 0o700) + try? FileManager.default.removeItem(atPath: path.string) + } + #endif + private func mode(of path: FilePath) throws -> Int { let attributes = try FileManager.default.attributesOfItem(atPath: path.string) return (attributes[.posixPermissions] as? NSNumber)?.intValue ?? -1 From e884564e70045307f28aa61f188e9e3c421272ab Mon Sep 17 00:00:00 2001 From: brennobemoura <37243584+brennobemoura@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:09:35 -0300 Subject: [PATCH 2/2] Cover the branches of the cache fallback that the tests did not reach The fallback chain is a function of its own now (sharedCachesDirectory(under:)), the unguessable part of a name can be given, and the tests cover a parent that is a file, a directory that cannot be made, a name that is taken, every name taken, and a fallback that finds nothing. Co-Authored-By: Claude Sonnet 5.5 --- .../Extensions/FilePath+.swift | 6 +- .../Extensions/FilePath+OwnedDirectory.swift | 42 ++++--- .../InternalsCacheDirectoryTests.swift | 104 ++++++++++++++++++ 3 files changed, 132 insertions(+), 20 deletions(-) diff --git a/Sources/RequestDLInternals/Extensions/FilePath+.swift b/Sources/RequestDLInternals/Extensions/FilePath+.swift index 1b30aa8f..88884933 100644 --- a/Sources/RequestDLInternals/Extensions/FilePath+.swift +++ b/Sources/RequestDLInternals/Extensions/FilePath+.swift @@ -106,11 +106,7 @@ extension FilePath { private static let sharedCachesDirectoryOnce: FilePath = { #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) - let temporary = temporaryDirectory - - return ownedDirectory(named: "request-dl-nio-\(geteuid())", under: temporary) - ?? exclusiveDirectory(prefix: "request-dl-nio", under: temporary) - ?? temporary + return sharedCachesDirectory(under: temporaryDirectory) #else return temporaryDirectory #endif diff --git a/Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift b/Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift index 822801e0..f37100e9 100644 --- a/Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift +++ b/Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift @@ -56,14 +56,12 @@ extension FilePath { return nil } - guard let status = status(of: path) else { - return nil - } - - let isDirectory = status.mode & 0o170000 == 0o040000 - let isOwnerOnly = status.mode & 0o077 == 0 - - guard isDirectory, isOwnerOnly, status.owner == owner else { + guard + let status = status(of: path), + status.mode & 0o170000 == 0o040000, + status.mode & 0o077 == 0, + status.owner == owner + else { return nil } @@ -74,12 +72,16 @@ extension FilePath { /// What the cache uses when `ownedDirectory(named:under:owner:)` found nothing it can trust: /// it does not survive the process, since every process makes its own, but nobody else /// could have made it first. + /// + /// - Parameter nextSuffix: Where the unguessable part of the name comes from. Random, except + /// in a test that needs two names to collide. package static func exclusiveDirectory( prefix: String, - under parent: FilePath + under parent: FilePath, + nextSuffix: () -> UInt64 = { UInt64.random(in: .min ... .max) } ) -> FilePath? { for _ in 0..<8 { - let path = parent.appending("\(prefix)-\(UInt64.random(in: .min ... .max))") + let path = parent.appending("\(prefix)-\(nextSuffix())") switch makeDirectory(atPath: path.string) { case .created: @@ -94,14 +96,24 @@ extension FilePath { return nil } + /// Where the cache goes when there is no per-user directory for it: `/request-dl-nio-` + /// if ``ownedDirectory(named:under:owner:)`` trusts it, else a directory with a name nobody + /// could have guessed (``exclusiveDirectory(prefix:under:nextSuffix:)``), else `parent` + /// itself, as it always was. + package static func sharedCachesDirectory( + under parent: FilePath, + owner: UInt32 = UInt32(geteuid()), + nextSuffix: () -> UInt64 = { UInt64.random(in: .min ... .max) } + ) -> FilePath { + ownedDirectory(named: "request-dl-nio-\(owner)", under: parent, owner: owner) + ?? exclusiveDirectory(prefix: "request-dl-nio", under: parent, nextSuffix: nextSuffix) + ?? parent + } + // MARK: - Private static methods private static func isUntamperable(parent: FilePath, owner: UInt32) -> Bool { - guard let status = status(of: parent) else { - return false - } - - guard status.mode & 0o170000 == 0o040000 else { + guard let status = status(of: parent), status.mode & 0o170000 == 0o040000 else { return false } diff --git a/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift b/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift index d27eb181..dae7da9d 100644 --- a/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift +++ b/Tests/RequestDLInternalsTests/Sources/File System Manager/InternalsCacheDirectoryTests.swift @@ -232,6 +232,110 @@ struct InternalsCacheDirectoryTests { #expect(FilePath.exclusiveDirectory(prefix: "cache", under: parent) == nil) } + @Test + func ownedDirectory_whenTheParentIsNotADirectory_isNotUsed() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + let file = parent.appending("file") + #expect(FileManager.default.createFile(atPath: file.string, contents: Data())) + + // When / Then + #expect(FilePath.ownedDirectory(named: "cache", under: file) == nil) + } + + /// A parent this user can look into but not write to: the directory cannot be made. + @Test(.disabled(if: geteuid() == 0, "root can write wherever it likes")) + func ownedDirectory_whenItCannotBeCreated_isNotUsed() throws { + // Given + let parent = try makeParent(mode: 0o500) + defer { remove(parent) } + + // When / Then + #expect(FilePath.ownedDirectory(named: "cache", under: parent) == nil) + } + + @Test + func exclusiveDirectory_whenTheNameIsTaken_triesAnotherOne() throws { + // Given: "cache-1" is there already. + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + try FileManager.default.createDirectory( + atPath: parent.appending("cache-1").string, + withIntermediateDirectories: false + ) + + var suffixes: [UInt64] = [1, 2] + + // When + let directory = FilePath.exclusiveDirectory(prefix: "cache", under: parent) { + suffixes.removeFirst() + } + + // Then + #expect(directory == parent.appending("cache-2")) + } + + @Test + func exclusiveDirectory_whenEveryNameIsTaken_givesUp() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + try FileManager.default.createDirectory( + atPath: parent.appending("cache-1").string, + withIntermediateDirectories: false + ) + + // When / Then + #expect(FilePath.exclusiveDirectory(prefix: "cache", under: parent) { 1 } == nil) + } + + // MARK: - The fallback chain + + @Test + func sharedCachesDirectoryUnder_whenTheOwnedDirectoryIsTrusted_isIt() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + // When + let directory = FilePath.sharedCachesDirectory(under: parent, owner: UInt32(geteuid())) + + // Then + #expect(directory == parent.appending("request-dl-nio-\(geteuid())")) + } + + /// Someone else got there first: a directory with the name this user's would have, that is + /// open to others. It is left alone, and the cache goes to one nobody could have guessed. + @Test + func sharedCachesDirectoryUnder_whenTheOwnedDirectoryIsNotTrusted_isAnExclusiveOne() throws { + // Given + let parent = try makeParent(mode: 0o700) + defer { remove(parent) } + + let taken = parent.appending("request-dl-nio-\(geteuid())") + try FileManager.default.createDirectory(atPath: taken.string, withIntermediateDirectories: false) + #expect(chmod(taken.string, 0o755) == 0) + + // When + let directory = FilePath.sharedCachesDirectory(under: parent, nextSuffix: { 7 }) + + // Then + #expect(directory == parent.appending("request-dl-nio-7")) + #expect(try mode(of: taken) == 0o755) + } + + /// Nothing to be made in a place that is not there: the parent itself, as it always was. + @Test + func sharedCachesDirectoryUnder_whenNothingCanBeMade_isTheParent() { + let parent = FilePath.temporaryDirectory.appending("requestdl-missing-\(UUID().uuidString)") + + #expect(FilePath.sharedCachesDirectory(under: parent) == parent) + } + /// The directory the cache falls back to is the process's, not the temporary directory: owner /// only, and the same every time it is asked. @Test