Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
and `package-lock.json` pins the complete install. No source checkout is used.
The vendored set is the core release
[core-2.60.0-core.hb0d0b54f320a8e3f](https://github.com/ZenNotes/zennotes/releases/tag/core-2.60.0-core.hb0d0b54f320a8e3f)
(desktop commit `15829394`, clean tree). Run `npm run
[core-2.60.1-core.h05ebb55c14afffb2](https://github.com/ZenNotes/zennotes/releases/tag/core-2.60.1-core.h05ebb55c14afffb2)
(desktop commit `4c74b478`, clean tree). Run `npm run
boundaries:check` to verify archives, installed versions, singleton
editor/React peers, and imports; it refuses an archive built from a dirty
upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out.
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@
<provider android:name="md.zennotes.SafFixtureProvider"
android:authorities="md.zennotes.test.saf-fixture"
android:exported="true" android:grantUriPermissions="true" />
<provider android:name="md.zennotes.CloudUploadFixtureProvider"
android:authorities="md.zennotes.test.cloud-files"
android:readPermission="android.permission.MANAGE_DOCUMENTS"
android:exported="true" android:grantUriPermissions="true" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package md.zennotes;

import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;

/** Protected like a document provider; the test must explicitly receive a URI grant. */
public class CloudUploadFixtureProvider extends SafFixtureProvider {
@Override public Bundle call(String method, String arg, Bundle extras) {
if (!"grant".equals(method) || !"md.zennotes".equals(arg)) throw new SecurityException("Unsupported fixture call");
Uri uri = Uri.parse("content://md.zennotes.test.cloud-files/tree/root/document/large");
getContext().grantUriPermission(arg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
return Bundle.EMPTY;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package md.zennotes;

import static org.junit.Assert.*;

import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.provider.DocumentsContract;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.getcapacitor.JSObject;
import com.getcapacitor.PluginCall;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class DirectUploadInstrumentedTest {
private static class Result extends PluginCall {
String message;
JSObject value;
Result(JSObject data) { super(null, "ZenDirectUpload", "fixture", "test", data); }
@Override public void resolve(JSObject data) { value = data; }
@Override public void resolve() { value = new JSObject(); }
@Override public void reject(String message, String code, Exception error, JSObject data) { this.message = message + " " + error; }
}

private DirectUploadPlugin plugin(Context context) {
return new DirectUploadPlugin() {
@Override public Context getContext() { return context; }
@Override public void execute(Runnable work) { work.run(); }
};
}

@Test public void inspectsAndUploadsLocalAndDocumentProviderFilesWithoutBase64() throws Exception {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
File folder = new File(context.getFilesDir(), "ZenNotes/CapacityFixture");
assertTrue(folder.isDirectory() || folder.mkdirs());
File file = new File(folder, "large.bin");
try {
byte[] chunk = new byte[64 * 1024];
Arrays.fill(chunk, (byte) 197);
try (FileOutputStream output = new FileOutputStream(file)) {
for (int left = 6_000_000; left > 0; left -= Math.min(left, chunk.length)) {
output.write(chunk, 0, Math.min(left, chunk.length));
}
}
Uri tree = DocumentsContract.buildTreeDocumentUri("md.zennotes.test.cloud-files", "root");
for (Uri uri : new Uri[] { Uri.fromFile(file), DocumentsContract.buildDocumentUriUsingTree(tree, "large") }) {
DirectUploadPlugin plugin = plugin(context);
if ("content".equals(uri.getScheme())) {
context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
Result denied = new Result(new JSObject().put("uri", uri.toString()));
plugin.inspect(denied);
assertNotNull(denied.message);
context.getContentResolver().call(uri, "grant", context.getPackageName(), null);
}
Result inspected = new Result(new JSObject().put("uri", uri.toString()).put("textCandidate", false));
plugin.inspect(inspected);
assertNull(uri.toString(), inspected.message);
assertEquals(6_000_000L, inspected.value.getLong("byteLength"));
assertFalse(inspected.value.has("inlineBase64"));
File copy = new File(folder, "copy.bin");
Result copied = new Result(new JSObject().put("from", uri.toString()).put("to", Uri.fromFile(copy).toString())
.put("byteLength", 6_000_000).put("sha256", inspected.value.getString("sha256")));
plugin.copy(copied);
assertNull(copied.message);
Result copyCheck = new Result(new JSObject().put("uri", Uri.fromFile(copy).toString()));
plugin.inspect(copyCheck);
assertEquals(inspected.value.getString("sha256"), copyCheck.value.getString("sha256"));
assertTrue(copy.delete());
try (ServerSocket server = new ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1"))) {
CompletableFuture<String> received = CompletableFuture.supplyAsync(() -> receive(server));
Result uploaded = new Result(new JSObject()
.put("url", "http://127.0.0.1:" + server.getLocalPort() + "/object")
.put("uri", uri.toString()).put("byteLength", 6_000_000)
.put("sha256", inspected.value.getString("sha256")));
plugin.put(uploaded);
assertNull(uploaded.message);
assertEquals(200, uploaded.value.getInteger("status").intValue());
assertEquals(inspected.value.getString("sha256"), received.get(10, TimeUnit.SECONDS));
}
}
} finally {
file.delete();
new File(context.getCacheDir(), "saf-capacity-fixture.bin").delete();
}
}

@Test public void refusesFilesOutsideVaultStorage() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
Result request = new Result(new JSObject().put("uri", Uri.fromFile(new File(context.getFilesDir(), "credential.json")).toString()));
plugin(context).inspect(request);
assertNotNull(request.message);
assertNull(request.value);
}

private static String receive(ServerSocket server) {
try (Socket socket = server.accept()) {
socket.setSoTimeout(10_000);
InputStream input = socket.getInputStream();
ByteArrayOutputStream header = new ByteArrayOutputStream();
int ending = 0;
while (ending != 0x0d0a0d0a && header.size() < 16_384) {
int value = input.read();
if (value < 0) throw new IllegalStateException("Incomplete upload headers");
header.write(value);
ending = (ending << 8) | value;
}
String headers = header.toString("UTF-8").toLowerCase();
assertTrue(headers.contains("content-length: 6000000"));
assertFalse(headers.contains("transfer-encoding:"));
assertFalse(headers.contains("authorization:"));
MessageDigest hash = MessageDigest.getInstance("SHA-256");
byte[] chunk = new byte[64 * 1024];
int remaining = 6_000_000;
while (remaining > 0) {
int count = input.read(chunk, 0, Math.min(chunk.length, remaining));
if (count < 0) throw new IllegalStateException("Truncated upload");
hash.update(chunk, 0, count);
remaining -= count;
}
socket.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
StringBuilder digest = new StringBuilder();
for (byte value : hash.digest()) digest.append(String.format("%02x", value));
return digest.toString();
} catch (Exception error) { throw new IllegalStateException(error); }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import android.provider.DocumentsContract;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

Expand All @@ -35,8 +36,19 @@ public class SafFixtureProvider extends ContentProvider {
}
@Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
String id = DocumentsContract.getDocumentId(uri);
if (!id.equals("present")) throw new FileNotFoundException("Provider cannot open this document");
if (!id.equals("present") && !id.equals("large")) throw new FileNotFoundException("Provider cannot open this document");
try {
if (id.equals("large")) {
File large = new File(getContext().getCacheDir(), "saf-capacity-fixture.bin");
try (FileOutputStream output = new FileOutputStream(large)) {
byte[] chunk = new byte[64 * 1024];
java.util.Arrays.fill(chunk, (byte) 197);
for (int left = 6_000_000; left > 0; left -= Math.min(left, chunk.length)) {
output.write(chunk, 0, Math.min(left, chunk.length));
}
}
return ParcelFileDescriptor.open(large, ParcelFileDescriptor.MODE_READ_ONLY);
}
File file = new File(getContext().getCacheDir(), "saf-fixture.md");
Files.write(file.toPath(), "Exact café 日本語. \n".getBytes(StandardCharsets.UTF_8));
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
Expand Down
114 changes: 114 additions & 0 deletions android/app/src/main/java/md/zennotes/CloudFileStream.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package md.zennotes;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.Buffer;
import java.nio.CharBuffer;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/** Bounded file inspection and copying, including UTF-8 sequences split across reads. */
final class CloudFileStream {
static final int BUFFER_SIZE = 64 * 1024;
static final int INLINE_LIMIT = 5 * 1024 * 1024;

static final class Fingerprint {
final long byteLength;
final String sha256;
final boolean utf8;
final byte[] inlineBytes;

Fingerprint(long byteLength, String sha256, boolean utf8, byte[] inlineBytes) {
this.byteLength = byteLength;
this.sha256 = sha256;
this.utf8 = utf8;
this.inlineBytes = inlineBytes;
}
}

static Fingerprint inspect(InputStream input, boolean textCandidate) throws IOException {
MessageDigest digest = digest();
byte[] chunk = new byte[BUFFER_SIZE];
ByteArrayOutputStream inline = new ByteArrayOutputStream();
Utf8Check text = textCandidate ? new Utf8Check() : null;
long length = 0;
int count;
while ((count = input.read(chunk)) != -1) {
if (Thread.currentThread().isInterrupted()) throw new IOException("File inspection cancelled.");
digest.update(chunk, 0, count);
length += count;
if (inline != null) {
if (length <= INLINE_LIMIT) inline.write(chunk, 0, count);
else inline = null;
}
if (text != null) text.accept(chunk, count, false);
}
if (text != null) text.accept(chunk, 0, true);
return new Fingerprint(length, hex(digest.digest()), text != null && text.valid,
inline == null ? null : inline.toByteArray());
}

static void copyVerified(InputStream input, OutputStream output, long expectedBytes, String expectedHash) throws IOException {
MessageDigest digest = digest();
byte[] chunk = new byte[BUFFER_SIZE];
long written = 0;
int count;
while ((count = input.read(chunk)) != -1) {
if (Thread.currentThread().isInterrupted()) throw new IOException("File upload cancelled.");
written += count;
if (written > expectedBytes) throw new IOException("The upload file changed size.");
digest.update(chunk, 0, count);
output.write(chunk, 0, count);
}
if (written != expectedBytes || !hex(digest.digest()).equals(expectedHash)) {
throw new IOException("The upload file changed after its scan.");
}
output.flush();
}

private static MessageDigest digest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}

private static String hex(byte[] bytes) {
char[] alphabet = "0123456789abcdef".toCharArray();
char[] out = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
out[i * 2] = alphabet[(bytes[i] & 255) >>> 4];
out[i * 2 + 1] = alphabet[bytes[i] & 15];
}
return new String(out);
}

private static final class Utf8Check {
final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
final ByteBuffer pending = ByteBuffer.allocate(BUFFER_SIZE + 4);
final CharBuffer characters = CharBuffer.allocate(8192);
boolean valid = true;

void accept(byte[] chunk, int count, boolean last) {
if (!valid) return;
pending.put(chunk, 0, count);
((Buffer) pending).flip();
CoderResult result;
do {
((Buffer) characters).clear();
result = decoder.decode(pending, characters, last);
} while (result.isOverflow());
valid = !result.isError();
pending.compact();
}
}
}
Loading
Loading