From eeaae99e9175a25abcbe695d800f8bf5317e4d51 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Fri, 11 Jul 2025 12:09:59 +0200 Subject: [PATCH 1/2] Fix Data Base64 length encoding bug ### Motivation We have an issue in encodeComputeCapacity. If we add lineBreaks, we assumed to add line breaks for lines that ended at the max capacity. ### Changes - encodeComputeCapacity checks if the last line uses the full length and removes unnecessary seperatorBytes if needed rdar://155204772 --- .../Data/Data+Base64.swift | 7 ++- .../FoundationEssentialsTests/DataTests.swift | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Sources/FoundationEssentials/Data/Data+Base64.swift b/Sources/FoundationEssentials/Data/Data+Base64.swift index fb0960f96..b5860b416 100644 --- a/Sources/FoundationEssentials/Data/Data+Base64.swift +++ b/Sources/FoundationEssentials/Data/Data+Base64.swift @@ -395,7 +395,12 @@ extension Base64 { let lineLength = options.contains(.lineLength64Characters) ? 64 : 76 let lineBreaks = capacityWithoutBreaks / lineLength - let lineBreakCapacity = lineBreaks * seperatorBytes + var lineBreakCapacity = lineBreaks * seperatorBytes + // in case the last row uses all available space, we don't need to add line breaks + // but we can't remove bytes if we have an empty input + if capacityWithoutBreaks % lineLength == 0 && capacityWithoutBreaks > seperatorBytes { + lineBreakCapacity -= seperatorBytes + } return capacityWithoutBreaks + lineBreakCapacity } diff --git a/Tests/FoundationEssentialsTests/DataTests.swift b/Tests/FoundationEssentialsTests/DataTests.swift index 64648c118..2718cc1da 100644 --- a/Tests/FoundationEssentialsTests/DataTests.swift +++ b/Tests/FoundationEssentialsTests/DataTests.swift @@ -2276,6 +2276,62 @@ extension DataTests { #expect("TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gVXQgYXQgdGluY2lkdW50IGFyY3UuIFN1c3BlbmRpc3NlIG5lYyBzb2RhbGVzIGVyYXQsIHNpdCBhbWV0IGltcGVyZGlldCBpcHN1bS4gRXRpYW0gc2VkIG9ybmFyZSBmZWxpcy4gTnVuYyBtYXVyaXMgdHVycGlzLCBiaWJlbmR1bSBub24gbGVjdHVzIHF1aXMsIG1hbGVzdWFkYSBwbGFjZXJhdCB0dXJwaXMuIE5hbSBhZGlwaXNjaW5nIG5vbiBtYXNzYSBldCBzZW1wZXIuIE51bGxhIGNvbnZhbGxpcyBzZW1wZXIgYmliZW5kdW0uIEFsaXF1YW0gZGljdHVtIG51bGxhIGN1cnN1cyBtaSB1bHRyaWNpZXMsIGF0IHRpbmNpZHVudCBtaSBzYWdpdHRpcy4gTnVsbGEgZmF1Y2lidXMgYXQgZHVpIHF1aXMgc29kYWxlcy4gTW9yYmkgcnV0cnVtLCBkdWkgaWQgdWx0cmljZXMgdmVuZW5hdGlzLCBhcmN1IHVybmEgZWdlc3RhcyBmZWxpcywgdmVsIHN1c2NpcGl0IG1hdXJpcyBhcmN1IHF1aXMgcmlzdXMuIE51bmMgdmVuZW5hdGlzIGxpZ3VsYSBhdCBvcmNpIHRyaXN0aXF1ZSwgZXQgbWF0dGlzIHB1cnVzIHB1bHZpbmFyLiBFdGlhbSB1bHRyaWNpZXMgZXN0IG9kaW8uIE51bmMgZWxlaWZlbmQgbWFsZXN1YWRhIGp1c3RvLCBuZWMgZXVpc21vZCBzZW0gdWx0cmljZXMgcXVpcy4gRXRpYW0gbmVjIG5pYmggc2l0IGFtZXQgbG9yZW0gZmF1Y2lidXMgZGFwaWJ1cyBxdWlzIG5lYyBsZW8uIFByYWVzZW50IHNpdCBhbWV0IG1hdXJpcyB2ZWwgbGFjdXMgaGVuZHJlcml0IHBvcnRhIG1vbGxpcyBjb25zZWN0ZXR1ciBtaS4gRG9uZWMgZWdldCB0b3J0b3IgZHVpLiBNb3JiaSBpbXBlcmRpZXQsIGFyY3Ugc2l0IGFtZXQgZWxlbWVudHVtIGludGVyZHVtLCBxdWFtIG5pc2wgdGVtcG9yIHF1YW0sIHZpdGFlIGZldWdpYXQgYXVndWUgcHVydXMgc2VkIGxhY3VzLiBJbiBhYyB1cm5hIGFkaXBpc2NpbmcgcHVydXMgdmVuZW5hdGlzIHZvbHV0cGF0IHZlbCBldCBtZXR1cy4gTnVsbGFtIG5lYyBhdWN0b3IgcXVhbS4gUGhhc2VsbHVzIHBvcnR0aXRvciBmZWxpcyBhYyBuaWJoIGdyYXZpZGEgc3VzY2lwaXQgdGVtcHVzIGF0IGFudGUuIE51bmMgcGVsbGVudGVzcXVlIGlhY3VsaXMgc2FwaWVuIGEgbWF0dGlzLiBBZW5lYW4gZWxlaWZlbmQgZG9sb3Igbm9uIG51bmMgbGFvcmVldCwgbm9uIGRpY3R1bSBtYXNzYSBhbGlxdWFtLiBBZW5lYW4gcXVpcyB0dXJwaXMgYXVndWUuIFByYWVzZW50IGF1Z3VlIGxlY3R1cywgbW9sbGlzIG5lYyBlbGVtZW50dW0gZXUsIGRpZ25pc3NpbSBhdCB2ZWxpdC4gVXQgY29uZ3VlIG5lcXVlIGlkIHVsbGFtY29ycGVyIHBlbGxlbnRlc3F1ZS4gTWFlY2VuYXMgZXVpc21vZCBpbiBlbGl0IGV1IHZlaGljdWxhLiBOdWxsYW0gdHJpc3RpcXVlIGR1aSBudWxsYSwgbmVjIGNvbnZhbGxpcyBtZXR1cyBzdXNjaXBpdCBlZ2V0LiBDcmFzIHNlbXBlciBhdWd1ZSBuZWMgY3Vyc3VzIGJsYW5kaXQuIE51bGxhIHJob25jdXMgZXQgb2RpbyBxdWlzIGJsYW5kaXQuIFByYWVzZW50IGxvYm9ydGlzIGRpZ25pc3NpbSB2ZWxpdCB1dCBwdWx2aW5hci4gRHVpcyBpbnRlcmR1bSBxdWFtIGFkaXBpc2NpbmcgZG9sb3Igc2VtcGVyIHNlbXBlci4gTnVuYyBiaWJlbmR1bSBjb252YWxsaXMgZHVpLCBlZ2V0IG1vbGxpcyBtYWduYSBoZW5kcmVyaXQgZXQuIE1vcmJpIGZhY2lsaXNpcywgYXVndWUgZXUgZnJpbmdpbGxhIGNvbnZhbGxpcywgbWF1cmlzIGVzdCBjdXJzdXMgZG9sb3IsIGV1IHBvc3VlcmUgb2RpbyBudW5jIHF1aXMgb3JjaS4gVXQgZXUganVzdG8gc2VtLiBQaGFzZWxsdXMgdXQgZXJhdCByaG9uY3VzLCBmYXVjaWJ1cyBhcmN1IHZpdGFlLCB2dWxwdXRhdGUgZXJhdC4gQWxpcXVhbSBuZWMgbWFnbmEgdml2ZXJyYSwgaW50ZXJkdW0gZXN0IHZpdGFlLCByaG9uY3VzIHNhcGllbi4gRHVpcyB0aW5jaWR1bnQgdGVtcG9yIGlwc3VtIHV0IGRhcGlidXMuIE51bGxhbSBjb21tb2RvIHZhcml1cyBtZXR1cywgc2VkIHNvbGxpY2l0dWRpbiBlcm9zLiBFdGlhbSBuZWMgb2RpbyBldCBkdWkgdGVtcG9yIGJsYW5kaXQgcG9zdWVyZS4=" == base64, "medium base64 conversion should work") } + @Test func testBase64LineLengthOptions() { + let expected46 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + let length46String = Data(repeating:0, count: 46).base64EncodedString(options: .lineLength64Characters) + #expect(length46String == expected46) + let length46Data = Data(repeating:0, count: 46).base64EncodedData(options: .lineLength64Characters) + #expect(length46Data.count == 64) + #expect(String(decoding: length46Data, as: Unicode.UTF8.self) == expected46) + + let expected47 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + let length47String = Data(repeating:0, count: 47).base64EncodedString(options: .lineLength64Characters) + #expect(length47String == expected47) + let length47Data = Data(repeating:0, count: 47).base64EncodedData(options: .lineLength64Characters) + #expect(length47Data.count == 64) + #expect(String(decoding: length47Data, as: Unicode.UTF8.self) == expected47) + + let expected48 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + let length48String = Data(repeating:0, count: 48).base64EncodedString(options: .lineLength64Characters) + #expect(length48String == expected48) + let length48Data = Data(repeating:0, count: 48).base64EncodedData(options: .lineLength64Characters) + #expect(length48Data.count == 64) + #expect(String(decoding: length48Data, as: Unicode.UTF8.self) == expected48) + + let length49 = Data(repeating:0, count: 49).base64EncodedString(options: .lineLength64Characters) + #expect(length49 == #"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\#r\#nAA=="#) + #expect(Array(length49.utf8)[64] == 13) + #expect(Array(length49.utf8)[65] == 10) + } + + // we have more encodeToStringTests than we have encodeToDataTests. + // lets fix this by ensuring data output matches string output. + @Test func testBase64DataOutputMatchesStingOutput() { + for count in 0..<10_000 { + let data = Data(repeating: 0, count: count) + let stringBase64 = data.base64EncodedString(options: .lineLength64Characters) + let dataBase64 = data.base64EncodedData(options: .lineLength64Characters) + + #expect(stringBase64 == String(decoding: dataBase64, as: Unicode.UTF8.self)) + } + + for count in 0..<10_000 { + let data = Data(repeating: 0, count: count) + let stringBase64 = data.base64EncodedString(options: .lineLength76Characters) + let dataBase64 = data.base64EncodedData(options: .lineLength76Characters) + + #expect(stringBase64 == String(decoding: dataBase64, as: Unicode.UTF8.self)) + } + + for count in 0..<10_000 { + let data = Data(repeating: 0, count: count) + let stringBase64 = data.base64EncodedString() + let dataBase64 = data.base64EncodedData() + + #expect(stringBase64 == String(decoding: dataBase64, as: Unicode.UTF8.self)) + } + } + @Test func anyHashableContainingData() { let values: [Data] = [ Data(base64Encoded: "AAAA")!, From 56dde3d19dd3c1501564c6a99b6bd3571430a5d0 Mon Sep 17 00:00:00 2001 From: Fabian Fett Date: Mon, 14 Jul 2025 15:30:35 +0200 Subject: [PATCH 2/2] Made the test range smaller --- .../FoundationEssentialsTests/DataTests.swift | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/Tests/FoundationEssentialsTests/DataTests.swift b/Tests/FoundationEssentialsTests/DataTests.swift index 2718cc1da..3b5798b25 100644 --- a/Tests/FoundationEssentialsTests/DataTests.swift +++ b/Tests/FoundationEssentialsTests/DataTests.swift @@ -2306,27 +2306,23 @@ extension DataTests { // we have more encodeToStringTests than we have encodeToDataTests. // lets fix this by ensuring data output matches string output. - @Test func testBase64DataOutputMatchesStingOutput() { - for count in 0..<10_000 { - let data = Data(repeating: 0, count: count) - let stringBase64 = data.base64EncodedString(options: .lineLength64Characters) - let dataBase64 = data.base64EncodedData(options: .lineLength64Characters) - #expect(stringBase64 == String(decoding: dataBase64, as: Unicode.UTF8.self)) - } - - for count in 0..<10_000 { - let data = Data(repeating: 0, count: count) - let stringBase64 = data.base64EncodedString(options: .lineLength76Characters) - let dataBase64 = data.base64EncodedData(options: .lineLength76Characters) - - #expect(stringBase64 == String(decoding: dataBase64, as: Unicode.UTF8.self)) - } + @Test( + arguments: [ + Data.Base64EncodingOptions.lineLength64Characters, + [.lineLength64Characters, .endLineWithCarriageReturn], + .lineLength76Characters, + [.lineLength64Characters, .endLineWithLineFeed], + [], + ] + ) + func testBase64DataOutputMatchesStingOutput(options: Data.Base64EncodingOptions) { + let iterations = 1_000 - for count in 0..<10_000 { + for count in 0..