mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 02:18:47 +00:00
feat(apple): use .zip for logs (#9536)
This PR replaces the use of Apple Archive with an API that allows us to zip the log file contents. This API doesn't handle symlinks well so we move the symlink out of the way before making the zip. The symlink is then moved back after the process is completed. Any errors in this process are ignored as the symlink itself is not a critical component of Firezone. The zip compression is marginally less efficient than the Apple Archive. Instead of compressing ~2GB of logs to 11.8 MB we now get an archive of 12.4 MB. Considering how much easier zip files are to handle, this seems like a fine trade-off. <img width="774" alt="Screenshot 2025-06-16 at 00 04 52" src="https://github.com/user-attachments/assets/8fb6bade-5308-40b9-a446-2a2c364cb621" /> Resolves: #7475 --------- Signed-off-by: Thomas Eizinger <thomas@eizinger.io> Co-authored-by: Jamil Bou Kheir <jamilbk@users.noreply.github.com>
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
//
|
||||
// LogCompressor.swift
|
||||
//
|
||||
//
|
||||
// Created by Jamil Bou Kheir on 3/28/24.
|
||||
//
|
||||
|
||||
import AppleArchive
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
/// This module handles the business work of interacting with the AppleArchive framework to do the actual
|
||||
/// compression. It's used from both the app and tunnel process in nearly the same way, save for how the
|
||||
/// writeStream is opened.
|
||||
///
|
||||
/// In the tunnel process, the writeStream is a custom stream derived from our TunnelArchiveByteStream,
|
||||
/// which keeps state around the writing of compressed bytes in order to handle sending chunks back to the
|
||||
/// app process.
|
||||
///
|
||||
/// In the app process, the writeStream is derived from a passed file path where the Apple Archive
|
||||
/// framework handles writing for us -- no custom byte stream instance is needed.
|
||||
///
|
||||
/// Once the writeStream is opened, the remaining operations are the same for both.
|
||||
public struct LogCompressor {
|
||||
enum CompressionError: Error {
|
||||
case unableToReadSourceDirectory
|
||||
case unableToInitialize
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start(
|
||||
source directory: FilePath,
|
||||
to file: FilePath
|
||||
) throws {
|
||||
let stream = ArchiveByteStream.fileStream(
|
||||
path: file,
|
||||
mode: .writeOnly,
|
||||
options: [.create],
|
||||
permissions: FilePermissions(rawValue: 0o644)
|
||||
)
|
||||
|
||||
try compress(source: directory, writeStream: stream)
|
||||
}
|
||||
|
||||
// Compress to a given writeStream which was opened either from a FilePath or
|
||||
// TunnelArchiveByteStream
|
||||
private func compress(
|
||||
source path: FilePath,
|
||||
writeStream: ArchiveByteStream?
|
||||
) throws {
|
||||
let headerKeys = "TYP,PAT,LNK,DEV,DAT,UID,GID,MOD,FLG,MTM,BTM,CTM"
|
||||
|
||||
guard let writeStream = writeStream,
|
||||
let compressionStream =
|
||||
ArchiveByteStream.compressionStream(
|
||||
using: .lzfse,
|
||||
writingTo: writeStream
|
||||
),
|
||||
let encodeStream =
|
||||
ArchiveStream.encodeStream(
|
||||
writingTo: compressionStream
|
||||
),
|
||||
let keySet = ArchiveHeader.FieldKeySet(headerKeys)
|
||||
else {
|
||||
throw CompressionError.unableToInitialize
|
||||
}
|
||||
|
||||
defer {
|
||||
try? encodeStream.close()
|
||||
try? compressionStream.close()
|
||||
try? writeStream.close()
|
||||
}
|
||||
|
||||
try encodeStream.writeDirectoryContents(
|
||||
archiveFrom: path,
|
||||
keySet: keySet
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ import System
|
||||
// 2. Create tunnel log archive from tunnel process
|
||||
let tunnelLogURL =
|
||||
sharedLogFolderURL
|
||||
.appendingPathComponent("tunnel.aar")
|
||||
.appendingPathComponent("tunnel.zip")
|
||||
fileManager.createFile(atPath: tunnelLogURL.path, contents: nil)
|
||||
let fileHandle = try FileHandle(forWritingTo: tunnelLogURL)
|
||||
|
||||
@@ -79,19 +79,19 @@ import System
|
||||
}
|
||||
|
||||
// 4. Create app log archive
|
||||
let appLogURL = sharedLogFolderURL.appendingPathComponent("app.aar")
|
||||
try LogCompressor().start(
|
||||
source: toPath(logFolderURL),
|
||||
to: toPath(appLogURL)
|
||||
let appLogURL = sharedLogFolderURL.appendingPathComponent("app.zip")
|
||||
try ZipService.createZip(
|
||||
source: logFolderURL,
|
||||
to: appLogURL
|
||||
)
|
||||
|
||||
// Remove existing archive if it exists
|
||||
try? fileManager.removeItem(at: archiveURL)
|
||||
|
||||
// Write final log archive
|
||||
try LogCompressor().start(
|
||||
source: toPath(sharedLogFolderURL),
|
||||
to: toPath(archiveURL)
|
||||
try ZipService.createZip(
|
||||
source: sharedLogFolderURL,
|
||||
to: archiveURL
|
||||
)
|
||||
|
||||
// Remove intermediate log archives
|
||||
@@ -109,7 +109,10 @@ import System
|
||||
}
|
||||
|
||||
static func export(to archiveURL: URL) async throws {
|
||||
guard let logFolderURL = SharedAccess.logFolderURL
|
||||
guard let logFolderURL = SharedAccess.logFolderURL,
|
||||
let connlibLogFolderURL = SharedAccess.connlibLogFolderURL,
|
||||
let cacheFolderURL = SharedAccess.cacheFolderURL
|
||||
|
||||
else {
|
||||
throw ExportError.invalidSourceDirectory
|
||||
}
|
||||
@@ -117,10 +120,21 @@ import System
|
||||
// Remove existing archive if it exists
|
||||
try? fileManager.removeItem(at: archiveURL)
|
||||
|
||||
let latestSymlink = connlibLogFolderURL.appendingPathComponent("latest")
|
||||
let tempSymlink = cacheFolderURL.appendingPathComponent(
|
||||
"latest")
|
||||
|
||||
// Move the `latest` symlink out of the way before creating the archive.
|
||||
// Apple's implementation of zip appears to not be able to handle symlinks well
|
||||
let _ = try? FileManager.default.moveItem(at: latestSymlink, to: tempSymlink)
|
||||
defer {
|
||||
let _ = try? FileManager.default.moveItem(at: tempSymlink, to: latestSymlink)
|
||||
}
|
||||
|
||||
// Write final log archive
|
||||
try LogCompressor().start(
|
||||
source: toPath(logFolderURL),
|
||||
to: toPath(archiveURL)
|
||||
try ZipService.createZip(
|
||||
source: logFolderURL,
|
||||
to: archiveURL
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +142,7 @@ import System
|
||||
// directory and then the OS will move it into place when the ShareSheet
|
||||
// is dismissed.
|
||||
static func tempFile() -> URL {
|
||||
let fileName = "firezone_logs_\(now()).aar"
|
||||
let fileName = "firezone_logs_\(now()).zip"
|
||||
return fileManager.temporaryDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Inspired from https://gist.github.com/dreymonde/793a8a7c2ed5443b1594f528bb7c88a7
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension URL {
|
||||
var isDirectory: Bool {
|
||||
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum CreateZipError: Swift.Error {
|
||||
case urlNotADirectory(URL)
|
||||
case failedToCreateZIP(Swift.Error)
|
||||
case failedToMoveZIP(Swift.Error)
|
||||
}
|
||||
|
||||
// MARK: - ZipService
|
||||
|
||||
public final class ZipService {
|
||||
|
||||
public static func createZip(
|
||||
source directoryURL: URL,
|
||||
to zipFinalURL: URL,
|
||||
) throws {
|
||||
// see URL extension above
|
||||
guard directoryURL.isDirectory else {
|
||||
throw CreateZipError.urlNotADirectory(directoryURL)
|
||||
}
|
||||
|
||||
var fileManagerError: Swift.Error?
|
||||
var coordinatorError: NSError?
|
||||
|
||||
NSFileCoordinator().coordinate(
|
||||
readingItemAt: directoryURL,
|
||||
options: .forUploading,
|
||||
error: &coordinatorError
|
||||
) { zipAccessURL in
|
||||
do {
|
||||
try FileManager.default.moveItem(at: zipAccessURL, to: zipFinalURL)
|
||||
} catch {
|
||||
fileManagerError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let error = coordinatorError {
|
||||
throw CreateZipError.failedToCreateZIP(error)
|
||||
}
|
||||
|
||||
if let error = fileManagerError {
|
||||
throw CreateZipError.failedToMoveZIP(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,7 +596,7 @@ public struct SettingsView: View {
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.prompt = "Save"
|
||||
savePanel.nameFieldLabel = "Save log archive to:"
|
||||
let fileName = "firezone_logs_\(LogExporter.now()).aar"
|
||||
let fileName = "firezone_logs_\(LogExporter.now()).zip"
|
||||
|
||||
savePanel.nameFieldStringValue = fileName
|
||||
|
||||
|
||||
@@ -244,16 +244,28 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
|
||||
case .idle:
|
||||
guard let logFolderURL = SharedAccess.logFolderURL,
|
||||
let logFolderPath = FilePath(logFolderURL)
|
||||
let cacheFolderURL = SharedAccess.cacheFolderURL,
|
||||
let connlibLogFolderURL = SharedAccess.connlibLogFolderURL
|
||||
else {
|
||||
completionHandler(nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let tunnelLogArchive = TunnelLogArchive(source: logFolderPath)
|
||||
let tunnelLogArchive = TunnelLogArchive(source: logFolderURL)
|
||||
|
||||
let latestSymlink = connlibLogFolderURL.appendingPathComponent("latest")
|
||||
let tempSymlink = cacheFolderURL.appendingPathComponent(
|
||||
"latest")
|
||||
|
||||
do {
|
||||
// Move the `latest` symlink out of the way before creating the archive.
|
||||
// Apple's implementation of zip appears to not be able to handle symlinks well
|
||||
let _ = try? FileManager.default.moveItem(at: latestSymlink, to: tempSymlink)
|
||||
defer {
|
||||
let _ = try? FileManager.default.moveItem(at: tempSymlink, to: latestSymlink)
|
||||
}
|
||||
|
||||
try tunnelLogArchive.archive()
|
||||
} catch {
|
||||
Log.error(error)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
// LICENSE: Apache-2.0
|
||||
//
|
||||
|
||||
import AppleArchive
|
||||
import FirezoneKit
|
||||
import Foundation
|
||||
import System
|
||||
@@ -24,8 +23,7 @@ import System
|
||||
///
|
||||
/// Currently this limit is set to 1 MB (chosen somewhat arbitrarily based on limited information found on the
|
||||
/// web), but can be easily enlarged in the future to reduce the number of IPC calls required to consume
|
||||
/// the entire archive. The LZFSE compression algorithm used by default in the Apple Archive Framework is
|
||||
/// quite efficient -- compression ratios for our logs can be as high as 100:1 using this format.
|
||||
/// the entire archive.
|
||||
class TunnelLogArchive {
|
||||
enum ArchiveError: Error {
|
||||
case unableToWriteArchive
|
||||
@@ -37,13 +35,13 @@ class TunnelLogArchive {
|
||||
let archiveURL = FileManager
|
||||
.default
|
||||
.temporaryDirectory
|
||||
.appendingPathComponent("logs.aar")
|
||||
.appendingPathComponent("logs.zip")
|
||||
|
||||
var offset: UInt64 = 0
|
||||
var fileHandle: FileHandle?
|
||||
var source: FilePath
|
||||
var source: URL
|
||||
|
||||
init(source: FilePath) {
|
||||
init(source: URL) {
|
||||
self.source = source
|
||||
}
|
||||
|
||||
@@ -52,16 +50,11 @@ class TunnelLogArchive {
|
||||
}
|
||||
|
||||
func archive() throws {
|
||||
guard let archivePath = FilePath(self.archiveURL)
|
||||
else {
|
||||
throw ArchiveError.unableToWriteArchive
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: self.archiveURL)
|
||||
|
||||
try LogCompressor().start(
|
||||
try ZipService.createZip(
|
||||
source: source,
|
||||
to: archivePath
|
||||
to: self.archiveURL
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ export default function Apple() {
|
||||
Fixes an issue where certain log files would not be recreated after
|
||||
logs were cleared.
|
||||
</ChangeItem>
|
||||
<ChangeItem pull="9536">
|
||||
Uses `.zip` to compress logs instead of Apple Archive.
|
||||
</ChangeItem>
|
||||
</Unreleased>
|
||||
<Entry version="1.5.3" date={new Date("2025-06-19")}>
|
||||
<ChangeItem pull="9564">
|
||||
|
||||
Reference in New Issue
Block a user