Skip to content

Commit 68a8dbb

Browse files
authored
Make TextBundleWrapper init nullable and fail on non-UTF-8 text (#25968)
* Make TextBundleWrapper init nullable and fail on non-UTF-8 text * Add TextBundleWrapper nullability regression tests * Add TextBundleWrapper tests for assets, type, and malformed info.json * Guard against non-dictionary info.json in TextBundleWrapper * Propagate TextBundle read errors through the share extractor Now that the TextBundleWrapper initializer bridges to a throwing Swift initializer, surface its read failures instead of swallowing them with try?. Make TypeBasedExtensionContentExtractor.convert(payload:) throwing and thread the error up through handleTextBundle, handleTextPack, processLocalFile, and URLExtractor.convert. The loadItem completion — which already receives an Error — logs both load and conversion failures and balances the dispatch group via defer. * Surface share-extraction failures instead of loading blindly loadShare now returns a ShareLoadOutcome — the assembled share, the attachments that were skipped, and whether anything usable was extracted. extract() yields (items, failures) per provider, accumulated under a lock since loadItem calls back concurrently. The share and draft extensions act on it: cancel the request when nothing extracted and something errored, show a non-blocking notice on a partial failure, and open the editor as before when the share was merely empty.
1 parent 666da8c commit 68a8dbb

9 files changed

Lines changed: 350 additions & 68 deletions

File tree

‎Modules/Package.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let package = Package(
2020
.library(name: "ShareExtensionCore", targets: ["ShareExtensionCore"]),
2121
.library(name: "SFHFKeychainUtils", targets: ["SFHFKeychainUtils"]),
2222
.library(name: "Support", targets: ["Support"]),
23+
.library(name: "TextBundle", targets: ["TextBundle"]),
2324
.library(name: "WordPressFlux", targets: ["WordPressFlux"]),
2425
.library(name: "WordPressShared", targets: ["WordPressShared"]),
2526
.library(name: "WordPressUI", targets: ["WordPressUI"]),

‎Modules/Sources/TextBundle/TextBundleWrapper.m‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ - (instancetype)init
5050
return self;
5151
}
5252

53-
- (instancetype)initWithContentsOfURL:(NSURL *)url options:(NSFileWrapperReadingOptions)options error:(NSError **)error
53+
- (nullable instancetype)initWithContentsOfURL:(NSURL *)url options:(NSFileWrapperReadingOptions)options error:(NSError **)error
5454
{
5555
self = [self init];
5656
if (self) {
@@ -63,7 +63,7 @@ - (instancetype)initWithContentsOfURL:(NSURL *)url options:(NSFileWrapperReading
6363
return self;
6464
}
6565

66-
- (instancetype)initWithFileWrapper:(NSFileWrapper *)fileWrapper error:(NSError **)error
66+
- (nullable instancetype)initWithFileWrapper:(NSFileWrapper *)fileWrapper error:(NSError **)error
6767
{
6868
self = [self init];
6969
if (self) {
@@ -134,7 +134,14 @@ - (BOOL)readFromFilewrapper:(NSFileWrapper *)textBundleFileWrapper error:(NSErro
134134
if (error) { *error = jsonReadError; }
135135
return NO;
136136
}
137-
137+
138+
if (![jsonObject isKindOfClass:[NSDictionary class]]) {
139+
if (error) {
140+
*error = [NSError errorWithDomain:TextBundleErrorDomain code:TextBundleErrorInvalidFormat userInfo:nil];
141+
}
142+
return NO;
143+
}
144+
138145
self.metadata = [jsonObject mutableCopy];
139146
self.version = self.metadata[kTextBundleVersion];
140147
self.type = self.metadata[kTextBundleType];
@@ -158,6 +165,12 @@ - (BOOL)readFromFilewrapper:(NSFileWrapper *)textBundleFileWrapper error:(NSErro
158165
NSFileWrapper *textFileWrapper = [[textBundleFileWrapper fileWrappers] objectForKey:[self textFileNameInFileWrapper:textBundleFileWrapper]];
159166
if (textFileWrapper) {
160167
self.text = [[NSString alloc] initWithData:textFileWrapper.regularFileContents encoding:NSUTF8StringEncoding];
168+
if (self.text == nil) {
169+
if (error) {
170+
*error = [NSError errorWithDomain:TextBundleErrorDomain code:TextBundleErrorInvalidFormat userInfo:nil];
171+
}
172+
return NO;
173+
}
161174
}
162175
else {
163176
if (error) {
@@ -201,7 +214,7 @@ - (NSString *)textFilenameForType:(NSString *)type
201214

202215
#pragma mark - Assets
203216

204-
- (NSFileWrapper *)fileWrapperForAssetFilename:(NSString *)filename
217+
- (nullable NSFileWrapper *)fileWrapperForAssetFilename:(NSString *)filename
205218
{
206219
__block NSFileWrapper *fileWrapper = nil;
207220
[[self.assetsFileWrapper fileWrappers] enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull __unused key, NSFileWrapper * _Nonnull __unused obj, BOOL * _Nonnull __unused stop) {

‎Modules/Sources/TextBundle/include/TextBundleWrapper.h‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ typedef NS_ENUM(NSInteger, TextBundleError)
8989
@param error If an error occurs, upon return contains an NSError object that describes the problem. Pass NULL if you do not want error information.
9090
@return A new TextBundleWrapper for the content at url.
9191
*/
92-
- (instancetype)initWithContentsOfURL:(NSURL *)url options:(NSFileWrapperReadingOptions)options error:(NSError **)error;
92+
- (nullable instancetype)initWithContentsOfURL:(NSURL *)url options:(NSFileWrapperReadingOptions)options error:(NSError **)error;
9393

9494

9595
/**
@@ -99,7 +99,7 @@ typedef NS_ENUM(NSInteger, TextBundleError)
9999
@param error If an error occurs, upon return contains an NSError object that describes the problem. Pass NULL if you do not want error information.
100100
@return A new TextBundleWrapper for the content of the fileWrapper.
101101
*/
102-
- (instancetype)initWithFileWrapper:(NSFileWrapper *)fileWrapper error:(NSError **)error;
102+
- (nullable instancetype)initWithFileWrapper:(NSFileWrapper *)fileWrapper error:(NSError **)error;
103103

104104

105105
/**
@@ -122,7 +122,7 @@ typedef NS_ENUM(NSInteger, TextBundleError)
122122
@param filename A filename in the asset/ folder
123123
@return A NSFilewrapper represeting filename or nil it the file doesn't exist
124124
*/
125-
- (NSFileWrapper *)fileWrapperForAssetFilename:(NSString *)filename;
125+
- (nullable NSFileWrapper *)fileWrapperForAssetFilename:(NSString *)filename;
126126

127127

128128

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import Foundation
2+
import Testing
3+
4+
import TextBundle
5+
6+
/// Regression tests for the nullability hardening in `TextBundleWrapper`:
7+
/// a read failure must surface as a thrown error (the initializer is now `nullable`,
8+
/// so it bridges to a throwing Swift initializer) rather than a nil-holding instance,
9+
/// and a text file that isn't valid UTF-8 must fail the read instead of leaving
10+
/// the `nonnull` `text` property nil.
11+
struct TextBundleWrapperTests {
12+
13+
// MARK: Helpers
14+
15+
/// Writes a `.textbundle` directory to a unique temporary location and returns its URL.
16+
/// Pass `nil` for `info` or `textFileName`/`textData` to omit that member.
17+
private func makeBundle(info: Data?, textFileName: String?, textData: Data?) throws -> URL {
18+
let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
19+
.appendingPathComponent("TextBundleTests-\(UUID().uuidString)", isDirectory: true)
20+
let bundle = root.appendingPathComponent("document.textbundle", isDirectory: true)
21+
try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true)
22+
if let info {
23+
try info.write(to: bundle.appendingPathComponent("info.json"))
24+
}
25+
if let textFileName, let textData {
26+
try textData.write(to: bundle.appendingPathComponent(textFileName))
27+
}
28+
return bundle
29+
}
30+
31+
private var validInfoJSON: Data {
32+
Data(#"{"version":2,"type":"net.daringfireball.markdown"}"#.utf8)
33+
}
34+
35+
// MARK: Happy path
36+
37+
@Test func validBundleLoadsText() throws {
38+
let url = try makeBundle(info: validInfoJSON, textFileName: "text.markdown", textData: Data("# Hello".utf8))
39+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
40+
41+
let wrapper = try TextBundleWrapper(contentsOf: url, options: .immediate)
42+
#expect(wrapper.text == "# Hello")
43+
#expect(wrapper.type == kUTTypeMarkdown)
44+
}
45+
46+
// MARK: Finding 1 — read failures must throw (nullable initializer)
47+
48+
@Test func missingTextFileThrows() throws {
49+
let url = try makeBundle(info: validInfoJSON, textFileName: nil, textData: nil)
50+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
51+
52+
#expect(throws: (any Error).self) {
53+
_ = try TextBundleWrapper(contentsOf: url, options: .immediate)
54+
}
55+
}
56+
57+
@Test func missingInfoJSONThrows() throws {
58+
let url = try makeBundle(info: nil, textFileName: "text.markdown", textData: Data("hi".utf8))
59+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
60+
61+
#expect(throws: (any Error).self) {
62+
_ = try TextBundleWrapper(contentsOf: url, options: .immediate)
63+
}
64+
}
65+
66+
@Test func unreadableURLThrows() {
67+
let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
68+
.appendingPathComponent("does-not-exist-\(UUID().uuidString).textbundle", isDirectory: true)
69+
70+
#expect(throws: (any Error).self) {
71+
_ = try TextBundleWrapper(contentsOf: url, options: .immediate)
72+
}
73+
}
74+
75+
// MARK: Finding 2 — non-UTF-8 text must fail the read, not leave `text` nil
76+
77+
@Test func nonUTF8TextThrows() throws {
78+
// 0xFF is never a valid UTF-8 byte, so NSString decoding returns nil.
79+
let url = try makeBundle(info: validInfoJSON, textFileName: "text.markdown", textData: Data([0xFF, 0xFE, 0xFF]))
80+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
81+
82+
#expect(throws: (any Error).self) {
83+
_ = try TextBundleWrapper(contentsOf: url, options: .immediate)
84+
}
85+
}
86+
87+
// MARK: Assets
88+
89+
@Test func assetsAreLoadedAndLookedUpByFilename() throws {
90+
let url = try makeBundle(info: validInfoJSON, textFileName: "text.markdown", textData: Data("# Hi".utf8))
91+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
92+
let assets = url.appendingPathComponent("assets", isDirectory: true)
93+
try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true)
94+
try Data([0x89, 0x50, 0x4E, 0x47]).write(to: assets.appendingPathComponent("foo.png"))
95+
96+
let wrapper = try TextBundleWrapper(contentsOf: url, options: .immediate)
97+
#expect(wrapper.assetsFileWrapper.fileWrappers?["foo.png"] != nil)
98+
#expect(wrapper.fileWrapper(forAssetFilename: "foo.png") != nil)
99+
#expect(wrapper.fileWrapper(forAssetFilename: "missing.png") == nil)
100+
}
101+
102+
// MARK: Metadata
103+
104+
@Test func nonMarkdownTypeIsReported() throws {
105+
let info = Data(#"{"version":2,"type":"public.plain-text"}"#.utf8)
106+
let url = try makeBundle(info: info, textFileName: "text.txt", textData: Data("hi".utf8))
107+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
108+
109+
let wrapper = try TextBundleWrapper(contentsOf: url, options: .immediate)
110+
#expect(wrapper.type == "public.plain-text")
111+
#expect(wrapper.type != kUTTypeMarkdown)
112+
}
113+
114+
@Test func invalidInfoJSONThrows() throws {
115+
let url = try makeBundle(
116+
info: Data("{ not valid json".utf8),
117+
textFileName: "text.markdown",
118+
textData: Data("hi".utf8)
119+
)
120+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
121+
122+
#expect(throws: (any Error).self) {
123+
_ = try TextBundleWrapper(contentsOf: url, options: .immediate)
124+
}
125+
}
126+
127+
@Test func nonDictionaryInfoJSONThrows() throws {
128+
// Valid JSON but a top-level array (not an object): must fail the read
129+
// instead of crashing on -[NSArray objectForKeyedSubscript:].
130+
let url = try makeBundle(info: Data("[1,2,3]".utf8), textFileName: "text.markdown", textData: Data("hi".utf8))
131+
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
132+
133+
#expect(throws: (any Error).self) {
134+
_ = try TextBundleWrapper(contentsOf: url, options: .immediate)
135+
}
136+
}
137+
}

‎Package.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ let package = Package(
5656
],
5757
path: "Modules/Tests/GutenbergProcessorsTests",
5858
swiftSettings: [.swiftLanguageMode(.v5)]
59+
),
60+
.testTarget(
61+
name: "TextBundleTests",
62+
dependencies: [.product(name: "TextBundle", package: "Modules")],
63+
path: "Modules/Tests/TextBundleTests"
5964
)
6065
]
6166
)

0 commit comments

Comments
 (0)