From dffe8619c13e9886b8daff9f9e3f5b7d7e6a5cac Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Wed, 12 Aug 2026 17:22:21 +0300 Subject: [PATCH 1/3] Fix opening files with special symbols --- .../Sources/CommonsLib/Constants.swift | 3 + Modules/CryptoLib/Package.swift | 11 +- .../Sources/CryptoObjC/include/Decrypt.mm | 8 +- .../Sources/CryptoObjC/include/Extensions.h | 13 ++- .../CryptoObjCWrapper/Domain/CdocInfo.swift | 9 +- .../Sources/CryptoSwift/CryptoContainer.swift | 101 ++++++++++++++---- .../Container/DigiDocContainerWrapper.mm | 18 +++- .../Domain/Container/ContainerWrapper.swift | 24 +++-- .../Extensions/CharacterSetExtensions.swift | 13 +-- .../Extensions/StringExtensions.swift | 49 +++++++-- .../UtilsLib/Extensions/URLExtensions.swift | 9 ++ .../FileOpening/FileOpeningService.swift | 7 +- .../Container/Crypto/EncryptView.swift | 3 +- .../Util/Language/LanguageSettings.swift | 2 + RIADigiDoc/ViewModel/EncryptViewModel.swift | 15 +-- RIADigiDoc/ViewModel/SigningViewModel.swift | 5 +- 16 files changed, 218 insertions(+), 72 deletions(-) diff --git a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift index f5e3295a..5282e1f0 100644 --- a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift +++ b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift @@ -88,6 +88,9 @@ public struct Constants { public static let AppLog = "ria_digidoc.txt" public static let LDAPCertsPem = "ldapCerts.pem" public static let nfcCANKey = "canKey.txt" + + // iOS allows 255 characters in a file name, but app may add "(1)" and more to file names. + public static let MaxNameBytes = 240 } public struct FileBaseName { diff --git a/Modules/CryptoLib/Package.swift b/Modules/CryptoLib/Package.swift index 9c04fb30..e3bbd931 100644 --- a/Modules/CryptoLib/Package.swift +++ b/Modules/CryptoLib/Package.swift @@ -26,7 +26,8 @@ let package = Package( .package(path: "../ConfigLib"), .package(path: "../CommonsLib"), .package(path: "../IdCardLib"), - .package(path: "../UtilsLib") + .package(path: "../UtilsLib"), + .package(path: "../Test/CommonsTestShared") ], targets: [ .binaryTarget( @@ -101,12 +102,14 @@ let package = Package( name: "CryptoSwiftTests", dependencies: [ "ConfigLib", - "CryptoLibMocks", + "CryptoSwift", "CryptoObjCWrapper", + "CryptoLibMocks", "CommonsLib", "UtilsLib", - .product(name: "FactoryTesting", package: "Factory"), - .product(name: "CommonsLibMocks", package: "commonslib") + "CommonsTestShared", + .product(name: "CommonsLibMocks", package: "commonslib"), + .product(name: "FactoryTesting", package: "Factory") ] ) ] diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm index 2fe45266..c1bd6995 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm @@ -264,7 +264,13 @@ + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:( } [data increaseLengthBy:16 * 1024]; } - [response setObject:data forKey:[NSString stringWithStdString:name]]; + + NSString *fileName = [NSString stringWithStdString:name]; + NSUInteger counter = response.count + 1; + while (fileName.length == 0 || response[fileName] != nil) { + fileName = [NSString stringWithFormat:@"datafile-%lu", (unsigned long)counter++]; + } + [response setObject:data forKey:fileName]; } if (reader.finishDecryption() != 0) return [NSError cryptoError:@"Failed to end decryption" error:error]; diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h index 06a7c0de..dee73379 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h @@ -26,7 +26,18 @@ static const NSInteger CryptoLibNetworkErrorCode = -300; // libcdoc::NetworkBack @implementation NSString (std_string) + (instancetype)stringWithStdString:(const std::string&)data { - return data.empty() ? nil : [NSString stringWithUTF8String:data.c_str()]; + if (data.empty()) { + return nil; + } + NSString *utf8 = [[NSString alloc] initWithBytes:data.data() + length:data.size() + encoding:NSUTF8StringEncoding]; + if (utf8 != nil) { + return utf8; + } + return [[NSString alloc] initWithBytes:data.data() + length:data.size() + encoding:NSISOLatin1StringEncoding]; } - (std::string)toString { diff --git a/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/CdocInfo.swift b/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/CdocInfo.swift index b6f2b14f..274645c3 100644 --- a/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/CdocInfo.swift +++ b/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/CdocInfo.swift @@ -87,8 +87,13 @@ class CdocParserDelegate: NSObject, XMLParserDelegate { addressees.append(Addressee(cert: dataFromBase64)) } case ("denc:EncryptionProperty", "orig_file"): - if let filename = currentData.split(separator: "|").first { - dataFiles.append(CryptoDataFile(filename: String(filename))) + let parts = currentData.components(separatedBy: "|") + let filename = parts.count > 3 + ? parts.dropLast(3).joined(separator: "|") + : (parts.first ?? "") + + if !filename.isEmpty { + dataFiles.append(CryptoDataFile(filename: filename)) } case ("denc:EncryptionProperty", "DocumentFormat"): format = currentData diff --git a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift index 704730e4..cbe981f7 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift @@ -70,12 +70,12 @@ public actor CryptoContainer: CryptoContainerProtocol, Loggable { var failedFileCount = 0 let totalFileCount = filesToAdd.count - let existingDataFiles = Set(dataFiles?.compactMap { $0?.lastPathComponent } ?? []) + let existingDataFiles = Set(dataFiles?.compactMap { $0?.lastPathComponent.sanitized() } ?? []) var duplicateFileName: String? for fileToAdd in filesToAdd { - let fileName = fileToAdd.lastPathComponent + let fileName = fileToAdd.lastPathComponent.sanitized() let destinationURL = cryptoContainersDirectory.appendingPathComponent(fileName) if existingDataFiles.contains(fileName) || @@ -90,8 +90,16 @@ public actor CryptoContainer: CryptoContainerProtocol, Loggable { continue } + guard destinationURL.isWithin(directory: cryptoContainersDirectory) else { + CryptoContainer.logger().error( + "Refusing to add file: destination escapes the container directory" + ) + failedFileCount += 1 + continue + } + do { - if fileManager.fileExists(atPath: destinationURL.path) { + if fileManager.fileExists(atPath: destinationURL.resolvedPath) { try fileManager.removeItem(at: destinationURL) } @@ -99,6 +107,9 @@ public actor CryptoContainer: CryptoContainerProtocol, Loggable { movedFiles.append(destinationURL) } catch { + CryptoContainer.logger().error( + "Unable to add '\(fileName, privacy: .public)' to container: \(error.localizedDescription)" + ) failedFileCount += 1 } } @@ -210,31 +221,55 @@ public actor CryptoContainer: CryptoContainerProtocol, Loggable { } private func save(_ source: URL, as destination: URL) throws { + if fileManager.fileExists(atPath: destination.resolvedPath) { + try fileManager.removeItem(at: destination) + } try fileManager.copyItem(at: source, to: destination) } public func saveDataFile(dataFile: URL, to directory: URL?) async throws -> URL { - let sanitizedName = dataFile.lastPathComponent.sanitized() let savedFilesDirectory = try directory ?? Directories.getCacheDirectory( subfolders: [CommonsLib.Constants.Folder.SavedFiles], fileManager: fileManager ) - let file = savedFilesDirectory.appending(path: sanitizedName) let dataFiles = await getDataFiles() - for containerDataFile in dataFiles - where dataFile.lastPathComponent == containerDataFile.lastPathComponent { - if !fileManager.fileExists(atPath: file.resolvedPath) { - try save(containerDataFile, as: file) - } - return file - } - throw CryptoError.containerDataFileSavingFailed( - CryptoErrorDetail( - message: "Could not find file in container", - userInfo: ["fileName": dataFile.lastPathComponent] + guard let index = dataFiles.firstIndex( + where: { $0.lastPathComponent == dataFile.lastPathComponent } + ) else { + throw CryptoError.containerDataFileSavingFailed( + CryptoErrorDetail( + message: "Could not find file in container", + userInfo: ["fileName": dataFile.lastPathComponent] + ) ) + } + + let file = savedFilesDirectory.appending( + path: savedFileName(for: index, in: dataFiles) ) + + guard file.isWithin(directory: savedFilesDirectory) else { + throw CryptoError.containerDataFileSavingFailed( + CryptoErrorDetail( + message: "Failed to save file", + userInfo: ["fileName": dataFile.lastPathComponent] + ) + ) + } + + try save(dataFiles[index], as: file) + return file + } + + private func savedFileName(for index: Int, in dataFiles: [URL]) -> String { + let sanitizedName = dataFiles[index].lastPathComponent.sanitized() + + let hasDuplicateName = dataFiles.enumerated().contains { offset, other in + offset != index && other.lastPathComponent.sanitized() == sanitizedName + } + + return hasDuplicateName ? sanitizedName.appendingIndex(index) : sanitizedName } } @@ -379,15 +414,35 @@ extension CryptoContainer { subfolders: [Constants.Folder.ContainerFolder, Constants.Folder.Temp], fileManager: fileManager ) - return try decryptedData.map { name, data in - let fileUrl = destinationPath.appending(path: name.sanitized(), directoryHint: .notDirectory) + var usedNames: Set = [] + var urlDataFiles: [URL] = [] + + for (name, data) in decryptedData { + let fileName = name.sanitized().uniqueFileName(taken: &usedNames) + let fileUrl = destinationPath.appending(path: fileName, directoryHint: .notDirectory) + + guard fileUrl.isWithin(directory: destinationPath) else { + throw CryptoError.containerOpeningFailed( + CryptoErrorDetail( + message: "Cannot open container with invalid CDOC info", + userInfo: ["fileName": fileName] + ) + ) + } + guard fileManager.createFile(atPath: fileUrl.resolvedPath, contents: data, attributes: nil) else { + CryptoContainer.logger().error( + "Unable to create file at path: \(fileUrl.resolvedPath, privacy: .public)" + ) throw CryptoError.containerDataFileSavingFailed( - CryptoErrorDetail(message: "Unable to create decrypted file", userInfo: ["fileName": name]) + CryptoErrorDetail(message: "Unable to create decrypted file", userInfo: ["fileName": fileName]) ) } - return fileUrl + + urlDataFiles.append(fileUrl) } + + return urlDataFiles } @MainActor @@ -444,10 +499,9 @@ extension CryptoContainer { var cryptoDataFiles: [CryptoDataFile] = [] for dataFile in dataFiles { - cryptoDataFiles.append( CryptoDataFile( - filename: dataFile.lastPathComponent, + filename: dataFile.lastPathComponent.sanitized(), filePath: dataFile.resolvedPath ) ) @@ -477,7 +531,8 @@ extension CryptoContainer { var dataFiles: [URL] = [] for dataFile in cryptoDataFiles { - let fileUrl = URL(fileURLWithPath: dataFile.filePath ?? "").appending(path: dataFile.filename) + let fileUrl = URL(fileURLWithPath: dataFile.filePath ?? "") + .appending(path: dataFile.filename) dataFiles.append(fileUrl) } diff --git a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm index 28244b0d..86394139 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm +++ b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm @@ -116,6 +116,18 @@ + (void)dispatch:(void (^)(void))command completion:(void (^)(NSError *error))co }); } ++ (NSString *)stringFromStdString:(const std::string &)value { + NSString *utf8 = [[NSString alloc] initWithBytes:value.data() + length:value.size() + encoding:NSUTF8StringEncoding]; + if (utf8 != nil) { + return utf8; + } + return [[NSString alloc] initWithBytes:value.data() + length:value.size() + encoding:NSISOLatin1StringEncoding] ?: @""; +} + + (NSString *)getSerialNumber:(NSString *)serialNumber { NSSet *types = [NSSet setWithObjects:@"PAS", @"IDC", @"PNO", @"TAX", @"TIN", nil]; @@ -303,7 +315,7 @@ + (nullable DigiDocContainer *)open:(NSString *)containerPath validateOnline:(BO for (const digidoc::DataFile *dataFile : container->dataFiles()) { DigiDocDataFile *digiDocDataFile = [DigiDocDataFile new]; digiDocDataFile.fileId = [NSString stringWithUTF8String:dataFile->id().c_str()]; - digiDocDataFile.fileName = [NSString stringWithUTF8String:dataFile->fileName().c_str()]; + digiDocDataFile.fileName = [DigiDocContainerWrapper stringFromStdString:dataFile->fileName()]; digiDocDataFile.fileSize = dataFile->fileSize(); digiDocDataFile.mediaType = [NSString stringWithUTF8String:dataFile->mediaType().c_str()]; [datafiles addObject:digiDocDataFile]; @@ -427,11 +439,11 @@ + (void)addDataFilesToContainerWithPath:(NSString *)containerPath + (void)container:(NSString *)containerPath saveDataFile:(NSString *)fileName to:(NSString *)path completion:(void (^)(NSError * _Nullable error))completion { [self open:containerPath validateOnline:TRUE command:^(digidoc::Container &container) { - const char *fileNameUTF8 = fileName.UTF8String; BOOL fileFound = NO; for (digidoc::DataFile *dataFile : container.dataFiles()) { - if (dataFile->fileName() == fileNameUTF8) { + NSString *entry = [DigiDocContainerWrapper stringFromStdString:dataFile->fileName()]; + if ([fileName isEqualToString:entry]) { dataFile->saveAs(path.UTF8String); fileFound = YES; break; diff --git a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift index 63ddb816..e553c236 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift +++ b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift @@ -75,12 +75,24 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { fileManager: fileManager ) - let sanitizedFilename = { - let name = dataFile.fileName.sanitized() - return name.isEmpty ? CommonsLib.Constants.Container.DefaultName : name - }() + let allDataFiles = await getDataFiles() + let sanitizedFilename = dataFile.fileName.sanitized() + let index = allDataFiles.firstIndex { $0.fileId == dataFile.fileId } ?? 0 + let hasDuplicateName = allDataFiles.enumerated().contains { offset, other in + offset != index && other.fileName.sanitized() == sanitizedFilename + } + let uniqueFilename = hasDuplicateName ? sanitizedFilename.appendingIndex(index) : sanitizedFilename + + let tempSavedFileLocation = savedFilesDirectory.appending(path: uniqueFilename) - let tempSavedFileLocation = savedFilesDirectory.appending(path: sanitizedFilename) + guard tempSavedFileLocation.isWithin(directory: savedFilesDirectory) else { + throw DigiDocError.containerDataFileSavingFailed( + ErrorDetail( + message: "Failed to save file", + userInfo: ["fileName": uniqueFilename] + ) + ) + } do { try await DigiDocContainerWrapper.container( @@ -89,7 +101,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { to: tempSavedFileLocation.resolvedPath ) ContainerWrapper.logger().info( - "Successfully saved \(sanitizedFilename, privacy: .public) to 'Saved Files' directory" + "Successfully saved \(uniqueFilename, privacy: .public) to 'Saved Files' directory" ) return tempSavedFileLocation } catch { diff --git a/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift b/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift index aa18bcb7..4102f84a 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift @@ -4,13 +4,10 @@ import Foundation extension CharacterSet { - static var extraSymbols: CharacterSet { - var symbolsSet = CharacterSet() - symbolsSet.insert(charactersIn: "½@%:^?[]'\"”’{}#&`\\~«»/´") - let rtlChars = ["\u{200E}", "\u{200F}", "\u{202E}", "\u{202A}", "\u{202B}"] - for char in rtlChars { - symbolsSet.insert(charactersIn: char) - } - return symbolsSet + static var forbiddenInFileName: CharacterSet { + var forbidden = CharacterSet.controlCharacters + forbidden.insert(charactersIn: "/\\<>:\"|?*") + forbidden.remove(charactersIn: "\u{200D}") + return forbidden } } diff --git a/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift b/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift index 9f8ba521..a615af0a 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift @@ -13,15 +13,12 @@ extension String { } public func sanitizedOrNil() -> String? { - var forbidden = CharacterSet.illegalCharacters - .union(.symbols) - .union(.extraSymbols) - forbidden.insert(charactersIn: "\n\r\t") - var cleanName = self - .components(separatedBy: forbidden) + .components(separatedBy: CharacterSet.forbiddenInFileName) .joined() .trimmingCharacters(in: .whitespacesAndNewlines) + .precomposedStringWithCanonicalMapping + .truncatedFileName(maxBytes: Constants.File.MaxNameBytes) while let first = cleanName.first, String.leadingDotCharacters.contains(first) { cleanName.removeFirst() @@ -33,6 +30,46 @@ extension String { return cleanName.isEmpty ? nil : cleanName } + public func appendingIndex(_ index: Int) -> String { + let base = (self as NSString).deletingPathExtension + let ext = (self as NSString).pathExtension + + return ext.isEmpty ? "\(base) (\(index))" : "\(base) (\(index)).\(ext)" + } + + public func uniqueFileName(taken: inout Set) -> String { + var candidate = self + var counter = 1 + + while taken.contains(candidate) { + candidate = appendingIndex(counter) + counter += 1 + } + + taken.insert(candidate) + return candidate + } + + public func truncatedFileName(maxBytes: Int) -> String { + guard utf8.count > maxBytes else { return self } + + let suffix = (self as NSString).pathExtension + let candidate = suffix.isEmpty ? "" : ".\(suffix)" + let ext = candidate.utf8.count < maxBytes ? candidate : "" + let base = ext.isEmpty ? self : (self as NSString).deletingPathExtension + + var truncated = "" + var byteCount = ext.utf8.count + for character in base { + let size = String(character).utf8.count + if byteCount + size > maxBytes { break } + truncated.append(character) + byteCount += size + } + + return truncated + ext + } + public func getURLFromText() -> AttributedString? { var attributedString = AttributedString(self) diff --git a/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift b/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift index 74ea9373..d6afceca 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift @@ -28,6 +28,15 @@ extension URL { URL(fileURLWithPath: (path as NSString).standardizingPath) } + public func isWithin(directory: URL) -> Bool { + let filePath = FilePath(resolvedPath).lexicallyNormalized() + let directoryPath = FilePath(directory.resolvedPath).lexicallyNormalized() + + guard filePath.components.count > directoryPath.components.count else { return false } + + return filePath.components.starts(with: directoryPath.components) + } + public var resolvedPath: String { let path = isFileURL ? standardizedFileURL.path(percentEncoded: false) diff --git a/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift index b0054067..83c586d8 100644 --- a/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift +++ b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift @@ -87,7 +87,12 @@ actor FileOpeningService: FileOpeningServiceProtocol { attributes: nil) } - let destinationURL = signedContainersDataFilesDirectory.appending(path: sourceURL.lastPathComponent) + let fileName = sourceURL.lastPathComponent.truncatedFileName(maxBytes: Constants.File.MaxNameBytes) + let destinationURL = signedContainersDataFilesDirectory.appending(path: fileName) + + guard destinationURL.isWithin(directory: signedContainersDataFilesDirectory) else { + throw FileOpeningError.noDataFiles + } if fileManager.fileExists(atPath: destinationURL.resolvedPath) { try fileManager.removeItem(at: destinationURL) diff --git a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift index 2152cfd2..977fa929 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift @@ -649,8 +649,7 @@ struct EncryptView: View { private func handleFileRename(to newContainerName: String) async { showRenameModal = false - let sanitizedContainerName = newContainerName.sanitized() - guard !sanitizedContainerName.isEmpty else { return } + guard let sanitizedContainerName = newContainerName.sanitizedOrNil() else { return } let containerNameWithExtension = containerExtension.isEmpty ? sanitizedContainerName diff --git a/RIADigiDoc/Util/Language/LanguageSettings.swift b/RIADigiDoc/Util/Language/LanguageSettings.swift index 15c95b4b..60840474 100644 --- a/RIADigiDoc/Util/Language/LanguageSettings.swift +++ b/RIADigiDoc/Util/Language/LanguageSettings.swift @@ -43,6 +43,8 @@ public final class LanguageSettings: LanguageSettingsProtocol { let bundle = localizedBundle ?? Bundle.main.path(forResource: selectedLanguage, ofType: "lproj").flatMap(Bundle.init) ?? Bundle.main let format = bundle.localizedString(forKey: key, value: nil, table: nil) + guard format != key else { return key } + return args.isEmpty ? format : String.localizedStringWithFormat(format, args) } diff --git a/RIADigiDoc/ViewModel/EncryptViewModel.swift b/RIADigiDoc/ViewModel/EncryptViewModel.swift index 4e9a6780..570e454b 100644 --- a/RIADigiDoc/ViewModel/EncryptViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptViewModel.swift @@ -115,10 +115,7 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { fileManager: fileManager ) - let filename = containerLocation.lastPathComponent.sanitized().isEmpty - ? CommonsLib.Constants.Container.DefaultName - : containerLocation.lastPathComponent.sanitized() - + let filename = containerLocation.lastPathComponent.sanitized() let tempSavedFileLocation = savedFilesDirectory.appending(path: filename) if fileManager.fileExists(atPath: tempSavedFileLocation.resolvedPath) { @@ -195,14 +192,10 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { if duplicateFileCount > 1 { errorMessage = ToastMessage(key: "Multiple documents already exist", args: [String(duplicateFileCount)]) - } else if duplicateFileCount == 1 { - if let fileName = errorDetail.userInfo["fileName"] { - errorMessage = ToastMessage(key: "Document already exists", args: [fileName]) - } else { - errorMessage = ToastMessage(key: errorDetail.message, args: [String(failedFileCount)]) - } + } else if duplicateFileCount == 1, let fileName = errorDetail.userInfo["fileName"] { + errorMessage = ToastMessage(key: "Document already exists", args: [fileName]) } else { - errorMessage = ToastMessage(key: errorDetail.message, args: [String(failedFileCount)]) + errorMessage = ToastMessage(key: "Could not add files", args: [String(failedFileCount)]) } default: diff --git a/RIADigiDoc/ViewModel/SigningViewModel.swift b/RIADigiDoc/ViewModel/SigningViewModel.swift index 86c82eea..382ac9c5 100644 --- a/RIADigiDoc/ViewModel/SigningViewModel.swift +++ b/RIADigiDoc/ViewModel/SigningViewModel.swift @@ -189,10 +189,7 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { fileManager: fileManager ) - let filename = containerLocation.lastPathComponent.sanitized().isEmpty - ? CommonsLib.Constants.Container.DefaultName - : containerLocation.lastPathComponent.sanitized() - + let filename = containerLocation.lastPathComponent.sanitized() let tempSavedFileLocation = savedFilesDirectory.appending(path: filename) if fileManager.fileExists(atPath: tempSavedFileLocation.resolvedPath) { From cc9ebb265727a8931e34350effd249574e99879c Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Wed, 12 Aug 2026 17:31:23 +0300 Subject: [PATCH 2/3] Add tests --- .../Tests/CryptoSwiftTests/CryptoTests.swift | 150 ++++++++++++++++++ .../Container/ContainerWrapperTests.swift | 27 ++++ .../Extensions/CharacterSetExtensions.swift | 1 + .../CharacterSetExtensionsTests.swift | 74 +++------ .../Extensions/StringExtensionsTests.swift | 137 ++++++++++++++-- .../Extensions/URLExtensionsTests.swift | 31 ++++ .../FileOpening/FileOpeningServiceTests.swift | 33 ++++ .../Util/Language/LanguageSettingsTests.swift | 19 ++- 8 files changed, 399 insertions(+), 73 deletions(-) create mode 100644 Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift diff --git a/Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift b/Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift new file mode 100644 index 00000000..a57751dc --- /dev/null +++ b/Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift @@ -0,0 +1,150 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing +import CommonsLib +import FactoryKit +import UtilsLib +import CryptoObjCWrapper +@testable import CryptoSwift + +struct CryptoContainerDataFileTests { + + private let fileManager: FileManagerProtocol = Container.shared.fileManager() + private let containerUtil: ContainerUtilProtocol = Container.shared.containerUtil() + + private func makeContainer(dataFiles: [URL] = []) -> CryptoContainer { + CryptoContainer( + containerFile: URL(fileURLWithPath: "/tmp/container.cdoc2"), + fileManager: fileManager, + containerUtil: containerUtil, + dataFiles: dataFiles + ) + } + + private func makeSourceFile(named name: String, contents: String) throws -> URL { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "CryptoContainerTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let file = directory.appending(path: name) + try Data(contents.utf8).write(to: file) + return file + } + + @Test + func addDataFiles_keepsSpecialSymbolsInTheFileName() async throws { + let source = try makeSourceFile(named: "O&U #1 (100%) @2026 €5.txt", contents: "a") + let container = makeContainer() + + try await container.addDataFiles([source]) + + let names = await container.getDataFiles().map(\.lastPathComponent) + #expect(names == ["O&U #1 (100%) @2026 €5.txt"]) + } + + @Test + func addDataFiles_keepsUnicodeAndEmoji() async throws { + let source = try makeSourceFile(named: "ää test ää 😀.txt", contents: "a") + let container = makeContainer() + + try await container.addDataFiles([source]) + + let names = await container.getDataFiles().map(\.lastPathComponent) + #expect(names == ["ää test ää 😀.txt"]) + } + + @Test + func addDataFiles_storesFileInsideContainerDirectoryWhenNameLooksLikeTraversal() async throws { + let source = try makeSourceFile(named: "..to.txt", contents: "a") + let container = makeContainer() + + try await container.addDataFiles([source]) + + let cacheDirectory = try Directories.getCacheDirectory( + subfolders: [Constants.Folder.ContainerFolder, Constants.Folder.Temp], + fileManager: fileManager + ) + let dataFiles = await container.getDataFiles() + + #expect(dataFiles.count == 1) + #expect(dataFiles[0].isWithin(directory: cacheDirectory)) + } + + @Test + func saveDataFile_returnsItsOwnBytesWhenTwoNamesSanitizeToTheSameName() async throws { + let first = try makeSourceFile(named: "ab.txt", contents: "second") + let container = makeContainer(dataFiles: [first, second]) + + let destination = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "CryptoSaveTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + + let savedFirst = try await container.saveDataFile(dataFile: first, to: destination) + let savedSecond = try await container.saveDataFile(dataFile: second, to: destination) + + #expect(savedFirst != savedSecond) + #expect(try String(contentsOf: savedFirst, encoding: .utf8) == "first") + #expect(try String(contentsOf: savedSecond, encoding: .utf8) == "second") + } + + @Test + func saveDataFile_isIdempotentForRepeatedCalls() async throws { + let file = try makeSourceFile(named: "report.pdf", contents: "content") + let container = makeContainer(dataFiles: [file]) + + let destination = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "CryptoSaveTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + + let firstCall = try await container.saveDataFile(dataFile: file, to: destination) + let secondCall = try await container.saveDataFile(dataFile: file, to: destination) + + #expect(firstCall == secondCall) + #expect(try String(contentsOf: secondCall, encoding: .utf8) == "content") + } + + @Test(arguments: [ + ("report.pdf|123|application/pdf|D0", "report.pdf"), + ("Q1|Q2 report.pdf|123|application/pdf|D0", "Q1|Q2 report.pdf"), + ("a|b|c.txt|9|text/plain|D1", "a|b|c.txt") + ]) + func cdocInfo_readsNamesContainingAPipe(origFile: String, expected: String) throws { + let xml = """ + + + + ENCDOC-XML|1.1 + \(origFile) + + + """ + + let path = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "cdocinfo-\(UUID().uuidString).cdoc") + try xml.write(to: path, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: path) } + + let info = try CdocInfo(cdoc1Path: path.path(percentEncoded: false)) + + #expect(info.dataFiles.map(\.filename) == [expected]) + } +} diff --git a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift index 1d0dcb8b..acebea33 100644 --- a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift +++ b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift @@ -350,6 +350,33 @@ struct ContainerWrapperTests { #expect(savedFileURL.lastPathComponent == dataFile.fileName) } + @Test + func saveDataFile_disambiguatesEntriesThatSanitizeToTheSameName() async throws { + let first = MockDataFileWrapper.mockDataFileWrapper( + fileId: "D0", fileName: "ab.txt", fileSize: 1, + mediaType: CommonsLib.Constants.Extension.Default) + + let wrapper = ContainerWrapper( + containerURL: URL(fileURLWithPath: "/tmp/does-not-exist.asice"), + dataFiles: [first, second], + fileManager: mockFileManager + ) + + do { + _ = try await wrapper.saveDataFile(dataFile: second, to: nil) + Issue.record("Expected an error") + } catch let error as DigiDocError { + guard case .containerDataFileSavingFailed(let detail) = error else { + Issue.record("Unexpected DigiDocError: \(error)") + return + } + #expect(detail.userInfo["fileName"] as? String == "ab (1).txt") + } + } + @Test func saveDataFile_throwErrorWhenInvalidDataFile() async throws { let dataFile = MockDataFileWrapper.mockDataFileWrapper( diff --git a/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift b/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift index 4102f84a..d3be5a16 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift @@ -6,6 +6,7 @@ import Foundation extension CharacterSet { static var forbiddenInFileName: CharacterSet { var forbidden = CharacterSet.controlCharacters + .union(.illegalCharacters) forbidden.insert(charactersIn: "/\\<>:\"|?*") forbidden.remove(charactersIn: "\u{200D}") return forbidden diff --git a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift index ba00f469..6bdf9369 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift @@ -7,72 +7,34 @@ import Testing class CharacterSetExtensionsTests { - @Test - func extraSymbols_successCheckingCharacterSet() { - let expectedCharacters = "½@%:^?[]'\"”’{}#&`\\~«»/´" - let rtlChars = ["\u{200E}", "\u{200F}", "\u{202E}", "\u{202A}", "\u{202B}"] - - let extraSymbolsSet = CharacterSet.extraSymbols - - for char in expectedCharacters { - guard let scalar = char.unicodeScalars.first else { - Issue.record("Unable to get Unicode scalar for character \(char)") - return - } - #expect(extraSymbolsSet.contains(scalar)) - } + @Test(arguments: ["/", "\\", "<", ">", ":", "\"", "|", "?", "*"]) + func forbiddenInFileName_containsPathSeparatorsAndReservedCharacters(character: String) throws { + let scalar = try #require(character.unicodeScalars.first) - for rtlChar in rtlChars { - guard let scalar = rtlChar.unicodeScalars.first else { - Issue.record("Unable to get Unicode scalar for RTL character \(rtlChar)") - return - } - #expect(extraSymbolsSet.contains(scalar)) - } + #expect(CharacterSet.forbiddenInFileName.contains(scalar)) } - @Test - func extraSymbols_checkCharacterSetDoesNotContainOtherCharacters() { - let nonExpectedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - let extraSymbolsSet = CharacterSet.extraSymbols + @Test(arguments: [ + "\u{0000}", "\u{0001}", "\u{0009}", "\u{000A}", "\u{000D}", "\u{007F}", "\u{0085}", + "\u{200B}", "\u{200E}", "\u{200F}", "\u{202A}", "\u{202B}", "\u{202C}", "\u{202E}", + "\u{2060}", "\u{2066}", "\u{FEFF}", "\u{061C}" + ]) + func forbiddenInFileName_containsControlAndFormatCharacters(character: String) throws { + let scalar = try #require(character.unicodeScalars.first) - for char in nonExpectedCharacters { - guard let scalar = char.unicodeScalars.first else { - Issue.record("Unable to get Unicode scalar for character \(char)") - return - } - #expect(!extraSymbolsSet.contains(scalar)) - } + #expect(CharacterSet.forbiddenInFileName.contains(scalar)) } @Test - func extraSymbols_checkCharacterSetIncludesSpecialEdgeCases() { - let specialEdgeCases = ["½", "@", "”", "’", "\\", "~", "«", "»", "/"] - let extraSymbolsSet = CharacterSet.extraSymbols + func forbiddenInFileName_allowsCharactersThatAreLegalInAFileName() { + let allowed = "ABCabc019 #&+@%$=~^[]{}'.,;()!-_½«»´€äöüõ😀" - for char in specialEdgeCases { - guard let scalar = char.unicodeScalars.first else { - Issue.record("Failed to get Unicode scalar for character \(char)") + for character in allowed { + guard let scalar = character.unicodeScalars.first else { + Issue.record("Unable to get Unicode scalar for character \(character)") return } - #expect(extraSymbolsSet.contains(scalar)) + #expect(!CharacterSet.forbiddenInFileName.contains(scalar), "\(character) should be allowed") } } - - @Test - func extraSymbols_checkCharacterSetContainsRTLCharacters() { - let rtlChars = ["\u{200E}", "\u{200F}", "\u{202E}", "\u{202A}", "\u{202B}"] - let extraSymbolsSet = CharacterSet.extraSymbols - - #expect(rtlChars.contains { rtlChar in - guard let scalar = rtlChar.unicodeScalars.first else { return false } - return extraSymbolsSet.contains(scalar) - }) - } - - @Test - func extraSymbols_checkCharacterSetIsNotEmpty() { - let extraSymbolsSet = CharacterSet.extraSymbols - #expect(!extraSymbolsSet.isEmpty) - } } diff --git a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift index 9b9f6fe8..a1b241bb 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift @@ -23,10 +23,16 @@ class StringSanitizationTests { #expect("a\u{2026}b".sanitizedOrNil() == "a\u{2026}b") } + @Test + func sanitizedOrNil_stripsUnassignedCodePointsButKeepsAssignedSymbols() { + #expect("\u{FFFF}\u{FFFE}\u{1F600}".sanitizedOrNil() == "\u{1F600}") + #expect("@%^?[]{}#&".sanitizedOrNil() == "@%^[]{}#&") + } + @Test func sanitizedOrNil_returnsNilWhenNothingUsableRemains() { - #expect("\u{FFFF}\u{FFFE}\u{1F600}".sanitizedOrNil() == nil) - #expect("@%^?[]{}#&".sanitizedOrNil() == nil) + #expect("\u{0000}\u{0001}\u{202E}".sanitizedOrNil() == nil) + #expect("/\\<>:\"|?*".sanitizedOrNil() == nil) } @Test @@ -39,35 +45,87 @@ class StringSanitizationTests { @Test func sanitizedOrNil_returnsCleanedNameWhenSomethingRemains() { #expect("TestData".sanitizedOrNil() == "TestData") - #expect("Test@Data".sanitizedOrNil() == "TestData") + #expect("Test@Data".sanitizedOrNil() == "Test@Data") } @Test func sanitized_stillSubstitutesFallbackWhereSanitizedOrNilIsNil() { #expect("".sanitized() == Constants.Container.DefaultName) - #expect("@%^?[]{}#&".sanitized() == Constants.Container.DefaultName) + #expect("\u{0000}\u{202E}".sanitized() == Constants.Container.DefaultName) + } + + @Test(arguments: [ + ("O&U report #1 (100%) @2026.pdf", "O&U report #1 (100%) @2026.pdf"), + ("t\u{20AC}4t.pdf", "t\u{20AC}4t.pdf"), + ("emoji\u{1F600}.pdf", "emoji\u{1F600}.pdf"), + ("team\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}.pdf", "team\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}.pdf"), + ("a+b=c~d^e.txt", "a+b=c~d^e.txt"), + ("test[1]{2}.txt", "test[1]{2}.txt"), + ("it's a \u{00BD} share.txt", "it's a \u{00BD} share.txt"), + ("Test\u{2211}\u{221E}\u{00A9}\u{00AE}\u{2190}\u{2192}Data", "Test\u{2211}\u{221E}\u{00A9}\u{00AE}\u{2190}\u{2192}Data"), + (".test", "test"), + ("v1..2 test.zip", "v1..2 test.zip") + ]) + func sanitized_keepsCharactersThatAreLegalInAFileName(input: String, expected: String) { + #expect(expected == input.sanitized()) + } + + @Test(arguments: [ + ("../../etc/test", "etctest"), + ("..\\..\\test", "test"), + ("a/b.txt", "ab.txt") + ]) + func sanitized_removesPathSeparatorsSoNameStaysOneComponent(input: String, expected: String) { + let sanitized = input.sanitized() + + #expect(expected == sanitized) + #expect(!sanitized.contains("/")) + #expect(!sanitized.contains("\\")) + } + + @Test(arguments: [".", "..", "...."]) + func sanitized_replacesDirectoryReferencesWithAPlaceholder(input: String) { + #expect("_" == input.sanitized()) } @Test - func sanitized_removesIllegalCharacters() { - let input = "\u{FFFF}\u{FFFE}\u{1F600}" - let expected = Constants.Container.DefaultName + func sanitized_removesControlAndFormatCharacters() { + let input = "a\u{0000}b\u{0001}c\u{007F}d\u{202E}e\u{200B}f\u{FEFF}g\u{2066}h" - #expect(expected == input.sanitized()) + #expect("abcdefgh" == input.sanitized()) + } + + @Test(arguments: ["<", ">", ":", "\"", "|", "?", "*"]) + func sanitized_removesCharactersReservedForInterchange(character: String) { + #expect("ab.txt" == "a\(character)b.txt".sanitized()) } @Test - func sanitized_removesSymbols() { - let symbols = "Test∑∞€©®←→Data" - #expect(symbols.sanitized() == "TestData") + func sanitized_returnsDefaultNameWhenNothingIsLeft() { + #expect(Constants.Container.DefaultName == "".sanitized()) + #expect(Constants.Container.DefaultName == "///\\\\\\".sanitized()) } @Test - func sanitized_extraSymbolsAndRTLCharacters() { - let input = "½@%:^?[]'\"”’{}#&`\\~«»/´\u{200E}\u{200F}\u{202E}\u{202A}\u{202B}" - let expected = Constants.Container.DefaultName + func uniqueFileName_disambiguatesWithinABatchOnly() { + var taken: Set = [] - #expect(expected == input.sanitized()) + #expect("a.txt".uniqueFileName(taken: &taken) == "a.txt") + #expect("a.txt".uniqueFileName(taken: &taken) == "a (1).txt") + #expect("a.txt".uniqueFileName(taken: &taken) == "a (2).txt") + + var fresh: Set = [] + #expect("a.txt".uniqueFileName(taken: &fresh) == "a.txt") + } + + @Test + func uniqueFileName_keepsTheExtensionAndHandlesNamesWithout() { + var taken: Set = [] + + #expect("report".uniqueFileName(taken: &taken) == "report") + #expect("report".uniqueFileName(taken: &taken) == "report (1)") + #expect("a.tar.gz".uniqueFileName(taken: &taken) == "a.tar.gz") + #expect("a.tar.gz".uniqueFileName(taken: &taken) == "a.tar (1).gz") } @Test @@ -78,6 +136,55 @@ class StringSanitizationTests { #expect(expected == input.sanitized()) } + @Test + func sanitized_normalizesToPrecomposedFormSoEquivalentNamesMatch() { + let decomposed = "teste\u{0301}.txt" + let precomposed = "test\u{00E9}.txt" + + #expect(precomposed == decomposed.sanitized()) + #expect(decomposed.sanitized() == precomposed.sanitized()) + } + + @Test + func sanitized_truncatesLongNameKeepingExtension() { + let input = String(repeating: "a", count: 500) + ".pdf" + + let sanitized = input.sanitized() + + #expect(sanitized.utf8.count <= Constants.File.MaxNameBytes) + #expect(sanitized.hasSuffix(".pdf")) + } + + @Test + func sanitized_truncatesOnCharacterBoundariesSoNoReplacementCharacterAppears() { + let input = String(repeating: "ä", count: 300) + ".pdf" + + let sanitized = input.sanitized() + + #expect(sanitized.utf8.count <= Constants.File.MaxNameBytes) + #expect(!sanitized.contains("\u{FFFD}")) + } + + @Test + func sanitized_keepsALongEndingWhileItStillFitsUnderTheCap() { + let input = String(repeating: "a", count: 300) + "." + String(repeating: "b", count: 100) + + let sanitized = input.sanitized() + + #expect(sanitized.utf8.count <= Constants.File.MaxNameBytes) + #expect(sanitized.hasSuffix("." + String(repeating: "b", count: 100))) + } + + @Test + func sanitized_dropsAnEndingTooLongToLeaveRoomForAName() { + let input = String(repeating: "a", count: 300) + "." + String(repeating: "b", count: 500) + + let sanitized = input.sanitized() + + #expect(sanitized.utf8.count <= Constants.File.MaxNameBytes) + #expect(!sanitized.contains("b")) + } + @Test func getURLFromText_successWithSingleURL() throws { let input = "Additional information: https://example.com" diff --git a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/URLExtensionsTests.swift b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/URLExtensionsTests.swift index 8ea31142..38e2c457 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/URLExtensionsTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/URLExtensionsTests.swift @@ -344,6 +344,37 @@ struct URLExtensionsTests { #expect(result.isEmpty) } + @Test + func isWithin_successWhenFileIsInsideDirectory() { + let directory = URL(fileURLWithPath: "/tmp/containers") + + #expect(directory.appending(path: "a#b.txt").isWithin(directory: directory)) + #expect(directory.appending(path: "nested/a.txt").isWithin(directory: directory)) + } + + @Test + func isWithin_failsWhenNameTraversesOutOfDirectory() { + let directory = URL(fileURLWithPath: "/tmp/containers") + + #expect(!directory.appending(path: "../escaped.txt").isWithin(directory: directory)) + #expect(!directory.appending(path: "../../etc/passwd").isWithin(directory: directory)) + } + + @Test + func isWithin_failsForSiblingDirectorySharingANamePrefix() { + let directory = URL(fileURLWithPath: "/tmp/containers") + let sibling = URL(fileURLWithPath: "/tmp/containers-evil/a.txt") + + #expect(!sibling.isWithin(directory: directory)) + } + + @Test + func isWithin_failsForTheDirectoryItself() { + let directory = URL(fileURLWithPath: "/tmp/containers") + + #expect(!directory.isWithin(directory: directory)) + } + @Test func standardizedPathURL_success() { let url = URL(fileURLWithPath: "/tmp/folder/file.txt") diff --git a/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift b/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift index 79ec9100..5528f654 100644 --- a/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift +++ b/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift @@ -90,6 +90,39 @@ struct FileOpeningServiceTests { #expect(validFiles.count == 2) } + @Test + func getValidFiles_keepsNamesThatDifferOnlyBySpecialCharactersApart() async throws { + let tempURL = URL(fileURLWithPath: mockFileManager.temporaryDirectory.appending(path: "tmp").resolvedPath) + let first = tempURL.appending(path: "Report: Q1.pdf") + let second = tempURL.appending(path: "Report Q1.pdf") + + mockFileUtil.getValidPathHandler = { url in url } + mockFileManager.urlsHandler = { _, _ in [tempURL] } + mockFileInspector.fileSizeHandler = { _ in 100 } + + let validFiles = try await service.getValidFiles(.success([first, second])) + + #expect(validFiles.count == 2) + #expect(validFiles[0] != validFiles[1]) + } + + @Test + func getValidFiles_truncatesNameLongerThanTheFilesystemAllows() async throws { + let tempURL = URL(fileURLWithPath: mockFileManager.temporaryDirectory.appending(path: "tmp").resolvedPath) + let longURL = tempURL.appending(path: String(repeating: "a", count: 1000) + ".txt") + + mockFileUtil.getValidPathHandler = { _ in longURL } + mockFileManager.urlsHandler = { _, _ in [tempURL] } + mockFileInspector.fileSizeHandler = { _ in 100 } + + let validFiles = try await service.getValidFiles(.success([longURL])) + + let cachedName = try #require(validFiles.first?.lastPathComponent) + #expect(cachedName.utf8.count <= CommonsLib.Constants.File.MaxNameBytes) + #expect(cachedName.hasSuffix(".txt")) + #expect(cachedName.utf8.count + 11 < 255) + } + @Test func getValidFiles_successWithDuplicateFiles() async throws { let tempURL = URL(fileURLWithPath: mockFileManager.temporaryDirectory.appending(path: "tmp").resolvedPath) diff --git a/RIADigiDocTests/Util/Language/LanguageSettingsTests.swift b/RIADigiDocTests/Util/Language/LanguageSettingsTests.swift index 3d8800e8..5e81c550 100644 --- a/RIADigiDocTests/Util/Language/LanguageSettingsTests.swift +++ b/RIADigiDocTests/Util/Language/LanguageSettingsTests.swift @@ -15,8 +15,6 @@ struct LanguageSettingsTests { languageSettings = await LanguageSettings(dataStore: mockDataStore) } - // MARK: - Tests - @Test func getSelectedLanguage_success() async throws { let allowedLanguageCodes: [String] = ["en", "et"] @@ -24,6 +22,23 @@ struct LanguageSettingsTests { #expect(allowedLanguageCodes.contains(selectedLanguage)) } + @Test + func localized_doesNotTreatAnUnknownKeyAsAFormatString() async throws { + let key = "Document with same file name report %@ %d.pdf already exists" + + let result = await languageSettings.localized(key, ["ignored"]) + + #expect(result == key) + } + + @Test + func localized_stillFormatsAKnownKey() async throws { + let result = await languageSettings.localized("Could not add files", ["3"]) + + #expect(result != "Could not add files") + #expect(result.contains("3")) + } + @Test func setSelectedLanguage_success() async throws { let testLanguageCode: String = "et" From d72a37d467b213ebc28f4b8d19f2e6ed60a3598d Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Fri, 9 Oct 2026 18:32:04 +0300 Subject: [PATCH 3/3] Fix special symbols remaining in first added file and container name --- .../Sources/CryptoSwift/CryptoContainer.swift | 13 ++++++-- .../Tests/CryptoSwiftTests/CryptoTests.swift | 28 +++++++++++++++++ .../Extensions/StringExtensionsTests.swift | 8 ++--- .../ViewModel/SigningViewModelTests.swift | 31 +++++++++++++------ 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift index cbe981f7..8a16e3ef 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift @@ -325,21 +325,28 @@ extension CryptoContainer { fileManager: fileManager ) + let sanitizedContainerURL = cryptoContainersDirectory + .appending(path: containerURL.lastPathComponent.sanitized()) + CryptoContainer.logger().info("Getting an unique crypto container name") // Get unique container name file (1) if name already exists let uniqueContainerURL = containerUtil.getContainerFile( - for: containerURL, + for: sanitizedContainerURL, in: cryptoContainersDirectory ) CryptoContainer.logger().info("Creating a new crypto container") - return try await create( + let container = try await create( containerFile: uniqueContainerURL, - dataFiles: dataFiles, + dataFiles: [], recipients: [], isDecrypted: false, isEncrypted: false ) + + try await container.addDataFiles(dataFiles) + + return container } CryptoContainer.logger().info("Opening existing crypto container") diff --git a/Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift b/Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift index a57751dc..e6eeb920 100644 --- a/Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift +++ b/Modules/CryptoLib/Tests/CryptoSwiftTests/CryptoTests.swift @@ -88,6 +88,34 @@ struct CryptoContainerDataFileTests { #expect(dataFiles[0].isWithin(directory: cacheDirectory)) } + @Test + func openOrCreate_sanitizesContainerNameAndFirstDataFile() async throws { + let suffix = UUID().uuidString + let source = try makeSourceFile(named: "a\\b-\(suffix).txt", contents: "a") + + let container = try await CryptoContainer.openOrCreate(dataFiles: [source]) + + let containerName = await container.getContainerName() + let names = await container.getDataFiles().map(\.lastPathComponent) + + #expect(!containerName.contains("\\")) + #expect(names == ["ab-\(suffix).txt"]) + } + + @Test + func openOrCreate_firstAndSecondFileGetTheSameTreatment() async throws { + let suffix = UUID().uuidString + let first = try makeSourceFile(named: "e\\f-\(suffix).txt", contents: "first") + let second = try makeSourceFile(named: "g\\h-\(suffix).txt", contents: "second") + + let container = try await CryptoContainer.openOrCreate(dataFiles: [first]) + try await container.addDataFiles([second]) + + let names = await container.getDataFiles().map(\.lastPathComponent) + + #expect(names == ["ef-\(suffix).txt", "gh-\(suffix).txt"]) + } + @Test func saveDataFile_returnsItsOwnBytesWhenTwoNamesSanitizeToTheSameName() async throws { let first = try makeSourceFile(named: "a URL { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "SigningViewModelTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let file = directory.appending(path: name) + try Data("content".utf8).write(to: file) + return file + } + init() async throws { mockFileManager = FileManagerProtocolMock() mockFileInspector = FileInspectorProtocolMock() @@ -1778,9 +1788,10 @@ struct SigningViewModelTests: Loggable { func convertToCryptoContainer_successWithExistingContainer() async throws { let mockSignedContainer = SignedContainerProtocolMock() - mockSignedContainer.getRawContainerFileHandler = { - URL(filePath: "/mock/path/to/container.asice") - } + let containerFile = try makeTemporaryFile(named: "container.asice") + + mockMimeTypeDecoder.parseHandler = { _ in .asice } + mockSignedContainer.getRawContainerFileHandler = { containerFile } let dataFileWrapper = MockDataFileWrapper.mockDataFileWrapper( fileId: "1", @@ -1810,17 +1821,17 @@ struct SigningViewModelTests: Loggable { func convertToCryptoContainer_successWithUnsignedContainer() async throws { let mockSignedContainer = SignedContainerProtocolMock() - mockSignedContainer.getRawContainerFileHandler = { - URL(filePath: "/mock/path/to/container.asice") - } + let containerFile = try makeTemporaryFile(named: "container.asice") + let savedDataFile = try makeTemporaryFile(named: "text.txt") + + mockMimeTypeDecoder.parseHandler = { _ in .asice } + mockSignedContainer.getRawContainerFileHandler = { containerFile } mockContainerUtil.getContainerDataFilesDirHandler = { _ in - URL(filePath: "/mock/path/to/container.asice/datafiles") + savedDataFile.deletingLastPathComponent() } - mockSignedContainer.saveDataFileHandler = { _, _ in - URL(filePath: "/mock/path/to/container.asice/datafiles/text.txt") - } + mockSignedContainer.saveDataFileHandler = { _, _ in savedDataFile } let dataFileWrapper = MockDataFileWrapper.mockDataFileWrapper( fileId: "1",