mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			595 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			595 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
 | |
| //
 | |
| // stringlint:disable
 | |
| 
 | |
| import Foundation
 | |
| import CryptoKit
 | |
| import Compression
 | |
| 
 | |
| // MARK: - Log.Category
 | |
| 
 | |
| private extension Log.Category {
 | |
|     static let cat: Log.Category = .create("DirectoryArchiver", defaultLevel: .info)
 | |
| }
 | |
| 
 | |
| // MARK: - ArchiveError
 | |
| 
 | |
| public enum ArchiveError: Error, CustomStringConvertible {
 | |
|     case invalidSourcePath
 | |
|     case archiveFailed
 | |
|     case unarchiveFailed
 | |
|     case decryptionFailed(Error)
 | |
|     case incompatibleVersion
 | |
|     case unableToFindDatabaseKey
 | |
|     case importedFileCountMismatch
 | |
|     case importedFileCountMetadataMismatch
 | |
|     
 | |
|     public var description: String {
 | |
|         switch self {
 | |
|             case .invalidSourcePath: "Invalid source path provided."
 | |
|             case .archiveFailed: "Failed to archive."
 | |
|             case .unarchiveFailed: "Failed to unarchive."
 | |
|             case .decryptionFailed(let error): "Decryption failed due to error: \(error)."
 | |
|             case .incompatibleVersion: "This exported bundle is not compatible with this version of Session."
 | |
|             case .unableToFindDatabaseKey: "Unable to find database key."
 | |
|             case .importedFileCountMismatch: "The number of files imported doesn't match the number of files written to disk."
 | |
|             case .importedFileCountMetadataMismatch: "The number of files imported doesn't match the number of files reported."
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - DirectoryArchiver
 | |
| 
 | |
| public class DirectoryArchiver {
 | |
|     /// This value is here in case we need to change the structure of the exported data in the future, this would allow us to have
 | |
|     /// some form of backwards compatibility if desired
 | |
|     private static let version: UInt32 = 1
 | |
|     
 | |
|     /// Archive an entire directory
 | |
|     /// - Parameters:
 | |
|     ///   - sourcePath: Full path to the directory to compress
 | |
|     ///   - destinationPath: Full path where the compressed file will be saved
 | |
|     ///   - password: Optional password for encryption
 | |
|     /// - Throws: ArchiveError if archiving fails
 | |
|     public static func archiveDirectory(
 | |
|         sourcePath: String,
 | |
|         destinationPath: String,
 | |
|         filenamesToExclude: [String] = [],
 | |
|         additionalPaths: [String] = [],
 | |
|         password: String?,
 | |
|         progressChanged: ((Int, Int, UInt64, UInt64) -> Void)?
 | |
|     ) throws {
 | |
|         guard FileManager.default.fileExists(atPath: sourcePath) else {
 | |
|             throw ArchiveError.invalidSourcePath
 | |
|         }
 | |
|         
 | |
|         let sourceUrl: URL = URL(fileURLWithPath: sourcePath)
 | |
|         let destinationUrl: URL = URL(fileURLWithPath: destinationPath)
 | |
|         
 | |
|         // Create output stream for backup and compression
 | |
|         guard let outputStream: OutputStream = OutputStream(url: destinationUrl, append: false) else {
 | |
|             throw ArchiveError.archiveFailed
 | |
|         }
 | |
|         
 | |
|         outputStream.open()
 | |
|         defer { outputStream.close() }
 | |
|         
 | |
|         // Stream-based directory traversal and compression
 | |
|         let enumerator: FileManager.DirectoryEnumerator? = FileManager.default.enumerator(
 | |
|             at: sourceUrl,
 | |
|             includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey, .isDirectoryKey]
 | |
|         )
 | |
|         let fileUrls: [URL] = (enumerator?.allObjects
 | |
|             .compactMap { $0 as? URL }
 | |
|             .filter { url -> Bool in
 | |
|                 guard !filenamesToExclude.contains(url.lastPathComponent) else { return false }
 | |
|                 guard
 | |
|                     let resourceValues = try? url.resourceValues(
 | |
|                         forKeys: [.isRegularFileKey, .isHiddenKey, .isDirectoryKey]
 | |
|                     )
 | |
|                 else { return true }
 | |
|                 
 | |
|                 return (
 | |
|                     resourceValues.isRegularFile == true &&
 | |
|                     resourceValues.isHidden != true
 | |
|                 )
 | |
|             })
 | |
|             .defaulting(to: [])
 | |
|         var index: Int = 0
 | |
|         progressChanged?(index, (fileUrls.count + additionalPaths.count), 0, 0)
 | |
|         
 | |
|         // Include the archiver version so we can validate compatibility when importing
 | |
|         var version: UInt32 = DirectoryArchiver.version
 | |
|         let versionData: [UInt8] = Array(Data(bytes: &version, count: MemoryLayout<UInt32>.size))
 | |
|         try write(versionData, to: outputStream, blockSize: UInt8.self, password: password)
 | |
|         
 | |
|         // Store general metadata to help with validation and any other non-file related info
 | |
|         var fileCount: UInt32 = UInt32(fileUrls.count)
 | |
|         var additionalFileCount: UInt32 = UInt32(additionalPaths.count)
 | |
|         
 | |
|         let metadata: Data = (
 | |
|             Data(bytes: &fileCount, count: MemoryLayout<UInt32>.size) +
 | |
|             Data(bytes: &additionalFileCount, count: MemoryLayout<UInt32>.size)
 | |
|         )
 | |
|         try write(Array(metadata), to: outputStream, blockSize: UInt64.self, password: password)
 | |
|         
 | |
|         // Write the main file content
 | |
|         try fileUrls.forEach { url in
 | |
|             index += 1
 | |
|             
 | |
|             try exportFile(
 | |
|                 sourcePath: sourcePath,
 | |
|                 fileURL: url,
 | |
|                 customRelativePath: nil,
 | |
|                 outputStream: outputStream,
 | |
|                 password: password,
 | |
|                 index: index,
 | |
|                 totalFiles: (fileUrls.count + additionalPaths.count),
 | |
|                 isExtraFile: false,
 | |
|                 progressChanged: progressChanged
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         // Add any extra files which we want to include
 | |
|         try additionalPaths.forEach { path in
 | |
|             index += 1
 | |
|             
 | |
|             let fileUrl: URL = URL(fileURLWithPath: path)
 | |
|             try exportFile(
 | |
|                 sourcePath: sourcePath,
 | |
|                 fileURL: fileUrl,
 | |
|                 customRelativePath: "_extra/\(fileUrl.lastPathComponent)",
 | |
|                 outputStream: outputStream,
 | |
|                 password: password,
 | |
|                 index: index,
 | |
|                 totalFiles: (fileUrls.count + additionalPaths.count),
 | |
|                 isExtraFile: true,
 | |
|                 progressChanged: progressChanged
 | |
|             )
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public static func unarchiveDirectory(
 | |
|         archivePath: String,
 | |
|         destinationPath: String,
 | |
|         password: String?,
 | |
|         progressChanged: ((Int, Int, UInt64, UInt64) -> Void)?
 | |
|     ) throws -> (paths: [String], additional: [String]) {
 | |
|         // Remove any old imported data as we don't want to muddy the new data
 | |
|         if FileManager.default.fileExists(atPath: destinationPath) {
 | |
|             try? FileManager.default.removeItem(atPath: destinationPath)
 | |
|         }
 | |
|         
 | |
|         // Create the destination directory
 | |
|         try FileManager.default.createDirectory(
 | |
|             atPath: destinationPath,
 | |
|             withIntermediateDirectories: true
 | |
|         )
 | |
|         
 | |
|         guard
 | |
|             let values: URLResourceValues = try? URL(fileURLWithPath: archivePath).resourceValues(
 | |
|                 forKeys: [.fileSizeKey]
 | |
|             ),
 | |
|             let encryptedFileSize: UInt64 = values.fileSize.map({ UInt64($0) }),
 | |
|             let inputStream: InputStream = InputStream(fileAtPath: archivePath)
 | |
|         else { throw ArchiveError.unarchiveFailed }
 | |
|         
 | |
|         inputStream.open()
 | |
|         defer { inputStream.close() }
 | |
|         
 | |
|         // First we need to check the version included in the export is compatible with the current one
 | |
|         Log.info(.cat, "Retrieving archive version data")
 | |
|         let (versionData, _, _): ([UInt8], Int, UInt8) = try read(from: inputStream, password: password)
 | |
|         
 | |
|         guard !versionData.isEmpty else {
 | |
|             Log.error(.cat, "Missing archive version data")
 | |
|             throw ArchiveError.incompatibleVersion
 | |
|         }
 | |
|         
 | |
|         var version: UInt32 = 0
 | |
|         _ = withUnsafeMutableBytes(of: &version) { versionBuffer in
 | |
|             versionData.copyBytes(to: versionBuffer)
 | |
|         }
 | |
|         
 | |
|         // Retrieve and process the general metadata
 | |
|         Log.info(.cat, "Retrieving archive metadata")
 | |
|         var metadataOffset = 0
 | |
|         let (metadataBytes, _, _): ([UInt8], Int, UInt64) = try read(from: inputStream, password: password)
 | |
|         
 | |
|         guard !metadataBytes.isEmpty else {
 | |
|             Log.error(.cat, "Failed to extract metadata")
 | |
|             throw ArchiveError.unarchiveFailed
 | |
|         }
 | |
|         
 | |
|         // Extract path length and path
 | |
|         Log.info(.cat, "Starting to extract files")
 | |
|         let expectedFileCountRange: Range<Int> = metadataOffset..<(metadataOffset + MemoryLayout<UInt32>.size)
 | |
|         var expectedFileCount: UInt32 = 0
 | |
|         _ = withUnsafeMutableBytes(of: &expectedFileCount) { expectedFileCountBuffer in
 | |
|             metadataBytes.copyBytes(to: expectedFileCountBuffer, from: expectedFileCountRange)
 | |
|         }
 | |
|         metadataOffset += MemoryLayout<UInt32>.size
 | |
|         
 | |
|         let expectedAdditionalFileCountRange: Range<Int> = metadataOffset..<(metadataOffset + MemoryLayout<UInt32>.size)
 | |
|         var expectedAdditionalFileCount: UInt32 = 0
 | |
|         _ = withUnsafeMutableBytes(of: &expectedAdditionalFileCount) { expectedAdditionalFileCountBuffer in
 | |
|             metadataBytes.copyBytes(to: expectedAdditionalFileCountBuffer, from: expectedAdditionalFileCountRange)
 | |
|         }
 | |
|         
 | |
|         var filePaths: [String] = []
 | |
|         var additionalFilePaths: [String] = []
 | |
|         var skippedFilePaths: [String] = []
 | |
|         var fileAmountProcessed: UInt64 = 0
 | |
|         progressChanged?(0, Int(expectedFileCount + expectedAdditionalFileCount), 0, encryptedFileSize)
 | |
|         while inputStream.hasBytesAvailable {
 | |
|             let (metadata, blockSizeBytesRead, encryptedSize): ([UInt8], Int, UInt64) = try read(
 | |
|                 from: inputStream,
 | |
|                 password: password
 | |
|             )
 | |
|             fileAmountProcessed += UInt64(blockSizeBytesRead)
 | |
|             progressChanged?(
 | |
|                 (filePaths.count + skippedFilePaths.count + additionalFilePaths.count),
 | |
|                 Int(expectedFileCount + expectedAdditionalFileCount),
 | |
|                 fileAmountProcessed,
 | |
|                 encryptedFileSize
 | |
|             )
 | |
|             
 | |
|             // Stop here if we have finished reading
 | |
|             guard blockSizeBytesRead > 0 else {
 | |
|                 Log.info(.cat, "Finished reading file (block size was 0)")
 | |
|                 continue
 | |
|             }
 | |
|             
 | |
|             // Process the metadata
 | |
|             var offset = 0
 | |
|             
 | |
|             // Extract path length and path
 | |
|             let pathLengthRange: Range<Int> = offset..<(offset + MemoryLayout<UInt32>.size)
 | |
|             var pathLength: UInt32 = 0
 | |
|             _ = withUnsafeMutableBytes(of: &pathLength) { pathLengthBuffer in
 | |
|                 metadata.copyBytes(to: pathLengthBuffer, from: pathLengthRange)
 | |
|             }
 | |
|             offset += MemoryLayout<UInt32>.size
 | |
|             
 | |
|             let pathRange: Range<Int> = offset..<(offset + Int(pathLength))
 | |
|             let relativePath: String = String(data: Data(metadata[pathRange]), encoding: .utf8)!
 | |
|             offset += Int(pathLength)
 | |
|             
 | |
|             // Extract file size
 | |
|             let fileSizeRange: Range<Int> = offset..<(offset + MemoryLayout<UInt64>.size)
 | |
|             var fileSize: UInt64 = 0
 | |
|             _ = withUnsafeMutableBytes(of: &fileSize) { fileSizeBuffer in
 | |
|                 metadata.copyBytes(to: fileSizeBuffer, from: fileSizeRange)
 | |
|             }
 | |
|             offset += Int(MemoryLayout<UInt64>.size)
 | |
|             
 | |
|             // Extract extra file flag
 | |
|             let isExtraFileRange: Range<Int> = offset..<(offset + MemoryLayout<Bool>.size)
 | |
|             var isExtraFile: Bool = false
 | |
|             _ = withUnsafeMutableBytes(of: &isExtraFile) { isExtraFileBuffer in
 | |
|                 metadata.copyBytes(to: isExtraFileBuffer, from: isExtraFileRange)
 | |
|             }
 | |
|             
 | |
|             // Construct full file path
 | |
|             let fullPath: String = (destinationPath as NSString).appendingPathComponent(relativePath)
 | |
|             try FileManager.default.createDirectory(
 | |
|                 atPath: (fullPath as NSString).deletingLastPathComponent,
 | |
|                 withIntermediateDirectories: true
 | |
|             )
 | |
|             fileAmountProcessed += encryptedSize
 | |
|             progressChanged?(
 | |
|                 (filePaths.count + skippedFilePaths.count + additionalFilePaths.count),
 | |
|                 Int(expectedFileCount + expectedAdditionalFileCount),
 | |
|                 fileAmountProcessed,
 | |
|                 encryptedFileSize
 | |
|             )
 | |
|             
 | |
|             // Read and decrypt file content
 | |
|             let outputStream: OutputStream?
 | |
|             let isHiddenFile: Bool = URL(fileURLWithPath: relativePath).lastPathComponent.starts(with: ".")
 | |
|             defer { outputStream?.close() }
 | |
|             
 | |
|             switch isHiddenFile {
 | |
|                 case true:
 | |
|                     // If the file is a hidden file (shouldn't be possible anymore but old backups had this
 | |
|                     // issue) then just skip the file - any hidden files are from Apple and seem to fail to
 | |
|                     // decrypt causing the entire import to fail
 | |
|                     //
 | |
|                     // Note: We still need to process the file in order to ensure the inputStream is moved
 | |
|                     // the correct amount, otherwise out byte alignment could be off which will result in
 | |
|                     // at best a failed import, but more likely a crash due to invalid size data
 | |
|                     Log.warn(.cat, "Skipping hidden file to avoid breaking the import: \(relativePath)")
 | |
|                     skippedFilePaths.append(fullPath)
 | |
|                     outputStream = nil
 | |
|                 
 | |
|                 case false:
 | |
|                     // It's a valid file so ensure the OutputStream was opened successfully
 | |
|                     outputStream = OutputStream(toFileAtPath: fullPath, append: false)
 | |
|                     outputStream?.open()
 | |
|                     
 | |
|                     guard outputStream != nil else {
 | |
|                         Log.error(.cat, "Failed to create output stream")
 | |
|                         throw ArchiveError.unarchiveFailed
 | |
|                     }
 | |
|             }
 | |
|             
 | |
|             // Process the file chunk by chunk
 | |
|             var remainingFileSize: Int = Int(fileSize)
 | |
|             while remainingFileSize > 0 {
 | |
|                 let (chunk, chunkSizeBytesRead, encryptedSize): ([UInt8], Int, UInt32) = try read(
 | |
|                     from: inputStream,
 | |
|                     password: password
 | |
|                 )
 | |
|                 
 | |
|                 // Write to the output
 | |
|                 outputStream?.write(chunk, maxLength: chunk.count)
 | |
|                 remainingFileSize -= chunk.count
 | |
|                 
 | |
|                 // Update the progress
 | |
|                 fileAmountProcessed += UInt64(chunkSizeBytesRead) + UInt64(encryptedSize)
 | |
|                 progressChanged?(
 | |
|                     (filePaths.count + skippedFilePaths.count + additionalFilePaths.count),
 | |
|                     Int(expectedFileCount + expectedAdditionalFileCount),
 | |
|                     fileAmountProcessed,
 | |
|                     encryptedFileSize
 | |
|                 )
 | |
|             }
 | |
|             
 | |
|             // Store the file path info and update the progress
 | |
|             switch (isExtraFile, isHiddenFile) {
 | |
|                 case (_, true): break
 | |
|                 case (false, false): filePaths.append(fullPath)
 | |
|                 case (true, false): additionalFilePaths.append(fullPath)
 | |
|             }
 | |
|             progressChanged?(
 | |
|                 (filePaths.count + skippedFilePaths.count + additionalFilePaths.count),
 | |
|                 Int(expectedFileCount + expectedAdditionalFileCount),
 | |
|                 fileAmountProcessed,
 | |
|                 encryptedFileSize
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         // Validate that the number of files exported matches the number of paths we got back
 | |
|         let testEnumerator: FileManager.DirectoryEnumerator? = FileManager.default.enumerator(
 | |
|             at: URL(fileURLWithPath: destinationPath),
 | |
|             includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey]
 | |
|         )
 | |
|         let tempFileUrls: [URL] = (testEnumerator?.allObjects
 | |
|             .compactMap { $0 as? URL }
 | |
|             .filter { url -> Bool in
 | |
|                 guard
 | |
|                     let resourceValues = try? url.resourceValues(
 | |
|                         forKeys: [.isRegularFileKey, .isDirectoryKey]
 | |
|                     )
 | |
|                 else { return true }
 | |
|                 
 | |
|                 return (resourceValues.isRegularFile == true)
 | |
|             })
 | |
|             .defaulting(to: [])
 | |
|         
 | |
|         guard tempFileUrls.count == (filePaths.count + additionalFilePaths.count) else {
 | |
|             Log.error(.cat, "The number of files decrypted (\(tempFileUrls.count)) didn't match the expected number of files (\(filePaths.count + additionalFilePaths.count))")
 | |
|             throw ArchiveError.importedFileCountMismatch
 | |
|         }
 | |
|         guard
 | |
|             (filePaths.count + skippedFilePaths.count) == expectedFileCount &&
 | |
|             additionalFilePaths.count == expectedAdditionalFileCount
 | |
|         else {
 | |
|             switch (((filePaths.count + skippedFilePaths.count) == expectedFileCount), additionalFilePaths.count == expectedAdditionalFileCount) {
 | |
|                 case (false, true):
 | |
|                     Log.error(.cat, "The number of main files decrypted (\(filePaths.count)) plus skipped files (\(skippedFilePaths.count)) didn't match the expected number of main files (\(expectedFileCount))")
 | |
|                     
 | |
|                 case (true, false):
 | |
|                     Log.error(.cat, "The number of additional files decrypted (\(additionalFilePaths.count)) didn't match the expected number of additional files (\(expectedAdditionalFileCount))")
 | |
|                     
 | |
|                 default: break
 | |
|             }
 | |
|             throw ArchiveError.importedFileCountMetadataMismatch
 | |
|         }
 | |
|         
 | |
|         return (filePaths, additionalFilePaths)
 | |
|     }
 | |
|     
 | |
|     private static func encrypt(buffer: [UInt8], password: String) throws -> [UInt8] {
 | |
|         guard let passwordData: Data = password.data(using: .utf8) else {
 | |
|             return buffer
 | |
|         }
 | |
|         
 | |
|         // Use HKDF for key derivation
 | |
|         let salt: Data = Data(count: 16)
 | |
|         let key: SymmetricKey = SymmetricKey(data: passwordData)
 | |
|         let symmetricKey: SymmetricKey = SymmetricKey(
 | |
|             data: HKDF<SHA256>.deriveKey(
 | |
|                 inputKeyMaterial: key,
 | |
|                 salt: salt,
 | |
|                 outputByteCount: 32
 | |
|             )
 | |
|         )
 | |
|         let nonce: AES.GCM.Nonce = AES.GCM.Nonce()
 | |
|         let sealedBox: AES.GCM.SealedBox = try AES.GCM.seal(
 | |
|             Data(buffer),
 | |
|             using: symmetricKey,
 | |
|             nonce: nonce
 | |
|         )
 | |
|         
 | |
|         // Combine nonce, ciphertext, and tag
 | |
|         return [UInt8](nonce) + sealedBox.ciphertext + sealedBox.tag
 | |
|     }
 | |
|     
 | |
|     private static func decrypt(buffer: [UInt8], password: String) throws -> [UInt8] {
 | |
|         guard let passwordData: Data = password.data(using: .utf8) else {
 | |
|             return buffer
 | |
|         }
 | |
|         
 | |
|         let salt: Data = Data(count: 16)
 | |
|         let key: SymmetricKey = SymmetricKey(data: passwordData)
 | |
|         let symmetricKey: SymmetricKey = SymmetricKey(
 | |
|             data: HKDF<SHA256>.deriveKey(
 | |
|                 inputKeyMaterial: key,
 | |
|                 salt: salt,
 | |
|                 outputByteCount: 32
 | |
|             )
 | |
|         )
 | |
|         
 | |
|         // Extract nonce, ciphertext, and tag
 | |
|         do {
 | |
|             let nonce: AES.GCM.Nonce = try AES.GCM.Nonce(data: Data(buffer.prefix(12)))
 | |
|             let ciphertext: Data = Data(buffer[12..<(buffer.count-16)])
 | |
|             let tag: Data = Data(buffer.suffix(16))
 | |
|             
 | |
|             // Decrypt with AES-GCM
 | |
|             let sealedBox: AES.GCM.SealedBox = try AES.GCM.SealedBox(
 | |
|                 nonce: nonce,
 | |
|                 ciphertext: ciphertext,
 | |
|                 tag: tag
 | |
|             )
 | |
|             
 | |
|             let decryptedData: Data = try AES.GCM.open(sealedBox, using: symmetricKey)
 | |
|             return [UInt8](decryptedData)
 | |
|         }
 | |
|         catch {
 | |
|             Log.error(.cat, "\(ArchiveError.decryptionFailed(error))")
 | |
|             throw ArchiveError.decryptionFailed(error)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private static func write<T>(
 | |
|         _ data: [UInt8],
 | |
|         to outputStream: OutputStream,
 | |
|         blockSize: T.Type,
 | |
|         password: String?
 | |
|     ) throws where T: FixedWidthInteger, T: UnsignedInteger {
 | |
|         let processedBytes: [UInt8]
 | |
|         
 | |
|         switch password {
 | |
|             case .none: processedBytes = data
 | |
|             case .some(let password):
 | |
|                 processedBytes = try encrypt(
 | |
|                     buffer: data,
 | |
|                     password: password
 | |
|                 )
 | |
|         }
 | |
|         
 | |
|         var blockSize: T = T(processedBytes.count)
 | |
|         let blockSizeData: [UInt8] = Array(Data(bytes: &blockSize, count: MemoryLayout<T>.size))
 | |
|         outputStream.write(blockSizeData, maxLength: blockSizeData.count)
 | |
|         outputStream.write(processedBytes, maxLength: processedBytes.count)
 | |
|     }
 | |
|     
 | |
|     private static func read<T>(
 | |
|         from inputStream: InputStream,
 | |
|         password: String?
 | |
|     ) throws -> (value: [UInt8], blockSizeBytesRead: Int, encryptedSize: T) where T: FixedWidthInteger, T: UnsignedInteger {
 | |
|         var blockSizeBytes: [UInt8] = [UInt8](repeating: 0, count: MemoryLayout<T>.size)
 | |
|         let bytesRead: Int = inputStream.read(&blockSizeBytes, maxLength: blockSizeBytes.count)
 | |
|         
 | |
|         switch bytesRead {
 | |
|             case 0: return ([], bytesRead, 0)           // We have finished reading
 | |
|             case blockSizeBytes.count: break            // We have started the next block
 | |
|             default:
 | |
|                 Log.error(.cat, "Read block size was invalid")
 | |
|                 throw ArchiveError.unarchiveFailed // Invalid
 | |
|         }
 | |
|         
 | |
|         var blockSize: T = 0
 | |
|         _ = withUnsafeMutableBytes(of: &blockSize) { blockSizeBuffer in
 | |
|             blockSizeBytes.copyBytes(to: blockSizeBuffer, from: ..<MemoryLayout<T>.size)
 | |
|         }
 | |
|         
 | |
|         var encryptedResult: [UInt8] = [UInt8](repeating: 0, count: Int(blockSize))
 | |
|         guard inputStream.read(&encryptedResult, maxLength: encryptedResult.count) == encryptedResult.count else {
 | |
|             Log.error(.cat, "The size read from the input stream didn't match the encrypted result block size")
 | |
|             throw ArchiveError.unarchiveFailed
 | |
|         }
 | |
|         
 | |
|         let result: [UInt8]
 | |
|         switch password {
 | |
|             case .none: result = encryptedResult
 | |
|             case .some(let password): result = try decrypt(buffer: encryptedResult, password: password)
 | |
|         }
 | |
|         
 | |
|         return (result, bytesRead, blockSize)
 | |
|     }
 | |
|     
 | |
|     private static func exportFile(
 | |
|         sourcePath: String,
 | |
|         fileURL: URL,
 | |
|         customRelativePath: String?,
 | |
|         outputStream: OutputStream,
 | |
|         password: String?,
 | |
|         index: Int,
 | |
|         totalFiles: Int,
 | |
|         isExtraFile: Bool,
 | |
|         progressChanged: ((Int, Int, UInt64, UInt64) -> Void)?
 | |
|     ) throws {
 | |
|         guard
 | |
|             let values: URLResourceValues = try? fileURL.resourceValues(
 | |
|                 forKeys: [.isRegularFileKey, .fileSizeKey]
 | |
|             ),
 | |
|             values.isRegularFile == true,
 | |
|             var fileSize: UInt64 = values.fileSize.map({ UInt64($0) })
 | |
|         else {
 | |
|             progressChanged?(index, totalFiles, 1, 1)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Relative path preservation
 | |
|         let relativePath: String = customRelativePath
 | |
|             .defaulting(
 | |
|                 to: fileURL.path
 | |
|                     .replacingOccurrences(of: sourcePath, with: "")
 | |
|                     .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
 | |
|             )
 | |
|         
 | |
|         // Write path length and path
 | |
|         let pathData: Data = relativePath.data(using: .utf8)!
 | |
|         var pathLength: UInt32 = UInt32(pathData.count)
 | |
|         var isExtraFile: Bool = isExtraFile
 | |
|         
 | |
|         // Encrypt and write metadata (path length + path data)
 | |
|         let metadata: Data = (
 | |
|             Data(bytes: &pathLength, count: MemoryLayout<UInt32>.size) +
 | |
|             pathData +
 | |
|             Data(bytes: &fileSize, count: MemoryLayout<UInt64>.size) +
 | |
|             Data(bytes: &isExtraFile, count: MemoryLayout<Bool>.size)
 | |
|         )
 | |
|         try write(Array(metadata), to: outputStream, blockSize: UInt64.self, password: password)
 | |
|         
 | |
|         // Stream file contents
 | |
|         guard let inputStream: InputStream = InputStream(url: fileURL) else {
 | |
|             progressChanged?(index, totalFiles, 1, 1)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         inputStream.open()
 | |
|         defer { inputStream.close() }
 | |
|         
 | |
|         var buffer: [UInt8] = [UInt8](repeating: 0, count: 4096)
 | |
|         var currentFileProcessAmount: UInt64 = 0
 | |
|         while inputStream.hasBytesAvailable {
 | |
|             let bytesRead: Int = inputStream.read(&buffer, maxLength: buffer.count)
 | |
|             currentFileProcessAmount += UInt64(bytesRead)
 | |
|             progressChanged?(index, totalFiles, currentFileProcessAmount, fileSize)
 | |
|             
 | |
|             if bytesRead > 0 {
 | |
|                 try write(
 | |
|                     Array(buffer.prefix(bytesRead)),
 | |
|                     to: outputStream,
 | |
|                     blockSize: UInt32.self,
 | |
|                     password: password
 | |
|                 )
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| fileprivate extension InputStream {
 | |
|     func readEncryptedChunk(password: String, maxLength: Int) -> Data? {
 | |
|         var buffer: [UInt8] = [UInt8](repeating: 0, count: maxLength)
 | |
|         let bytesRead: Int = self.read(&buffer, maxLength: maxLength)
 | |
|         guard bytesRead > 0 else { return nil }
 | |
|         
 | |
|         return Data(buffer.prefix(bytesRead))
 | |
|     }
 | |
| }
 |