From fdc1d1a1cfb9336591bf4e3b914ddd9076c243d6 Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 07:07:13 -0700 Subject: [PATCH] Extract native share-file preparation from AndroidDocumentsPlugin Moves Markdown share preparation into a focused SharePreparation helper: the share-cache directory, temporary Markdown file writing behind the path-containment guard, case-insensitive unique in-batch file naming, suggested-name normalization, image attachment collection (the marktext-image link rewrite with its missing-file skip) and byte-for- byte attachment copying, and the ClipData built over the prepared stream URIs. ShareMarkdownPayload becomes a package-visible top-level class, and the supported-image-extension rule moves along with its only remaining callers so the list cannot drift between share and import. The plugin keeps the final Capacitor handling and the Android share Intent dispatch: both share plugin methods still validate through the codec, assemble the SEND/SEND_MULTIPLE intent, exclude the app from its own chooser, and resolve the call. File names, cache layout, provider authority, ClipData shape, and log lines are unchanged. Adds SharePreparationTest (6 JUnit tests) for name normalization, batch-name deduplication, unsafe-image-name rejection, extension rules, byte-for-byte attachment copying, and the path-traversal guard; multi-document sharing was smoke-verified on the emulator end to end through the new helper. --- .../AndroidDocumentsPlugin.java | 209 ++---------------- .../marktextandroid/ShareMarkdownPayload.java | 16 ++ .../marktextandroid/SharePreparation.java | 209 ++++++++++++++++++ .../marktextandroid/SharePreparationTest.java | 80 +++++++ 4 files changed, 321 insertions(+), 193 deletions(-) create mode 100644 android/app/src/main/java/io/github/renakoni/marktextandroid/ShareMarkdownPayload.java create mode 100644 android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java create mode 100644 android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java index 61de9c7..e4b0113 100644 --- a/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/AndroidDocumentsPlugin.java @@ -37,8 +37,6 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @CapacitorPlugin(name = "AndroidDocuments") public class AndroidDocumentsPlugin extends Plugin { @@ -51,11 +49,6 @@ public class AndroidDocumentsPlugin extends Plugin { private static final String EVENT_OPEN_WITH_DOCUMENT = "openWithDocument"; private static final String EVENT_SHARE_DOCUMENT = "shareDocument"; private static final String IMPORTED_IMAGE_DIRECTORY = "images"; - private static final String SHARE_CACHE_DIRECTORY = "shared-markdown"; - private static final Pattern MARKTEXT_IMAGE_SOURCE_PATTERN = Pattern.compile( - "marktext-image://local/([^\\s)]+)", - Pattern.CASE_INSENSITIVE - ); private String lastHandledIncomingIntentId = ""; private String defaultMarkdownEncoding = "utf8"; private boolean autoDetectMarkdownEncoding = true; @@ -141,7 +134,7 @@ public void createMarkdownDocument(PluginCall call) { "text/plain" } ); - intent.putExtra(Intent.EXTRA_TITLE, normalizeSuggestedMarkdownName(call.getString("suggestedName", ""))); + intent.putExtra(Intent.EXTRA_TITLE, SharePreparation.normalizeSuggestedMarkdownName(call.getString("suggestedName", ""))); intent.addFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | @@ -170,11 +163,11 @@ public void shareMarkdownDocument(PluginCall call) { return; } - String suggestedName = normalizeSuggestedMarkdownName(call.getString("suggestedName", "")); + String suggestedName = SharePreparation.normalizeSuggestedMarkdownName(call.getString("suggestedName", "")); boolean attachImages = call.getBoolean("attachImages", true); MarkdownWriteOptions writeOptions = getMarkdownWriteOptions(call); ShareMarkdownPayload sharePayload = attachImages - ? buildShareMarkdownPayload(markdown) + ? SharePreparation.buildShareMarkdownPayload(markdown, getImportedImageDirectoryFile()) : new ShareMarkdownPayload(markdown, new LinkedHashMap<>()); byte[] bytes; try { @@ -186,13 +179,13 @@ public void shareMarkdownDocument(PluginCall call) { } try { - File shareDirectory = getShareCacheDirectory(); - Uri markdownUri = writeShareCacheFile(shareDirectory, suggestedName, bytes); + File shareDirectory = SharePreparation.getShareCacheDirectory(getContext()); + Uri markdownUri = SharePreparation.writeShareCacheFile(getContext(), shareDirectory, suggestedName, bytes); ArrayList streamUris = new ArrayList<>(); streamUris.add(markdownUri); for (Map.Entry imageEntry : sharePayload.images.entrySet()) { - File sharedImage = copyShareImageFile(shareDirectory, imageEntry.getKey(), imageEntry.getValue()); + File sharedImage = SharePreparation.copyShareImageFile(shareDirectory, imageEntry.getKey(), imageEntry.getValue()); streamUris.add( FileProvider.getUriForFile( getContext(), @@ -213,7 +206,7 @@ public void shareMarkdownDocument(PluginCall call) { shareIntent.putExtra(Intent.EXTRA_TITLE, suggestedName); shareIntent.putExtra(Intent.EXTRA_SUBJECT, suggestedName); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - shareIntent.setClipData(buildShareClipData(suggestedName, streamUris)); + shareIntent.setClipData(SharePreparation.buildShareClipData(getContext(), suggestedName, streamUris)); Intent chooser = Intent.createChooser(shareIntent, "Share Markdown"); chooser.putExtra( @@ -262,7 +255,7 @@ public void shareMarkdownDocuments(PluginCall call) { MarkdownWriteOptions writeOptions = getMarkdownWriteOptions(call); try { - File shareDirectory = getShareCacheDirectory(); + File shareDirectory = SharePreparation.getShareCacheDirectory(getContext()); ArrayList streamUris = new ArrayList<>(); Set usedNames = new java.util.HashSet<>(); long totalBytes = 0; @@ -277,15 +270,15 @@ public void shareMarkdownDocuments(PluginCall call) { } byte[] bytes = MarkdownCodec.validateBytes(markdown, writeOptions); - String fileName = uniqueShareFileName( - normalizeSuggestedMarkdownName(document.optString("suggestedName", "")), + String fileName = SharePreparation.uniqueShareFileName( + SharePreparation.normalizeSuggestedMarkdownName(document.optString("suggestedName", "")), usedNames ); if (firstName == null) { firstName = fileName; } - streamUris.add(writeShareCacheFile(shareDirectory, fileName, bytes)); + streamUris.add(SharePreparation.writeShareCacheFile(getContext(), shareDirectory, fileName, bytes)); totalBytes += bytes.length; } @@ -300,7 +293,7 @@ public void shareMarkdownDocuments(PluginCall call) { shareIntent.putExtra(Intent.EXTRA_TITLE, firstName); shareIntent.putExtra(Intent.EXTRA_SUBJECT, firstName); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - shareIntent.setClipData(buildShareClipData(firstName, streamUris)); + shareIntent.setClipData(SharePreparation.buildShareClipData(getContext(), firstName, streamUris)); Intent chooser = Intent.createChooser(shareIntent, "Share Markdown"); chooser.putExtra( @@ -347,7 +340,7 @@ public void renameMarkdownDocument(PluginCall call) { call.reject("A document name is required", "INVALID_DOCUMENT_NAME"); return; } - String newName = normalizeSuggestedMarkdownName(rawName); + String newName = SharePreparation.normalizeSuggestedMarkdownName(rawName); try { if (!DocumentsContract.isDocumentUri(getContext(), uri)) { @@ -615,7 +608,7 @@ private void emitSharedStream(Uri uri, Intent intent) { private void emitSharedText(String markdown, String mimeType, String rawTitle) { // The parser already gated the MIME type and validated the byte size. - String displayName = normalizeSuggestedMarkdownName(rawTitle); + String displayName = SharePreparation.normalizeSuggestedMarkdownName(rawTitle); JSObject document = new JSObject(); document.put("canceled", false); document.put("sourceUri", JSObject.NULL); @@ -958,77 +951,6 @@ private Uri parseContentUri(String value) { return uri; } - private String normalizeSuggestedMarkdownName(String suggestedName) { - String cleaned = suggestedName == null ? "" : suggestedName.trim(); - cleaned = cleaned.replaceAll("[\\\\/:*?\"<>|\\r\\n]+", " "); - cleaned = cleaned.replaceAll("\\s+", " ").trim(); - if (cleaned.length() == 0) { - cleaned = "Untitled"; - } - - String lowerName = cleaned.toLowerCase(Locale.US); - if ( - lowerName.endsWith(".md") || - lowerName.endsWith(".markdown") || - lowerName.endsWith(".mdown") || - lowerName.endsWith(".mkdn") || - lowerName.endsWith(".mkd") - ) { - return cleaned; - } - return cleaned + ".md"; - } - - private ShareMarkdownPayload buildShareMarkdownPayload(String markdown) { - Map images = new LinkedHashMap<>(); - Matcher matcher = MARKTEXT_IMAGE_SOURCE_PATTERN.matcher(markdown); - StringBuffer rewrittenMarkdown = new StringBuffer(); - - while (matcher.find()) { - String fileName = normalizeImportedImageFileName(Uri.decode(matcher.group(1))); - if (fileName.length() == 0) { - continue; - } - - File importedImage = new File(getImportedImageDirectoryFile(), fileName); - if (!isFileInDirectory(importedImage, getImportedImageDirectoryFile()) || !importedImage.isFile()) { - Log.w(TAG, "Skipping missing Android image during share: " + safeForLog(fileName)); - continue; - } - - images.put(fileName, importedImage); - matcher.appendReplacement(rewrittenMarkdown, Matcher.quoteReplacement(fileName)); - } - // TODO: Add linked Android image attachments when the linked images setting is wired. - matcher.appendTail(rewrittenMarkdown); - - return new ShareMarkdownPayload(rewrittenMarkdown.toString(), images); - } - - private ClipData buildShareClipData(String suggestedName, ArrayList streamUris) { - ClipData clipData = ClipData.newUri(getContext().getContentResolver(), suggestedName, streamUris.get(0)); - for (int index = 1; index < streamUris.size(); index++) { - clipData.addItem(new ClipData.Item(streamUris.get(index))); - } - return clipData; - } - - private String uniqueShareFileName(String fileName, Set usedNames) { - if (usedNames.add(fileName.toLowerCase(Locale.US))) { - return fileName; - } - - int extensionIndex = fileName.lastIndexOf('.'); - String baseName = extensionIndex > 0 ? fileName.substring(0, extensionIndex) : fileName; - String extension = extensionIndex > 0 ? fileName.substring(extensionIndex) : ""; - for (int counter = 2; ; counter++) { - String candidate = baseName + " " + counter + extension; - if (usedNames.add(candidate.toLowerCase(Locale.US))) { - return candidate; - } - } - } - private int getDocumentFlags(Uri uri) { try ( Cursor cursor = getContext() @@ -1041,70 +963,6 @@ private int getDocumentFlags(Uri uri) { } return 0; } - - private File getShareCacheDirectory() throws IOException { - File directory = new File(getContext().getCacheDir(), SHARE_CACHE_DIRECTORY); - if (!directory.exists() && !directory.mkdirs()) { - throw new IOException("Could not create share cache directory"); - } - return directory; - } - - private Uri writeShareCacheFile(File directory, String displayName, byte[] bytes) throws IOException { - File outputFile = new File(directory, normalizeSuggestedMarkdownName(displayName)); - if (!isFileInDirectory(outputFile, directory)) { - throw new SecurityException("Share cache path escaped the expected directory"); - } - - try (OutputStream output = new FileOutputStream(outputFile, false)) { - output.write(bytes); - output.flush(); - } - - return FileProvider.getUriForFile( - getContext(), - getContext().getPackageName() + ".fileprovider", - outputFile - ); - } - - private File copyShareImageFile(File directory, String displayName, File sourceFile) throws IOException { - String normalizedName = normalizeImportedImageFileName(displayName); - if (normalizedName.length() == 0) { - throw new SecurityException("Invalid shared image file name"); - } - - File outputFile = new File(directory, normalizedName); - if (!isFileInDirectory(outputFile, directory)) { - throw new SecurityException("Shared image path escaped the expected directory"); - } - - try ( - InputStream input = new java.io.FileInputStream(sourceFile); - OutputStream output = new FileOutputStream(outputFile, false) - ) { - byte[] buffer = new byte[8192]; - int read; - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); - } - output.flush(); - } - - return outputFile; - } - - private boolean isFileInDirectory(File file, File directory) { - try { - String directoryPath = directory.getCanonicalPath(); - String filePath = file.getCanonicalPath(); - return filePath.startsWith(directoryPath + File.separator); - } catch (IOException ex) { - Log.w(TAG, "Could not validate file path", ex); - return false; - } - } - private File getImportedImageDirectoryFile() { return new File(getContext().getFilesDir(), IMPORTED_IMAGE_DIRECTORY); } @@ -1299,7 +1157,7 @@ private boolean isSupportedImage(String displayName, String mimeType) { return ( mimeType.length() == 0 || "application/octet-stream".equals(mimeType) - ) && hasSupportedImageExtension(displayName); + ) && SharePreparation.hasSupportedImageExtension(displayName); } private boolean isSupportedImageMimeType(String mimeType) { @@ -1313,18 +1171,6 @@ private boolean isSupportedImageMimeType(String mimeType) { ); } - private boolean hasSupportedImageExtension(String value) { - String lowerName = value == null ? "" : value.toLowerCase(Locale.US); - return ( - lowerName.endsWith(".jpg") || - lowerName.endsWith(".jpeg") || - lowerName.endsWith(".png") || - lowerName.endsWith(".gif") || - lowerName.endsWith(".webp") || - lowerName.endsWith(".svg") - ); - } - private String normalizeImageDisplayName(String displayName, String mimeType) { String cleaned = displayName == null ? "" : displayName.trim(); cleaned = cleaned.replaceAll("[\\\\/:*?\"<>|\\r\\n]+", " "); @@ -1333,7 +1179,7 @@ private String normalizeImageDisplayName(String displayName, String mimeType) { cleaned = "Image"; } - if (hasSupportedImageExtension(cleaned)) { + if (SharePreparation.hasSupportedImageExtension(cleaned)) { return cleaned; } return cleaned + extensionForImageMimeType(mimeType); @@ -1358,19 +1204,6 @@ private String createStoredImageName(String displayName, String mimeType) { return System.currentTimeMillis() + "-" + unique + "-" + baseName + extension; } - private String normalizeImportedImageFileName(String fileName) { - String normalized = fileName == null ? "" : fileName.trim(); - if ( - normalized.length() == 0 || - normalized.contains("/") || - normalized.contains("\\") || - !hasSupportedImageExtension(normalized) - ) { - return ""; - } - return normalized; - } - private String extensionFromImageName(String displayName) { String lowerName = displayName == null ? "" : displayName.toLowerCase(Locale.US); if (lowerName.endsWith(".jpeg")) { @@ -1454,14 +1287,4 @@ private String safeForLog(String value) { return value.replace('\r', ' ').replace('\n', ' ').trim(); } - private static class ShareMarkdownPayload { - - final String markdown; - final Map images; - - ShareMarkdownPayload(String markdown, Map images) { - this.markdown = markdown; - this.images = images; - } - } } diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/ShareMarkdownPayload.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/ShareMarkdownPayload.java new file mode 100644 index 0000000..c95ac5c --- /dev/null +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/ShareMarkdownPayload.java @@ -0,0 +1,16 @@ +package io.github.renakoni.marktextandroid; + +import java.io.File; +import java.util.Map; + +/** Markdown rewritten for sharing plus the image files to attach. */ +class ShareMarkdownPayload { + + final String markdown; + final Map images; + + ShareMarkdownPayload(String markdown, Map images) { + this.markdown = markdown; + this.images = images; + } +} diff --git a/android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java b/android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java new file mode 100644 index 0000000..05336c8 --- /dev/null +++ b/android/app/src/main/java/io/github/renakoni/marktextandroid/SharePreparation.java @@ -0,0 +1,209 @@ +package io.github.renakoni.marktextandroid; + +import android.content.ClipData; +import android.content.Context; +import android.net.Uri; +import android.util.Log; +import androidx.core.content.FileProvider; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Prepares Markdown shares: the share-cache directory, temporary Markdown + * files, unique in-batch file names, image attachment collection and copying, + * and the ClipData over the prepared stream URIs. The plugin keeps the final + * Capacitor handling and the Android share-Intent dispatch. + */ +final class SharePreparation { + + private static final String TAG = "MarkTextAndroid"; + private static final String SHARE_CACHE_DIRECTORY = "shared-markdown"; + private static final Pattern MARKTEXT_IMAGE_SOURCE_PATTERN = Pattern.compile( + "marktext-image://local/([^\\s)]+)", + Pattern.CASE_INSENSITIVE + ); + + private SharePreparation() {} + + static String normalizeSuggestedMarkdownName(String suggestedName) { + String cleaned = suggestedName == null ? "" : suggestedName.trim(); + cleaned = cleaned.replaceAll("[\\\\/:*?\"<>|\\r\\n]+", " "); + cleaned = cleaned.replaceAll("\\s+", " ").trim(); + if (cleaned.length() == 0) { + cleaned = "Untitled"; + } + + String lowerName = cleaned.toLowerCase(Locale.US); + if ( + lowerName.endsWith(".md") || + lowerName.endsWith(".markdown") || + lowerName.endsWith(".mdown") || + lowerName.endsWith(".mkdn") || + lowerName.endsWith(".mkd") + ) { + return cleaned; + } + return cleaned + ".md"; + } + + static String uniqueShareFileName(String fileName, Set usedNames) { + if (usedNames.add(fileName.toLowerCase(Locale.US))) { + return fileName; + } + + int extensionIndex = fileName.lastIndexOf('.'); + String baseName = extensionIndex > 0 ? fileName.substring(0, extensionIndex) : fileName; + String extension = extensionIndex > 0 ? fileName.substring(extensionIndex) : ""; + for (int counter = 2; ; counter++) { + String candidate = baseName + " " + counter + extension; + if (usedNames.add(candidate.toLowerCase(Locale.US))) { + return candidate; + } + } + } + + static ShareMarkdownPayload buildShareMarkdownPayload(String markdown, File importedImageDirectory) { + Map images = new LinkedHashMap<>(); + Matcher matcher = MARKTEXT_IMAGE_SOURCE_PATTERN.matcher(markdown); + StringBuffer rewrittenMarkdown = new StringBuffer(); + + while (matcher.find()) { + String fileName = normalizeImportedImageFileName(Uri.decode(matcher.group(1))); + if (fileName.length() == 0) { + continue; + } + + File importedImage = new File(importedImageDirectory, fileName); + if (!isFileInDirectory(importedImage, importedImageDirectory) || !importedImage.isFile()) { + Log.w(TAG, "Skipping missing Android image during share: " + safeForLog(fileName)); + continue; + } + + images.put(fileName, importedImage); + matcher.appendReplacement(rewrittenMarkdown, Matcher.quoteReplacement(fileName)); + } + // TODO: Add linked Android image attachments when the linked images setting is wired. + matcher.appendTail(rewrittenMarkdown); + + return new ShareMarkdownPayload(rewrittenMarkdown.toString(), images); + } + + static ClipData buildShareClipData(Context context, String suggestedName, ArrayList streamUris) { + ClipData clipData = ClipData.newUri(context.getContentResolver(), suggestedName, streamUris.get(0)); + for (int index = 1; index < streamUris.size(); index++) { + clipData.addItem(new ClipData.Item(streamUris.get(index))); + } + return clipData; + } + + static File getShareCacheDirectory(Context context) throws IOException { + File directory = new File(context.getCacheDir(), SHARE_CACHE_DIRECTORY); + if (!directory.exists() && !directory.mkdirs()) { + throw new IOException("Could not create share cache directory"); + } + return directory; + } + + static Uri writeShareCacheFile( + Context context, + File directory, + String displayName, + byte[] bytes + ) throws IOException { + File outputFile = new File(directory, normalizeSuggestedMarkdownName(displayName)); + if (!isFileInDirectory(outputFile, directory)) { + throw new SecurityException("Share cache path escaped the expected directory"); + } + + try (OutputStream output = new FileOutputStream(outputFile, false)) { + output.write(bytes); + output.flush(); + } + + return FileProvider.getUriForFile( + context, + context.getPackageName() + ".fileprovider", + outputFile + ); + } + + static File copyShareImageFile(File directory, String displayName, File sourceFile) throws IOException { + String normalizedName = normalizeImportedImageFileName(displayName); + if (normalizedName.length() == 0) { + throw new SecurityException("Invalid shared image file name"); + } + + File outputFile = new File(directory, normalizedName); + if (!isFileInDirectory(outputFile, directory)) { + throw new SecurityException("Shared image path escaped the expected directory"); + } + + try ( + InputStream input = new FileInputStream(sourceFile); + OutputStream output = new FileOutputStream(outputFile, false) + ) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + output.flush(); + } + + return outputFile; + } + + static boolean hasSupportedImageExtension(String value) { + String lowerName = value == null ? "" : value.toLowerCase(Locale.US); + return ( + lowerName.endsWith(".jpg") || + lowerName.endsWith(".jpeg") || + lowerName.endsWith(".png") || + lowerName.endsWith(".gif") || + lowerName.endsWith(".webp") || + lowerName.endsWith(".svg") + ); + } + + static String normalizeImportedImageFileName(String fileName) { + String normalized = fileName == null ? "" : fileName.trim(); + if ( + normalized.length() == 0 || + normalized.contains("/") || + normalized.contains("\\") || + !hasSupportedImageExtension(normalized) + ) { + return ""; + } + return normalized; + } + + private static boolean isFileInDirectory(File file, File directory) { + try { + String directoryPath = directory.getCanonicalPath(); + String filePath = file.getCanonicalPath(); + return filePath.startsWith(directoryPath + File.separator); + } catch (IOException ex) { + Log.w(TAG, "Could not validate file path", ex); + return false; + } + } + + private static String safeForLog(String value) { + if (value == null) { + return ""; + } + return value.replace('\r', ' ').replace('\n', ' ').trim(); + } +} diff --git a/android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java b/android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java new file mode 100644 index 0000000..62df980 --- /dev/null +++ b/android/app/src/test/java/io/github/renakoni/marktextandroid/SharePreparationTest.java @@ -0,0 +1,80 @@ +package io.github.renakoni.marktextandroid; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import org.junit.Test; + +public class SharePreparationTest { + + @Test + public void normalizesSuggestedNamesToSafeMarkdownFiles() { + assertEquals("Meeting A B.md", SharePreparation.normalizeSuggestedMarkdownName("Meeting: A/B?")); + assertEquals("Untitled.md", SharePreparation.normalizeSuggestedMarkdownName(" ")); + assertEquals("notes.markdown", SharePreparation.normalizeSuggestedMarkdownName("notes.markdown")); + assertEquals("Trip plan.md", SharePreparation.normalizeSuggestedMarkdownName(" Trip plan ")); + } + + @Test + public void deduplicatesBatchFileNamesCaseInsensitively() { + Set used = new HashSet<>(); + + assertEquals("Trip.md", SharePreparation.uniqueShareFileName("Trip.md", used)); + assertEquals("trip 2.md", SharePreparation.uniqueShareFileName("trip.md", used)); + assertEquals("Trip 3.md", SharePreparation.uniqueShareFileName("Trip.md", used)); + assertEquals("Other.md", SharePreparation.uniqueShareFileName("Other.md", used)); + } + + @Test + public void rejectsUnsafeImportedImageNames() { + assertEquals("", SharePreparation.normalizeImportedImageFileName("../escape.png")); + assertEquals("", SharePreparation.normalizeImportedImageFileName("dir\\escape.png")); + assertEquals("", SharePreparation.normalizeImportedImageFileName("script.js")); + assertEquals("", SharePreparation.normalizeImportedImageFileName(null)); + assertEquals("photo.webp", SharePreparation.normalizeImportedImageFileName(" photo.webp ")); + } + + @Test + public void recognizesSupportedImageExtensions() { + assertTrue(SharePreparation.hasSupportedImageExtension("a.PNG")); + assertTrue(SharePreparation.hasSupportedImageExtension("a.svg")); + assertFalse(SharePreparation.hasSupportedImageExtension("a.bmp")); + } + + @Test + public void copiesShareImagesByteForByte() throws Exception { + File directory = Files.createTempDirectory("share-prep-test").toFile(); + File source = new File(directory, "source.bin"); + byte[] payload = new byte[] { 1, 2, 3, 4, 5 }; + try (FileOutputStream output = new FileOutputStream(source)) { + output.write(payload); + } + + File copied = SharePreparation.copyShareImageFile(directory, "picture.png", source); + + assertEquals("picture.png", copied.getName()); + assertArrayEquals(payload, Files.readAllBytes(copied.toPath())); + } + + @Test + public void refusesToCopyImagesWithInvalidNames() throws Exception { + File directory = Files.createTempDirectory("share-prep-test").toFile(); + File source = new File(directory, "source.bin"); + assertTrue(source.createNewFile()); + + try { + SharePreparation.copyShareImageFile(directory, "../escape.png", source); + fail("expected SecurityException"); + } catch (SecurityException expected) { + // Path-traversal names must never reach the filesystem. + } + } +}