1import { describe, expect, it, vi } from "@effect/vitest";
2import {
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
6} from "@t3tools/contracts";
7import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
8
9import {
10 buildIncomingShareDraft,
11 hasIncomingShareContent,
12 isShareFileUriUnderOwnedRoots,
13 selectIncomingShareAttachments,
14 selectIncomingShareAttachmentsForServer,
15} from "./incoming-share-model";
16
17describe("incoming native shares", () => {
18 it("converts shared text, URLs, and images into a durable composer draft", async () => {
19 const image: SharePayload = {
20 shareType: "image",
21 value: "file:///shared/Screenshot.png",
22 mimeType: "image/png",
23 };
24 const payloads: SharePayload[] = [
25 { shareType: "text", value: "Please explain this error" },
26 { shareType: "url", value: "https://example.com/issue/1" },
27 { shareType: "text", value: "Please explain this error" },
28 image,
29 ];
30 const resolvedImage: ResolvedSharePayload = {
31 ...image,
32 contentUri: image.value,
33 contentType: "image",
34 contentMimeType: "image/png",
35 contentSize: 3,
36 originalName: "Screenshot.png",
37 };
38 const removeOwnedFile = vi.fn(() => Promise.resolve());
39
40 const result = await buildIncomingShareDraft({
41 id: "share-1",
42 createdAt: "2026-07-15T10:00:00.000Z",
43 payloads,
44 resolvedPayloads: [resolvedImage],
45 fileReader: {
46 readBase64: async () => "YWJj",
47 removeOwnedFile,
48 },
49 });
50
51 expect(result).toEqual({
52 schemaVersion: 1,
53 id: "share-1",
54 createdAt: "2026-07-15T10:00:00.000Z",
55 text: "Please explain this error\n\nhttps://example.com/issue/1",
56 attachments: [
57 {
58 id: "share-1:image:3",
59 type: "image",
60 name: "Screenshot.png",
61 mimeType: "image/png",
62 sizeBytes: 3,
63 dataUrl: "data:image/png;base64,YWJj",
64 previewUri: "data:image/png;base64,YWJj",
65 },
66 ],
67 warnings: [],
68 });
69 expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
70 expect(hasIncomingShareContent(result)).toBe(true);
71 });
72
73 it("skips oversized images and releases the temporary native file", async () => {
74 const image: SharePayload = {
75 shareType: "image",
76 value: "file:///shared/huge.png",
77 mimeType: "image/png",
78 };
79 const readBase64 = vi.fn(async () => "unused");
80 const removeOwnedFile = vi.fn(() => Promise.resolve());
81
82 const result = await buildIncomingShareDraft({
83 id: "share-2",
84 createdAt: "2026-07-15T10:00:00.000Z",
85 payloads: [image],
86 resolvedPayloads: [
87 {
88 ...image,
89 contentUri: image.value,
90 contentType: "image",
91 contentMimeType: "image/png",
92 contentSize: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1,
93 originalName: "huge.png",
94 },
95 ],
96 fileReader: { readBase64, removeOwnedFile },
97 });
98
99 expect(result.attachments).toEqual([]);
100 expect(result.warnings).toEqual(["'huge.png' exceeds the 10 MB attachment limit."]);
101 expect(readBase64).not.toHaveBeenCalled();
102 expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
103 expect(hasIncomingShareContent(result)).toBe(false);
104 });
105
106 it("keeps a shared PDF on disk without converting its contents to base64", async () => {
107 const file: SharePayload = {
108 shareType: "file",
109 value: "file:///shared/report.pdf",
110 mimeType: "application/pdf",
111 };
112 const readBase64 = vi.fn(async () => "unused");
113 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
114 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
115
116 const result = await buildIncomingShareDraft({
117 id: "share-report",
118 createdAt: "2026-07-15T10:00:00.000Z",
119 payloads: [file],
120 resolvedPayloads: [
121 {
122 ...file,
123 contentUri: file.value,
124 contentType: "file",
125 contentMimeType: "application/pdf",
126 contentSize: 42,
127 originalName: "report.pdf",
128 },
129 ],
130 fileReader: { readBase64, persistFile, removeOwnedFile },
131 });
132
133 expect(result.attachments).toEqual([
134 {
135 id: "share-report:file:0",
136 type: "file",
137 name: "report.pdf",
138 mimeType: "application/pdf",
139 sizeBytes: 42,
140 fileUri: "file:///documents/report.pdf",
141 },
142 ]);
143 expect(readBase64).not.toHaveBeenCalled();
144 expect(persistFile).toHaveBeenCalledWith(file.value, "report.pdf");
145 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
146 });
147
148 it("rejects shared files that exceed the generic attachment limit", async () => {
149 const file: SharePayload = {
150 shareType: "file",
151 value: "file:///shared/huge.zip",
152 mimeType: "application/zip",
153 };
154 const persistFile = vi.fn(async () => "file:///documents/huge.zip");
155 const removeOwnedFile = vi.fn(async () => undefined);
156
157 const result = await buildIncomingShareDraft({
158 id: "share-huge",
159 createdAt: "2026-07-15T10:00:00.000Z",
160 payloads: [file],
161 resolvedPayloads: [
162 {
163 ...file,
164 contentUri: file.value,
165 contentType: "file",
166 contentMimeType: "application/zip",
167 contentSize: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1,
168 originalName: "huge.zip",
169 },
170 ],
171 fileReader: {
172 readBase64: async () => "unused",
173 persistFile,
174 removeOwnedFile,
175 },
176 });
177
178 expect(result.attachments).toEqual([]);
179 expect(result.warnings).toEqual(["'huge.zip' exceeds the 50 MB attachment limit."]);
180 expect(persistFile).not.toHaveBeenCalled();
181 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
182 });
183
184 it.each([
185 { value: "file:///shared/clip.MOV", mimeType: "video/quicktime", originalName: "clip.MOV" },
186 { value: "content://media/videos/12", mimeType: "video/mp4", originalName: "clip.mp4" },
187 ])("imports a shared video from $value without reading it as an image", async (video) => {
188 const sizeBytes = 20 * 1024 * 1024;
189 const fileUri = `file:///documents/${video.originalName}`;
190 const readBase64 = vi.fn(async () => "unused");
191 const persistFile = vi.fn(async () => fileUri);
192 const removeOwnedFile = vi.fn(async () => undefined);
193
194 const result = await buildIncomingShareDraft({
195 id: "share-video",
196 createdAt: "2026-08-30T10:00:00.000Z",
197 payloads: [{ ...video, shareType: "video" }],
198 resolvedPayloads: [],
199 fileReader: { readBase64, persistFile, readSize: async () => sizeBytes, removeOwnedFile },
200 });
201
202 expect(result.warnings).toEqual([]);
203 expect(result.attachments).toEqual([
204 {
205 id: "share-video:file:0",
206 type: "file",
207 name: video.originalName,
208 mimeType: video.mimeType,
209 sizeBytes,
210 fileUri,
211 },
212 ]);
213 expect(readBase64).not.toHaveBeenCalled();
214 expect(removeOwnedFile).toHaveBeenCalledWith(video.value);
215 expect(
216 selectIncomingShareAttachments({
217 attachments: result.attachments,
218 maxFileAttachmentBytes: 50 * 1024 * 1024,
219 }),
220 ).toEqual({ attachments: result.attachments, warnings: [] });
221 expect(
222 selectIncomingShareAttachments({
223 attachments: result.attachments,
224 maxFileAttachmentBytes: 10 * 1024 * 1024,
225 }),
226 ).toEqual({
227 attachments: [],
228 warnings: [`'${video.originalName}' exceeds the 10 MB attachment limit.`],
229 });
230 });
231
232 it("reports an unreadable shared file without calling it oversized", async () => {
233 const file: SharePayload = {
234 shareType: "file",
235 value: "file:///shared/empty.txt",
236 mimeType: "text/plain",
237 };
238
239 const result = await buildIncomingShareDraft({
240 id: "share-empty",
241 createdAt: "2026-07-15T10:00:00.000Z",
242 payloads: [file],
243 resolvedPayloads: [],
244 fileReader: {
245 readBase64: async () => "unused",
246 readSize: async () => 0,
247 removeOwnedFile: async () => undefined,
248 },
249 });
250
251 expect(result.attachments).toEqual([]);
252 expect(result.warnings).toEqual(["'empty.txt' is empty or could not be read."]);
253 });
254
255 it("reads an Android content URI's size after copying it into app-owned storage", async () => {
256 const file: SharePayload = {
257 shareType: "file",
258 value: "content://shared/report",
259 mimeType: "application/pdf",
260 };
261 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
262 const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? null : 42));
263
264 const result = await buildIncomingShareDraft({
265 id: "share-android-report",
266 createdAt: "2026-07-15T10:00:00.000Z",
267 payloads: [file],
268 resolvedPayloads: [],
269 fileReader: {
270 readBase64: async () => "unused",
271 persistFile,
272 readSize,
273 removeOwnedFile: async () => undefined,
274 },
275 });
276
277 expect(result.attachments).toEqual([
278 {
279 id: "share-android-report:file:0",
280 type: "file",
281 name: "report",
282 mimeType: "application/pdf",
283 sizeBytes: 42,
284 fileUri: "file:///documents/report.pdf",
285 },
286 ]);
287 expect(readSize.mock.calls).toEqual([
288 ["content://shared/report"],
289 ["file:///documents/report.pdf"],
290 ]);
291 });
292
293 it("records the stored copy's measured size when a content URI under-reports", async () => {
294 const file: SharePayload = {
295 shareType: "file",
296 value: "content://shared/report",
297 mimeType: "application/pdf",
298 };
299 // The source claims 42 bytes but the stored copy measures 4200.
300 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
301 const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? 42 : 4200));
302
303 const result = await buildIncomingShareDraft({
304 id: "share-android-report",
305 createdAt: "2026-07-15T10:00:00.000Z",
306 payloads: [file],
307 resolvedPayloads: [],
308 fileReader: {
309 readBase64: async () => "unused",
310 persistFile,
311 readSize,
312 removeOwnedFile: async () => undefined,
313 },
314 });
315
316 expect(result.attachments).toHaveLength(1);
317 expect(result.attachments[0]?.sizeBytes).toBe(4200);
318 });
319
320 it("treats a zero-length Android content URI as unknown until its copy is measured", async () => {
321 const file: SharePayload = {
322 shareType: "file",
323 value: "content://shared/report",
324 mimeType: "application/pdf",
325 };
326
327 const result = await buildIncomingShareDraft({
328 id: "share-zero-metadata",
329 createdAt: "2026-07-15T10:00:00.000Z",
330 payloads: [file],
331 resolvedPayloads: [],
332 fileReader: {
333 readBase64: async () => "unused",
334 persistFile: async () => "file:///documents/report.pdf",
335 readSize: async (uri) => (uri.startsWith("content:") ? 0 : 42),
336 removeOwnedFile: async () => undefined,
337 },
338 });
339
340 expect(result.attachments[0]?.sizeBytes).toBe(42);
341 expect(result.warnings).toEqual([]);
342 });
343
344 it("rejects a shared file whose persisted copy measures empty and releases the copy", async () => {
345 const file: SharePayload = {
346 shareType: "file",
347 value: "content://shared/report",
348 mimeType: "application/pdf",
349 };
350 const persistedUri = "file:///documents/report.pdf";
351 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
352
353 const result = await buildIncomingShareDraft({
354 id: "share-empty-copy",
355 createdAt: "2026-07-15T10:00:00.000Z",
356 payloads: [file],
357 resolvedPayloads: [],
358 fileReader: {
359 readBase64: async () => "unused",
360 persistFile: async () => persistedUri,
361 // The source claims 42 bytes but the stored copy measures zero: the
362 // copy is what uploads, so its measured size wins and the empty file
363 // is rejected instead of shipped with a made-up size.
364 readSize: async (uri) => (uri.startsWith("content:") ? 42 : 0),
365 removeOwnedFile,
366 },
367 });
368
369 expect(result.attachments).toEqual([]);
370 expect(result.warnings).toEqual(["'report' is empty or could not be read."]);
371 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri);
372 });
373
374 it("keeps the Android display name without copying the file into the Expo cache", async () => {
375 const file = {
376 shareType: "file" as const,
377 value: "content://shared/12345",
378 mimeType: "application/pdf",
379 originalName: "quarterly-report.pdf",
380 };
381
382 const result = await buildIncomingShareDraft({
383 id: "share-named-report",
384 createdAt: "2026-07-15T10:00:00.000Z",
385 payloads: [file],
386 resolvedPayloads: [],
387 fileReader: {
388 readBase64: async () => "unused",
389 readSize: async () => 42,
390 persistFile: async (_uri, name) => `file:///documents/${name}`,
391 removeOwnedFile: async () => undefined,
392 },
393 });
394
395 expect(result.attachments).toEqual([
396 {
397 id: "share-named-report:file:0",
398 type: "file",
399 name: "quarterly-report.pdf",
400 mimeType: "application/pdf",
401 sizeBytes: 42,
402 fileUri: "file:///documents/quarterly-report.pdf",
403 },
404 ]);
405 });
406
407 it("keeps a no-copy file source that the returned attachment still owns", async () => {
408 const sourceUri = "file:///documents/report.pdf";
409 const removeOwnedFile = vi.fn(async () => undefined);
410
411 const result = await buildIncomingShareDraft({
412 id: "share-no-copy",
413 createdAt: "2026-07-15T10:00:00.000Z",
414 payloads: [{ shareType: "file", value: sourceUri, mimeType: "application/pdf" }],
415 resolvedPayloads: [],
416 fileReader: {
417 readBase64: async () => "unused",
418 readSize: async () => 42,
419 removeOwnedFile,
420 },
421 });
422
423 expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: sourceUri });
424 expect(removeOwnedFile).not.toHaveBeenCalled();
425 });
426
427 it("keeps a persisted copy and releases distinct temporary source URIs", async () => {
428 const payloadUri = "content://shared/report";
429 const resolvedUri = "file:///cache/report.pdf";
430 const persistedUri = "file:///documents/report.pdf";
431 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
432
433 const result = await buildIncomingShareDraft({
434 id: "share-copy",
435 createdAt: "2026-07-15T10:00:00.000Z",
436 payloads: [{ shareType: "file", value: payloadUri, mimeType: "application/pdf" }],
437 resolvedPayloads: [
438 {
439 shareType: "file",
440 value: payloadUri,
441 mimeType: "application/pdf",
442 contentUri: resolvedUri,
443 contentType: "file",
444 contentMimeType: "application/pdf",
445 contentSize: 42,
446 originalName: "report.pdf",
447 },
448 ],
449 fileReader: {
450 readBase64: async () => "unused",
451 readSize: async () => 42,
452 persistFile: async () => persistedUri,
453 removeOwnedFile,
454 },
455 });
456
457 expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: persistedUri });
458 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toEqual([resolvedUri, payloadUri]);
459 });
460
461 it("keeps images and rejects shared files on servers without file support", () => {
462 const image = {
463 id: "image-1",
464 type: "image" as const,
465 name: "image.png",
466 mimeType: "image/png",
467 sizeBytes: 3,
468 dataUrl: "data:image/png;base64,YWJj",
469 previewUri: "data:image/png;base64,YWJj",
470 };
471 const file = {
472 id: "file-1",
473 type: "file" as const,
474 name: "report.pdf",
475 mimeType: "application/pdf",
476 sizeBytes: 42,
477 fileUri: "file:///documents/report.pdf",
478 };
479
480 expect(
481 selectIncomingShareAttachments({
482 attachments: [image, file],
483 maxFileAttachmentBytes: null,
484 }),
485 ).toEqual({
486 attachments: [image],
487 warnings: ["'report.pdf' was skipped because this server does not support files."],
488 });
489 });
490
491 it("uses the destination server's attachment limit in share warnings", () => {
492 const file = {
493 id: "file-1",
494 type: "file" as const,
495 name: "report.pdf",
496 mimeType: "application/pdf",
497 sizeBytes: 6 * 1024 * 1024,
498 fileUri: "file:///documents/report.pdf",
499 };
500
501 expect(
502 selectIncomingShareAttachments({
503 attachments: [file],
504 maxFileAttachmentBytes: 5 * 1024 * 1024,
505 }),
506 ).toEqual({
507 attachments: [],
508 warnings: ["'report.pdf' exceeds the 5 MB attachment limit."],
509 });
510 });
511
512 it("uses current server support and limits when selecting a reserved share", () => {
513 const file = {
514 id: "file-1",
515 type: "file" as const,
516 name: "report.pdf",
517 mimeType: "application/pdf",
518 sizeBytes: 6 * 1024 * 1024,
519 fileUri: "file:///documents/report.pdf",
520 };
521
522 expect(
523 selectIncomingShareAttachmentsForServer({ attachments: [file], serverConfig: null }),
524 ).toEqual({ status: "pending" });
525 expect(
526 selectIncomingShareAttachmentsForServer({
527 attachments: [file],
528 serverConfig: { environment: { capabilities: { attachmentUploads: true } } },
529 }),
530 ).toMatchObject({ status: "ready", attachments: [] });
531 expect(
532 selectIncomingShareAttachmentsForServer({
533 attachments: [file],
534 serverConfig: {
535 environment: {
536 capabilities: {
537 attachmentUploads: true,
538 fileAttachments: { maxUploadBytes: 5 * 1024 * 1024 },
539 },
540 },
541 },
542 }),
543 ).toMatchObject({ status: "ready", attachments: [] });
544 expect(
545 selectIncomingShareAttachmentsForServer({
546 attachments: [file],
547 serverConfig: {
548 environment: {
549 capabilities: {
550 attachmentUploads: true,
551 fileAttachments: { maxUploadBytes: 10 * 1024 * 1024 },
552 },
553 },
554 },
555 }),
556 ).toMatchObject({ status: "ready", attachments: [file] });
557 });
558
559 it("releases every temporary file when a share exceeds the attachment limit", async () => {
560 const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
561 shareType: "image" as const,
562 value: `file:///shared/${index}.png`,
563 mimeType: "image/png",
564 }));
565 const removeOwnedFile = vi.fn(() => Promise.resolve());
566 const readBase64 = vi.fn(async () => "YWJj");
567
568 const result = await buildIncomingShareDraft({
569 id: "share-3",
570 createdAt: "2026-07-15T10:00:00.000Z",
571 payloads,
572 resolvedPayloads: [],
573 fileReader: { readBase64, removeOwnedFile },
574 });
575
576 expect(result.attachments).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS);
577 expect(result.warnings).toEqual([
578 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`,
579 ]);
580 expect(readBase64).toHaveBeenCalledTimes(PROVIDER_SEND_TURN_MAX_ATTACHMENTS);
581 expect(removeOwnedFile).toHaveBeenCalledTimes(payloads.length);
582 });
583
584 it("maps duplicate image payloads to distinct resolved files", async () => {
585 const duplicate: SharePayload = {
586 shareType: "image",
587 value: "content://shared/screenshot",
588 mimeType: "image/png",
589 };
590 const resolvedPayloads: ResolvedSharePayload[] = [
591 {
592 ...duplicate,
593 contentUri: "file:///cache/first.png",
594 contentType: "image",
595 contentMimeType: "image/png",
596 contentSize: 3,
597 originalName: "first.png",
598 },
599 {
600 ...duplicate,
601 contentUri: "file:///cache/second.png",
602 contentType: "image",
603 contentMimeType: "image/png",
604 contentSize: 3,
605 originalName: "second.png",
606 },
607 ];
608 const readBase64 = vi.fn(async (uri: string) =>
609 uri.includes("first") ? "Zmlyc3Q=" : "c2Vjb25k",
610 );
611 const removeOwnedFile = vi.fn(async () => undefined);
612
613 const result = await buildIncomingShareDraft({
614 id: "share-duplicates",
615 createdAt: "2026-07-16T08:00:00.000Z",
616 payloads: [duplicate, duplicate],
617 resolvedPayloads,
618 fileReader: { readBase64, removeOwnedFile },
619 });
620
621 expect(readBase64.mock.calls.map(([uri]) => uri)).toEqual([
622 "file:///cache/first.png",
623 "file:///cache/second.png",
624 ]);
625 expect(result.attachments.map((attachment) => attachment.name)).toEqual([
626 "first.png",
627 "second.png",
628 ]);
629 expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/first.png");
630 expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/second.png");
631 });
632
633 it("keeps imported content when temporary-file cleanup fails", async () => {
634 const image: SharePayload = {
635 shareType: "image",
636 value: "file:///shared/screenshot.png",
637 mimeType: "image/png",
638 };
639
640 const result = await buildIncomingShareDraft({
641 id: "share-cleanup-failure",
642 createdAt: "2026-07-16T08:00:00.000Z",
643 payloads: [image],
644 resolvedPayloads: [],
645 fileReader: {
646 readBase64: async () => "YWJj",
647 removeOwnedFile: async () => {
648 throw new Error("file is busy");
649 },
650 },
651 });
652
653 expect(result.attachments).toHaveLength(1);
654 expect(result.warnings).toEqual([]);
655 });
656
657 it("releases a persisted copy when a later step fails to read it", async () => {
658 const file: SharePayload = {
659 shareType: "file",
660 value: "content://shared/report",
661 mimeType: "application/pdf",
662 };
663 const persistedUri = "file:///documents/t3-composer-attachments/report.pdf";
664 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
665
666 const result = await buildIncomingShareDraft({
667 id: "share-persist-leak",
668 createdAt: "2026-07-16T08:00:00.000Z",
669 payloads: [file],
670 resolvedPayloads: [],
671 fileReader: {
672 readBase64: async () => "unused",
673 persistFile: async () => persistedUri,
674 readSize: async (uri) => {
675 if (uri === persistedUri) {
676 throw new Error("read failed");
677 }
678 return null;
679 },
680 removeOwnedFile,
681 },
682 });
683
684 expect(result.attachments).toEqual([]);
685 expect(result.warnings).toEqual(["read failed"]);
686 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri);
687 });
688});
689
690describe("share cleanup ownership", () => {
691 const ownedRoots = [
692 "file:///var/mobile/Containers/Data/Application/APP/Documents/",
693 "file:///var/mobile/Containers/Shared/AppGroup/GROUP",
694 ];
695
696 it("allows deleting files inside the app's own directories", () => {
697 expect(
698 isShareFileUriUnderOwnedRoots(
699 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/shared.pdf",
700 ownedRoots,
701 ),
702 ).toBe(true);
703 });
704
705 it("treats /private/var and /var as the same iOS location", () => {
706 expect(
707 isShareFileUriUnderOwnedRoots(
708 "file:///private/var/mobile/Containers/Shared/AppGroup/GROUP/shared.pdf",
709 ownedRoots,
710 ),
711 ).toBe(true);
712 expect(
713 isShareFileUriUnderOwnedRoots(
714 "file:///var/mobile/Containers/Data/Application/APP/Documents/t3-composer-attachments/a.pdf",
715 ["file:///private/var/mobile/Containers/Data/Application/APP/Documents/"],
716 ),
717 ).toBe(true);
718 });
719
720 it("refuses to delete a sender-owned open-in-place document", () => {
721 expect(
722 isShareFileUriUnderOwnedRoots(
723 "file:///private/var/mobile/Containers/Shared/FileProvider/OTHER/File%20Provider%20Storage/taxes.pdf",
724 ownedRoots,
725 ),
726 ).toBe(false);
727 });
728
729 it("refuses traversal segments that escape an owned root", () => {
730 // An encoded separator survives URL normalization: "..%2F.." decodes to
731 // "../..", so the lexical check must reject it before containment.
732 expect(
733 isShareFileUriUnderOwnedRoots(
734 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/..%2F..%2FsenderDoc.pdf",
735 ownedRoots,
736 ),
737 ).toBe(false);
738 expect(
739 isShareFileUriUnderOwnedRoots(
740 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/../senderDoc.pdf",
741 ownedRoots,
742 ),
743 ).toBe(false);
744 expect(
745 isShareFileUriUnderOwnedRoots(
746 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/%2e%2e/senderDoc.pdf",
747 ownedRoots,
748 ),
749 ).toBe(false);
750 });
751
752 it("refuses non-file URIs and the owned root itself", () => {
753 expect(isShareFileUriUnderOwnedRoots("content://shared/report", ownedRoots)).toBe(false);
754 expect(
755 isShareFileUriUnderOwnedRoots(
756 "file:///var/mobile/Containers/Shared/AppGroup/GROUP",
757 ownedRoots,
758 ),
759 ).toBe(false);
760 });
761});