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. + } + } +}