// C o p y r i g h t © 2 0 2 4 R a n g e p r o o f P t y L t d . A l l r i g h t s r e s e r v e d .
//
// s t r i n g l i n t : d i s a b l e
import Foundation
import CryptoKit
import Compression
// MARK: - L o g . C a t e g o r y
private extension Log . Category {
static let cat : Log . Category = . create ( " DirectoryArchiver " , defaultLevel : . info )
}
// MARK: - A r c h i v e E r r o r
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: - D i r e c t o r y A r c h i v e r
public class DirectoryArchiver {
// / T h i s v a l u e i s h e r e i n c a s e w e n e e d t o c h a n g e t h e s t r u c t u r e o f t h e e x p o r t e d d a t a i n t h e f u t u r e , t h i s w o u l d a l l o w u s t o h a v e
// / s o m e f o r m o f b a c k w a r d s c o m p a t i b i l i t y i f d e s i r e d
private static let version : UInt32 = 1
// / A r c h i v e a n e n t i r e d i r e c t o r y
// / - P a r a m e t e r s :
// / - s o u r c e P a t h : F u l l p a t h t o t h e d i r e c t o r y t o c o m p r e s s
// / - d e s t i n a t i o n P a t h : F u l l p a t h w h e r e t h e c o m p r e s s e d f i l e w i l l b e s a v e d
// / - p a s s w o r d : O p t i o n a l p a s s w o r d f o r e n c r y p t i o n
// / - T h r o w s : A r c h i v e E r r o r i f a r c h i v i n g f a i l s
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 )
// C r e a t e o u t p u t s t r e a m f o r b a c k u p a n d c o m p r e s s i o n
guard let outputStream : OutputStream = OutputStream ( url : destinationUrl , append : false ) else {
throw ArchiveError . archiveFailed
}
outputStream . open ( )
defer { outputStream . close ( ) }
// S t r e a m - b a s e d d i r e c t o r y t r a v e r s a l a n d c o m p r e s s i o n
let enumerator : FileManager . DirectoryEnumerator ? = FileManager . default . enumerator (
at : sourceUrl ,
includingPropertiesForKeys : [ . isRegularFileKey , . 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 , . isDirectoryKey ]
)
else { return true }
return ( resourceValues . isRegularFile = = true )
} )
. defaulting ( to : [ ] )
var index : Int = 0
progressChanged ? ( index , ( fileUrls . count + additionalPaths . count ) , 0 , 0 )
// I n c l u d e t h e a r c h i v e r v e r s i o n s o w e c a n v a l i d a t e c o m p a t i b i l i t y w h e n i m p o r t i n g
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 )
// S t o r e g e n e r a l m e t a d a t a t o h e l p w i t h v a l i d a t i o n a n d a n y o t h e r n o n - f i l e r e l a t e d i n f o
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 )
// W r i t e t h e m a i n f i l e c o n t e n t
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
)
}
// A d d a n y e x t r a f i l e s w h i c h w e w a n t t o i n c l u d e
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 ] ) {
// R e m o v e a n y o l d i m p o r t e d d a t a a s w e d o n ' t w a n t t o m u d d y t h e n e w d a t a
if FileManager . default . fileExists ( atPath : destinationPath ) {
try ? FileManager . default . removeItem ( atPath : destinationPath )
}
// C r e a t e t h e d e s t i n a t i o n d i r e c t o r y
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 ( ) }
// F i r s t w e n e e d t o c h e c k t h e v e r s i o n i n c l u d e d i n t h e e x p o r t i s c o m p a t i b l e w i t h t h e c u r r e n t o n e
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 )
}
// R e t r i e v e a n d p r o c e s s t h e g e n e r a l m e t a d a t a
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
}
// E x t r a c t p a t h l e n g t h a n d p a t h
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 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 + additionalFilePaths . count ) ,
Int ( expectedFileCount + expectedAdditionalFileCount ) ,
fileAmountProcessed ,
encryptedFileSize
)
// S t o p h e r e i f w e h a v e f i n i s h e d r e a d i n g
guard blockSizeBytesRead > 0 else {
Log . info ( . cat , " Finished reading file (block size was 0) " )
continue
}
// P r o c e s s t h e m e t a d a t a
var offset = 0
// E x t r a c t p a t h l e n g t h a n d p a t h
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 )
// E x t r a c t f i l e s i z e
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 )
// E x t r a c t e x t r a f i l e f l a g
let isExtraFileRange : Range < Int > = offset . . < ( offset + MemoryLayout < Bool > . size )
var isExtraFile : Bool = false
_ = withUnsafeMutableBytes ( of : & isExtraFile ) { isExtraFileBuffer in
metadata . copyBytes ( to : isExtraFileBuffer , from : isExtraFileRange )
}
// C o n s t r u c t f u l l f i l e p a t h
let fullPath : String = ( destinationPath as NSString ) . appendingPathComponent ( relativePath )
try FileManager . default . createDirectory (
atPath : ( fullPath as NSString ) . deletingLastPathComponent ,
withIntermediateDirectories : true
)
fileAmountProcessed += encryptedSize
progressChanged ? (
( filePaths . count + additionalFilePaths . count ) ,
Int ( expectedFileCount + expectedAdditionalFileCount ) ,
fileAmountProcessed ,
encryptedFileSize
)
// R e a d a n d d e c r y p t f i l e c o n t e n t
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
)
// W r i t e t o t h e o u t p u t
outputStream . write ( chunk , maxLength : chunk . count )
remainingFileSize -= chunk . count
// U p d a t e t h e p r o g r e s s
fileAmountProcessed += UInt64 ( chunkSizeBytesRead ) + UInt64 ( encryptedSize )
progressChanged ? (
( filePaths . count + additionalFilePaths . count ) ,
Int ( expectedFileCount + expectedAdditionalFileCount ) ,
fileAmountProcessed ,
encryptedFileSize
)
}
// S t o r e t h e f i l e p a t h i n f o a n d u p d a t e t h e p r o g r e s s
switch isExtraFile {
case false : filePaths . append ( fullPath )
case true : additionalFilePaths . append ( fullPath )
}
progressChanged ? (
( filePaths . count + additionalFilePaths . count ) ,
Int ( expectedFileCount + expectedAdditionalFileCount ) ,
fileAmountProcessed ,
encryptedFileSize
)
}
// V a l i d a t e t h a t t h e n u m b e r o f f i l e s e x p o r t e d m a t c h e s t h e n u m b e r o f p a t h s w e g o t b a c k
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 = = expectedFileCount &&
additionalFilePaths . count = = expectedAdditionalFileCount
else {
switch ( ( filePaths . count = = expectedFileCount ) , additionalFilePaths . count = = expectedAdditionalFileCount ) {
case ( false , true ) :
Log . error ( . cat , " The number of main files decrypted ( \( filePaths . 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
}
// U s e H K D F f o r k e y d e r i v a t i o n
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
)
// C o m b i n e n o n c e , c i p h e r t e x t , a n d t a g
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
)
)
// E x t r a c t n o n c e , c i p h e r t e x t , a n d t a g
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 ) )
// D e c r y p t w i t h A E S - G C M
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 ) // W e h a v e f i n i s h e d r e a d i n g
case blockSizeBytes . count : break // W e h a v e s t a r t e d t h e n e x t b l o c k
default :
Log . error ( . cat , " Read block size was invalid " )
throw ArchiveError . unarchiveFailed // I n v a l i d
}
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
}
// R e l a t i v e p a t h p r e s e r v a t i o n
let relativePath : String = customRelativePath
. defaulting (
to : fileURL . path
. replacingOccurrences ( of : sourcePath , with : " " )
. trimmingCharacters ( in : CharacterSet ( charactersIn : " / " ) )
)
// W r i t e p a t h l e n g t h a n d p a t h
let pathData : Data = relativePath . data ( using : . utf8 ) !
var pathLength : UInt32 = UInt32 ( pathData . count )
var isExtraFile : Bool = isExtraFile
// E n c r y p t a n d w r i t e m e t a d a t a ( p a t h l e n g t h + p a t h d a t a )
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 )
// S t r e a m f i l e c o n t e n t s
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 ) )
}
}