SwiftLint/Source/SwiftLintFramework/Extensions/Configuration+Remote.swift

277 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: String = ".swiftlint"
/// This should never be touched. Change the version number for changes to the cache format
private static let remoteCachePath: String = "\(swiftlintPath)/RemoteConfigCache"
/// If the format of the caching is changed in the future, change this version number
private static let remoteCacheVersionNumber: String = "v1"
/// Use this to get the path to the cache directory for the current cache format
private static let versionedRemoteCachePath: String = "\(remoteCachePath)/\(remoteCacheVersionNumber)"
/// The path to the gitignore file.
private static let gitignorePath: String = ".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 ConfigurationError.generic("Invalid configuration entry: \"\(urlString)\" isn't a valid url.")
}
// Load from url
var taskResult: (Data?, URLResponse?, 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
} else {
throw ConfigurationError.generic(
"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
} else {
if taskDone {
throw ConfigurationError.generic(
"Unable to load remote config from \"\(urlString)\". "
+ "Also didn't found cached version to fallback to."
)
} else {
throw ConfigurationError.generic(
"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("Unable to cache remote config from \"\(urlString)\". Using cached version as a fallback.")
self = .existing(path: cachedFilePath)
return cachedFilePath
} else {
throw ConfigurationError.generic(
"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 ConfigurationError.generic("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
)
}
}
}
}