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:
Thomas Eizinger
2025-06-24 00:25:57 +02:00
committed by GitHub
parent 0cd919a5e2
commit f211c9d46a
7 changed files with 108 additions and 109 deletions

View File

@@ -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
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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">