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.
590 lines
24 KiB
Swift
590 lines
24 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
|
|
)
|
|
|
|
// 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
|
|
guard !URL(fileURLWithPath: relativePath).lastPathComponent.starts(with: ".") else {
|
|
Log.warn(.cat, "Skipping hidden file to avoid breaking the import: \(relativePath)")
|
|
skippedFilePaths.append(fullPath)
|
|
|
|
// Update the progress
|
|
fileAmountProcessed += fileSize
|
|
progressChanged?(
|
|
(filePaths.count + skippedFilePaths.count + additionalFilePaths.count),
|
|
Int(expectedFileCount + expectedAdditionalFileCount),
|
|
fileAmountProcessed,
|
|
encryptedFileSize
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Read and decrypt file content
|
|
guard let outputStream: OutputStream = OutputStream(toFileAtPath: fullPath, append: false) else {
|
|
Log.error(.cat, "Failed to create output stream")
|
|
throw ArchiveError.unarchiveFailed
|
|
}
|
|
outputStream.open()
|
|
defer { outputStream.close() }
|
|
|
|
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 {
|
|
case false: filePaths.append(fullPath)
|
|
case true: 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))
|
|
}
|
|
}
|