From 2d854df2888162b160c96feb1a9fc6616474a3fa Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Mon, 5 Oct 2026 17:41:27 +0300 Subject: [PATCH] Fix opening containers with many signatures --- .../include/Conf/DigiDocConfWrapper.mm | 40 ++-- .../Container/DigiDocContainerWrapper.h | 30 +++ .../Container/DigiDocContainerWrapper.mm | 192 ++++++++++++++++- .../include/Signature/DigiDocSigningWrapper.h | 4 +- .../Signature/DigiDocSigningWrapper.mm | 168 ++++++++++----- .../Domain/Container/ContainerWrapper.swift | 204 ++++++++++++++++-- .../Container/ContainerWrapperProtocol.swift | 3 + .../LibdigidocSwift/SignedContainer.swift | 11 +- .../SignedContainerProtocol.swift | 3 + .../DigiDocSigningWrapperTests.swift | 141 ++++++++++++ .../Container/ContainerWrapperTests.swift | 60 ++++++ .../SignedContainerTests.swift | 4 +- .../UtilsLib/Container/Name/NameUtil.swift | 8 +- .../Extensions/StringExtensions.swift | 10 +- .../Extensions/StringExtensionsTests.swift | 40 ++++ .../Container/ContainerNameView.swift | 50 +++-- .../Container/DataFilesListView.swift | 4 +- .../Container/Signing/SignatureView.swift | 5 +- .../Signing/SignaturesListView.swift | 13 +- .../Container/Signing/SigningView.swift | 44 ++-- .../UI/Component/Shared/LoadingView.swift | 16 +- .../Component/Shared/Modal/InputModal.swift | 6 + .../Shared/Modal/ModalContainer.swift | 4 +- RIADigiDoc/UI/Theme/Dimensions.swift | 1 + RIADigiDoc/ViewModel/SigningViewModel.swift | 84 ++++++-- .../ViewModel/SigningViewModelTests.swift | 54 +++++ 26 files changed, 1024 insertions(+), 175 deletions(-) create mode 100644 Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocObjC/DigiDocSigningWrapperTests.swift diff --git a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Conf/DigiDocConfWrapper.mm b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Conf/DigiDocConfWrapper.mm index 1de73faa..e6bdd6c0 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Conf/DigiDocConfWrapper.mm +++ b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Conf/DigiDocConfWrapper.mm @@ -8,19 +8,11 @@ #import "digidocpp/Exception.h" #import "DigiDocConfWrapper.h" #import "../Model/DigiDocConfig.h" +#import "../Container/DigiDocContainerWrapper.h" #import "Exception/Util/ExceptionUtil.h" static constexpr int kTSLTimeOutSeconds = 5; -static dispatch_queue_t libdigidocppQueue(void) { - static dispatch_queue_t queue; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - queue = dispatch_queue_create("ee.ria.digidoc.libdigidocpp", DISPATCH_QUEUE_SERIAL); - }); - return queue; -} - struct DigiDocConfCurrent final : public digidoc::ConfCurrent { private: DigiDocConfig *currentConf; @@ -215,23 +207,27 @@ int logLevel() const final { private: template static void withCurrentConf(Func&& fn) { - digidoc::Conf *conf = DigiDocConfCurrent::instance(); - if (!conf) return; - DigiDocConfCurrent *currentConf = dynamic_cast(conf); - if (currentConf) { - fn(currentConf); + @synchronized ([DigiDocContainerWrapper class]) { + digidoc::Conf *conf = DigiDocConfCurrent::instance(); + if (!conf) return; + DigiDocConfCurrent *currentConf = dynamic_cast(conf); + if (currentConf) { + fn(currentConf); + } } } public: static void initConf(DigiDocConfig *conf, NSString *userAgent, void (^completion)(NSError * _Nullable error)) { - dispatch_async(libdigidocppQueue(), ^{ + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ NSError *error = nil; try { std::string userAgentInfo = userAgent.UTF8String; - DigiDocConfCurrent *currentConf = new DigiDocConfCurrent(conf); - digidoc::Conf::init(currentConf); - digidoc::initialize(userAgentInfo, userAgentInfo); + @synchronized ([DigiDocContainerWrapper class]) { + DigiDocConfCurrent *currentConf = new DigiDocConfCurrent(conf); + digidoc::Conf::init(currentConf); + digidoc::initialize(userAgentInfo, userAgentInfo); + } } catch (const digidoc::Exception &e) { std::vector causes = e.causes(); NSDictionary *userInfo = @{ @@ -249,9 +245,11 @@ static void initConf(DigiDocConfig *conf, NSString *userAgent, void (^completion } void updateConfiguration(DigiDocConfig *conf) { - dispatch_async(dispatch_get_main_queue(), ^{ - DigiDocConfCurrent *newCurrentConf = new DigiDocConfCurrent(conf); - digidoc::Conf::init(newCurrentConf); + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + @synchronized ([DigiDocContainerWrapper class]) { + DigiDocConfCurrent *newCurrentConf = new DigiDocConfCurrent(conf); + digidoc::Conf::init(newCurrentConf); + } }); } diff --git a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.h b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.h index 77401829..8c5d52ab 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.h +++ b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.h @@ -8,10 +8,16 @@ NS_ASSUME_NONNULL_BEGIN @interface DigiDocContainerWrapper : NSObject ++ (dispatch_queue_t)libraryQueue; + + (void)create:(NSString *)containerPath withDataFilePaths:(NSArray *)dataFilePaths completion:(void (^)(NSError * _Nullable error))completion; + (nullable DigiDocContainer *)open:(NSString *)containerPath validateOnline:(BOOL)validateOnline error:(NSError **)error; ++ (void)open:(NSString *)containerPath +validateOnline:(BOOL)validateOnline + completion:(void (^)(DigiDocContainer * _Nullable container, NSError * _Nullable error))completion; + + (void)addDataFilesToContainerWithPath:(NSString *)containerPath withDataFilePaths:(NSArray *)dataFilePaths completion:(void (^)(NSError * _Nullable error))completion; + (void)container:(NSString *)containerPath saveDataFile:(NSString *)fileName to:(NSString *)path completion:(void (^)(NSError * _Nullable error))completion; @@ -34,4 +40,28 @@ NS_ASSUME_NONNULL_BEGIN @end +NS_SWIFT_SENDABLE +@interface DigiDocValidationSession : NSObject + +@property (nonatomic, readonly) NSUInteger signatureCount; + ++ (void)open:(NSString *)containerPath +validateOnline:(BOOL)validateOnline + completion:(void (^NS_SWIFT_SENDABLE)(DigiDocValidationSession * _Nullable session, + DigiDocContainer * _Nullable container, + NSError * _Nullable error))completion + NS_SWIFT_NAME(open(_:validateOnline:completion:)); + +- (void)validateSignatureAtIndex:(NSUInteger)index + completion:(void (^NS_SWIFT_SENDABLE)(DigiDocSignature * _Nullable signature, + NSError * _Nullable error))completion + NS_SWIFT_NAME(validateSignature(at:completion:)); + +- (void)closeWithCompletion:(void (^NS_SWIFT_SENDABLE)(void))completion + NS_SWIFT_NAME(close(completion:)); + +- (instancetype)init NS_UNAVAILABLE; + +@end + NS_ASSUME_NONNULL_END diff --git a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm index 17220018..28244b0d 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm +++ b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Container/DigiDocContainerWrapper.mm @@ -19,6 +19,9 @@ @interface DigiDocContainerWrapper () + (DigiDocSignatureStatus)determineSignatureStatus:(int)status; + (DigiDocSignatureWarning)determineSignatureWarning:(digidoc::Exception::ExceptionCode)code; ++ (NSError *)errorFromException:(const digidoc::Exception &)e; ++ (DigiDocSignature *)getSignatureMetadata:(digidoc::Signature *)signature pos:(int)pos; ++ (DigiDocSignature *)getSignature:(digidoc::Signature *)signature pos:(int)pos; @end struct DigiDocContainerOpenCB: public digidoc::ContainerOpenCB { @@ -80,12 +83,23 @@ static bool canExtend(const std::vector &signatures, const @implementation DigiDocContainerWrapper {} ++ (dispatch_queue_t)libraryQueue { + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("ee.ria.digidoc.libdigidocpp", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + + (void)dispatch:(void (^)(void))command completion:(void (^)(NSError *error))completion { - dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_async([self libraryQueue], ^{ NSError *error = nil; try { - command(); + @synchronized ([DigiDocContainerWrapper class]) { + command(); + } } catch (const digidoc::Exception &e) { std::vector causes = e.causes(); NSDictionary *userInfo = @{ @@ -152,7 +166,7 @@ + (DigiDocSignatureWarning)determineSignatureWarning:(digidoc::Exception::Except } } -+ (DigiDocSignature *)getSignature:(digidoc::Signature *)signature pos:(int)pos mediaType:(const std::string&)mediaType dataFileCount:(NSInteger)dataFileCount { ++ (DigiDocSignature *)getSignatureMetadata:(digidoc::Signature *)signature pos:(int)pos { digidoc::X509Cert signingCert = signature->signingCertificate(); digidoc::X509Cert ocspCert = signature->OCSPCertificate(); @@ -184,7 +198,8 @@ + (DigiDocSignature *)getSignature:(digidoc::Signature *)signature pos:(int)pos digiDocSignature.timeStampTime = [NSString stringWithUTF8String:signature->TimeStampTime().c_str()]; digiDocSignature.signedBy = [NSString stringWithUTF8String:name.c_str()]; digiDocSignature.format = [NSString stringWithUTF8String:signature->profile().c_str()]; - digiDocSignature.messageImprint = [NSData dataWithBytes:signature->messageImprint().data() length:signature->messageImprint().size()]; + auto messageImprint = signature->messageImprint(); + digiDocSignature.messageImprint = [NSData dataWithBytes:messageImprint.data() length:messageImprint.size()]; digiDocSignature.trustedSigningTime = [NSString stringWithUTF8String:signature->trustedSigningTime().c_str()]; auto archiveTimestamps = signature->ArchiveTimeStamps(); @@ -209,10 +224,19 @@ + (DigiDocSignature *)getSignature:(digidoc::Signature *)signature pos:(int)pos digiDocSignature.country = [NSString stringWithUTF8String:signature->countryName().c_str()]; digiDocSignature.zipCode = [NSString stringWithUTF8String:signature->postalCode().c_str()]; + digiDocSignature.status = UnknownStatus; + digiDocSignature.diagnosticsInfo = @""; + digiDocSignature.warnings = @[]; + + return digiDocSignature; +} + ++ (DigiDocSignature *)getSignature:(digidoc::Signature *)signature pos:(int)pos { + DigiDocSignature *digiDocSignature = [DigiDocContainerWrapper getSignatureMetadata:signature pos:pos]; + digidoc::Signature::Validator validator(signature); - digidoc::Signature::Validator::Status status = validator.status(); digiDocSignature.diagnosticsInfo = [NSString stringWithUTF8String:validator.diagnostics().c_str()]; - digiDocSignature.status = [DigiDocContainerWrapper determineSignatureStatus:status]; + digiDocSignature.status = [DigiDocContainerWrapper determineSignatureStatus:validator.status()]; std::vector validatorWarnings = validator.warnings(); NSMutableArray *warnings = [NSMutableArray arrayWithCapacity:validatorWarnings.size()]; @@ -222,7 +246,6 @@ + (DigiDocSignature *)getSignature:(digidoc::Signature *)signature pos:(int)pos digiDocSignature.warnings = warnings; return digiDocSignature; - } + (void)create:(NSString *)containerPath withDataFilePaths:(NSArray *)dataFilePaths completion:(void (^)(NSError * _Nullable error))completion { @@ -291,7 +314,7 @@ + (nullable DigiDocContainer *)open:(NSString *)containerPath validateOnline:(BO NSMutableArray *signatures = [NSMutableArray array]; int pos = 0; for (digidoc::Signature *signature: container->signatures()) { - [signatures addObject:[DigiDocContainerWrapper getSignature:signature pos:pos++ mediaType:container->mediaType() dataFileCount:container->dataFiles().size()]]; + [signatures addObject:[DigiDocContainerWrapper getSignature:signature pos:pos++]]; } NSString *mediatype = [NSString stringWithUTF8String:container->mediaType().c_str()]; @@ -316,6 +339,28 @@ + (nullable DigiDocContainer *)open:(NSString *)containerPath validateOnline:(BO } } ++ (void)open:(NSString *)containerPath +validateOnline:(BOOL)validateOnline + completion:(void (^)(DigiDocContainer * _Nullable container, NSError * _Nullable error))completion { + dispatch_async([self libraryQueue], ^{ + NSError *error = nil; + DigiDocContainer *container = [self open:containerPath validateOnline:validateOnline error:&error]; + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(container, error); + }); + }); +} + ++ (NSError *)errorFromException:(const digidoc::Exception &)e { + std::vector causes = e.causes(); + NSDictionary *userInfo = @{ + NSLocalizedDescriptionKey: [NSString stringWithUTF8String:e.msg().c_str()], + @"causes": [ExceptionUtil exceptionCauses:static_cast(&causes)] + }; + return [NSError errorWithDomain:@"LibdigidocLib" code:e.code() userInfo:userInfo]; +} + + (NSString *)libdigidocppVersion { return [NSString stringWithUTF8String:digidoc::version().c_str()]; } @@ -477,4 +522,135 @@ + (void)extendContainerToLTA:(NSString *)containerPath } +@end + +@interface DigiDocValidationSession () +- (instancetype)initWithContainer:(std::unique_ptr &&)container; +@end + +@implementation DigiDocValidationSession { + std::unique_ptr _container; + std::vector _signatures; +} + +- (instancetype)initWithContainer:(std::unique_ptr &&)container { + self = [super init]; + if (self) { + _container = std::move(container); + _signatures = _container->signatures(); + _signatureCount = _signatures.size(); + } + return self; +} + ++ (void)open:(NSString *)containerPath +validateOnline:(BOOL)validateOnline + completion:(void (^NS_SWIFT_SENDABLE)(DigiDocValidationSession * _Nullable session, + DigiDocContainer * _Nullable container, + NSError * _Nullable error))completion { + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + NSError *error = nil; + DigiDocValidationSession *session = nil; + DigiDocContainer *parsed = nil; + + @synchronized ([DigiDocContainerWrapper class]) { + try { + DigiDocContainerOpenCB cb(validateOnline); + std::unique_ptr container = + digidoc::Container::openPtr(containerPath.UTF8String, &cb); + + if (!container) { + error = [NSError errorWithDomain:@"LibdigidocLib" code:3 userInfo:@{ + NSLocalizedDescriptionKey: @"Unable to open container" + }]; + } else { + NSMutableArray *datafiles = [NSMutableArray array]; + 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.fileSize = dataFile->fileSize(); + digiDocDataFile.mediaType = [NSString stringWithUTF8String:dataFile->mediaType().c_str()]; + [datafiles addObject:digiDocDataFile]; + } + + NSMutableArray *signatures = [NSMutableArray array]; + int pos = 0; + for (digidoc::Signature *signature: container->signatures()) { + [signatures addObject:[DigiDocContainerWrapper getSignatureMetadata:signature pos:pos++]]; + } + + parsed = [[DigiDocContainer alloc] + initWithFileName:containerPath.lastPathComponent + filePath:containerPath + dataFiles:datafiles + signatures:signatures + mediatype:[NSString stringWithUTF8String:container->mediaType().c_str()]]; + + session = [[DigiDocValidationSession alloc] initWithContainer:std::move(container)]; + } + } catch(const digidoc::Exception &e) { + error = [DigiDocContainerWrapper errorFromException:e]; + } + } + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(session, parsed, error); + }); + }); +} + +- (void)validateSignatureAtIndex:(NSUInteger)index + completion:(void (^NS_SWIFT_SENDABLE)(DigiDocSignature * _Nullable signature, + NSError * _Nullable error))completion { + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + DigiDocSignature *signature = nil; + NSError *error = nil; + + @synchronized ([DigiDocContainerWrapper class]) { + if (!self->_container || index >= self->_signatures.size()) { + error = [NSError errorWithDomain:@"LibdigidocLib" code:4 userInfo:@{ + NSLocalizedDescriptionKey: @"Validation session is closed or the index is out of range" + }]; + } else { + try { + signature = [DigiDocContainerWrapper getSignature:self->_signatures[index] pos:(int)index]; + } catch(const digidoc::Exception &e) { + error = [DigiDocContainerWrapper errorFromException:e]; + } + } + } + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(signature, error); + }); + }); +} + +- (void)closeWithCompletion:(void (^NS_SWIFT_SENDABLE)(void))completion { + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + @synchronized ([DigiDocContainerWrapper class]) { + self->_signatures.clear(); + self->_container.reset(); + } + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + }); +} + +- (void)dealloc { + digidoc::Container *container = _container.release(); + if (!container) { + return; + } + + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + @synchronized ([DigiDocContainerWrapper class]) { + delete container; + } + }); +} + @end diff --git a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.h b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.h index 43b4c80d..9e45ec07 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.h +++ b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.h @@ -8,8 +8,8 @@ NS_ASSUME_NONNULL_BEGIN @interface DigiDocSigningWrapper : NSObject -- (void)prepareSignature:(NSData *)cert containerPath:(NSString *)containerPath roleData:(DigiDocRoleData *)roleData userAgent:(NSString *)userAgent completion:(void (^)(NSData * _Nullable dataToSign, NSError * _Nullable error))completion; -- (void)addSignature:(NSData *)data completion:(void (^)(BOOL valid, NSError * _Nullable error))completion; +- (void)prepareSignature:(NSData *)cert containerPath:(NSString *)containerPath roleData:(DigiDocRoleData *)roleData userAgent:(NSString *)userAgent completion:(void (^NS_SWIFT_SENDABLE)(NSData * _Nullable dataToSign, NSError * _Nullable error))completion; +- (void)addSignature:(NSData *)data completion:(void (^NS_SWIFT_SENDABLE)(BOOL valid, NSError * _Nullable error))completion; @end diff --git a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.mm b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.mm index 3f97ebf0..f3deec97 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.mm +++ b/Modules/LibdigidocLib/Sources/LibdigidocObjC/include/Signature/DigiDocSigningWrapper.mm @@ -16,6 +16,7 @@ #import "../Model/DigiDocDataFile.h" #import "../Model/DigiDocSignature.h" #import "../Model/DigiDocRoleData.h" +#import "../Container/DigiDocContainerWrapper.h" #import "Exception/Util/ExceptionUtil.h" @implementation DigiDocSigningWrapper { @@ -35,82 +36,131 @@ - (instancetype)init { } - (void)prepareSignature:(NSData *)cert containerPath:(NSString *)containerPath roleData:(DigiDocRoleData *)roleData userAgent:(NSString *)userAgent completion:(void (^)(NSData * _Nullable, NSError * _Nullable))completion { - NSError *error = nil; - try { - _signer = std::make_unique(digidoc::X509Cert(reinterpret_cast(cert.bytes), cert.length)); - _signature = NULL; + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + NSError *error = nil; + NSData *dataToSign = nil; + try { + @synchronized ([DigiDocContainerWrapper class]) { + self->_signature = NULL; + self->_docContainer.reset(); + self->_signer = std::make_unique(digidoc::X509Cert(reinterpret_cast(cert.bytes), cert.length)); - DigiDocContainerOpenCB cb(TRUE); + DigiDocContainerOpenCB cb(TRUE); - _docContainer = digidoc::Container::openPtr(containerPath.UTF8String, &cb); + self->_docContainer = digidoc::Container::openPtr(containerPath.UTF8String, &cb); - _signer->setProfile("time-stamp"); + self->_signer->setProfile("time-stamp"); - _signer->setSignatureProductionPlace(roleData.city.UTF8String ?: "", - roleData.state.UTF8String ?: "", - roleData.zipcode.UTF8String ?: "", - roleData.country.UTF8String ?: ""); + self->_signer->setSignatureProductionPlace(roleData.city.UTF8String ?: "", + roleData.state.UTF8String ?: "", + roleData.zipcode.UTF8String ?: "", + roleData.country.UTF8String ?: ""); - _signer->setUserAgent(userAgent.UTF8String); + self->_signer->setUserAgent(userAgent.UTF8String); - std::vector rolesList; - for (NSString *role in roleData.roles) { - if (role.length > 0) { - rolesList.push_back(role.UTF8String); + std::vector rolesList; + for (NSString *role in roleData.roles) { + if (role.length > 0) { + rolesList.push_back(role.UTF8String); + } + } + + self->_signer->setSignerRoles(rolesList); + + self->_signature = self->_docContainer->prepareSignature(self->_signer.get()); + dataToSign = [DigiDocSigningWrapper getNSDataFromVector:self->_signature->dataToSign()]; } + } catch(const digidoc::Exception &e) { + std::vector causes = e.causes(); + NSDictionary *userInfo = @{ + NSLocalizedDescriptionKey: [NSString stringWithUTF8String:e.msg().c_str()] ?: @"Signature preparing failed", + @"causes": [ExceptionUtil exceptionCauses:static_cast(&causes)] + }; + + dataToSign = nil; + error = [NSError errorWithDomain:@"LibdigidocLib" code:e.code() userInfo:userInfo]; + } catch(const std::exception &e) { + dataToSign = nil; + error = [NSError errorWithDomain:@"LibdigidocLib" code:-1 userInfo:@{ + NSLocalizedDescriptionKey: [NSString stringWithUTF8String:e.what()] ?: @"Signature preparing failed" + }]; + } catch(...) { + dataToSign = nil; + error = [NSError errorWithDomain:@"LibdigidocLib" code:-1 userInfo:@{ + NSLocalizedDescriptionKey: @"Signature preparing failed" + }]; } - _signer->setSignerRoles(rolesList); - - _signature = _docContainer->prepareSignature(_signer.get()); - NSData *data = [DigiDocSigningWrapper getNSDataFromVector:_signature->dataToSign()]; - if (completion) completion(data, nil); - } catch(const digidoc::Exception &e) { - std::vector causes = e.causes(); - NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey: [NSString stringWithUTF8String:e.msg().c_str()], - @"causes": [ExceptionUtil exceptionCauses:static_cast(&causes)] - }; + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(dataToSign, error); + }); + }); +} - error = [NSError errorWithDomain:@"LibdigidocLib" code:e.code() userInfo:userInfo]; +- (void)addSignature:(NSData *)data completion:(void (^)(BOOL success, NSError * _Nullable error))completion { + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + NSError *error = nil; + BOOL success = NO; + + try { + @synchronized ([DigiDocContainerWrapper class]) { + if (!self->_signature) { + error = [NSError errorWithDomain:@"LibdigidocLib" code:2 userInfo:@{ + NSLocalizedDescriptionKey: @"Did not find signature" + }]; + } else if (auto timeStampTime = self->_signature->TimeStampTime(); !timeStampTime.empty()) { + success = YES; + } else { + auto *bytes = reinterpret_cast(data.bytes); + self->_signature->setSignatureValue({bytes, bytes + data.length}); + self->_signature->extendSignatureProfile(self->_signer.get()); + self->_signature->validate(); + self->_docContainer->save(); + success = YES; + } + } + } catch(const digidoc::Exception &e) { + std::vector causes = e.causes(); + NSDictionary *userInfo = @{ + NSLocalizedDescriptionKey: [NSString stringWithUTF8String:e.msg().c_str()] ?: @"Signature adding failed", + @"causes": [ExceptionUtil exceptionCauses:static_cast(&causes)] + }; + + success = NO; + error = [NSError errorWithDomain:@"LibdigidocLib" code:e.code() userInfo:userInfo]; + } catch(const std::exception &e) { + success = NO; + error = [NSError errorWithDomain:@"LibdigidocLib" code:-1 userInfo:@{ + NSLocalizedDescriptionKey: [NSString stringWithUTF8String:e.what()] ?: @"Signature adding failed" + }]; + } catch(...) { + success = NO; + error = [NSError errorWithDomain:@"LibdigidocLib" code:-1 userInfo:@{ + NSLocalizedDescriptionKey: @"Signature adding failed" + }]; + } - if (completion) completion(nil, error); - } + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(success, error); + }); + }); } -- (void)addSignature:(NSData *)data completion:(void (^)(BOOL success, NSError * _Nullable error))completion { - NSError *error = nil; - if (!_signature) { - error = [NSError errorWithDomain:@"LibdigidocLib" code:2 userInfo:@{ - NSLocalizedDescriptionKey: @"Did not find signature" - }]; - if (completion) completion(NO, error); - return; - } +- (void)dealloc { + digidoc::Container *container = _docContainer.release(); + digidoc::Signer *signer = _signer.release(); + _signature = NULL; - if (auto timeStampTime = _signature->TimeStampTime(); !timeStampTime.empty()) { - if (completion) completion(YES, error); + if (!container && !signer) { return; } - try { - auto *bytes = reinterpret_cast(data.bytes); - _signature->setSignatureValue({bytes, bytes + data.length}); - _signature->extendSignatureProfile(_signer.get()); - _signature->validate(); - _docContainer->save(); - if (completion) completion(YES, error); - } catch(const digidoc::Exception &e) { - std::vector causes = e.causes(); - NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey: [NSString stringWithUTF8String:e.msg().c_str()], - @"causes": [ExceptionUtil exceptionCauses:static_cast(&causes)] - }; - - error = [NSError errorWithDomain:@"LibdigidocLib" code:e.code() userInfo:userInfo]; - - if (completion) completion(NO, error); - } + dispatch_async([DigiDocContainerWrapper libraryQueue], ^{ + @synchronized ([DigiDocContainerWrapper class]) { + delete container; + delete signer; + } + }); } + (NSData *)getNSDataFromVector:(const std::vector&)vectorData { diff --git a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift index 98341d0d..63ddb816 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift +++ b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapper.swift @@ -12,6 +12,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { private var dataFiles: [DataFileWrapper] private var signatures: [SignatureWrapper] private var mediatype: String + private var validationTask: Task? private let fileManager: FileManagerProtocol @@ -36,6 +37,21 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { return DigiDocContainerWrapper.libdigidocppVersion() } + public func awaitValidation() async { + await validationTask?.value + } + + public func cancelValidation() { + validationTask?.cancel() + } + + private func stopValidation() async { + guard let task = validationTask else { return } + task.cancel() + await task.value + validationTask = nil + } + public func getSignatures() async -> [SignatureWrapper] { return self.signatures } @@ -96,26 +112,177 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { } } + struct ParsedContainer: Sendable { + let filePath: String + let dataFiles: [DataFileWrapper] + let signatures: [SignatureWrapper] + let mediatype: String + } + + private static func openNatively( + path: String, + validateOnline: Bool + ) async throws -> ParsedContainer { + try await withCheckedThrowingContinuation { continuation in + DigiDocContainerWrapper.open(path, validateOnline: validateOnline) { container, error in + if let container { + continuation.resume(returning: ParsedContainer( + filePath: container.filePath, + dataFiles: ContainerWrapper.getDataFiles(from: container), + signatures: ContainerWrapper.getSignatures(from: container), + mediatype: container.mediatype + )) + } else { + continuation.resume( + throwing: error ?? NSError( + domain: "ContainerWrapper - cannot open container", + code: 3 + ) + ) + } + } + } + } + + @MainActor + public func openStaged(containerFile: URL, isSivaConfirmed: Bool) async throws -> ContainerWrapper { + ContainerWrapper.logger().info( + "Opening container file '\(containerFile.lastPathComponent, privacy: .public)' in stages" + ) + + do { + let opened = try await ContainerWrapper.openValidationSession( + path: containerFile.resolvedPath, + validateOnline: isSivaConfirmed + ) + + await setContainerURL(URL(fileURLWithPath: opened.parsed.filePath)) + + let wrapper = await self.updateContainer( + datafiles: opened.parsed.dataFiles, + signatures: opened.parsed.signatures, + mediaType: opened.parsed.mediatype + ) + + let session = opened.session + let task = Task { [weak self] in + let validated = await ContainerWrapper.validateAll(in: session) + await ContainerWrapper.close(session) + guard !Task.isCancelled else { return } + await self?.setValidatedSignatures(validated) + } + await self.setValidationTask(task) + + return wrapper + } catch { + let nsError = (error as NSError?) ?? NSError(domain: "ContainerWrapper - cannot open container", code: 3) + throw DigiDocError.containerOpeningFailed( + ErrorDetail(nsError: nsError, extraInfo: ["fileName": containerFile.lastPathComponent]) + ) + } + } + + private struct OpenedSession: Sendable { + let session: DigiDocValidationSession + let parsed: ParsedContainer + } + + private static func openValidationSession(path: String, validateOnline: Bool) async throws -> OpenedSession { + try await withCheckedThrowingContinuation { continuation in + DigiDocValidationSession.open(path, validateOnline: validateOnline) { session, container, error in + guard let session, let container else { + continuation.resume( + throwing: error ?? NSError(domain: "ContainerWrapper - cannot open container", code: 3) + ) + return + } + + continuation.resume(returning: OpenedSession( + session: session, + parsed: ParsedContainer( + filePath: container.filePath, + dataFiles: ContainerWrapper.getDataFiles(from: container), + signatures: ContainerWrapper.getSignatures(from: container), + mediatype: container.mediatype + ) + )) + } + } + } + + @concurrent + private static func validateAll(in session: DigiDocValidationSession) async -> [SignatureWrapper] { + var validated: [SignatureWrapper] = [] + validated.reserveCapacity(Int(session.signatureCount)) + + for index in 0.. SignatureWrapper { + try await withCheckedThrowingContinuation { continuation in + session.validateSignature(at: UInt(index)) { signature, error in + guard let signature else { + continuation.resume( + throwing: error ?? NSError(domain: "ContainerWrapper - cannot validate signature", code: 4) + ) + return + } + + continuation.resume(returning: ContainerWrapper.makeSignature(from: signature)) + } + } + } + + private static func close(_ session: DigiDocValidationSession) async { + await withCheckedContinuation { continuation in + session.close { continuation.resume() } + } + } + + private func setValidationTask(_ task: Task) { + validationTask?.cancel() + validationTask = task + } + + private func setValidatedSignatures(_ validated: [SignatureWrapper]) { + guard validated.count <= signatures.count else { return } + signatures = validated + signatures.dropFirst(validated.count) + } + @MainActor public func open(containerFile: URL, isSivaConfirmed: Bool) async throws -> ContainerWrapper { ContainerWrapper.logger().info("Opening container file '\(containerFile.lastPathComponent, privacy: .public)'") do { - let container = try DigiDocContainerWrapper.open( - containerFile.resolvedPath, + let parsed = try await ContainerWrapper.openNatively( + path: containerFile.resolvedPath, validateOnline: isSivaConfirmed ) - await setContainerURL(URL(fileURLWithPath: container.filePath)) - - let datafiles = ContainerWrapper.getDataFiles(from: container) - let signatures = ContainerWrapper.getSignatures(from: container) - let mediatype = container.mediatype + await setContainerURL(URL(fileURLWithPath: parsed.filePath)) return await self.updateContainer( - datafiles: datafiles, - signatures: signatures, - mediaType: mediatype + datafiles: parsed.dataFiles, + signatures: parsed.signatures, + mediaType: parsed.mediatype ) } catch { let nsError = (error as NSError?) ?? NSError(domain: "ContainerWrapper - cannot open container", code: 3) @@ -130,6 +297,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { @discardableResult @MainActor public func addDataFiles(containerFile: URL, dataFiles: [URL]) async throws -> ContainerWrapperProtocol { + await stopValidation() let dataFilesPaths = dataFiles.compactMap { $0.resolvedPath } do { try await DigiDocContainerWrapper.addDataFilesToContainer( @@ -179,6 +347,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { @MainActor @discardableResult public func removeSignature(index: Int, containerFile: URL) async throws -> ContainerWrapperProtocol { + await stopValidation() do { try await DigiDocContainerWrapper.removeSignature( UInt(index), @@ -199,6 +368,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { @MainActor @discardableResult public func removeDataFile(index: Int, containerFile: URL) async throws -> ContainerWrapperProtocol { + await stopValidation() do { try await DigiDocContainerWrapper.removeDataFileFromContainer( withPath: containerFile.resolvedPath, @@ -223,6 +393,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { roleData: RoleData?, userAgent: String ) async throws -> Data { + await stopValidation() return try await digiDocSigningWrapper .prepareSignature( cert, @@ -240,6 +411,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { @MainActor public func addSignature(signature: Data, containerFile: URL) async throws -> ContainerWrapperProtocol { + await stopValidation() do { try await digiDocSigningWrapper.addSignature(signature) @@ -257,6 +429,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { @discardableResult public func extendSignatureToLTA(containerFile: URL) async throws -> ContainerWrapperProtocol { + await stopValidation() do { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in DigiDocContainerWrapper.extendLastSignature(toLTA: containerFile.resolvedPath) { error in @@ -277,6 +450,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { @discardableResult public func extendSignaturesToLTA(containerFile: URL) async throws -> ContainerWrapperProtocol { + await stopValidation() do { ContainerWrapper.logger().info("Extending signatures to LTA") let outputURL = containerFile @@ -381,7 +555,7 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { return dataFiles.compactMap { item in guard let dataFile = item as? DigiDocDataFile else { ContainerWrapper.logger().error("Unexpected type: \(type(of: item), privacy: .public)") - return DataFileWrapper(fileId: "", fileName: "", fileSize: 0, mediaType: "") + return nil } return DataFileWrapper( @@ -395,7 +569,12 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { private static func getSignatures(from container: DigiDocContainer) -> [SignatureWrapper] { return container.signatures.compactMap { signature in - SignatureWrapper( + makeSignature(from: signature) + } + } + + static func makeSignature(from signature: DigiDocSignature) -> SignatureWrapper { + return SignatureWrapper( pos: Int(signature.pos), signingCert: signature.signingCert, timestampCert: signature.timestampCert, @@ -424,6 +603,5 @@ public actor ContainerWrapper: ContainerWrapperProtocol, Loggable { ) } ?? [] ) - } } } diff --git a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapperProtocol.swift b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapperProtocol.swift index 16a18493..7d98121b 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapperProtocol.swift +++ b/Modules/LibdigidocLib/Sources/LibdigidocSwift/Domain/Container/ContainerWrapperProtocol.swift @@ -12,6 +12,9 @@ public protocol ContainerWrapperProtocol: Sendable { func getContainerURL() async -> URL func create(file: URL, dataFiles: [String]) async throws func open(containerFile: URL, isSivaConfirmed: Bool) async throws -> ContainerWrapper + func openStaged(containerFile: URL, isSivaConfirmed: Bool) async throws -> ContainerWrapper + func awaitValidation() async + func cancelValidation() async @discardableResult func addDataFiles(containerFile: URL, dataFiles: [URL]) async throws -> ContainerWrapperProtocol func saveDataFile(dataFile: DataFileWrapper, to directory: URL?) async throws -> URL @discardableResult func removeSignature(index: Int, containerFile: URL) async throws -> ContainerWrapperProtocol diff --git a/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainer.swift b/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainer.swift index 3ecc9bca..f4f630e4 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainer.swift +++ b/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainer.swift @@ -70,6 +70,14 @@ public actor SignedContainer: SignedContainerProtocol, Loggable { return signingDate < exemptionDate } + public func awaitValidation() async { + await container.awaitValidation() + } + + public func cancelValidation() async { + await container.cancelValidation() + } + public func getTimestamps() async -> [SignatureWrapper] { return timestamps } @@ -233,6 +241,7 @@ public actor SignedContainer: SignedContainerProtocol, Loggable { fileManager: fileManager ).open(containerFile: nestedTimestampedFile, isSivaConfirmed: true) + await container.awaitValidation() let timestamps = await container.getSignatures() return SignedContainer( @@ -423,7 +432,7 @@ extension SignedContainer { let container = try await ContainerWrapper( fileManager: fileManager - ).open(containerFile: renamedContainerFile, isSivaConfirmed: isSivaConfirmed) + ).openStaged(containerFile: renamedContainerFile, isSivaConfirmed: isSivaConfirmed) return SignedContainer( containerFile: renamedContainerFile, diff --git a/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainerProtocol.swift b/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainerProtocol.swift index 3be248fa..7b01b64e 100644 --- a/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainerProtocol.swift +++ b/Modules/LibdigidocLib/Sources/LibdigidocSwift/SignedContainerProtocol.swift @@ -8,6 +8,9 @@ import CommonsLib public protocol SignedContainerProtocol: GeneralContainer, Sendable { func getDataFiles() async -> [DataFileWrapper] func getSignatures() async -> [SignatureWrapper] + + func awaitValidation() async + func cancelValidation() async func getTimestamps() async -> [SignatureWrapper] func getContainerName() async -> String func getContainerMimetype() async -> String diff --git a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocObjC/DigiDocSigningWrapperTests.swift b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocObjC/DigiDocSigningWrapperTests.swift new file mode 100644 index 00000000..17017801 --- /dev/null +++ b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocObjC/DigiDocSigningWrapperTests.swift @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation +import Synchronization +import Testing +import LibdigidocLibObjC + +private final class CompletionFlag: Sendable { + private let completed = Mutex(false) + + var isCompleted: Bool { + completed.withLock { $0 } + } + + func markCompleted() { + completed.withLock { $0 = true } + } +} + +struct DigiDocSigningWrapperTests { + + private func waitUntilCompleted(_ flag: CompletionFlag) async -> Bool { + for _ in 0..<200 { + if flag.isCompleted { + return true + } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } + + @Test + func addSignature_runsItsWorkOnTheLibraryQueue() async { + let wrapper = DigiDocSigningWrapper() + let completed = CompletionFlag() + let gate = DispatchSemaphore(value: 0) + + DigiDocContainerWrapper.libraryQueue().async { gate.wait() } + + wrapper.addSignature(Data([0x01, 0x02])) { _, _ in + completed.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + #expect(!completed.isCompleted) + + gate.signal() + + #expect(await waitUntilCompleted(completed)) + } + + @Test + func prepareSignature_runsItsWorkOnTheLibraryQueue() async { + let wrapper = DigiDocSigningWrapper() + let completed = CompletionFlag() + let gate = DispatchSemaphore(value: 0) + + DigiDocContainerWrapper.libraryQueue().async { gate.wait() } + + wrapper.prepareSignature( + Data([0x01, 0x02]), + containerPath: "/nonexistent/container.asice", + roleData: DigiDocRoleData(roles: nil, city: nil, state: nil, country: nil, zipcode: nil), + userAgent: "DigiDocSigningWrapperTests" + ) { _, _ in + completed.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + #expect(!completed.isCompleted) + + gate.signal() + + #expect(await waitUntilCompleted(completed)) + } + + @Test + func addSignature_deliversCompletionOnMainThreadWhenSignatureIsMissing() async { + let wrapper = DigiDocSigningWrapper() + + let isMainThread: Bool = await withCheckedContinuation { continuation in + wrapper.addSignature(Data([0x01, 0x02])) { _, _ in + continuation.resume(returning: Thread.isMainThread) + } + } + + #expect(isMainThread) + } + + @Test + func prepareSignature_deliversCompletionOnMainThreadWhenContainerCannotBeOpened() async { + let wrapper = DigiDocSigningWrapper() + + let isMainThread: Bool = await withCheckedContinuation { continuation in + wrapper.prepareSignature( + Data([0x01, 0x02]), + containerPath: "/nonexistent/container.asice", + roleData: DigiDocRoleData(roles: nil, city: nil, state: nil, country: nil, zipcode: nil), + userAgent: "DigiDocSigningWrapperTests" + ) { _, _ in + continuation.resume(returning: Thread.isMainThread) + } + } + + #expect(isMainThread) + } + + @Test + func addSignature_reportsErrorWhenNoSignatureWasPrepared() async { + let wrapper = DigiDocSigningWrapper() + + let error: NSError? = await withCheckedContinuation { continuation in + wrapper.addSignature(Data([0x01, 0x02])) { _, error in + continuation.resume(returning: error as NSError?) + } + } + + #expect(error?.domain == "LibdigidocLib") + #expect(error?.code == 2) + } + + @Test + func prepareSignature_reportsErrorWhenContainerCannotBeOpened() async { + let wrapper = DigiDocSigningWrapper() + + let result: (Data?, NSError?) = await withCheckedContinuation { continuation in + wrapper.prepareSignature( + Data([0x01, 0x02]), + containerPath: "/nonexistent/container.asice", + roleData: DigiDocRoleData(roles: nil, city: nil, state: nil, country: nil, zipcode: nil), + userAgent: "DigiDocSigningWrapperTests" + ) { dataToSign, error in + continuation.resume(returning: (dataToSign, error as NSError?)) + } + } + + #expect(result.0 == nil) + #expect(result.1?.domain == "LibdigidocLib") + } +} diff --git a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift index cfb50130..1d0dcb8b 100644 --- a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift +++ b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/Container/ContainerWrapperTests.swift @@ -194,6 +194,66 @@ struct ContainerWrapperTests { #expect(dataFiles.count == 2) } + @Test + func openStaged_deliversTheSameResultsAsEagerOpenOnceValidationFinishes() async throws { + let containerFile = try copyExampleContainer() + defer { try? FileManager.default.removeItem(at: containerFile.deletingLastPathComponent()) } + + let expected = try await ContainerWrapper(fileManager: mockFileManager) + .open(containerFile: containerFile, isSivaConfirmed: true) + .getSignatures() + try #require(!expected.isEmpty) + try #require( + expected.contains { !$0.diagnosticsInfo.isEmpty || $0.status != .unknown }, + "The fixture's results must differ from the unvalidated placeholders, or this test proves nothing" + ) + + let staged = try await ContainerWrapper(fileManager: mockFileManager) + .openStaged(containerFile: containerFile, isSivaConfirmed: true) + #expect(await staged.getSignatures().count == expected.count) + + await staged.awaitValidation() + let validated = await staged.getSignatures() + + #expect(validated.map(\.signatureId) == expected.map(\.signatureId)) + #expect(validated.map(\.status) == expected.map(\.status)) + #expect(validated.map(\.diagnosticsInfo) == expected.map(\.diagnosticsInfo)) + + await staged.awaitValidation() + #expect(await staged.getSignatures().map(\.diagnosticsInfo) == expected.map(\.diagnosticsInfo)) + } + + @Test + func cancelValidation_letsAwaitValidationReturn() async throws { + let containerFile = try copyExampleContainer() + defer { try? FileManager.default.removeItem(at: containerFile.deletingLastPathComponent()) } + + let staged = try await ContainerWrapper(fileManager: mockFileManager) + .openStaged(containerFile: containerFile, isSivaConfirmed: true) + + await staged.cancelValidation() + await staged.awaitValidation() + + #expect(await !staged.getSignatures().isEmpty) + } + + @Test + func awaitValidation_returnsImmediatelyWhenNothingIsValidating() async { + await containerWrapper.awaitValidation() + + #expect(await containerWrapper.getSignatures().isEmpty) + } + + private func copyExampleContainer() throws -> URL { + let example = try #require(TestFileUtil.pathForResourceFile(fileName: "example", ext: "asice")) + let directory = try TestFileUtil.getTemporaryDirectory( + subfolder: "ContainerWrapperTests/\(UUID().uuidString)" + ) + let copy = directory.appending(path: "example.asice") + try FileManager.default.copyItem(at: example, to: copy) + return copy + } + @Test func open_success() async throws { let dataFilesUrls: [URL] = dataFileURLs.compactMap { $0 } diff --git a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift index ecc20091..fb46a589 100644 --- a/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift +++ b/Modules/LibdigidocLib/Tests/LibdigidocLibTests/LibdigidocSwift/SignedContainerTests.swift @@ -153,7 +153,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") @@ -236,7 +236,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/Container/Name/NameUtil.swift b/Modules/UtilsLib/Sources/UtilsLib/Container/Name/NameUtil.swift index 49c033de..d8f73452 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Container/Name/NameUtil.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Container/Name/NameUtil.swift @@ -46,11 +46,15 @@ public struct NameUtil: NameUtilProtocol { return components.joined(separator: ", ") } + private static let capitalizationRegex = try? NSRegularExpression( + pattern: #"([\p{L}\d])([\p{L}\d]*)"#, + options: [.useUnicodeWordBoundaries] + ) + private func capitalizeName(_ name: String) -> String { let lowercaseName = name.lowercased() - let pattern = #"([\p{L}\d])([\p{L}\d]*)"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.useUnicodeWordBoundaries]) else { + guard let regex = NameUtil.capitalizationRegex else { return lowercaseName } diff --git a/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift b/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift index 7002ff82..9f8ba521 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/Extensions/StringExtensions.swift @@ -6,7 +6,13 @@ import CommonsLib extension String { + private static let leadingDotCharacters: Set = [".", "\u{2026}"] + public func sanitized() -> String { + return sanitizedOrNil() ?? Constants.Container.DefaultName + } + + public func sanitizedOrNil() -> String? { var forbidden = CharacterSet.illegalCharacters .union(.symbols) .union(.extraSymbols) @@ -17,14 +23,14 @@ extension String { .joined() .trimmingCharacters(in: .whitespacesAndNewlines) - while cleanName.hasPrefix(".") { + while let first = cleanName.first, String.leadingDotCharacters.contains(first) { cleanName.removeFirst() if cleanName.isEmpty { cleanName = "_" } } - return cleanName.isEmpty ? Constants.Container.DefaultName : cleanName + return cleanName.isEmpty ? nil : cleanName } public func getURLFromText() -> AttributedString? { diff --git a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift index 2204852b..9b9f6fe8 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/Extensions/StringExtensionsTests.swift @@ -8,6 +8,46 @@ import CommonsLib class StringSanitizationTests { + @Test + func sanitizedOrNil_stripsLeadingEllipsisLikeLeadingDots() { + #expect("\u{2026}".sanitizedOrNil() == "_") + #expect("\u{2026}\u{2026}".sanitizedOrNil() == "_") + #expect("\u{2026}report".sanitizedOrNil() == "report") + #expect("...".sanitizedOrNil() == "_") + #expect(".\u{2026}.".sanitizedOrNil() == "_") + } + + @Test + func sanitizedOrNil_keepsEllipsisThatIsNotLeading() { + #expect("report\u{2026}".sanitizedOrNil() == "report\u{2026}") + #expect("a\u{2026}b".sanitizedOrNil() == "a\u{2026}b") + } + + @Test + func sanitizedOrNil_returnsNilWhenNothingUsableRemains() { + #expect("\u{FFFF}\u{FFFE}\u{1F600}".sanitizedOrNil() == nil) + #expect("@%^?[]{}#&".sanitizedOrNil() == nil) + } + + @Test + func sanitizedOrNil_returnsNilForEmptyOrWhitespaceOnlyInput() { + #expect("".sanitizedOrNil() == nil) + #expect(" ".sanitizedOrNil() == nil) + #expect(" \n\t ".sanitizedOrNil() == nil) + } + + @Test + func sanitizedOrNil_returnsCleanedNameWhenSomethingRemains() { + #expect("TestData".sanitizedOrNil() == "TestData") + #expect("Test@Data".sanitizedOrNil() == "TestData") + } + + @Test + func sanitized_stillSubstitutesFallbackWhereSanitizedOrNilIsNil() { + #expect("".sanitized() == Constants.Container.DefaultName) + #expect("@%^?[]{}#&".sanitized() == Constants.Container.DefaultName) + } + @Test func sanitized_removesIllegalCharacters() { let input = "\u{FFFF}\u{FFFE}\u{1F600}" diff --git a/RIADigiDoc/UI/Component/Container/ContainerNameView.swift b/RIADigiDoc/UI/Component/Container/ContainerNameView.swift index d71f3fb4..483b5779 100644 --- a/RIADigiDoc/UI/Component/Container/ContainerNameView.swift +++ b/RIADigiDoc/UI/Component/Container/ContainerNameView.swift @@ -55,6 +55,10 @@ struct ContainerNameView: View { ) } + private var hasVisibleActions: Bool { + bottomSheetActions.contains { $0.showButton } + } + var body: some View { ZStack { VStack(alignment: .leading, spacing: Dimensions.Padding.XSPadding) { @@ -87,28 +91,30 @@ struct ContainerNameView: View { Spacer() - Button(action: accessibleAction( - voiceOverEnabled: voiceOverEnabled, - focusedField: $focusedField - ) { - showBottomSheetFromButton = true - }, label: { - Image("ic_m3_more_vert_48pt_wght400") - .resizable() - .scaledToFit() - .frame( - width: Dimensions.Icon.IconSizeXXS, - height: Dimensions.Icon.IconSizeXXS - ) - .foregroundStyle(theme.onSurfaceVariant) - }) - .accessibilityFocusRestore( - focusedField: $focusedField, - field: .container(.openContainerOptionsButton), - when: showBottomSheetFromButton - ) - .accessibilityLabel(languageSettings.localized("More options")) - .bottomSheet(isPresented: $showBottomSheetFromButton, actions: bottomSheetActions) + if hasVisibleActions { + Button(action: accessibleAction( + voiceOverEnabled: voiceOverEnabled, + focusedField: $focusedField + ) { + showBottomSheetFromButton = true + }, label: { + Image("ic_m3_more_vert_48pt_wght400") + .resizable() + .scaledToFit() + .frame( + width: Dimensions.Icon.IconSizeXXS, + height: Dimensions.Icon.IconSizeXXS + ) + .foregroundStyle(theme.onSurfaceVariant) + }) + .accessibilityFocusRestore( + focusedField: $focusedField, + field: .container(.openContainerOptionsButton), + when: showBottomSheetFromButton + ) + .accessibilityLabel(languageSettings.localized("More options")) + .bottomSheet(isPresented: $showBottomSheetFromButton, actions: bottomSheetActions) + } } if showLeftActionButton || showRightActionButton { diff --git a/RIADigiDoc/UI/Component/Container/DataFilesListView.swift b/RIADigiDoc/UI/Component/Container/DataFilesListView.swift index 2d37c510..e5ef3225 100644 --- a/RIADigiDoc/UI/Component/Container/DataFilesListView.swift +++ b/RIADigiDoc/UI/Component/Container/DataFilesListView.swift @@ -22,7 +22,7 @@ struct DataFilesListView: View { var body: some View { LazyVStack { if #available(iOS 26.0, *) { - ForEach(dataFiles.enumerated(), id: \.offset) { index, dataFile in + ForEach(dataFiles.enumerated(), id: \.element.fileId) { index, dataFile in if index > 0 { Divider() } @@ -40,7 +40,7 @@ struct DataFilesListView: View { ) } } else { - ForEach(Array(dataFiles.enumerated()), id: \.offset) { index, dataFile in + ForEach(Array(dataFiles.enumerated()), id: \.element.fileId) { index, dataFile in if index > 0 { Divider() } diff --git a/RIADigiDoc/UI/Component/Container/Signing/SignatureView.swift b/RIADigiDoc/UI/Component/Container/Signing/SignatureView.swift index 5454c255..61ab709d 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SignatureView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SignatureView.swift @@ -106,6 +106,7 @@ struct SignatureView: View { dateTimeString: signature.trustedSigningTime, isUTC: false ) + let archiveTimestamp = archiveTimestampInfo VStack { HStack { Image(isTimestamp ? "ic_m3_approval_48dp_wght400" : "ic_m3_stylus_note_48pt_wght400") @@ -143,8 +144,8 @@ struct SignatureView: View { signatureUtil.getSignatureStatusText(status: signature.status, isTimestamp: isTimestamp) ), status: signature.status, - archiveTimestampText: archiveTimestampInfo?.text, - isArchiveTimestampExpired: archiveTimestampInfo?.isExpired ?? false + archiveTimestampText: archiveTimestamp?.text, + isArchiveTimestampExpired: archiveTimestamp?.isExpired ?? false ) .multilineTextAlignment(.center) diff --git a/RIADigiDoc/UI/Component/Container/Signing/SignaturesListView.swift b/RIADigiDoc/UI/Component/Container/Signing/SignaturesListView.swift index 57fe26f3..cdec1453 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SignaturesListView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SignaturesListView.swift @@ -25,9 +25,9 @@ struct SignaturesListView: View { @AccessibilityFocusState private var focusedSignatureIndex: Int? var body: some View { - VStack { + LazyVStack { if #available(iOS 26.0, *) { - ForEach(timestamps.enumerated(), id: \.offset) { index, timestamp in + ForEach(timestamps.enumerated(), id: \.element.pos) { index, timestamp in SignatureView( signatureIndex: index + 1, containerMimetype: containerMimetype, @@ -45,7 +45,7 @@ struct SignaturesListView: View { .accessibilityFocused($focusedSignatureIndex, equals: index) } } else { - ForEach(Array(timestamps.enumerated()), id: \.offset) { index, timestamp in + ForEach(Array(timestamps.enumerated()), id: \.element.pos) { index, timestamp in SignatureView( signatureIndex: index + 1, containerMimetype: containerMimetype, @@ -65,7 +65,7 @@ struct SignaturesListView: View { } if #available(iOS 26.0, *) { - ForEach(signatures.enumerated(), id: \.offset) { index, signature in + ForEach(signatures.enumerated(), id: \.element.pos) { index, signature in SignatureView( signatureIndex: index + 1, containerMimetype: containerMimetype, @@ -79,7 +79,7 @@ struct SignaturesListView: View { selectedSignature = signature } ) - .id(index) + .id(signature.pos) .accessibilityFocused($focusedSignatureIndex, equals: index) } .onChange(of: focusedIndex) { _, newValue in @@ -88,7 +88,7 @@ struct SignaturesListView: View { } } } else { - ForEach(Array(signatures.enumerated()), id: \.offset) { index, signature in + ForEach(Array(signatures.enumerated()), id: \.element.pos) { index, signature in SignatureView( signatureIndex: index + 1, containerMimetype: containerMimetype, @@ -102,6 +102,7 @@ struct SignaturesListView: View { selectedSignature = signature } ) + .id(signature.pos) .accessibilityFocused($focusedSignatureIndex, equals: index) } } diff --git a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift index a11817a1..ccfad706 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift @@ -70,8 +70,12 @@ struct SigningView: View { languageSettings.localized("Share container") } + private var areContainerActionsEnabled: Bool { + !viewModel.areSignaturesLoading + } + private var isSignedContainer: Bool { - viewModel.signatures.count > 0 + viewModel.signatures.count > 0 || viewModel.expectedSignatureCount > 0 } private var closeIcon: String { @@ -188,13 +192,18 @@ struct SigningView: View { icon: containerIcon, containerNameTitle: containerNameTitle, name: $viewModel.containerName, - isEditContainerButtonShown: !isContainerSigned && !isNestedContainer, - isSaveButtonShown: true, + isEditContainerButtonShown: areContainerActionsEnabled + && !isContainerSigned && !isNestedContainer, + isSaveButtonShown: areContainerActionsEnabled, isSignButtonShown: false, - isEncryptButtonShown: !isContainerSigned && viewModel.canEncrypt, - isExtendSignaturesButtonShown: isExtendSignaturesButtonShown, - showLeftActionButton: isContainerSigned && isSignButtonShown, - showRightActionButton: isContainerSigned && viewModel.canEncrypt, + isEncryptButtonShown: areContainerActionsEnabled + && !isContainerSigned && viewModel.canEncrypt, + isExtendSignaturesButtonShown: areContainerActionsEnabled + && isExtendSignaturesButtonShown, + showLeftActionButton: areContainerActionsEnabled + && isContainerSigned && isSignButtonShown, + showRightActionButton: areContainerActionsEnabled + && isContainerSigned && viewModel.canEncrypt, leftActionButtonName: languageSettings.localized("Add signature"), rightActionButtonName: encryptLabel, leftActionButtonAccessibilityLabel: signAccessibilityLabel.lowercased(), @@ -271,6 +280,12 @@ struct SigningView: View { navigateToNestedCryptoContainerView: $viewModel.navigateToNestedCryptoContainerView ) + } else if viewModel.areSignaturesLoading { + LoadingView( + size: Dimensions.Icon.IconSizeXS, + isFullScreen: false + ) + .padding(.vertical, Dimensions.Padding.XLPadding) } else { SignaturesListView( signatures: viewModel.isTimestampedContainer ? @@ -331,7 +346,7 @@ struct SigningView: View { .padding(Dimensions.Padding.SPadding) .scrollPosition(id: $scrollPosition, anchor: .bottom) .onChange(of: viewModel.signatures.count) { previousCount, newCount in - guard newCount > previousCount else { return } + guard viewModel.didAddSignature, newCount > previousCount else { return } scrollToBottom() } @@ -346,7 +361,7 @@ struct SigningView: View { } } else { UnsignedBottomBarView( - showLeftButton: true, + showLeftButton: areContainerActionsEnabled, leftButtonIconName: "ic_m3_add_48pt_wght400", leftButtonLabel: addMoreFilesLabel, leftButtonAccessibilityLabel: addMoreFilesLabel.lowercased(), @@ -354,7 +369,7 @@ struct SigningView: View { isImportingAddedFiles = true }, - rightButtonEnabled: true, + rightButtonEnabled: areContainerActionsEnabled, rightButtonIconName: "ic_m3_stylus_note_48pt_wght400", rightButtonLabel: signLabel, rightButtonAccessibilityLabel: signAccessibilityLabel.lowercased(), @@ -540,12 +555,12 @@ struct SigningView: View { private func scrollToBottom() { DispatchQueue.main.async { - guard let lastSignature = viewModel.signatures.indices.last else { return } + guard let lastIndex = viewModel.signatures.indices.last else { return } - scrollPosition = lastSignature + scrollPosition = viewModel.signatures[lastIndex].pos DispatchQueue.main.async { - focusedSignatureIndex = lastSignature + focusedSignatureIndex = lastIndex } } } @@ -608,8 +623,7 @@ struct SigningView: 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/UI/Component/Shared/LoadingView.swift b/RIADigiDoc/UI/Component/Shared/LoadingView.swift index 01a9c5bf..6a4c316c 100644 --- a/RIADigiDoc/UI/Component/Shared/LoadingView.swift +++ b/RIADigiDoc/UI/Component/Shared/LoadingView.swift @@ -7,14 +7,25 @@ import FactoryKit struct LoadingView: View { @Environment(LanguageSettings.self) private var languageSettings + var size: CGFloat = Dimensions.Icon.IconSizeSpinner + var isFullScreen: Bool = true + @State private var isLoading: Bool = false @State private var rotationAngle: Double = 0 var body: some View { + if isFullScreen { + spinner.navigationBarBackButtonHidden(true) + } else { + spinner + } + } + + private var spinner: some View { ZStack { Image("Spinner") .resizable() - .frame(width: 100, height: 100) + .frame(width: size, height: size) .rotationEffect(.degrees(rotationAngle)) .accessibilityLabel(languageSettings.localized("Loading")) .onChange(of: isLoading) { @@ -26,8 +37,7 @@ struct LoadingView: View { isLoading = true } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationBarBackButtonHidden(true) + .frame(maxWidth: .infinity, maxHeight: isFullScreen ? .infinity : nil) } } diff --git a/RIADigiDoc/UI/Component/Shared/Modal/InputModal.swift b/RIADigiDoc/UI/Component/Shared/Modal/InputModal.swift index 2b825643..b0679b25 100644 --- a/RIADigiDoc/UI/Component/Shared/Modal/InputModal.swift +++ b/RIADigiDoc/UI/Component/Shared/Modal/InputModal.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import SwiftUI +import UtilsLib struct InputModal: View { @Environment(LanguageSettings.self) private var languageSettings @@ -14,10 +15,15 @@ struct InputModal: View { var onConfirm: () -> Void var onCancel: () -> Void + private var isNameEntered: Bool { + text.sanitizedOrNil() != nil + } + var body: some View { ModalContainer( icon: icon, title: title, + isConfirmButtonEnabled: isNameEntered, onConfirm: onConfirm, onCancel: onCancel ) { diff --git a/RIADigiDoc/UI/Component/Shared/Modal/ModalContainer.swift b/RIADigiDoc/UI/Component/Shared/Modal/ModalContainer.swift index a2bc4ad5..78c8153a 100644 --- a/RIADigiDoc/UI/Component/Shared/Modal/ModalContainer.swift +++ b/RIADigiDoc/UI/Component/Shared/Modal/ModalContainer.swift @@ -13,6 +13,7 @@ struct ModalContainer: View { var icon: String? var title: String var isConfirmButtonVisible: Bool = true + var isConfirmButtonEnabled: Bool = true var confirmButtonTitle: String = "OK" var cancelButtonTitle: String = "Cancel" var confirmButtonAccessibility: String? @@ -83,7 +84,8 @@ struct ModalContainer: View { if isConfirmButtonVisible { Button(languageSettings.localized(confirmButtonTitle)) { onConfirm() } .font(typography.labelLarge) - .foregroundStyle(theme.primary) + .foregroundStyle(isConfirmButtonEnabled ? theme.primary : theme.onSurfaceVariant) + .disabled(!isConfirmButtonEnabled) .minimumScaleFactor(0.5) .accessibilityLabel( confirmButtonAccessibility ?? diff --git a/RIADigiDoc/UI/Theme/Dimensions.swift b/RIADigiDoc/UI/Theme/Dimensions.swift index d81eb7f0..196061fe 100644 --- a/RIADigiDoc/UI/Theme/Dimensions.swift +++ b/RIADigiDoc/UI/Theme/Dimensions.swift @@ -23,6 +23,7 @@ enum Dimensions { static let IconSizeXS: CGFloat = 48 static let IconSizeM: CGFloat = 72 static let IconSizeXXL: CGFloat = 144 + static let IconSizeSpinner: CGFloat = 100 } enum Padding { diff --git a/RIADigiDoc/ViewModel/SigningViewModel.swift b/RIADigiDoc/ViewModel/SigningViewModel.swift index 5955ee05..86c82eea 100644 --- a/RIADigiDoc/ViewModel/SigningViewModel.swift +++ b/RIADigiDoc/ViewModel/SigningViewModel.swift @@ -33,10 +33,18 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { var isCadesContainer = false var isXadesContainer = false var isLastDataFileRemoved = false + + var areSignaturesLoading = false + + private(set) var expectedSignatureCount = 0 + + private(set) var didAddSignature = false + var navigateToNestedCryptoContainerView = false var showExtendSivaConfirmation = false var showCannotExtendContainerDialog = false private var pendingExtendedContainer: PendingExtendedContainer? + private var loadGeneration = 0 private(set) var containerNotifications: [ContainerNotificationType] = [] private(set) var errorMessage: ToastMessage? private(set) var successMessage: ToastMessage? @@ -81,31 +89,66 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { func loadContainerData(signedContainer: SignedContainerProtocol?) async { SigningViewModel.logger().info("Loading signed container data") + loadGeneration += 1 + let generation = loadGeneration + didAddSignature = sharedContainerViewModel.getIsSignatureAdded() sharedContainerViewModel.setIsSignatureAdded(false) + let openedContainer = (signedContainer ?? sharedContainerViewModel.currentContainer()) as? any SignedContainerProtocol guard let openedContainer else { + areSignaturesLoading = false SigningViewModel.logger().error("Cannot load signed container data. Signed container is nil.") return } + let loadedContainerName = await openedContainer.getContainerName() + let loadedDataFiles = await openedContainer.getDataFiles() + let loadedMimetype = await openedContainer.getContainerMimetype() + let loadedContainerURL = await openedContainer.getRawContainerFile() + let signatureCount = await openedContainer.getSignatures().count + + guard generation == loadGeneration else { return } + self.signedContainer = openedContainer + self.containerName = loadedContainerName + self.dataFiles = loadedDataFiles + self.containerMimetype = loadedMimetype + self.containerURL = loadedContainerURL + self.expectedSignatureCount = signatureCount + self.areSignaturesLoading = signatureCount > 0 + + SigningViewModel.logger().info("Container contents loaded; awaiting signature validation") + + await loadSignatureData(for: openedContainer, generation: generation) + } + + private func loadSignatureData(for container: SignedContainerProtocol, generation: Int) async { + await container.awaitValidation() - let name = await openedContainer.getContainerName() - let mimetype = await openedContainer.getContainerMimetype() + let loadedSignatures = await container.getSignatures() + let loadedTimestamps = await container.getTimestamps() + let loadedIsTimestamped = await isTimestampedContainer() + let loadedIsCades = await container.isCades() + let loadedIsXades = await container.isXades() - self.containerName = name - self.dataFiles = await openedContainer.getDataFiles() - self.signatures = await openedContainer.getSignatures() - self.timestamps = await openedContainer.getTimestamps() - self.containerMimetype = mimetype - self.containerURL = await openedContainer.getRawContainerFile() - self.isTimestampedContainer = await isTimestampedContainer() - self.isCadesContainer = await openedContainer.isCades() - self.isXadesContainer = await openedContainer.isXades() - self.containerNotifications = await getContainerNotifications(container: openedContainer) + guard generation == loadGeneration else { return } - SigningViewModel.logger().info("Signed container data loaded") + self.isCadesContainer = loadedIsCades + self.isXadesContainer = loadedIsXades + + let loadedNotifications = await getContainerNotifications(container: container) + + guard generation == loadGeneration else { return } + + self.signatures = loadedSignatures + self.timestamps = loadedTimestamps + self.isTimestampedContainer = loadedIsTimestamped + self.containerNotifications = loadedNotifications + self.expectedSignatureCount = loadedSignatures.count + self.areSignaturesLoading = false + + SigningViewModel.logger().info("Signature data loaded") } func getContainerNotifications(container: SignedContainerProtocol) async -> [ContainerNotificationType] { @@ -131,7 +174,7 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { } func isSigned() -> Bool { - return !signatures.isEmpty + return !signatures.isEmpty || expectedSignatureCount > 0 } func createCopyOfContainerForSaving(containerURL: URL?) -> URL? { @@ -201,6 +244,12 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { } } + private func cancelLoading() { + loadGeneration += 1 + let container = signedContainer + Task { await container?.cancelValidation() } + } + public func isSignatureAdded() -> Bool { sharedContainerViewModel.getIsSignatureAdded() } @@ -475,6 +524,7 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { } func handleBackButton() async -> Bool { + cancelLoading() await MainActor.run { navigateToNestedCryptoContainerView = false } @@ -561,6 +611,12 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { return } + guard !isSigned() else { + SigningViewModel.logger().error("Refusing to remove a data file from a signed container") + errorMessage = ToastMessage(key: "Failed to remove file from container", args: [dataFile.fileName]) + return + } + do { if dataFiles.count == 1 { try fileManager.removeItem(at: containerFile) diff --git a/RIADigiDocTests/ViewModel/SigningViewModelTests.swift b/RIADigiDocTests/ViewModel/SigningViewModelTests.swift index 6b39062f..d7414bc6 100644 --- a/RIADigiDocTests/ViewModel/SigningViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/SigningViewModelTests.swift @@ -147,6 +147,36 @@ struct SigningViewModelTests: Loggable { #expect(signatures.isEmpty) } + @Test + func loadContainerData_includesCadesNotificationOnFirstLoad() async { + let mockSignedContainer = SignedContainerProtocolMock() + mockSignedContainer.isCadesHandler = { true } + + await viewModel.loadContainerData(signedContainer: mockSignedContainer) + + #expect(viewModel.containerNotifications.contains(.cadesFile)) + } + + @Test + func loadContainerData_ignoresLoadSupersededByBackNavigation() async { + let mockSignedContainer = SignedContainerProtocolMock() + let staleDataFile = MockDataFileWrapper.mockDataFileWrapper(fileName: "stale.txt") + mockSignedContainer.getDataFilesHandler = { + try? await Task.sleep(for: .milliseconds(200)) + return [staleDataFile] + } + mockSharedContainerViewModel.containersHandler = { [] } + + let load = Task { await viewModel.loadContainerData(signedContainer: mockSignedContainer) } + while mockSignedContainer.getDataFilesCallCount == 0 { + await Task.yield() + } + _ = await viewModel.handleBackButton() + await load.value + + #expect(viewModel.dataFiles.isEmpty) + } + @Test func createCopyOfContainerForSaving_success() async throws { let tempFolderURL = URL(fileURLWithPath: "/tmp") @@ -1310,6 +1340,30 @@ struct SigningViewModelTests: Loggable { #expect(viewModel.isLastDataFileRemoved) } + @Test + func removeDataFile_refusesWhenContainerIsSigned() async { + let mockDataFile = MockDataFileWrapper.mockDataFileWrapper() + let mockSignedContainer = SignedContainerProtocolMock() + mockSignedContainer.getRawContainerFileHandler = { URL(fileURLWithPath: "/mock/path/mockContainer.asice") } + mockSignedContainer.getDataFilesHandler = { [mockDataFile] } + mockSignedContainer.getSignaturesHandler = { [MockSignatureWrapper.mockSignatureWrapper()] } + mockSignedContainer.removeDataFileHandler = { _, _ in mockSignedContainer } + + await viewModel.loadContainerData(signedContainer: mockSignedContainer) + + await viewModel.removeDataFile(mockDataFile) + + #expect( + viewModel.errorMessage == ToastMessage( + key: "Failed to remove file from container", + args: [mockDataFile.fileName] + ) + ) + #expect(mockFileManager.removeItemCallCount == 0) + #expect(mockSignedContainer.removeDataFileCallCount == 0) + #expect(!viewModel.isLastDataFileRemoved) + } + @Test func removeDataFile_throwErrorWhenContainerDataNotLoaded() async { let mockDataFile = MockDataFileWrapper.mockDataFileWrapper()