diff --git a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift index e6ea5d41..a3fcd096 100644 --- a/Modules/CommonsLib/Sources/CommonsLib/Constants.swift +++ b/Modules/CommonsLib/Sources/CommonsLib/Constants.swift @@ -101,6 +101,9 @@ public struct Constants { public static let LibDigidocLog = "libdigidocpp.log" 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 4ea74b6d..b1dd100f 100644 --- a/Modules/CryptoLib/Package.swift +++ b/Modules/CryptoLib/Package.swift @@ -23,7 +23,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( @@ -98,12 +99,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 11678f5a..6626197b 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm @@ -272,7 +272,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 encryption" error:error]; diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h index 5c8af62d..3093d306 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h @@ -40,7 +40,18 @@ static const NSInteger CryptoLibWrongKeyErrorCode = -109; // libcdoc::WRONG_KEY @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 bf583d54..20af1b47 100644 --- a/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/CdocInfo.swift +++ b/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/CdocInfo.swift @@ -103,8 +103,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 8de16e6c..c118bfe7 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift @@ -86,12 +86,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) || @@ -106,8 +106,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) } @@ -115,6 +123,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 } } @@ -226,31 +237,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 } } @@ -395,15 +430,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 @@ -460,10 +515,9 @@ extension CryptoContainer { var cryptoDataFiles: [CryptoDataFile] = [] for dataFile in dataFiles { - cryptoDataFiles.append( CryptoDataFile( - filename: dataFile.lastPathComponent, + filename: dataFile.lastPathComponent.sanitized(), filePath: dataFile.resolvedPath ) ) @@ -493,7 +547,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/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/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm index 26c3e819..63bf4168 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm +++ b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm @@ -117,6 +117,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]; @@ -269,7 +281,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]; @@ -371,11 +383,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 86019342..2b0d9f6d 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/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift index 92e1327a..26b0fac6 100644 --- a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift +++ b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift @@ -304,6 +304,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/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift index 25e391de..8a719829 100644 --- a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift +++ b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift @@ -167,7 +167,7 @@ final class SignedContainerTests { let exampleContainer = try #require( TestFileUtil.pathForResourceFile(fileName: "example", ext: "asice")) let newFileName = "renamed.asice" let tempDirectoryURL = try TestFileUtil.getTemporaryDirectory( - subfolder: "SignedContainerTests" + subfolder: "SignedContainerTests-\(UUID().uuidString)" ) let uniqueFileURL = tempDirectoryURL.appending(path: "renamed_unique.asice") @@ -250,7 +250,7 @@ final class SignedContainerTests { let exampleContainer = try #require( TestFileUtil.pathForResourceFile(fileName: "example", ext: "asice")) let emptyNewName = "" let tempDirectoryURL = try TestFileUtil.getTemporaryDirectory( - subfolder: "SignedContainerTests" + subfolder: "SignedContainerTests-\(UUID().uuidString)" ) let defaultFileName = CommonsLib.Constants.Container.DefaultName let uniqueFileURL = tempDirectoryURL.appending(path: "\(defaultFileName)_unique.asice") diff --git a/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift b/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift index 4b3c7415..e4188db2 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/CharacterSetExtensions.swift @@ -20,13 +20,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 a65bcaa0..c5f554c3 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift @@ -23,24 +23,58 @@ import CommonsLib extension String { public func sanitized() -> String { - var forbidden = CharacterSet.illegalCharacters - .union(.symbols) - .union(.extraSymbols) - forbidden.insert(charactersIn: "\n\r\t") - - var cleanName = self - .components(separatedBy: forbidden) + let cleanName = self + .components(separatedBy: CharacterSet.forbiddenInFileName) .joined() .trimmingCharacters(in: .whitespacesAndNewlines) + .precomposedStringWithCanonicalMapping + .truncatedFileName(maxBytes: Constants.File.MaxNameBytes) - while cleanName.hasPrefix(".") { - cleanName.removeFirst() - if cleanName.isEmpty { - cleanName = "_" - } + if cleanName.isEmpty || cleanName.allSatisfy({ $0 == "." }) { + return Constants.Container.DefaultName + } + + return 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 cleanName.isEmpty ? Constants.Container.DefaultName : cleanName + return truncated + ext } public func getURLFromText() -> AttributedString? { diff --git a/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift b/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift index 01e97c48..7e510e6b 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/URLExtensions.swift @@ -44,6 +44,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/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift index 38f80d8a..3f0286e5 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/CharacterSetExtensionsTests.swift @@ -23,72 +23,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 43e2edf7..6a19eda0 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift @@ -24,26 +24,78 @@ import CommonsLib class StringSanitizationTests { + @Test(arguments: [ + ("O&U report #1 (100%) @2026.pdf", "O&U report #1 (100%) @2026.pdf"), + ("t€4t.pdf", "t€4t.pdf"), + ("emoji😀.pdf", "emoji😀.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 ½ share.txt", "it's a ½ share.txt"), + ("Test∑∞©®←→Data", "Test∑∞©®←→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_replacesDirectoryReferencesWithDefaultName(input: String) { + #expect(Constants.Container.DefaultName == 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 @@ -54,6 +106,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 fe4c84f8..d6e9a3d7 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/URLExtensionsTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/URLExtensionsTests.swift @@ -360,6 +360,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/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift index dfa9f83d..69393006 100644 --- a/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift +++ b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift @@ -103,7 +103,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 f6d9bb75..b99d4a5e 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift @@ -659,8 +659,8 @@ struct EncryptView: View { private func handleFileRename(to newContainerName: String) async { showRenameModal = false + guard !newContainerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } let sanitizedContainerName = newContainerName.sanitized() - guard !sanitizedContainerName.isEmpty else { return } let containerNameWithExtension = containerExtension.isEmpty ? sanitizedContainerName diff --git a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift index db75f08d..c66b2743 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift @@ -615,8 +615,8 @@ struct SigningView: View { private func handleFileRename(to newContainerName: String) async { showRenameModal = false + guard !newContainerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } let sanitizedContainerName = newContainerName.sanitized() - guard !sanitizedContainerName.isEmpty else { return } let containerNameWithExtension = containerExtension.isEmpty ? sanitizedContainerName diff --git a/RIADigiDoc/Util/Language/LanguageSettings.swift b/RIADigiDoc/Util/Language/LanguageSettings.swift index c7d2294e..02a09762 100644 --- a/RIADigiDoc/Util/Language/LanguageSettings.swift +++ b/RIADigiDoc/Util/Language/LanguageSettings.swift @@ -59,6 +59,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 18ab7bbe..aaa9b221 100644 --- a/RIADigiDoc/ViewModel/EncryptViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptViewModel.swift @@ -125,10 +125,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) { @@ -205,14 +202,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 c8d49594..55296062 100644 --- a/RIADigiDoc/ViewModel/SigningViewModel.swift +++ b/RIADigiDoc/ViewModel/SigningViewModel.swift @@ -156,10 +156,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) { diff --git a/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift b/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift index fe71b533..219d8b60 100644 --- a/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift +++ b/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift @@ -106,6 +106,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 814072e6..2a9bb136 100644 --- a/RIADigiDocTests/Util/Language/LanguageSettingsTests.swift +++ b/RIADigiDocTests/Util/Language/LanguageSettingsTests.swift @@ -30,8 +30,6 @@ struct LanguageSettingsTests { languageSettings = await LanguageSettings(dataStore: mockDataStore) } - // MARK: - Tests - @Test func getSelectedLanguage_success() async throws { let allowedLanguageCodes: [String] = ["en", "et"] @@ -39,6 +37,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"