diff --git a/ios/MullvadLogging/CustomFormatLogHandler.swift b/ios/MullvadLogging/CustomFormatLogHandler.swift index 92dcb26b7220..6a209e1acf96 100644 --- a/ios/MullvadLogging/CustomFormatLogHandler.swift +++ b/ios/MullvadLogging/CustomFormatLogHandler.swift @@ -16,14 +16,6 @@ public struct CustomFormatLogHandler: LogHandler { private let label: String private let streams: [TextOutputStream] - private let dateFormatter = Self.makeDateFormatter() - - public static func makeDateFormatter() -> DateFormatter { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "YYYY-MM-dd HH:mm:ss.SSS" - return dateFormatter - } - public init(label: String, streams: [TextOutputStream]) { self.label = label self.streams = streams @@ -54,7 +46,7 @@ public struct CustomFormatLogHandler: LogHandler { } let prettyMetadata = Self.formatMetadata(mergedMetadata) let metadataOutput = prettyMetadata.isEmpty ? "" : " \(prettyMetadata)" - let timestamp = dateFormatter.string(from: Date()) + let timestamp = Date().logFormatted let formattedMessage = "[\(timestamp)][\(label)][\(level)]\(metadataOutput) \(message)\n" for var stream in streams { diff --git a/ios/MullvadLogging/Date+LogFormat.swift b/ios/MullvadLogging/Date+LogFormat.swift index 463d340b5992..0cd51c08b1d3 100644 --- a/ios/MullvadLogging/Date+LogFormat.swift +++ b/ios/MullvadLogging/Date+LogFormat.swift @@ -9,16 +9,22 @@ import Foundation extension Date { - public func logFormatDate() -> String { + public var logFormatted: String { let formatter = DateFormatter() + formatter.dateFormat = "dd/MM/yyyy @ HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "UTC") return formatter.string(from: self) } - public func logFormatFilename() -> String { + public var logFileFormatted: String { let formatter = DateFormatter() + formatter.dateFormat = "dd-MM-yyyy'T'HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "UTC") return formatter.string(from: self) } diff --git a/ios/MullvadLogging/LogFileOutputStream.swift b/ios/MullvadLogging/LogFileOutputStream.swift index 26b059638e81..46be37d77213 100644 --- a/ios/MullvadLogging/LogFileOutputStream.swift +++ b/ios/MullvadLogging/LogFileOutputStream.swift @@ -16,10 +16,14 @@ private let reopenFileLogInterval: Duration = .seconds(5) class LogFileOutputStream: TextOutputStream { private let queue = DispatchQueue(label: "LogFileOutputStreamQueue", qos: .utility) - private let fileURL: URL + private let baseFileURL: URL + private var fileURL: URL private let encoding: String.Encoding - private let maxBufferCapacity: Int + private let maximumBufferCapacity: Int private let fileHeader: String + private let fileSizeLimit: UInt64 + private var partialFileSizeCounter: UInt64 = 0 + private var partialFileNameCounter = 1 private var state: State = .closed { didSet { @@ -45,11 +49,20 @@ class LogFileOutputStream: TextOutputStream { case waitingToReopen } - init(fileURL: URL, header: String, encoding: String.Encoding = .utf8, maxBufferCapacity: Int = 16 * 1024) { + init( + fileURL: URL, + header: String, + fileSizeLimit: UInt64 = ApplicationConfiguration.logMaximumFileSize, + encoding: String.Encoding = .utf8, + maxBufferCapacity: Int = 16 * 1024 + ) { self.fileURL = fileURL - self.encoding = encoding - self.maxBufferCapacity = maxBufferCapacity self.fileHeader = header + self.fileSizeLimit = fileSizeLimit + self.encoding = encoding + self.maximumBufferCapacity = maxBufferCapacity + + baseFileURL = fileURL.deletingPathExtension() } deinit { @@ -89,7 +102,16 @@ class LogFileOutputStream: TextOutputStream { } } - @discardableResult private func write(fileHandle: FileHandle, data: Data) throws -> Int { + private func write(fileHandle: FileHandle, data: Data) throws { + let predictedFileSize = partialFileSizeCounter + UInt64(data.count) + + // Rotate file if threshold has been met, then rerun the write operation. + guard predictedFileSize <= fileSizeLimit else { + try rotateFile(handle: fileHandle) + write(String(data: data, encoding: encoding) ?? "") + return + } + let bytesWritten = data.withUnsafeBytes { buffer -> Int in guard let ptr = buffer.baseAddress else { return 0 } @@ -99,9 +121,17 @@ class LogFileOutputStream: TextOutputStream { if bytesWritten == -1 { let code = POSIXErrorCode(rawValue: errno)! throw POSIXError(code) - } else { - return bytesWritten } + + partialFileSizeCounter += UInt64(bytesWritten) + } + + private func rotateFile(handle: FileHandle) throws { + try handle.close() + + state = .closed + partialFileSizeCounter = 0 + fileURL = try incrementFileName() } private func openFile() throws -> FileHandle { @@ -171,8 +201,18 @@ class LogFileOutputStream: TextOutputStream { private func bufferData(_ data: Data) { buffer.append(data) - if buffer.count > maxBufferCapacity { - buffer.removeFirst(buffer.count - maxBufferCapacity) + if buffer.count > maximumBufferCapacity { + buffer.removeFirst(buffer.count - maximumBufferCapacity) + } + } + + private func incrementFileName() throws -> URL { + partialFileNameCounter += 1 + + if let url = URL(string: baseFileURL.relativePath + "_\(partialFileNameCounter).log") { + return url + } else { + throw POSIXError(.ENOENT) } } } diff --git a/ios/MullvadLogging/LogRotation.swift b/ios/MullvadLogging/LogRotation.swift index 23d1e5f17815..6b19da497e58 100644 --- a/ios/MullvadLogging/LogRotation.swift +++ b/ios/MullvadLogging/LogRotation.swift @@ -36,7 +36,7 @@ public enum LogRotation { public var errorDescription: String? { switch self { case .rotateLogFiles: - return "Failure to rotate the source log file to backup." + return "Failure to rotate logs" } } @@ -74,30 +74,23 @@ public enum LogRotation { log1.creationDate > log2.creationDate } - try deleteLogsOlderThan(options.oldestAllowedDate, in: logs) - try deleteLogsWithCombinedSizeLargerThan(options.storageSizeLimit, in: logs) + try deleteLogs(dateThreshold: options.oldestAllowedDate, sizeThreshold: options.storageSizeLimit, in: logs) } catch { throw Error.rotateLogFiles(error) } } - private static func deleteLogsOlderThan(_ dateThreshold: Date, in logs: [LogData]) throws { + private static func deleteLogs(dateThreshold: Date, sizeThreshold: Int, in logs: [LogData]) throws { let fileManager = FileManager.default - for log in logs where log.creationDate < dateThreshold { - try fileManager.removeItem(at: log.path) - } - } - - private static func deleteLogsWithCombinedSizeLargerThan(_ sizeThreshold: Int, in logs: [LogData]) throws { - let fileManager = FileManager.default - - // Delete all logs outside maximum capacity (ordered newest to oldest). var fileSizes = UInt64.zero for log in logs { fileSizes += log.size - if fileSizes > sizeThreshold { + let logIsTooOld = log.creationDate < dateThreshold + let logCapacityIsExceeded = fileSizes > sizeThreshold + + if logIsTooOld || logCapacityIsExceeded { try fileManager.removeItem(at: log.path) } } diff --git a/ios/MullvadLogging/Logging.swift b/ios/MullvadLogging/Logging.swift index 60ad30de6fe1..4b5b7a3965fd 100644 --- a/ios/MullvadLogging/Logging.swift +++ b/ios/MullvadLogging/Logging.swift @@ -78,7 +78,7 @@ public struct LoggerBuilder { let rotationLogger = Logger(label: "LogRotation") for error in logRotationErrors { - rotationLogger.error(error: error, message: "Failed to rotate log") + rotationLogger.error(error: error, message: error.localizedDescription) } } } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 80f5499f8b54..122403359416 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -603,6 +603,8 @@ 7ADCB2D82B6A6EB300C88F89 /* AnyRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ADCB2D72B6A6EB300C88F89 /* AnyRelay.swift */; }; 7ADCB2DA2B6A730400C88F89 /* IPOverrideRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ADCB2D92B6A730400C88F89 /* IPOverrideRepositoryStub.swift */; }; 7AE044BB2A935726003915D8 /* Routing.h in Headers */ = {isa = PBXBuildFile; fileRef = 7A88DCD02A8FABBE00D2FF0E /* Routing.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7AED35CC2BD13F60002A67D1 /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; }; + 7AED35CD2BD13FC4002A67D1 /* ApplicationTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C76A072A33850E00100D75 /* ApplicationTarget.swift */; }; 7AEF7F1A2AD00F52006FE45D /* AppMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEF7F192AD00F52006FE45D /* AppMessageHandler.swift */; }; 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF10EB12ADE859200C090B9 /* AlertViewController.swift */; }; 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */; }; @@ -5584,6 +5586,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7AED35CC2BD13F60002A67D1 /* ApplicationConfiguration.swift in Sources */, + 7AED35CD2BD13FC4002A67D1 /* ApplicationTarget.swift in Sources */, 58D22402294C90050029F5F8 /* Logger+Errors.swift in Sources */, 58D22404294C90050029F5F8 /* Date+LogFormat.swift in Sources */, 58D22403294C90050029F5F8 /* Logging.swift in Sources */, diff --git a/ios/MullvadVPN/AddressCacheTracker/AddressCacheTracker.swift b/ios/MullvadVPN/AddressCacheTracker/AddressCacheTracker.swift index 2734729c46a4..f36d9b6df618 100644 --- a/ios/MullvadVPN/AddressCacheTracker/AddressCacheTracker.swift +++ b/ios/MullvadVPN/AddressCacheTracker/AddressCacheTracker.swift @@ -65,7 +65,7 @@ final class AddressCacheTracker { let scheduleDate = _nextScheduleDate() - logger.debug("Schedule address cache update at \(scheduleDate.logFormatDate()).") + logger.debug("Schedule address cache update at \(scheduleDate.logFormatted).") scheduleEndpointsUpdate(startTime: .now() + scheduleDate.timeIntervalSinceNow) } @@ -165,7 +165,7 @@ final class AddressCacheTracker { let scheduleDate = self._nextScheduleDate() self.logger - .debug("Schedule next address cache update at \(scheduleDate.logFormatDate()).") + .debug("Schedule next address cache update at \(scheduleDate.logFormatted).") self.scheduleEndpointsUpdate(startTime: .now() + scheduleDate.timeIntervalSinceNow) } diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index a1087d70a95c..156b4771428d 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -307,7 +307,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD let request = BGAppRefreshTaskRequest(identifier: BackgroundTask.appRefresh.identifier) request.earliestBeginDate = date - logger.debug("Schedule app refresh task at \(date.logFormatDate()).") + logger.debug("Schedule app refresh task at \(date.logFormatted).") try BGTaskScheduler.shared.submit(request) } catch { @@ -325,7 +325,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD request.requiresNetworkConnectivity = true request.earliestBeginDate = date - logger.debug("Schedule key rotation task at \(date.logFormatDate()).") + logger.debug("Schedule key rotation task at \(date.logFormatted).") try BGTaskScheduler.shared.submit(request) } catch { @@ -341,7 +341,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD request.requiresNetworkConnectivity = true request.earliestBeginDate = date - logger.debug("Schedule address cache update task at \(date.logFormatDate()).") + logger.debug("Schedule address cache update task at \(date.logFormatted).") try BGTaskScheduler.shared.submit(request) } catch { diff --git a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift index 0876e86deada..48dbf6a72349 100644 --- a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift +++ b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift @@ -8,7 +8,6 @@ import Foundation -private let kLogMaxReadBytes: UInt64 = 128 * 1024 private let kLogDelimiter = "====================" private let kRedactedPlaceholder = "[REDACTED]" private let kRedactedAccountPlaceholder = "[REDACTED ACCOUNT NUMBER]" @@ -93,7 +92,7 @@ class ConsolidatedApplicationLog: TextOutputStreamable { let path = fileURL.path let redactedPath = redact(string: path) - if let lossyString = Self.readFileLossy(path: path, maxBytes: kLogMaxReadBytes) { + if let lossyString = Self.readFileLossy(path: path, maxBytes: ApplicationConfiguration.logMaximumFileSize) { let redactedString = redact(string: lossyString) logs.append(LogAttachment(label: redactedPath, content: redactedString)) @@ -126,7 +125,7 @@ class ConsolidatedApplicationLog: TextOutputStreamable { fileHandle.seek(toFileOffset: 0) } - let data = fileHandle.readData(ofLength: Int(kLogMaxReadBytes)) + let data = fileHandle.readData(ofLength: Int(ApplicationConfiguration.logMaximumFileSize)) let replacementCharacter = Character(UTF8.decode(UTF8.encodedReplacementCharacter)) let lossyString = String( String(decoding: data, as: UTF8.self) diff --git a/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift b/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift index 8efbc960958e..acb32083cd21 100644 --- a/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift +++ b/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift @@ -140,7 +140,7 @@ class SendStoreReceiptOperation: ResultOperation String { + (0 ..< size).map { "\($0%10)" }.joined(separator: "") + } + private func writeDataToDisk(path: URL, fileSize: Int) throws { let data = Data((0 ..< fileSize).map { UInt8($0 & 0xff) }) try data.write(to: path) diff --git a/ios/MullvadVPNTests/LoggingTests.swift b/ios/MullvadVPNTests/LoggingTests.swift index bdc08e959c22..63817d6aa202 100644 --- a/ios/MullvadVPNTests/LoggingTests.swift +++ b/ios/MullvadVPNTests/LoggingTests.swift @@ -26,7 +26,7 @@ class MullvadLoggingTests: XCTestCase { return fileURL } - func testLogFileOutputStreamWritesHeader() { + func testLogFileOutputStreamWritesHeader() throws { let headerText = "This is a header" let logMessage = "And this is a log message\n" let fileURL = temporaryFileURL() @@ -34,11 +34,11 @@ class MullvadLoggingTests: XCTestCase { stream.write(logMessage) sync() - let contents = String(decoding: try! Data(contentsOf: fileURL), as: UTF8.self) + let contents = try XCTUnwrap(String(contentsOf: fileURL)) XCTAssertEqual(contents, "\(headerText)\n\(logMessage)") } - func testLogHeader() { + func testLogHeader() throws { let expectedHeader = "Header of a log file" var builder = LoggerBuilder(header: expectedHeader) @@ -51,7 +51,7 @@ class MullvadLoggingTests: XCTestCase { sync() - let contents = String(decoding: try! Data(contentsOf: fileURL), as: UTF8.self) + let contents = try XCTUnwrap(String(contentsOf: fileURL)) XCTAssert(contents.hasPrefix(expectedHeader)) } diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index b3ebedda0953..c8848e2a46cb 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -196,7 +196,9 @@ class RelayTests: LoggedInWithTimeUITestCase { .enterText("4001") .dismissKeyboard() .swipeDownToDismissModal() - .swipeDownToDismissModal() // After editing text field the table is first responder for the first swipe so we need to swipe twice to swipe the modal + + // After editing text field the table is first responder for the first swipe so we need to swipe twice to swipe the modal + .swipeDownToDismissModal() TunnelControlPage(app) .tapSecureConnectionButton() diff --git a/ios/Shared/ApplicationConfiguration.swift b/ios/Shared/ApplicationConfiguration.swift index 9acea1b97135..4e2b28c62f54 100644 --- a/ios/Shared/ApplicationConfiguration.swift +++ b/ios/Shared/ApplicationConfiguration.swift @@ -24,24 +24,40 @@ enum ApplicationConfiguration { /// Returns URL for new log file associated with application target and located within shared container. static func newLogFileURL(for target: ApplicationTarget) -> URL { containerURL.appendingPathComponent( - "\(target.bundleIdentifier)_\(Date().logFormatFilename()).log", + "\(target.bundleIdentifier)_\(Date().logFileFormatted).log", isDirectory: false ) } /// Returns URLs for log files associated with application target and located within shared container. static func logFileURLs(for target: ApplicationTarget) -> [URL] { + let fileManager = FileManager.default let containerUrl = containerURL + let filePathsInDirectory = try? fileManager.contentsOfDirectory(atPath: containerURL.relativePath) - return (try? FileManager.default.contentsOfDirectory(atPath: containerURL.relativePath))?.compactMap { file in - if file.split(separator: ".").last == "log" { - containerUrl.appendingPathComponent(file) - } else { - nil - } - }.sorted { $0.relativePath > $1.relativePath } ?? [] + let filteredFilePaths: [URL] = filePathsInDirectory?.compactMap { path in + let pathIsLog = path.split(separator: ".").last == "log" + let pathBelongsToTarget = path.contains(target.bundleIdentifier) + + return pathIsLog && pathBelongsToTarget ? containerUrl.appendingPathComponent(path) : nil + } ?? [] + + let sortedFilePaths = try? filteredFilePaths.sorted { path1, path2 in + let path1Attributes = try fileManager.attributesOfItem(atPath: path1.relativePath) + let date1 = (path1Attributes[.creationDate] as? Date) ?? Date.distantPast + + let path2Attributes = try fileManager.attributesOfItem(atPath: path2.relativePath) + let date2 = (path2Attributes[.creationDate] as? Date) ?? Date.distantPast + + return date1 > date2 + } + + return sortedFilePaths ?? [] } + // Maximum file size for writing and reading logs. + static let logMaximumFileSize: UInt64 = 131_072 // 128 kB. + /// Privacy policy URL. static let privacyPolicyURL = URL(string: "https://mullvad.net/help/privacy-policy/")!