Files
SwiftLint/Source/SwiftLintFramework/Configuration/Configuration+Remote.swift
T
Danny Mösch a6c4fd98bc Move files from SwiftLintCore to SwiftLintFramework
Ideally, SwiftLintCore would some day only contain components
that are needed to define rules. Consequently, it would be the
only bundle required to import for (external) rule development.
2024-12-23 12:51:43 +01:00

275 lines
11 KiB
Swift

import Foundation // swiftlint:disable:this file_name
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
internal extension Configuration.FileGraph.FilePath {
// MARK: - Properties: Remote Cache
/// This should never be touched.
private static let swiftlintPath = ".swiftlint"
/// This should never be touched. Change the version number for changes to the cache format
private static let remoteCachePath = "\(swiftlintPath)/RemoteConfigCache"
/// If the format of the caching is changed in the future, change this version number
private static let remoteCacheVersionNumber = "v1"
/// Use this to get the path to the cache directory for the current cache format
private static let versionedRemoteCachePath = "\(remoteCachePath)/\(remoteCacheVersionNumber)"
/// The path to the gitignore file.
private static let gitignorePath = ".gitignore"
/// This dictionary has URLs as its keys and contents of those URLs as its values
/// In production mode, this should be empty. For tests, it may be filled.
static var mockedNetworkResults: [String: String] = [:]
// MARK: - Methods: Resolving
mutating func resolve(
remoteConfigTimeout: Double,
remoteConfigTimeoutIfCached: Double
) throws -> String {
switch self {
case let .existing(path):
return path
case let .promised(urlString):
return try resolve(
urlString: urlString,
remoteConfigTimeout: remoteConfigTimeout,
remoteConfigTimeoutIfCached: remoteConfigTimeoutIfCached
)
}
}
private mutating func resolve(
urlString: String,
remoteConfigTimeout: Double,
remoteConfigTimeoutIfCached: Double
) throws -> String {
// Always use top level as root directory for remote files
let rootDirectory = FileManager.default.currentDirectoryPath.bridge().standardizingPath
// Get cache path
let cachedFilePath = getCachedFilePath(urlString: urlString, rootDirectory: rootDirectory)
let configString: String
if let mockedValue = Configuration.FileGraph.FilePath.mockedNetworkResults[urlString] {
configString = mockedValue
} else {
// Handle missing network
guard Reachability.connectivityStatus != .disconnected else {
return try handleMissingNetwork(urlString: urlString, cachedFilePath: cachedFilePath)
}
// Handle wrong url format
guard let url = URL(string: urlString) else {
throw Issue.genericWarning("Invalid configuration entry: \"\(urlString)\" isn't a valid url.")
}
// Load from url
var taskResult: (Data?, URLResponse?, (any Error)?)
var taskDone = false
// `.ephemeral` disables caching (which we don't want to be managed by the system)
let task = URLSession(configuration: .ephemeral).dataTask(with: url) { data, response, error in
taskResult = (data, response, error)
taskDone = true
}
task.resume()
let timeout = cachedFilePath == nil ? remoteConfigTimeout : remoteConfigTimeoutIfCached
let startDate = Date()
// Block main thread until timeout is reached / task is done
while true {
if taskDone { break }
if Date().timeIntervalSince(startDate) > timeout { task.cancel(); break }
usleep(50_000) // Sleep for 50 ms
}
// Handle wrong data
guard
taskResult.2 == nil, // No error
(taskResult.1 as? HTTPURLResponse)?.statusCode == 200,
let configStr = (taskResult.0.flatMap { String(data: $0, encoding: .utf8) })
else {
return try handleWrongData(
urlString: urlString,
cachedFilePath: cachedFilePath,
taskDone: taskDone,
timeout: timeout
)
}
configString = configStr
}
// Handle file write failure
guard let filePath = cache(configString: configString, from: urlString, rootDirectory: rootDirectory) else {
return try handleFileWriteFailure(urlString: urlString, cachedFilePath: cachedFilePath)
}
// Handle success
self = .existing(path: filePath)
return filePath
}
private mutating func handleMissingNetwork(urlString: String, cachedFilePath: String?) throws -> String {
if let cachedFilePath {
queuedPrintError(
"warning: No internet connectivity: Unable to load remote config from \"\(urlString)\". "
+ "Using cached version as a fallback."
)
self = .existing(path: cachedFilePath)
return cachedFilePath
}
throw Issue.genericWarning(
"No internet connectivity: Unable to load remote config from \"\(urlString)\". "
+ "Also didn't found cached version to fallback to."
)
}
private mutating func handleWrongData(
urlString: String,
cachedFilePath: String?,
taskDone: Bool,
timeout: TimeInterval
) throws -> String {
if let cachedFilePath {
if taskDone {
queuedPrintError(
"warning: Unable to load remote config from \"\(urlString)\". Using cached version as a fallback."
)
} else {
queuedPrintError(
"warning: Timeout (\(timeout) sec): Unable to load remote config from \"\(urlString)\". "
+ "Using cached version as a fallback."
)
}
self = .existing(path: cachedFilePath)
return cachedFilePath
}
if taskDone {
throw Issue.genericWarning(
"Unable to load remote config from \"\(urlString)\". "
+ "Also didn't found cached version to fallback to."
)
}
throw Issue.genericWarning(
"Timeout (\(timeout) sec): Unable to load remote config from \"\(urlString)\". "
+ "Also didn't found cached version to fallback to."
)
}
private mutating func handleFileWriteFailure(urlString: String, cachedFilePath: String?) throws -> String {
if let cachedFilePath {
queuedPrintError(
"warning: Unable to cache remote config from \"\(urlString)\". Using cached version as a fallback."
)
self = .existing(path: cachedFilePath)
return cachedFilePath
}
throw Issue.genericWarning(
"Unable to cache remote config from \"\(urlString)\". Also didn't found cached version to fallback to."
)
}
// MARK: Caching
private func getCachedFilePath(urlString: String, rootDirectory: String) -> String? {
let path = filePath(for: urlString, rootDirectory: rootDirectory)
return FileManager.default.fileExists(atPath: path) ? path : nil
}
private func cache(configString: String, from urlString: String, rootDirectory: String) -> String? {
// Do cache maintenance
do {
try maintainRemoteConfigCache(rootDirectory: rootDirectory)
} catch {
return nil
}
// Add comment line at the top of the config string
let formatter = DateFormatter()
formatter.dateFormat = "dd/MM/yyyy 'at' HH:mm:ss"
let configString =
"#\n"
+ "# Automatically downloaded from \(urlString) by SwiftLint on \(formatter.string(from: Date())).\n"
+ "#\n"
+ configString
// Create file
let path = filePath(for: urlString, rootDirectory: rootDirectory)
return FileManager.default.createFile(
atPath: path,
contents: Data(configString.utf8),
attributes: [:]
) ? path : nil
}
private func filePath(for urlString: String, rootDirectory: String) -> String {
let adjustedUrlString = urlString.replacingOccurrences(of: "/", with: "_")
let path = Configuration.FileGraph.FilePath.versionedRemoteCachePath + "/\(adjustedUrlString).yml"
return path.bridge().absolutePathRepresentation(rootDirectory: rootDirectory)
}
/// As a safeguard, this method only works when there are mocked network results.
/// It deletes both the .gitignore and the remote cache that may have got created by a test.
static func deleteGitignoreAndSwiftlintCache() {
guard !mockedNetworkResults.isEmpty else { return }
try? FileManager.default.removeItem(atPath: gitignorePath)
try? FileManager.default.removeItem(atPath: remoteCachePath)
if (try? FileManager.default.contentsOfDirectory(atPath: swiftlintPath))?.isEmpty == true {
try? FileManager.default.removeItem(atPath: swiftlintPath)
}
}
private func maintainRemoteConfigCache(rootDirectory: String) throws {
// Create directory if needed
let directory = Configuration.FileGraph.FilePath.versionedRemoteCachePath
.bridge().absolutePathRepresentation(rootDirectory: rootDirectory)
if !FileManager.default.fileExists(atPath: directory) {
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
}
// Delete all cache folders except for the current version's folder
let directoryWithoutVersionNum = directory.components(separatedBy: "/").dropLast().joined(separator: "/")
try (try FileManager.default.subpathsOfDirectory(atPath: directoryWithoutVersionNum)).forEach {
if !$0.contains("/") && $0 != Configuration.FileGraph.FilePath.remoteCacheVersionNumber {
try FileManager.default.removeItem(atPath:
$0.bridge().absolutePathRepresentation(rootDirectory: directoryWithoutVersionNum)
)
}
}
// Add gitignore entry if needed
let requiredGitignoreAppendix = "\(Configuration.FileGraph.FilePath.remoteCachePath)"
let newGitignoreAppendix = "# SwiftLint Remote Config Cache\n\(requiredGitignoreAppendix)"
if !FileManager.default.fileExists(atPath: Configuration.FileGraph.FilePath.gitignorePath) {
guard FileManager.default.createFile(
atPath: Configuration.FileGraph.FilePath.gitignorePath,
contents: Data(newGitignoreAppendix.utf8),
attributes: [:]
) else {
throw Issue.genericWarning("Issue maintaining remote config cache.")
}
} else {
var contents = try String(contentsOfFile: Configuration.FileGraph.FilePath.gitignorePath, encoding: .utf8)
if !contents.contains(requiredGitignoreAppendix) {
contents += "\n\n\(newGitignoreAppendix)"
try contents.write(
toFile: Configuration.FileGraph.FilePath.gitignorePath,
atomically: true,
encoding: .utf8
)
}
}
}
}