Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion Sources/RequestDLInternals/Extensions/FilePath+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -80,9 +90,28 @@ 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)
return sharedCachesDirectory(under: temporaryDirectory)
#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.
Expand Down
160 changes: 160 additions & 0 deletions Sources/RequestDLInternals/Extensions/FilePath+OwnedDirectory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// 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),
status.mode & 0o170000 == 0o040000,
status.mode & 0o077 == 0,
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.
///
/// - 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,
nextSuffix: () -> UInt64 = { UInt64.random(in: .min ... .max) }
) -> FilePath? {
for _ in 0..<8 {
let path = parent.appending("\(prefix)-\(nextSuffix())")

switch makeDirectory(atPath: path.string) {
case .created:
return path
case .alreadyThere:
continue
case .failed:
return nil
}
}

return nil
}

/// Where the cache goes when there is no per-user directory for it: `<parent>/request-dl-nio-<owner>`
/// 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), 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
Loading
Loading