diff --git a/webrtc-media/src/main/cpp/include/media/JniBoundary.h b/webrtc-media/src/main/cpp/include/media/JniBoundary.h index edb5b13d..30b2a299 100644 --- a/webrtc-media/src/main/cpp/include/media/JniBoundary.h +++ b/webrtc-media/src/main/cpp/include/media/JniBoundary.h @@ -28,8 +28,14 @@ namespace ffmpeg // C++ heap, so that it can also report that an allocation failed. void ThrowJavaException(JNIEnv * env, const char * class_name, const char * message) noexcept; + // The message is UTF-8, as is everything FFmpeg and this library write. void ThrowIOException(JNIEnv * env, const std::string & message) noexcept; + // A Java string of the given UTF-8, in which what is not UTF-8 becomes + // U+FFFD, as it does in Java. Null if it could not be made, and a Java + // exception may be pending then. + jstring NewJavaString(JNIEnv * env, const std::string & utf8) noexcept; + // Turns the C++ exception being handled into a Java one: a failed // allocation into an OutOfMemoryError, anything else into a // RuntimeException. Only to be called from a catch block. @@ -102,8 +108,8 @@ namespace ffmpeg T ref_; }; - // The modified UTF-8 of a Java string, released when this goes out of - // scope, also when a copy of it fails on the way out. + // The characters of a Java string, released when this goes out of scope, + // also when a copy of them fails on the way out. class JavaStringChars { public: @@ -115,13 +121,17 @@ namespace ffmpeg JavaStringChars(const JavaStringChars &) = delete; JavaStringChars & operator=(const JavaStringChars &) = delete; - const char * get() const noexcept + const jchar * get() const noexcept { return chars_; } - // A copy that outlives this object. Throws std::bad_alloc. - std::string str() const; + // The string as UTF-8, which FFmpeg takes, and not as the modified + // UTF-8 of JNI, which writes the characters outside the Basic + // Multilingual Plane as surrogates and U+0000 as two bytes. A + // surrogate without its pair becomes '?', as it does in Java. + // Throws std::bad_alloc. + std::string utf8() const; // How many strings are held right now, for the tests to see that // none is left behind. @@ -130,7 +140,8 @@ namespace ffmpeg private: JNIEnv * env_; jstring string_; - const char * chars_; + const jchar * chars_; + jsize length_; }; } diff --git a/webrtc-media/src/main/cpp/src/JNI_AllocationFault.cpp b/webrtc-media/src/main/cpp/src/JNI_AllocationFault.cpp index eae3d326..6c951ab1 100644 --- a/webrtc-media/src/main/cpp/src/JNI_AllocationFault.cpp +++ b/webrtc-media/src/main/cpp/src/JNI_AllocationFault.cpp @@ -63,12 +63,12 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_AllocationFault_throw case 0: throw std::bad_alloc(); case 1: - throw std::runtime_error(text.get()); + throw std::runtime_error(text.utf8()); case 2: // A JNI call that failed, and a C++ exception after it. env->FindClass("dev/onvoid/webrtc/media/player/NoSuchClass"); - throw std::runtime_error(text.get()); + throw std::runtime_error(text.utf8()); default: throw kind; } diff --git a/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp b/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp index 945bb3f5..d51b0997 100644 --- a/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp +++ b/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp @@ -83,7 +83,14 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_open return 0; } - url = chars.str(); + url = chars.utf8(); + } + + // FFmpeg would read the source only up to it, and open another one. + if (url.find('\0') != std::string::npos) { + ffmpeg::ThrowIOException(env, "Source must not contain a null character"); + + return 0; } auto reader = std::make_unique(static_cast(timeoutUs)); diff --git a/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp b/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp index 47a5c215..3a2267eb 100644 --- a/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp +++ b/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp @@ -55,7 +55,7 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_crea return 0; } - path = chars.str(); + path = chars.utf8(); } auto recorder = std::make_unique(api, path); diff --git a/webrtc-media/src/main/cpp/src/media/JavaPlayerObserver.cpp b/webrtc-media/src/main/cpp/src/media/JavaPlayerObserver.cpp index 57fbcd5c..9a99ea3f 100644 --- a/webrtc-media/src/main/cpp/src/media/JavaPlayerObserver.cpp +++ b/webrtc-media/src/main/cpp/src/media/JavaPlayerObserver.cpp @@ -15,6 +15,7 @@ */ #include "media/JavaPlayerObserver.h" +#include "media/JniBoundary.h" namespace ffmpeg { @@ -109,7 +110,7 @@ namespace ffmpeg JNIEnv * env = Attach(&attached); if (CanCallJava(env)) { - jstring text = env->NewStringUTF(message.c_str()); + jstring text = NewJavaString(env, message); if (text != nullptr) { env->CallVoidMethod(player_, on_error_, text); diff --git a/webrtc-media/src/main/cpp/src/media/JavaRecorderObserver.cpp b/webrtc-media/src/main/cpp/src/media/JavaRecorderObserver.cpp index c7addf39..a44a46e4 100644 --- a/webrtc-media/src/main/cpp/src/media/JavaRecorderObserver.cpp +++ b/webrtc-media/src/main/cpp/src/media/JavaRecorderObserver.cpp @@ -15,6 +15,7 @@ */ #include "media/JavaRecorderObserver.h" +#include "media/JniBoundary.h" namespace ffmpeg { @@ -120,7 +121,7 @@ namespace ffmpeg JNIEnv * env = Attach(&attached); if (CanCallJava(env)) { - jstring text = env->NewStringUTF(message.c_str()); + jstring text = NewJavaString(env, message); if (text != nullptr) { env->CallVoidMethod(recorder_, method, text); diff --git a/webrtc-media/src/main/cpp/src/media/JniBoundary.cpp b/webrtc-media/src/main/cpp/src/media/JniBoundary.cpp index 68a179e3..c8ddd9ee 100644 --- a/webrtc-media/src/main/cpp/src/media/JniBoundary.cpp +++ b/webrtc-media/src/main/cpp/src/media/JniBoundary.cpp @@ -43,6 +43,108 @@ namespace buffer[length] = '\0'; } + + // Writes a code point as UTF-8, or as the modified UTF-8 of JNI, which + // writes U+0000 as two bytes and one beyond U+FFFF as its two UTF-16 + // surrogates, three bytes each. + void AppendCodePoint(std::string & out, char32_t c, bool modified) + { + if (modified && c > 0xFFFF) { + c -= 0x10000; + + AppendCodePoint(out, 0xD800 + (c >> 10), true); + AppendCodePoint(out, 0xDC00 + (c & 0x3FF), true); + } + else if (c < 0x80 && !(modified && c == 0)) { + out += static_cast(c); + } + else if (c < 0x800) { + out += static_cast(0xC0 | (c >> 6)); + out += static_cast(0x80 | (c & 0x3F)); + } + else if (c < 0x10000) { + out += static_cast(0xE0 | (c >> 12)); + out += static_cast(0x80 | ((c >> 6) & 0x3F)); + out += static_cast(0x80 | (c & 0x3F)); + } + else { + out += static_cast(0xF0 | (c >> 18)); + out += static_cast(0x80 | ((c >> 12) & 0x3F)); + out += static_cast(0x80 | ((c >> 6) & 0x3F)); + out += static_cast(0x80 | (c & 0x3F)); + } + } + + // Reads the code point at `pos` and moves past it. What is not UTF-8 is + // read as U+FFFD, one byte at a time. + char32_t NextCodePoint(const std::string & text, size_t & pos) noexcept + { + const unsigned char lead = static_cast(text[pos++]); + + if (lead < 0x80) { + return lead; + } + + size_t count; + char32_t c; + char32_t min; + + if ((lead & 0xE0) == 0xC0) { + count = 1; + c = lead & 0x1F; + min = 0x80; + } + else if ((lead & 0xF0) == 0xE0) { + count = 2; + c = lead & 0x0F; + min = 0x800; + } + else if ((lead & 0xF8) == 0xF0) { + count = 3; + c = lead & 0x07; + min = 0x10000; + } + else { + return 0xFFFD; + } + + if (text.size() - pos < count) { + return 0xFFFD; + } + + for (size_t i = 0; i < count; i++) { + const unsigned char next = static_cast(text[pos + i]); + + if ((next & 0xC0) != 0x80) { + return 0xFFFD; + } + + c = (c << 6) | (next & 0x3F); + } + + // Overlong, a surrogate, or beyond Unicode. + if (c < min || (c >= 0xD800 && c <= 0xDFFF) || c > 0x10FFFF) { + return 0xFFFD; + } + + pos += count; + + return c; + } + + std::string ToModifiedUtf8(const std::string & utf8) + { + std::string out; + size_t pos = 0; + + out.reserve(utf8.size()); + + while (pos < utf8.size()) { + AppendCodePoint(out, NextCodePoint(utf8, pos), true); + } + + return out; + } } namespace ffmpeg @@ -64,7 +166,22 @@ namespace ffmpeg void ThrowIOException(JNIEnv * env, const std::string & message) noexcept { - ThrowJavaException(env, "java/io/IOException", message.c_str()); + try { + ThrowJavaException(env, "java/io/IOException", ToModifiedUtf8(message).c_str()); + } + catch (const std::bad_alloc &) { + ThrowJavaException(env, "java/lang/OutOfMemoryError", "Native memory allocation failed"); + } + } + + jstring NewJavaString(JNIEnv * env, const std::string & utf8) noexcept + { + try { + return env->NewStringUTF(ToModifiedUtf8(utf8).c_str()); + } + catch (const std::bad_alloc &) { + return nullptr; + } } void ThrowCurrentException(JNIEnv * env) noexcept @@ -90,7 +207,8 @@ namespace ffmpeg JavaStringChars::JavaStringChars(JNIEnv * env, jstring string) noexcept : env_(env), string_(string), - chars_(nullptr) + chars_(nullptr), + length_(0) { if (string == nullptr) { ThrowJavaException(env, "java/lang/NullPointerException", "String is null"); @@ -98,9 +216,10 @@ namespace ffmpeg } // Null when the VM is out of memory, and it has thrown then. - chars_ = env->GetStringUTFChars(string, nullptr); + chars_ = env->GetStringChars(string, nullptr); if (chars_ != nullptr) { + length_ = env->GetStringLength(string); held_strings.fetch_add(1); } } @@ -108,16 +227,34 @@ namespace ffmpeg JavaStringChars::~JavaStringChars() { if (chars_ != nullptr) { - env_->ReleaseStringUTFChars(string_, chars_); + env_->ReleaseStringChars(string_, chars_); held_strings.fetch_sub(1); } } - std::string JavaStringChars::str() const + std::string JavaStringChars::utf8() const { AllocationFault::CheckStringCopy(); - return std::string(chars_); + std::string out; + + out.reserve(static_cast(length_)); + + for (jsize i = 0; i < length_; i++) { + char32_t c = chars_[i]; + + if (c >= 0xD800 && c <= 0xDBFF && i + 1 < length_ + && chars_[i + 1] >= 0xDC00 && chars_[i + 1] <= 0xDFFF) { + c = 0x10000 + ((c - 0xD800) << 10) + (chars_[++i] - 0xDC00); + } + else if (c >= 0xD800 && c <= 0xDFFF) { + c = '?'; + } + + AppendCodePoint(out, c, false); + } + + return out; } int JavaStringChars::Held() noexcept diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java index b3342b22..2410a332 100644 --- a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; @@ -31,6 +32,7 @@ import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; /** * Tests reading a media source. The asset is three seconds of VP8 and Opus in @@ -128,6 +130,41 @@ void missingSourceFails() { assertTrue(e.getMessage().contains("no-such-file.webm"), e.getMessage()); } + @Test + void opensPathBeyondBasicPlane(@TempDir Path dir) throws Exception { + // FFmpeg takes UTF-8, which is not the modified UTF-8 of JNI for + // characters beyond the Basic Multilingual Plane. + Path file = UnicodeFileName.resolve(dir, "webm"); + + Files.copy(asset(), file); + + try (MediaReader reader = new MediaReader(file)) { + assertTrue(reader.getInfo().hasVideo()); + } + try (MediaReader reader = new MediaReader(file.toString())) { + assertTrue(reader.getInfo().hasAudio()); + } + } + + @Test + void missingSourceBeyondBasicPlaneFails(@TempDir Path dir) { + Path file = UnicodeFileName.resolve(dir, "webm"); + + IOException e = assertThrows(IOException.class, () -> new MediaReader(file)); + + // The name comes back to Java as it went in. + assertTrue(e.getMessage().contains(UnicodeFileName.NAME), e.getMessage()); + } + + @Test + void rejectsSourceWithNullCharacter() throws Exception { + // Read up to the null character, the source would be the asset. + String source = asset().toAbsolutePath() + "\u0000.txt"; + + assertThrows(IOException.class, () -> new MediaReader(source)); + assertEquals(0, AllocationFault.heldStrings()); + } + @Test void sourceCopyFails() throws Exception { // The native copy of the source fails as an exhausted heap does: the diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/UnicodeFileName.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/UnicodeFileName.java new file mode 100644 index 00000000..aa3dcfab --- /dev/null +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/UnicodeFileName.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.player; + +import static org.junit.jupiter.api.Assumptions.abort; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; + +/** + * A file name that JNI and FFmpeg encode differently: characters of two, + * three and four bytes in UTF-8, the last two beyond the Basic Multilingual + * Plane, which the modified UTF-8 of JNI writes as surrogates. + * + * @author Alex Andres + */ +public final class UnicodeFileName { + + /** {@code ü}, {@code €}, an emoji and the G clef. */ + public static final String NAME = "clip-ü-€-😀-𝄞"; + + + private UnicodeFileName() { + // Static access only. + } + + /** + * Resolves the name with the given extension in the given directory, and + * skips the test where file names cannot hold it, as on Linux in the C + * locale. + * + * @param directory The directory of the file. + * @param extension The extension of the file, without the dot. + * + * @return The path of the file. + */ + public static Path resolve(Path directory, String extension) { + try { + return directory.resolve(NAME + "." + extension); + } + catch (InvalidPathException e) { + return abort("File names cannot hold " + NAME + ": " + e.getMessage()); + } + } + +} diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java index 03d2576f..19dcec88 100644 --- a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java @@ -22,13 +22,17 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import java.util.stream.Stream; import dev.onvoid.webrtc.PeerConnectionFactory; import dev.onvoid.webrtc.RTCRtpReceiver; @@ -37,6 +41,7 @@ import dev.onvoid.webrtc.media.player.AllocationFault; import dev.onvoid.webrtc.media.player.MediaInfo; import dev.onvoid.webrtc.media.player.MediaReader; +import dev.onvoid.webrtc.media.player.UnicodeFileName; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -152,6 +157,47 @@ void recordsSentCallIntoWebm() throws Exception { assertDuration(info); } + @Test + void recordsIntoPathBeyondBasicPlane() throws Exception { + // FFmpeg takes UTF-8, which is not the modified UTF-8 of JNI for + // characters beyond the Basic Multilingual Plane. A directory of its + // own shows that no file of another name was written. + Path dir = Files.createDirectory(tempDir.resolve("unicode")); + Path file = UnicodeFileName.resolve(dir, "mkv"); + Listener listener = new Listener(); + + try (TestCall call = new TestCall(factory); + MediaRecorder recorder = new MediaRecorder(file)) { + recorder.setListener(listener); + recorder.addTrack(call.getVideoSender()); + recorder.start(); + + assertTrue(listener.started.await(10, TimeUnit.SECONDS), "not started"); + + Thread.sleep(1000); + + assertTrue(recorder.stop()); + } + + assertNull(listener.error.get()); + + try (Stream files = Files.list(dir)) { + assertEquals(Collections.singletonList(file), files.collect(Collectors.toList())); + } + + assertEquals("vp8", readInfo(file).getVideoCodec()); + } + + @Test + void failedOpenBeyondBasicPlaneNamesPath() { + Path file = UnicodeFileName.resolve(tempDir.resolve("no-such-dir"), "mkv"); + + IOException e = assertThrows(IOException.class, () -> new MediaRecorder(file)); + + // The path comes back to Java as it went in. + assertTrue(e.getMessage().contains(UnicodeFileName.NAME), e.getMessage()); + } + @Test void recordsMuLawIntoMatroska() throws Exception { assertRecordsG711("PCMU", "pcm_mulaw");