diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 8508bcd0..005b8372 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -27,6 +27,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.Executors; /** * The Android half of the `Bugsee` TurboModule. diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 1fe32e22..d1bd3bf2 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -30,6 +30,8 @@ #import #import #import +#import +#import #else #import "BGSRNMainThread.h" #import "BGSRNWrapper.h" @@ -49,6 +51,8 @@ #import "BGSRNExceptions.h" #import "BGSRNSettleOnce.h" #import "BGSRNReportArgs.h" +#import "BGSRNCreatedReports.h" +#import "BGSRNCreatedReportOps.h" #endif /// The conformance lives here rather than in the Support package so that the @@ -220,6 +224,7 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse } static NSString *const kHandleDeadCode = @"E_REPORT_HANDLE_DEAD"; +static NSString *const kCreateBusyCode = @"E_REPORT_CREATE_BUSY"; static void BGSRNRejectHandleDead(RCTPromiseRejectBlock reject) { reject(kHandleDeadCode, @@ -228,6 +233,10 @@ static void BGSRNRejectHandleDead(RCTPromiseRejectBlock reject) { nil); } +static void BGSRNRejectCreatedHandleDead(RCTPromiseRejectBlock reject) { + reject(kHandleDeadCode, @"This created report handle is no longer valid.", nil); +} + /// No code of our own (React Native fills in `EUNSPECIFIED`, as Android's /// `promise.reject(e)` does): an unexpected fault is not one of the three the /// JS contract names, and inventing a fourth would make it matchable. @@ -388,6 +397,9 @@ - (void)invalidate { // And answers nil to every data request this module's JS was given, and // disables the view tree until the next runtime mounts its anchor. [BGSRNDataRequestBridge.shared detach:self]; + // The next runtime cannot know a created-report handle, and a slot left + // reserved would make every later createReport reject busy. + [BGSRNCreatedReports.shared clear]; } /// The SDK touches UIKit during start-up, so it must not be constructed on a @@ -928,70 +940,175 @@ - (void)reportAddDataAttachment:(NSString *)handleId } } -// Task 8.2c. No registry yet, so this does not call the SDK: the SDK made none. +/// On main, unlike the handler's report ops. `BugseeExtendedReport` is +/// unsynchronised, and the SDK hands it out and takes it back on main. +/// +/// `reserve` runs before that hop. `invalidate` calls `clear` on another +/// queue and cannot cancel a block already queued. Reserving inside the +/// block would run after that clear and hold the slot for a handle the +/// torn-down JS will never upload. - (void)createReport:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)reject; - resolve(nil); + BGSRNCreatedReports *registry = BGSRNCreatedReports.shared; + const NSUInteger reservation = [registry reserve]; + if (reservation == 0) { + NSLog(@"BugseeRN created report - busy"); + reject(kCreateBusyCode, @"a created report is already outstanding", nil); + return; + } + BGSRNRunOnMain(^{ + // One check with the mark. `clear` between an open test and the SDK call + // would still `-init` a `BugseeExtendedReport`. NO means `clear` already + // dropped this token and freed the slot. YES means `clear` from here on + // keeps the slot until `fulfil`, and that fulfil does not publish. + if (![registry beginCreate:reservation]) { + NSLog(@"BugseeRN created report - reservation ended"); + resolve(nil); + return; + } + @try { + // Captured above, before the hop. `invalidate` clears the registry + // without cancelling this completion. Once `beginCreate:` has run, that + // clear keeps the slot and this fulfil must not mint a handle. + // The completion runs on every path, with nil when the SDK is not launched. + [Bugsee createReportWithCompletion:^(BugseeExtendedReport *report) { + NSString *handle = [registry fulfil:report reservation:reservation]; + if (handle == nil) { + NSLog(@"BugseeRN created report - none"); + resolve(nil); + } else { + NSLog(@"BugseeRN created report %@ created", handle); + resolve(handle); + } + }]; + } @catch (NSException *exception) { + [registry fulfil:nil reservation:reservation]; + BGSRNRejectException(reject, exception); + } + }); } -// Task 8.2c. No created-report handle exists until the registry lands. - (void)createdReportRead:(NSString *)handleId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)handleId; - (void)resolve; - BGSRNRejectHandleDead(reject); + BGSRNRunOnMain(^{ + @try { + BugseeExtendedReport *report = [BGSRNCreatedReports.shared reportFor:handleId]; + if (report == nil) { + BGSRNRejectCreatedHandleDead(reject); + return; + } + resolve([BGSRNCreatedReportOps readReport:report]); + } @catch (NSException *exception) { + BGSRNRejectException(reject, exception); + } + }); } - -// Task 8.2c. No created-report handle exists until the registry lands. - (void)createdReportUpdate:(NSString *)handleId patchJson:(NSString *)patchJson resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)handleId; - (void)patchJson; - (void)resolve; - BGSRNRejectHandleDead(reject); + BGSRNRunOnMain(^{ + @try { + BugseeExtendedReport *report = [BGSRNCreatedReports.shared reportFor:handleId]; + if (report == nil) { + BGSRNRejectCreatedHandleDead(reject); + return; + } + NSError *error = nil; + if ([BGSRNCreatedReportOps applyPatchJSON:patchJson toReport:report error:&error]) { + resolve(nil); + } else { + reject(BGSRNReportErrorWireCode(error), error.localizedDescription, nil); + } + } @catch (NSException *exception) { + BGSRNRejectException(reject, exception); + } + }); } -// Task 8.2c. No created-report handle exists until the registry lands. +/// `mimeType` is accepted because JS sends it. `BugseeAttachment` has no type, +/// and iOS drops it (P7). - (void)createdReportAddDataAttachment:(NSString *)handleId base64:(NSString *)base64 name:(NSString *)name mimeType:(NSString * _Nullable)mimeType resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)handleId; - (void)base64; - (void)name; (void)mimeType; - (void)resolve; - BGSRNRejectHandleDead(reject); + BGSRNRunOnMain(^{ + @try { + BugseeExtendedReport *report = [BGSRNCreatedReports.shared reportFor:handleId]; + if (report == nil) { + BGSRNRejectCreatedHandleDead(reject); + return; + } + NSError *error = nil; + if ([BGSRNCreatedReportOps addData:base64 name:name toReport:report error:&error]) { + resolve(nil); + } else { + reject(BGSRNReportErrorWireCode(error), error.localizedDescription, nil); + } + } @catch (NSException *exception) { + BGSRNRejectException(reject, exception); + } + }); } -// Task 8.2c. No created-report handle exists until the registry lands. No move argument. +/// No `move`: the bytes are copied at add time. `mimeType` is ignored (P7). - (void)createdReportAddFileAttachment:(NSString *)handleId path:(NSString *)path name:(NSString *)name mimeType:(NSString * _Nullable)mimeType resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)handleId; - (void)path; - (void)name; (void)mimeType; - (void)resolve; - BGSRNRejectHandleDead(reject); + BGSRNRunOnMain(^{ + @try { + BugseeExtendedReport *report = [BGSRNCreatedReports.shared reportFor:handleId]; + if (report == nil) { + BGSRNRejectCreatedHandleDead(reject); + return; + } + NSError *error = nil; + if ([BGSRNCreatedReportOps addFileAtPath:path name:name toReport:report error:&error]) { + resolve(nil); + } else { + reject(BGSRNReportErrorWireCode(error), error.localizedDescription, nil); + } + } @catch (NSException *exception) { + BGSRNRejectException(reject, exception); + } + }); } -// Task 8.2c. No created-report handle exists until the registry lands. - (void)createdReportUpload:(NSString *)handleId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)handleId; - (void)resolve; - BGSRNRejectHandleDead(reject); + BGSRNRunOnMain(^{ + NSUInteger generation = 0; + @try { + BugseeExtendedReport *report = + [BGSRNCreatedReports.shared detachForUpload:handleId generation:&generation]; + if (report == nil) { + BGSRNRejectCreatedHandleDead(reject); + return; + } + // The handle is already dead. The slot stays taken until this upload + // ends: a second created report would reset beta3's file-scope attributes + // before `uploadReport:` copies them. The completion carries no success + // flag. It runs once the report is handed off, including when the SDK + // was not launched (it calls back immediately). `ok` is YES on that path. + [Bugsee uploadReport:report completion:^{ + [BGSRNCreatedReports.shared endUpload:generation]; + NSLog(@"BugseeRN created report %@ uploaded ok=%@", handleId, @YES); + resolve(@YES); + }]; + } @catch (NSException *exception) { + [BGSRNCreatedReports.shared endUpload:generation]; + BGSRNRejectException(reject, exception); + } + }); } - (std::shared_ptr)getTurboModule: diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNCreatedReportOps.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNCreatedReportOps.m new file mode 100644 index 00000000..c3512d17 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNCreatedReportOps.m @@ -0,0 +1,213 @@ +#import "BGSRNCreatedReportOps.h" +#import "BGSRNJSON.h" +#import "BGSRNReportOps.h" + +const NSUInteger BGSRNCreatedReportAttachmentMaxCount = 3; +const NSUInteger BGSRNCreatedReportAttachmentMaxBytes = 3u * 1024u * 1024u; + +static BOOL Fail(NSError **error, BGSRNReportError code, NSString *message) { + if (error != NULL) { + *error = [NSError errorWithDomain:BGSRNReportErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey : message}]; + } + return NO; +} + +/// The one count check. Upload drops anything past `ATTACHMENTS_LIMIT` and +/// still succeeds; rejecting here is the only way JS hears about it. +static BOOL AtAttachmentLimit(BugseeExtendedReport *report) { + return report.attachments.count >= BGSRNCreatedReportAttachmentMaxCount; +} + +@implementation BGSRNCreatedReportOps + ++ (NSDictionary *)readReport:(BugseeExtendedReport *)report { + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + + NSString *summary = report.summary; + if ([summary isKindOfClass:NSString.class]) { + result[@"summary"] = summary; + } + NSString *description = report.reportDescription; + if ([description isKindOfClass:NSString.class]) { + result[@"description"] = description; + } + + result[@"severity"] = @((NSInteger)report.severity); + + NSMutableArray *labels = [NSMutableArray array]; + for (id label in report.labels) { + if ([label isKindOfClass:NSString.class]) { + [labels addObject:label]; + } + } + result[@"labels"] = labels; + + NSMutableDictionary *attributes = [NSMutableDictionary dictionary]; + [report.attributes enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) { + if ([key isKindOfClass:NSString.class] && + ([value isKindOfClass:NSString.class] || [value isKindOfClass:NSNumber.class])) { + attributes[key] = value; + } + }]; + result[@"attributes"] = attributes; + + // One display. The extended report has a screenshot or it does not; the + // wire shape is still the handler's list of display ids. + result[@"screenshotDisplayIds"] = report.screenshot != nil ? @[ @0 ] : @[]; + + NSMutableArray *attachmentNames = [NSMutableArray array]; + for (BugseeAttachment *attachment in report.attachments) { + NSString *name = attachment.name; + if ([name isKindOfClass:NSString.class]) { + [attachmentNames addObject:name]; + } + } + result[@"attachmentNames"] = attachmentNames; + return result; +} + ++ (BOOL)applyPatchJSON:(NSString *)json + toReport:(BugseeExtendedReport *)report + error:(NSError **)error { + NSError *parseError = nil; + NSDictionary *patch = BGSRNJSONObject(json, &parseError); + if (patch == nil) { + return Fail(error, BGSRNReportErrorBadArgument, + [NSString stringWithFormat:@"update() patch is not a JSON object: %@", + parseError.localizedDescription]); + } + NSDictionary *valid = [BGSRNReportOps validatedPatch:patch error:error]; + if (valid == nil) { + return NO; + } + + id summary = valid[@"summary"]; + if (summary != nil) { + [report setSummary:summary == NSNull.null ? nil : summary]; + } + id description = valid[@"description"]; + if (description != nil) { + [report setDescription:description == NSNull.null ? nil : description]; + } + id severity = valid[@"severity"]; + if (severity != nil) { + [report setSeverity:(BugseeSeverityLevel)[severity integerValue]]; + } + id labels = valid[@"labels"]; + if (labels != nil) { + // Assignment replaces. There is no clear-then-add on this class. + report.labels = labels; + } + if (valid[@"clearAttributes"] != nil) { + [report clearAllAttributes]; + } + [valid[@"attributes"] enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { + if (value == NSNull.null) { + [report clearAttribute:key]; + } else { + [report setAttribute:key withValue:value]; + } + }]; + return YES; +} + ++ (BOOL)attachData:(NSData *)data + name:(NSString *)name + toReport:(BugseeExtendedReport *)report + error:(NSError **)error { + if (AtAttachmentLimit(report)) { + return Fail(error, BGSRNReportErrorAttachmentRejected, + [NSString stringWithFormat:@"a created report holds at most %lu attachments", + (unsigned long)BGSRNCreatedReportAttachmentMaxCount]); + } + if (data.length == 0 || data.length > BGSRNCreatedReportAttachmentMaxBytes) { + return Fail(error, BGSRNReportErrorAttachmentRejected, + @"a created-report attachment must be non-empty and at most 3 MiB"); + } + BugseeAttachment *attachment = [BugseeAttachment attachmentWithName:name filename:name data:data]; + if (attachment == nil) { + return Fail(error, BGSRNReportErrorAttachmentRejected, @"The SDK declined the attachment"); + } + [report setAttachment:attachment]; + return YES; +} + ++ (BOOL)addData:(NSString *)base64 + name:(NSString *)name + toReport:(BugseeExtendedReport *)report + error:(NSError **)error { + if (![base64 isKindOfClass:NSString.class]) { + return Fail(error, BGSRNReportErrorBadArgument, @"data must be base64-encoded"); + } + // An empty string decodes to empty data, which the SDK drops at upload. + // Reject it as an attachment, not as bad base64: the encoding is valid. + if (base64.length == 0) { + return Fail(error, BGSRNReportErrorAttachmentRejected, + @"a created-report attachment must be non-empty and at most 3 MiB"); + } + // 3145728 is divisible by 3, so its base64 form is exactly 4/3 with no + // padding. Reject a longer string before decoding it: this runs on the main + // thread, and the decoded bytes are checked again in `attachData`. + const NSUInteger maxBase64Chars = (BGSRNCreatedReportAttachmentMaxBytes / 3) * 4; + if (base64.length > maxBase64Chars) { + return Fail(error, BGSRNReportErrorAttachmentRejected, + @"a created-report attachment must be non-empty and at most 3 MiB"); + } + NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 options:0]; + if (data == nil) { + return Fail(error, BGSRNReportErrorBadArgument, @"data must be base64-encoded"); + } + return [self attachData:data name:name toReport:report error:error]; +} + ++ (BOOL)addFileAtPath:(NSString *)path + name:(NSString *)name + toReport:(BugseeExtendedReport *)report + error:(NSError **)error { + if (AtAttachmentLimit(report)) { + return Fail(error, BGSRNReportErrorAttachmentRejected, + [NSString stringWithFormat:@"a created report holds at most %lu attachments", + (unsigned long)BGSRNCreatedReportAttachmentMaxCount]); + } + + // The link's own length is the target path, not the file. Resolve first, + // then take that size, so a short symlink cannot admit a target over 3 MiB + // and the bytes we then read are the file we measured. + NSString *resolved = path.stringByResolvingSymlinksInPath; + if (resolved.length == 0) { + resolved = path; + } + NSError *attrError = nil; + NSDictionary *attributes = [NSFileManager.defaultManager attributesOfItemAtPath:resolved error:&attrError]; + if (attributes == nil) { + return Fail(error, BGSRNReportErrorAttachmentRejected, @"the file is missing or unreadable"); + } + if (![attributes[NSFileType] isEqualToString:NSFileTypeRegular]) { + return Fail(error, BGSRNReportErrorAttachmentRejected, @"the file is missing or unreadable"); + } + const unsigned long long size = [attributes[NSFileSize] unsignedLongLongValue]; + if (size == 0 || size > (unsigned long long)BGSRNCreatedReportAttachmentMaxBytes) { + return Fail(error, BGSRNReportErrorAttachmentRejected, + @"a created-report attachment must be non-empty and at most 3 MiB"); + } + + // Cap the read. The size above can grow between the stat and this read, and + // `dataWithContentsOfFile:` would then take the whole file on the main thread. + NSFileHandle *file = [NSFileHandle fileHandleForReadingAtPath:resolved]; + if (file == nil) { + return Fail(error, BGSRNReportErrorAttachmentRejected, @"the file is missing or unreadable"); + } + NSData *read = [file readDataOfLength:BGSRNCreatedReportAttachmentMaxBytes + 1]; + [file closeFile]; + if (read == nil) { + return Fail(error, BGSRNReportErrorAttachmentRejected, @"the file is missing or unreadable"); + } + // Own the bytes. The file is the app's; overwriting it later must not + // change what the report will upload. + NSData *owned = [NSData dataWithBytes:read.bytes length:read.length]; + return [self attachData:owned name:name toReport:report error:error]; +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNCreatedReports.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNCreatedReports.m new file mode 100644 index 00000000..1d1abc05 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNCreatedReports.m @@ -0,0 +1,184 @@ +#import "BGSRNCreatedReports.h" + +#import + +@implementation BGSRNCreatedReports { + os_unfair_lock _lock; + /// Set by `reserve`, cleared when that reservation is fulfilled or when + /// `clear` runs before `beginCreate:`. A held report keeps the slot on its + /// own. After `beginCreate:`, `clear` leaves this set until `fulfil`. + BOOL _creating; + /// Token of the open reservation. 0 is never live. Stays set across `clear` + /// once `beginCreate:` has run, so that completion can still free the slot. + NSUInteger _reservation; + /// Set by `beginCreate:`. `clear` then keeps the slot until `fulfil`. + BOOL _createStarted; + /// Set by `clear` after `beginCreate:`. `fulfil` frees the slot and does + /// not publish a handle. + BOOL _abandoned; + NSUInteger _nextReservation; + NSString *_handle; + BugseeExtendedReport *_report; + NSUInteger _nextHandle; + /// Set by `detachForUpload:` until `endUpload:` for that generation. The + /// handle is already gone; the slot stays taken so a second `createReport` + /// cannot construct another `BugseeExtendedReport` while `uploadReport:` is + /// still copying this one. beta3 keeps attributes in a file-scope global. + BOOL _uploading; + /// Generation of the in-flight upload. 0 is never live. `clear` moves it so + /// a completion from the upload `clear` abandoned cannot end a later one. + NSUInteger _uploadGeneration; +} + +- (instancetype)init { + if ((self = [super init])) { + _lock = OS_UNFAIR_LOCK_INIT; + _nextReservation = 1; + _nextHandle = 1; + } + return self; +} + ++ (BGSRNCreatedReports *)shared { + static BGSRNCreatedReports *shared; + static dispatch_once_t once; + dispatch_once(&once, ^{ + shared = [self new]; + }); + return shared; +} + +- (NSUInteger)reserve { + os_unfair_lock_lock(&_lock); + NSUInteger token = 0; + if (!_creating && _report == nil && !_uploading) { + _creating = YES; + if (_nextReservation == 0) { + _nextReservation = 1; + } + token = _nextReservation; + _reservation = token; + _nextReservation += 1; + if (_nextReservation == 0) { + _nextReservation = 1; + } + } + os_unfair_lock_unlock(&_lock); + return token; +} + +- (BOOL)reservationIsOpen:(NSUInteger)reservation { + os_unfair_lock_lock(&_lock); + const BOOL open = _creating && !_abandoned && reservation != 0 && reservation == _reservation; + os_unfair_lock_unlock(&_lock); + return open; +} + +- (BOOL)beginCreate:(NSUInteger)reservation { + os_unfair_lock_lock(&_lock); + const BOOL started = _creating && !_abandoned && !_createStarted && reservation != 0 && + reservation == _reservation; + if (started) { + _createStarted = YES; + } + os_unfair_lock_unlock(&_lock); + return started; +} + +- (nullable NSString *)fulfil:(nullable BugseeExtendedReport *)report + reservation:(NSUInteger)reservation { + os_unfair_lock_lock(&_lock); + NSString *handle = nil; + // A late completion still runs after `clear` or after this reservation + // already ended. It must not mint a handle on the next runtime's slot, and + // a nil must not drop a report that slot already holds. + if (_creating && reservation != 0 && reservation == _reservation) { + const BOOL publish = !_abandoned && report != nil; + _creating = NO; + _reservation = 0; + _createStarted = NO; + _abandoned = NO; + if (publish) { + handle = [NSString stringWithFormat:@"cr-%lu", (unsigned long)_nextHandle]; + _nextHandle += 1; + _handle = handle; + _report = report; + } + } + os_unfair_lock_unlock(&_lock); + return handle; +} + +- (nullable BugseeExtendedReport *)reportFor:(NSString *)handleId { + os_unfair_lock_lock(&_lock); + BugseeExtendedReport *report = [_handle isEqualToString:handleId] ? _report : nil; + os_unfair_lock_unlock(&_lock); + return report; +} + +- (nullable BugseeExtendedReport *)take:(NSString *)handleId { + os_unfair_lock_lock(&_lock); + BugseeExtendedReport *report = nil; + if ([_handle isEqualToString:handleId]) { + report = _report; + _report = nil; + _handle = nil; + } + os_unfair_lock_unlock(&_lock); + return report; +} + +- (nullable BugseeExtendedReport *)detachForUpload:(NSString *)handleId + generation:(NSUInteger *)generationOut { + os_unfair_lock_lock(&_lock); + BugseeExtendedReport *report = nil; + NSUInteger generation = 0; + if ([_handle isEqualToString:handleId] && _report != nil) { + report = _report; + _report = nil; + _handle = nil; + _uploading = YES; + _uploadGeneration += 1; + if (_uploadGeneration == 0) { + _uploadGeneration = 1; + } + generation = _uploadGeneration; + } + os_unfair_lock_unlock(&_lock); + if (generationOut != NULL) { + *generationOut = generation; + } + return report; +} + +- (void)endUpload:(NSUInteger)generation { + os_unfair_lock_lock(&_lock); + if (generation != 0 && generation == _uploadGeneration) { + _uploading = NO; + } + os_unfair_lock_unlock(&_lock); +} + +- (void)clear { + os_unfair_lock_lock(&_lock); + _report = nil; + _handle = nil; + if (_createStarted) { + // `createReportWithCompletion:` already started. Its `-init` resets the + // file-scope attributes beta3 shares, so the slot stays taken until that + // completion's `fulfil`. The handle is not published: this runtime's JS + // is gone. + _abandoned = YES; + } else { + _creating = NO; + _reservation = 0; + } + // An upload still in flight keeps the slot. `uploadReport:` copies attributes + // after it returns, and beta3 keeps those in a file-scope global. Clearing + // `_uploading` here would let the next runtime `-init` another + // `BugseeExtendedReport` and wipe the report being uploaded. `endUpload:` + // for this generation is what frees the slot. + os_unfair_lock_unlock(&_lock); +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReportOps.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReportOps.m index 63112f8a..90374248 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReportOps.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReportOps.m @@ -109,9 +109,8 @@ @implementation BGSRNReportOps return result; } -+ (BOOL)applyPatch:(NSDictionary *)patch - toReport:(id)report - error:(NSError **)error { ++ (nullable NSDictionary *)validatedPatch:(NSDictionary *)patch + error:(NSError **)error { static NSSet *knownKeys; static dispatch_once_t once; dispatch_once(&once, ^{ @@ -120,19 +119,22 @@ + (BOOL)applyPatch:(NSDictionary *)patch }); for (id key in patch) { if (![knownKeys containsObject:key]) { - return Fail(error, BGSRNReportErrorBadArgument, - [NSString stringWithFormat:@"update() received an unknown key \"%@\"", key]); + Fail(error, BGSRNReportErrorBadArgument, + [NSString stringWithFormat:@"update() received an unknown key \"%@\"", key]); + return nil; } } // Validate everything first. Nothing below the next comment can reject. id summary = patch[@"summary"]; if (summary != nil && summary != NSNull.null && ![summary isKindOfClass:NSString.class]) { - return Fail(error, BGSRNReportErrorBadArgument, @"summary must be a string or null"); + Fail(error, BGSRNReportErrorBadArgument, @"summary must be a string or null"); + return nil; } id description = patch[@"description"]; if (description != nil && description != NSNull.null && ![description isKindOfClass:NSString.class]) { - return Fail(error, BGSRNReportErrorBadArgument, @"description must be a string or null"); + Fail(error, BGSRNReportErrorBadArgument, @"description must be a string or null"); + return nil; } // Checked 1..5 HERE: the SDK setter ignores anything else and keeps the @@ -142,33 +144,38 @@ + (BOOL)applyPatch:(NSDictionary *)patch if (severity != nil) { const double value = IsFiniteNumber(severity) ? [severity doubleValue] : NAN; if (!(value == floor(value) && value >= BugseeSeverityLow && value <= BugseeSeverityBlocker)) { - return Fail(error, BGSRNReportErrorBadArgument, - [NSString stringWithFormat:@"severity must be an integer 1..5, got %@", severity]); + Fail(error, BGSRNReportErrorBadArgument, + [NSString stringWithFormat:@"severity must be an integer 1..5, got %@", severity]); + return nil; } } id labels = patch[@"labels"]; if (labels != nil) { if (![labels isKindOfClass:NSArray.class]) { - return Fail(error, BGSRNReportErrorBadArgument, @"labels must be an array of strings"); + Fail(error, BGSRNReportErrorBadArgument, @"labels must be an array of strings"); + return nil; } for (id label in (NSArray *)labels) { if (![label isKindOfClass:NSString.class]) { - return Fail(error, BGSRNReportErrorBadArgument, @"labels must all be strings"); + Fail(error, BGSRNReportErrorBadArgument, @"labels must all be strings"); + return nil; } } } id clearAttributes = patch[@"clearAttributes"]; if (clearAttributes != nil && !(IsBoolean(clearAttributes) && [clearAttributes boolValue])) { - return Fail(error, BGSRNReportErrorBadArgument, @"clearAttributes must be true when present"); + Fail(error, BGSRNReportErrorBadArgument, @"clearAttributes must be true when present"); + return nil; } id rawAttributes = patch[@"attributes"]; NSMutableDictionary *attributes = nil; if (rawAttributes != nil) { if (![rawAttributes isKindOfClass:NSDictionary.class]) { - return Fail(error, BGSRNReportErrorBadArgument, @"attributes must be a plain object"); + Fail(error, BGSRNReportErrorBadArgument, @"attributes must be a plain object"); + return nil; } attributes = [NSMutableDictionary dictionary]; for (id key in (NSDictionary *)rawAttributes) { @@ -176,39 +183,75 @@ + (BOOL)applyPatch:(NSDictionary *)patch const BOOL valid = value == NSNull.null || [value isKindOfClass:NSString.class] || IsBoolean(value) || IsFiniteNumber(value); if (![key isKindOfClass:NSString.class] || !valid) { - return Fail(error, BGSRNReportErrorBadArgument, - [NSString stringWithFormat:@"attribute \"%@\" must be a string, boolean, " - @"finite number or null", - key]); + Fail(error, BGSRNReportErrorBadArgument, + [NSString stringWithFormat:@"attribute \"%@\" must be a string, boolean, " + @"finite number or null", + key]); + return nil; } // Parity with the JS proxy, which rejects it before crossing. if ([(NSString *)key length] == 0) { - return Fail(error, BGSRNReportErrorBadArgument, @"attribute name must be a non-empty string"); + Fail(error, BGSRNReportErrorBadArgument, @"attribute name must be a non-empty string"); + return nil; } attributes[key] = IsFiniteNumber(value) ? WireNumber(value) : value; } } + NSMutableDictionary *valid = [NSMutableDictionary dictionary]; + if (summary != nil) { + valid[@"summary"] = summary; + } + if (description != nil) { + valid[@"description"] = description; + } + if (severity != nil) { + valid[@"severity"] = severity; + } + if (labels != nil) { + valid[@"labels"] = labels; + } + if (clearAttributes != nil) { + valid[@"clearAttributes"] = clearAttributes; + } + if (attributes != nil) { + valid[@"attributes"] = attributes; + } + return valid; +} + ++ (BOOL)applyPatch:(NSDictionary *)patch + toReport:(id)report + error:(NSError **)error { + NSDictionary *valid = [self validatedPatch:patch error:error]; + if (valid == nil) { + return NO; + } + // Everything is valid; apply. + id summary = valid[@"summary"]; if (summary != nil) { report.summary = summary == NSNull.null ? nil : summary; } + id description = valid[@"description"]; if (description != nil) { report.reportDescription = description == NSNull.null ? nil : description; } + id severity = valid[@"severity"]; if (severity != nil) { report.severity = (BugseeSeverityLevel)[severity integerValue]; } + id labels = valid[@"labels"]; if (labels != nil) { // One atomic call: clear-then-add would expose a label-less report between the two. [report replaceLabels:labels]; } - if (clearAttributes != nil) { + if (valid[@"clearAttributes"] != nil) { // Before the attributes, so clear-then-set holds whatever order the patch // was written in. [report clearAllAttributes]; } - [attributes enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { + [valid[@"attributes"] enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { if (value == NSNull.null) { [report removeAttributeForName:key]; } else { diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNCreatedReportOps.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNCreatedReportOps.h new file mode 100644 index 00000000..396e8758 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNCreatedReportOps.h @@ -0,0 +1,51 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Mirrors the SDK's `ATTACHMENTS_LIMIT`. A created report drops anything past +/// this at upload, silently; the bridge rejects at add time instead. +FOUNDATION_EXPORT const NSUInteger BGSRNCreatedReportAttachmentMaxCount; + +/// Mirrors the SDK's `MAX_SIZE_ATTACHMENT` (3 MiB). Same rule: reject up front. +FOUNDATION_EXPORT const NSUInteger BGSRNCreatedReportAttachmentMaxBytes; + +/// `BugseeExtendedReport` is not a `BGSReportContract`. Same wire keys as +/// `BGSRNReportOps`, different setters, and the SDK's attachment limits +/// applied here because upload would otherwise drop the file and still succeed. +@interface BGSRNCreatedReportOps : NSObject + +/// `summary`, `description` (omitted when unset), `severity` (as held, 0 when +/// the SDK has not been given one), `labels`, `attributes`, `screenshotDisplayIds` +/// (`@[@0]` when a screenshot is set, else empty) and `attachmentNames`. ++ (NSDictionary *)readReport:(BugseeExtendedReport *)report; + +/// `validatedPatch:error:` first, so a bad field changes nothing. Then +/// `setSummary:` / `setDescription:` (NSNull clears), `setSeverity:`, labels +/// replaced by assignment, `clearAllAttributes` before attribute edits, +/// `clearAttribute:` for NSNull and `setAttribute:withValue:` otherwise. ++ (BOOL)applyPatchJSON:(NSString *)json + toReport:(BugseeExtendedReport *)report + error:(NSError **)error; + +/// `BGSRNReportErrorBadArgument` when `base64` does not decode. +/// `BGSRNReportErrorAttachmentRejected` when the report already holds +/// `BGSRNCreatedReportAttachmentMaxCount`, or the data is empty or over +/// `BGSRNCreatedReportAttachmentMaxBytes`. ++ (BOOL)addData:(NSString *)base64 + name:(NSString *)name + toReport:(BugseeExtendedReport *)report + error:(NSError **)error; + +/// Size from the file's attributes before any read, then the bytes are read +/// now: a later overwrite of `path` must not change the attachment. +/// `BGSRNReportErrorAttachmentRejected` when the report is full, the file is +/// missing or unreadable, or it is empty or over the byte limit. ++ (BOOL)addFileAtPath:(NSString *)path + name:(NSString *)name + toReport:(BugseeExtendedReport *)report + error:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNCreatedReports.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNCreatedReports.h new file mode 100644 index 00000000..7e62478c --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNCreatedReports.h @@ -0,0 +1,81 @@ +#import +// Not `@import Bugsee`: this header is included from ObjC++, where C++ modules +// are off on both delivery paths and a module import is a hard error. +#import + +NS_ASSUME_NONNULL_BEGIN + +/// One outstanding created report per process. +/// +/// iOS beta3 keeps `BugseeExtendedReport` attributes in a file-scope global, +/// so a second created report wipes the first's (P5). The slot covers both +/// "the SDK is still making one" and "JS holds one", and `invalidate` clears +/// it because a reload's JS cannot know the handle. +/// +/// `os_unfair_lock`: `createReport` can overlap an `invalidate` from another +/// queue. The caller reserves before any hop `invalidate` cannot cancel. +/// A reserve that ran after `clear` would hold the slot for a handle the +/// torn-down JS never uploads. The report object itself is unsynchronised +/// and is only touched on main; this lock guards the slot, not the report. +@interface BGSRNCreatedReports : NSObject + +@property (class, readonly) BGSRNCreatedReports *shared; + +/// 0 while a created report is outstanding or being created. Otherwise the +/// generation token `fulfil:reservation:` must present. A non-zero token is +/// an admission, which is what the existing tests assert. `clear` before +/// `beginCreate:` makes every token issued so far fail that check. The SDK's +/// completion is not cancelled. +- (NSUInteger)reserve; + +/// YES when `reservation` is still the open one and `clear` has not run. +/// Does not take or free the slot. +- (BOOL)reservationIsOpen:(NSUInteger)reservation; + +/// Marks `reservation` as having called `createReportWithCompletion:`. +/// NO when the token is not the open one, including after `clear`. The hop +/// uses this instead of a separate open check: `clear` between the two would +/// still `-init` a `BugseeExtendedReport`. Once this returns YES, `clear` +/// keeps the slot until `fulfil:reservation:` and that fulfil does not publish. +- (BOOL)beginCreate:(NSUInteger)reservation; + +/// Ends the reservation `reservation` names. A report gets `cr-` (fresh for +/// this registry, never reused after a take) and holds the slot. nil frees the +/// slot and returns nil. A token that is not the current one — including a +/// token `clear` dropped before `beginCreate:`, and the token of a reservation +/// that has already been fulfilled — returns nil and does not store a report +/// or clear the slot. After `beginCreate:`, `clear` leaves the token current +/// so this call can free the slot; it still does not publish a handle. +- (nullable NSString *)fulfil:(nullable BugseeExtendedReport *)report + reservation:(NSUInteger)reservation; + +- (nullable BugseeExtendedReport *)reportFor:(NSString *)handleId; + +/// Removes the report and frees the slot. nil when `handleId` is not the one held. +- (nullable BugseeExtendedReport *)take:(NSString *)handleId; + +/// Drops the handle so later reads are dead, and keeps the slot until +/// `endUpload:` for `generationOut`. nil when `handleId` is not the one held; +/// `generationOut` is then 0. The slot stays taken because a second +/// `BugseeExtendedReport` resets the file-scope attributes beta3 shares. +- (nullable BugseeExtendedReport *)detachForUpload:(NSString *)handleId + generation:(NSUInteger *)generationOut; + +/// Frees the slot when `generation` is still the upload `detachForUpload:` +/// started. A later upload's generation does not match, so this does not free +/// that one. `clear` does not move the generation: the in-flight upload keeps +/// the slot until this runs. +- (void)endUpload:(NSUInteger)generation; + +/// Drops the handle and a reservation `beginCreate:` has not started. An +/// upload already detached keeps the slot until `endUpload:` for its +/// generation, and a `createReportWithCompletion:` already started keeps the +/// slot until `fulfil:reservation:`. Either way a reload cannot `-init` +/// another `BugseeExtendedReport` while beta3 is still using the file-scope +/// attributes of the one in flight. The in-flight create's fulfil does not +/// publish a handle the torn-down JS cannot upload. +- (void)clear; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReportOps.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReportOps.h index 88ab2291..832cf803 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReportOps.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReportOps.h @@ -41,6 +41,14 @@ FOUNDATION_EXPORT NSString *_Nullable BGSRNReportErrorWireCode(NSError *error); toReport:(id)report error:(NSError **)error; +/// The validation half of `applyPatch:toReport:error:`. nil, with `error`, +/// when any field is invalid — the caller applies nothing. Otherwise the +/// patch, with attribute numbers already in the form the SDK should store +/// (an exact integer as an integer). `BGSRNCreatedReportOps` applies the +/// same dictionary through `BugseeExtendedReport`'s own setters. ++ (nullable NSDictionary *)validatedPatch:(NSDictionary *)patch + error:(NSError **)error; + /// `applyPatch:toReport:error:` on the JSON text `reportUpdate` receives /// (`BGSRNJSONObject`), which is how a `null` -- clear the summary or /// description, remove an attribute -- survives the bridge. Text that is not diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNCreatedReportOpsTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNCreatedReportOpsTests.m new file mode 100644 index 00000000..cd3e80a4 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNCreatedReportOpsTests.m @@ -0,0 +1,273 @@ +@import XCTest; +@import Bugsee; +@import BugseeRNSupport; +@import UIKit; + +/// On a real `BugseeExtendedReport`. Its attributes live in a file-scope +/// global that `-init` resets, so each case that needs a value sets it on +/// the report it is about to read. +@interface BGSRNCreatedReportOpsTests : XCTestCase +@end + +@implementation BGSRNCreatedReportOpsTests { + BugseeExtendedReport *_report; +} + +- (void)setUp { + [super setUp]; + _report = [BugseeExtendedReport new]; +} + +- (BOOL)apply:(NSString *)json error:(NSError **)error { + return [BGSRNCreatedReportOps applyPatchJSON:json toReport:_report error:error]; +} + +- (UIImage *)onePixel { + UIGraphicsImageRenderer *renderer = + [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(1, 1)]; + return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { + [[UIColor redColor] setFill]; + [context fillRect:CGRectMake(0, 0, 1, 1)]; + }]; +} + +- (void)testReadsTheFields { + XCTAssertEqualObjects([BGSRNCreatedReportOps readReport:_report][@"screenshotDisplayIds"], @[]); + + [_report setSummary:@"sum"]; + [_report setDescription:@"desc"]; + [_report setSeverity:BugseeSeverityCritical]; + _report.labels = @[ @"a", @"b" ]; + [_report setAttribute:@"k" withValue:@"v"]; + [_report setScreenshot:[self onePixel]]; + BugseeAttachment *attachment = [BugseeAttachment attachmentWithName:@"note.txt" + filename:@"note.txt" + data:[@"hi" dataUsingEncoding:NSUTF8StringEncoding]]; + [_report setAttachment:attachment]; + + NSDictionary *read = [BGSRNCreatedReportOps readReport:_report]; + XCTAssertEqualObjects(read[@"summary"], @"sum"); + XCTAssertEqualObjects(read[@"description"], @"desc"); + XCTAssertEqualObjects(read[@"severity"], @4); + XCTAssertEqualObjects(read[@"labels"], (@[ @"a", @"b" ])); + XCTAssertEqualObjects(read[@"attributes"], @{ @"k" : @"v" }); + XCTAssertEqualObjects(read[@"screenshotDisplayIds"], (@[ @0 ])); + XCTAssertEqualObjects(read[@"attachmentNames"], @[ @"note.txt" ]); +} + +/// A valid field beside an invalid one must not land. +- (void)testPatchIsAllOrNothing { + [_report setSummary:@"old"]; + _report.labels = @[ @"keep" ]; + [_report setAttribute:@"k" withValue:@"v"]; + + NSError *error = nil; + XCTAssertFalse([self apply:@"{\"summary\":\"new\",\"labels\":[\"x\"],\"severity\":9}" error:&error]); + XCTAssertEqualObjects(error.domain, BGSRNReportErrorDomain); + XCTAssertEqual(error.code, BGSRNReportErrorBadArgument); + XCTAssertEqualObjects(_report.summary, @"old"); + XCTAssertEqualObjects(_report.labels, (@[ @"keep" ])); + XCTAssertEqualObjects(_report.attributes[@"k"], @"v"); +} + +- (void)testLabelsReplace { + _report.labels = @[ @"old" ]; + NSError *error = nil; + XCTAssertTrue([self apply:@"{\"labels\":[\"x\",\"y\"]}" error:&error], @"%@", error); + XCTAssertEqualObjects(_report.labels, (@[ @"x", @"y" ])); +} + +- (void)testNSNullClearsTheSummaryAndRemovesAnAttribute { + [_report setSummary:@"s"]; + [_report setDescription:@"d"]; + [_report setAttribute:@"gone" withValue:@"v"]; + [_report setAttribute:@"kept" withValue:@"v"]; + + NSError *error = nil; + XCTAssertTrue([self apply:@"{\"summary\":null,\"attributes\":{\"gone\":null}}" error:&error], @"%@", error); + XCTAssertNil(_report.summary); + XCTAssertEqualObjects(_report.reportDescription, @"d"); + XCTAssertNil(_report.attributes[@"gone"]); + XCTAssertEqualObjects(_report.attributes[@"kept"], @"v"); + + [_report setSummary:@"kept"]; + error = nil; + XCTAssertTrue([self apply:@"{\"description\":null}" error:&error], @"%@", error); + XCTAssertNil(_report.reportDescription); + XCTAssertEqualObjects(_report.summary, @"kept"); +} + +/// Clear runs before the attributes of the same patch, whatever key order +/// the dictionary walks in. +- (void)testClearAttributesRunsFirst { + [_report setAttribute:@"old" withValue:@"v"]; + NSError *error = nil; + XCTAssertTrue([self apply:@"{\"attributes\":{\"fresh\":\"n\"},\"clearAttributes\":true}" error:&error], + @"%@", error); + XCTAssertNil(_report.attributes[@"old"]); + XCTAssertEqualObjects(_report.attributes[@"fresh"], @"n"); +} + +- (void)testAFourthAttachmentIsRejected { + NSError *error = nil; + for (NSInteger i = 0; i < 3; i++) { + NSString *attachmentName = [NSString stringWithFormat:@"n-%ld", (long)i]; + XCTAssertTrue([BGSRNCreatedReportOps addData:@"AQ==" name:attachmentName toReport:_report error:&error], + @"%@", error); + } + error = nil; + XCTAssertFalse([BGSRNCreatedReportOps addData:@"AQ==" name:@"fourth" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorAttachmentRejected); + XCTAssertEqualObjects(BGSRNReportErrorWireCode(error), @"E_REPORT_ATTACHMENT_REJECTED"); + XCTAssertEqual(_report.attachments.count, 3u); + NSArray *names = [BGSRNCreatedReportOps readReport:_report][@"attachmentNames"]; + XCTAssertFalse([names containsObject:@"fourth"]); +} + +/// Longer than the 4/3 cap, and not a multiple of 4, so a decode would fail +/// as bad base64. The cap rejects it first. +- (void)testAnOverlongBase64StringIsRejectedBeforeDecoding { + const NSUInteger cap = (BGSRNCreatedReportAttachmentMaxBytes / 3) * 4; + NSString *over = [@"" stringByPaddingToLength:cap + 1 withString:@"A" startingAtIndex:0]; + NSError *error = nil; + XCTAssertFalse([BGSRNCreatedReportOps addData:over name:@"big.bin" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorAttachmentRejected); + XCTAssertEqual(_report.attachments.count, 0u); +} + +- (void)testAnAttachmentOver3MiBIsRejected { + NSMutableData *data = [NSMutableData dataWithLength:BGSRNCreatedReportAttachmentMaxBytes + 1]; + NSString *base64 = [data base64EncodedStringWithOptions:0]; + NSError *error = nil; + XCTAssertFalse([BGSRNCreatedReportOps addData:base64 name:@"big.bin" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorAttachmentRejected); + XCTAssertEqual(_report.attachments.count, 0u); +} + +- (void)testAnEmptyAttachmentIsRejected { + NSError *error = nil; + XCTAssertFalse([BGSRNCreatedReportOps addData:@"" name:@"empty.bin" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorAttachmentRejected); + XCTAssertEqual(_report.attachments.count, 0u); + + NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + XCTAssertTrue([@"" writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:nil]); + [self addTeardownBlock:^{ + [NSFileManager.defaultManager removeItemAtPath:path error:nil]; + }]; + error = nil; + XCTAssertFalse([BGSRNCreatedReportOps addFileAtPath:path name:@"empty.txt" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorAttachmentRejected); +} + +- (void)testAMissingFileIsRejected { + NSError *error = nil; + NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"missing-%@", NSUUID.UUID.UUIDString]]; + XCTAssertFalse([BGSRNCreatedReportOps addFileAtPath:path name:@"gone.txt" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorAttachmentRejected); + XCTAssertEqual(_report.attachments.count, 0u); +} + +/// The bytes are taken at add time. Overwriting the file afterwards leaves +/// the attachment holding what was there. +- (void)testAFileIsCapturedWhenAdded { + NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + NSData *original = [@"original-bytes" dataUsingEncoding:NSUTF8StringEncoding]; + XCTAssertTrue([original writeToFile:path atomically:YES]); + [self addTeardownBlock:^{ + [NSFileManager.defaultManager removeItemAtPath:path error:nil]; + }]; + + NSError *error = nil; + XCTAssertTrue([BGSRNCreatedReportOps addFileAtPath:path name:@"note.txt" toReport:_report error:&error], + @"%@", error); + + NSData *changed = [@"changed-bytes!" dataUsingEncoding:NSUTF8StringEncoding]; + XCTAssertEqual(changed.length, original.length); + XCTAssertTrue([changed writeToFile:path atomically:NO]); + + XCTAssertEqualObjects(_report.attachments.lastObject.data, original); + XCTAssertEqualObjects(_report.attachments.lastObject.name, @"note.txt"); + XCTAssertEqualObjects(_report.attachments.lastObject.filename, @"note.txt"); +} + +/// The size check follows the link. A symlink's own length is the target path, +/// so measuring that would admit a short link to a file over 3 MiB. +- (void)testASymlinkIsSizedByTheFileItNames { + NSString *dir = NSTemporaryDirectory(); + NSString *target = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"target-%@.bin", NSUUID.UUID.UUIDString]]; + NSString *link = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"link-%@.txt", NSUUID.UUID.UUIDString]]; + NSData *original = [@"through-the-link" dataUsingEncoding:NSUTF8StringEncoding]; + XCTAssertTrue([original writeToFile:target atomically:YES]); + NSError *linkError = nil; + XCTAssertTrue([NSFileManager.defaultManager createSymbolicLinkAtPath:link + withDestinationPath:target + error:&linkError], + @"%@", linkError); + [self addTeardownBlock:^{ + [NSFileManager.defaultManager removeItemAtPath:link error:nil]; + [NSFileManager.defaultManager removeItemAtPath:target error:nil]; + }]; + + NSError *error = nil; + XCTAssertTrue([BGSRNCreatedReportOps addFileAtPath:link name:@"note.txt" toReport:_report error:&error], + @"%@", error); + XCTAssertEqualObjects(_report.attachments.lastObject.data, original); + + NSString *big = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"big-%@.bin", NSUUID.UUID.UUIDString]]; + NSString *bigLink = [dir stringByAppendingPathComponent: + [NSString stringWithFormat:@"biglink-%@", NSUUID.UUID.UUIDString]]; + XCTAssertTrue([[NSFileManager defaultManager] createFileAtPath:big contents:nil attributes:nil]); + NSFileHandle *handle = [NSFileHandle fileHandleForWritingAtPath:big]; + XCTAssertNotNil(handle); + [handle truncateFileAtOffset:BGSRNCreatedReportAttachmentMaxBytes + 1]; + [handle closeFile]; + XCTAssertTrue([NSFileManager.defaultManager createSymbolicLinkAtPath:bigLink + withDestinationPath:big + error:nil]); + [self addTeardownBlock:^{ + [NSFileManager.defaultManager removeItemAtPath:bigLink error:nil]; + [NSFileManager.defaultManager removeItemAtPath:big error:nil]; + }]; + error = nil; + XCTAssertFalse([BGSRNCreatedReportOps addFileAtPath:bigLink name:@"big.bin" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorAttachmentRejected); + XCTAssertEqual(_report.attachments.count, 1u); +} + +- (void)testInvalidBase64IsABadArgument { + NSError *error = nil; + XCTAssertFalse([BGSRNCreatedReportOps addData:@"not base64!" name:@"blob" toReport:_report error:&error]); + XCTAssertEqual(error.code, BGSRNReportErrorBadArgument); + XCTAssertEqualObjects(BGSRNReportErrorWireCode(error), @"E_REPORT_BAD_ARGUMENT"); + XCTAssertEqual(_report.attachments.count, 0u); +} + +- (void)testTheLimitsMirrorTheSdk { + XCTAssertEqual(BGSRNCreatedReportAttachmentMaxCount, 3u, + @"BGSRNCreatedReportAttachmentMaxCount mirrors the SDK's ATTACHMENTS_LIMIT"); + XCTAssertEqual(BGSRNCreatedReportAttachmentMaxBytes, 3145728u, + @"BGSRNCreatedReportAttachmentMaxBytes mirrors the SDK's MAX_SIZE_ATTACHMENT"); +} + +/// beta3 stores `internalAttributes` in a file-scope global and `-init` +/// replaces it. When this fails, that bug is gone and P5 (one created report +/// at a time) can be lifted. +- (void)testTheSdkSharesAttributesAcrossExtendedReports { + BugseeExtendedReport *first = [BugseeExtendedReport new]; + [first setAttribute:@"only-a" withValue:@"1"]; + XCTAssertEqualObjects(first.attributes[@"only-a"], @"1"); + + BugseeExtendedReport *second = [BugseeExtendedReport new]; + XCTAssertNil(first.attributes[@"only-a"], + @"When this fails, BugseeExtendedReport no longer keeps attributes in a file-scope " + @"global that -init resets, and P5 (one created report at a time) can be lifted. " + @"The second report is %@", + second); +} + +@end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNCreatedReportsTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNCreatedReportsTests.m new file mode 100644 index 00000000..1bdd1504 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNCreatedReportsTests.m @@ -0,0 +1,240 @@ +@import XCTest; +@import Bugsee; +@import BugseeRNSupport; + +#include + +/// Mirrors Android's `CreatedReportsTest` by name, with a `test` prefix. +/// Each case builds its own registry: handles are `cr-1`, `cr-2`, … per +/// registry, so a shared process counter would make `cr-1` depend on order. +@interface BGSRNCreatedReportsTests : XCTestCase +@end + +@implementation BGSRNCreatedReportsTests + +- (BugseeExtendedReport *)newReport { + return [BugseeExtendedReport new]; +} + +- (void)testOneOutstandingAtATime { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + XCTAssertFalse([registry reserve]); + + NSString *handle = [registry fulfil:[self newReport] reservation:reservation]; + XCTAssertNotNil(handle); + XCTAssertFalse([registry reserve], @"a fulfilled report still holds the slot"); +} + +- (void)testANullReportFreesTheSlot { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + XCTAssertNil([registry fulfil:nil reservation:reservation]); + XCTAssertTrue([registry reserve], @"a nil report is the SDK making none, and the slot must open"); +} + +- (void)testTakeFreesTheSlot { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + BugseeExtendedReport *report = [self newReport]; + NSString *handle = [registry fulfil:report reservation:reservation]; + XCTAssertEqual([registry take:handle], report); + XCTAssertTrue([registry reserve]); +} + +- (void)testHandlesAreFreshAndPrefixed { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger firstReservation = [registry reserve]; + XCTAssertTrue(firstReservation); + NSString *first = [registry fulfil:[self newReport] reservation:firstReservation]; + XCTAssertEqualObjects(first, @"cr-1"); + + XCTAssertNotNil([registry take:first]); + const NSUInteger secondReservation = [registry reserve]; + XCTAssertTrue(secondReservation); + NSString *second = [registry fulfil:[self newReport] reservation:secondReservation]; + XCTAssertEqualObjects(second, @"cr-2"); + XCTAssertNotEqualObjects(first, second, @"a taken handle is never reused"); +} + +- (void)testATakenHandleIsGone { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + BugseeExtendedReport *report = [self newReport]; + NSString *handle = [registry fulfil:report reservation:reservation]; + XCTAssertEqual([registry reportFor:handle], report); + XCTAssertEqual([registry take:handle], report); + XCTAssertNil([registry reportFor:handle]); + XCTAssertNil([registry take:handle]); +} + +- (void)testClearFreesEverything { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + NSString *handle = [registry fulfil:[self newReport] reservation:reservation]; + [registry clear]; + XCTAssertNil([registry reportFor:handle]); + XCTAssertNil([registry take:handle]); + XCTAssertTrue([registry reserve]); + + // A reservation the SDK has not answered yet is still a held slot. + [registry clear]; + XCTAssertTrue([registry reserve]); +} + +/// `clear` does not cancel the SDK completion. That completion's token must +/// not store its report into the reservation a later runtime already opened. +- (void)testAStaleReportLeavesTheNextReservationEmpty { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger stale = [registry reserve]; + XCTAssertTrue(stale); + [registry clear]; + + const NSUInteger live = [registry reserve]; + XCTAssertTrue(live); + XCTAssertNotEqual(live, stale); + + BugseeExtendedReport *abandoned = [self newReport]; + XCTAssertNil([registry fulfil:abandoned reservation:stale]); + XCTAssertFalse([registry reserve], @"the new reservation is still open"); + XCTAssertNil([registry reportFor:@"cr-1"]); + + BugseeExtendedReport *created = [self newReport]; + XCTAssertEqualObjects([registry fulfil:created reservation:live], @"cr-1"); + XCTAssertEqual([registry reportFor:@"cr-1"], created); +} + +/// The handle dies at detach. The slot stays taken until `endUpload:` so a +/// second `createReport` cannot run while `uploadReport:` is still copying. +- (void)testAnUploadInFlightKeepsTheSlot { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + BugseeExtendedReport *report = [self newReport]; + NSString *handle = [registry fulfil:report reservation:reservation]; + NSUInteger generation = 0; + XCTAssertEqual([registry detachForUpload:handle generation:&generation], report); + XCTAssertNotEqual(generation, 0u); + XCTAssertNil([registry reportFor:handle]); + XCTAssertFalse([registry reserve]); + [registry endUpload:generation]; + XCTAssertTrue([registry reserve]); +} + +/// `clear` drops the handle and still holds the slot while the upload copies. +/// `endUpload:` then opens it. A repeat of that generation does not drop the +/// report reserved afterwards. +- (void)testClearDuringUploadKeepsTheSlotUntilEnd { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + NSString *handle = [registry fulfil:[self newReport] reservation:reservation]; + NSUInteger generation = 0; + XCTAssertNotNil([registry detachForUpload:handle generation:&generation]); + [registry clear]; + XCTAssertFalse([registry reserve]); + + [registry endUpload:generation]; + const NSUInteger next = [registry reserve]; + XCTAssertTrue(next); + BugseeExtendedReport *created = [self newReport]; + XCTAssertEqualObjects([registry fulfil:created reservation:next], @"cr-2"); + [registry endUpload:generation]; + XCTAssertEqual([registry reportFor:@"cr-2"], created); + XCTAssertFalse([registry reserve]); +} + +/// A nil completion for a reservation that already stored its report must not +/// drop that report. The token was consumed when the report was published. +- (void)testAStaleNilLeavesThePublishedHandle { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + BugseeExtendedReport *report = [self newReport]; + NSString *handle = [registry fulfil:report reservation:reservation]; + XCTAssertEqualObjects(handle, @"cr-1"); + + XCTAssertNil([registry fulfil:nil reservation:reservation]); + XCTAssertEqual([registry reportFor:handle], report); + XCTAssertFalse([registry reserve]); +} + +/// The module reserves before the main hop. `clear` on another queue ends +/// that token. The hop must see it closed, the stale report must not hold +/// the slot, and a later reserve must admit. +- (void)testClearBeforeTheHopClosesTheReservation { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + XCTAssertTrue([registry reservationIsOpen:reservation]); + + dispatch_group_t done = dispatch_group_create(); + dispatch_group_enter(done); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [registry clear]; + dispatch_group_leave(done); + }); + dispatch_group_wait(done, DISPATCH_TIME_FOREVER); + + XCTAssertFalse([registry reservationIsOpen:reservation]); + XCTAssertNil([registry fulfil:[self newReport] reservation:reservation]); + const NSUInteger next = [registry reserve]; + XCTAssertTrue(next); + XCTAssertNotEqual(next, reservation); + XCTAssertTrue([registry reservationIsOpen:next]); + XCTAssertFalse([registry reservationIsOpen:reservation]); +} + +/// Once `createReportWithCompletion:` has been called, `clear` must not free +/// the slot. A second `-init` would wipe the file-scope attributes beta3 +/// shares. The stale fulfil frees the slot and does not publish a handle. +- (void)testClearDuringCreateKeepsTheSlotUntilFulfil { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const NSUInteger reservation = [registry reserve]; + XCTAssertTrue(reservation); + XCTAssertTrue([registry beginCreate:reservation]); + XCTAssertFalse([registry beginCreate:reservation], @"the SDK call is started once"); + + [registry clear]; + XCTAssertFalse([registry reservationIsOpen:reservation]); + XCTAssertFalse([registry reserve], @"the in-flight create still holds the slot"); + XCTAssertNil([registry fulfil:[self newReport] reservation:reservation]); + XCTAssertNil([registry reportFor:@"cr-1"]); + + const NSUInteger next = [registry reserve]; + XCTAssertTrue(next); + XCTAssertNotEqual(next, reservation); +} + +/// Eight threads pass one start barrier and call `reserve`. The lock admits +/// one. A plain check-then-set lets more than one through. +- (void)testConcurrentReservationsAdmitOne { + BGSRNCreatedReports *registry = [BGSRNCreatedReports new]; + const int threads = 8; + __block atomic_int wins = 0; + dispatch_group_t start = dispatch_group_create(); + dispatch_group_t done = dispatch_group_create(); + dispatch_group_enter(start); + + for (int i = 0; i < threads; i++) { + dispatch_group_enter(done); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + dispatch_group_wait(start, DISPATCH_TIME_FOREVER); + if ([registry reserve]) { + atomic_fetch_add_explicit(&wins, 1, memory_order_relaxed); + } + dispatch_group_leave(done); + }); + } + + dispatch_group_leave(start); + dispatch_group_wait(done, DISPATCH_TIME_FOREVER); + XCTAssertEqual(atomic_load_explicit(&wins, memory_order_relaxed), 1); +} + +@end