From 849fdb2dfd099502ca37d1e55ec80762b65dcd96 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 11 Oct 2026 19:26:56 -0700 Subject: [PATCH] test(android): probe document-provider capabilities Add a disposable separate-UID provider lab covering returned URI grants, opaque identities, descriptor capabilities, rename, delayed visibility, offline recovery and process reopen. Bound cleanup attempts independently and retain failures without claiming commercial-provider durability. Assisted-by: gpt-6.1-sol --- tools/android/provider-lab/README.md | 56 ++++ tools/android/provider-lab/build.py | 71 +++++ .../provider-lab/client/AndroidManifest.xml | 8 + tools/android/provider-lab/client/Grant.java | 39 +++ tools/android/provider-lab/client/Probe.java | 269 ++++++++++++++++++ .../provider-lab/provider/AndroidManifest.xml | 10 + .../provider-lab/provider/Control.java | 37 +++ .../provider-lab/provider/LabProvider.java | 167 +++++++++++ tools/android/provider-lab/provider/Pick.java | 19 ++ tools/android/provider-lab/run.py | 82 ++++++ 10 files changed, 758 insertions(+) create mode 100644 tools/android/provider-lab/README.md create mode 100644 tools/android/provider-lab/build.py create mode 100644 tools/android/provider-lab/client/AndroidManifest.xml create mode 100644 tools/android/provider-lab/client/Grant.java create mode 100644 tools/android/provider-lab/client/Probe.java create mode 100644 tools/android/provider-lab/provider/AndroidManifest.xml create mode 100644 tools/android/provider-lab/provider/Control.java create mode 100644 tools/android/provider-lab/provider/LabProvider.java create mode 100644 tools/android/provider-lab/provider/Pick.java create mode 100644 tools/android/provider-lab/run.py diff --git a/tools/android/provider-lab/README.md b/tools/android/provider-lab/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ad41eca538ecd18d99c65c29289a43f649261c7c --- /dev/null +++ b/tools/android/provider-lab/README.md @@ -0,0 +1,56 @@ +# Android document-provider capability lab + +Two **test-only** APKs: a disposable `DocumentsProvider` and a separate-UID +instrumentation client. No product app, notebook backend, JNI, Gradle, or product UI. +Install only on an owned emulator; the exported controller intentionally grants, +revokes, and changes synthetic fixtures without authentication. + +Build with an installed SDK and JDK (no downloads or global changes): + +```sh +nice -n 10 python3 tools/android/provider-lab/build.py \ + --sdk /path/to/sdk --java-home /path/to/jdk \ + --output /absolute/private/build +python3 tools/android/provider-lab/run.py \ + --adb /path/to/sdk/platform-tools/adb --serial emulator-5556 \ + --build /absolute/private/build --output /absolute/private/run +``` + +Defaults are SDK/build-tools 36, with API 26 as the lab APK minimum. This does +not decide Snowbound's minimum Android version. Output directories must be new. +The runner refuses physical devices, installs the two packages, runs bounded +instrumentation, force-stops the client between phases, then uninstalls both. +The caller owns emulator creation, launch, and cleanup. Evidence stays private. + +A test-only provider Activity returns an Intent with prefix and persistable URI +grant flags; the client Activity persists only its actual returned read/write +flags. This substitutes for a picker result. This does **not** test +`ACTION_OPEN_DOCUMENT_TREE` UI. All data is synthetic bytes, not `.one` files. + +| Experiment | Assertion | +| --- | --- | +| Separate UID / tree grant | Ungranted read refused; read-only grant cannot widen to write; read/write grant persisted; available in a fresh client process; provider revocation refuses further reads and removes persisted grant. | +| Opaque IDs / metadata | Slash and non-ASCII identities survive contract URI construction; null size and timestamp remain unknown. | +| Local `rw` descriptor | Actual seek, partial overwrite, fsync, close and reopen retain the expected bytes. | +| Stream `r` / `w` | Actual pipes reject seek/fsync; `rw` is rejected; bytes round-trip after a bounded provider completion event. | +| Reliable peer failure | Provider `closeWithError` reaches client `checkError`. | +| Rename | Returned document identity changes; old URI fails; fresh listing contains the returned identity and bytes remain intact. | +| Delayed visibility | Writer's actual `rw` write/fsync/close/reopen succeed while the observer still reads the old bytes. Only an explicit provider publication event advances the observer. | +| Availability | Injected offline open fails while the persisted grant remains; restoring availability restores the bytes. | + +The fixture assigns duplicate display names; the probe does not compare their +display-name columns. + +Provider events are explicit barriers, not timing guesses. Pipe workers have a +bounded completion wait; every external build/runtime command has a timeout. +Reports contain actual operations, exceptions, fixture byte hashes, emulator +fingerprint, process IDs, and APK/source hashes. A successful local fsync or +close is **not a remote upload receipt**. This synthetic provider does not prove +commercial-provider behavior, remote durability, file locking, atomicity, CAS, +cloud-conflict recovery, or `.snowbound/` protocol correctness. + +Android contracts: +[ContentResolver modes](https://developer.android.com/reference/android/content/ContentResolver#openFileDescriptor(android.net.Uri,%20java.lang.String)), +[DocumentsProvider](https://developer.android.com/reference/android/provider/DocumentsProvider), +[rename identity](https://developer.android.com/reference/android/provider/DocumentsContract#renameDocument(android.content.ContentResolver,%20android.net.Uri,%20java.lang.String)), +[descriptor errors](https://developer.android.com/reference/android/os/ParcelFileDescriptor#checkError()). diff --git a/tools/android/provider-lab/build.py b/tools/android/provider-lab/build.py new file mode 100644 index 0000000000000000000000000000000000000000..2270f93f725a5683b9ef7e134edaad1329caa824 --- /dev/null +++ b/tools/android/provider-lab/build.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Build two test-only APKs with an explicitly installed SDK/JDK, without Gradle.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sdk", type=Path, required=True) + parser.add_argument("--java-home", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--platform", default="android-36") + parser.add_argument("--build-tools", default="36.0.0") + args = parser.parse_args() + source = Path(__file__).resolve().parent + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + java = args.java_home.resolve() / "bin" + tools = args.sdk.resolve() / "build-tools" / args.build_tools + android = args.sdk.resolve() / "platforms" / args.platform / "android.jar" + steps = [] + + def run(command): + command = [str(item) for item in command] + result = subprocess.run(command, capture_output=True, text=True, timeout=120) + steps.append({"command": command, "exitCode": result.returncode, + "stdout": result.stdout, "stderr": result.stderr}) + (output / "build.json").write_text(json.dumps(steps, indent=2) + "\n") + result.check_returncode() + + keystore = output / "test-only.jks" + run([java / "keytool", "-genkeypair", "-keystore", keystore, "-storepass", "android", + "-keypass", "android", "-alias", "lab", "-keyalg", "RSA", "-validity", "30", + "-dname", "CN=Disposable Snowbound Provider Lab"]) + for name in ("provider", "client"): + classes = output / name / "classes" + classes.mkdir(parents=True) + run([java / "javac", "--release", "8", "-classpath", android, + "-d", classes, *sorted((source / name).glob("*.java"))]) + dex = output / name / "dex" + dex.mkdir() + run([java / "java", "-Xmx512m", "-cp", tools / "lib" / "d8.jar", "com.android.tools.r8.D8", + "--min-api", "26", "--lib", android, "--output", dex, *sorted(classes.rglob("*.class"))]) + unsigned = output / name / "unsigned.apk" + run([tools / "aapt2", "link", "-I", android, "--manifest", source / name / "AndroidManifest.xml", + "-o", unsigned]) + import zipfile + with zipfile.ZipFile(unsigned, "a") as archive: + archive.write(dex / "classes.dex", "classes.dex") + aligned = output / name / "aligned.apk" + run([tools / "zipalign", "-p", "4", unsigned, aligned]) + apk = output / (name + ".apk") + run([java / "java", "-Xmx256m", "-jar", tools / "lib" / "apksigner.jar", "sign", + "--ks", keystore, "--ks-pass", "pass:android", "--out", apk, aligned]) + run([java / "java", "-jar", tools / "lib" / "apksigner.jar", "verify", "--verbose", apk]) + manifest = { + "sourceSha256": {str(p.relative_to(source)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(source.rglob("*")) if p.is_file() and "__pycache__" not in p.parts}, + "apkSha256": {name: hashlib.sha256((output / name).read_bytes()).hexdigest() + for name in ("provider.apk", "client.apk")}, + "runtimeTested": False, + } + (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(json.dumps(manifest, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/android/provider-lab/client/AndroidManifest.xml b/tools/android/provider-lab/client/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..4212f3ee7d1c0b81f458376ee01e6b38bb918ac7 --- /dev/null +++ b/tools/android/provider-lab/client/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/android/provider-lab/client/Grant.java b/tools/android/provider-lab/client/Grant.java new file mode 100644 index 0000000000000000000000000000000000000000..02dccad2f8bfc3e5144a47c1deaa311a9282476c --- /dev/null +++ b/tools/android/provider-lab/client/Grant.java @@ -0,0 +1,39 @@ +package net.paperclover.snowbound.providerprobe; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.os.Bundle; +import java.util.concurrent.CountDownLatch; + +public final class Grant extends Activity { + static volatile Bundle reply; + static CountDownLatch done; + + @Override public void onCreate(Bundle state) { + super.onCreate(state); + startActivityForResult(new Intent().setComponent(new ComponentName( + "net.paperclover.snowbound.providerlab", "net.paperclover.snowbound.providerlab.Pick")) + .putExtra("write", getIntent().getBooleanExtra("write", false)), 1); + } + + @Override protected void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + reply = new Bundle(); + try { + if (request != 1 || result != RESULT_OK || data == null) throw new IllegalStateException("Grant result missing"); + int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + getContentResolver().takePersistableUriPermission(data.getData(), flags); + reply.putBoolean("ok", true); + reply.putInt("receivedFlags", data.getFlags()); + reply.putInt("persistedFlags", flags); + reply.putString("uri", data.getData().toString()); + reply.putInt("providerUid", data.getIntExtra("providerUid", -1)); + reply.putInt("providerPid", data.getIntExtra("providerPid", -1)); + } catch (Exception e) { + reply.putString("error", e.toString()); + } + done.countDown(); + finish(); + } +} diff --git a/tools/android/provider-lab/client/Probe.java b/tools/android/provider-lab/client/Probe.java new file mode 100644 index 0000000000000000000000000000000000000000..fab3e27b0acbefdaf13d695350f47615c65862d4 --- /dev/null +++ b/tools/android/provider-lab/client/Probe.java @@ -0,0 +1,269 @@ +package net.paperclover.snowbound.providerprobe; + +import android.app.Instrumentation; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import android.system.Os; +import android.system.OsConstants; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.json.JSONArray; +import org.json.JSONObject; + +public final class Probe extends Instrumentation { + private static final String AUTHORITY = "net.paperclover.snowbound.providerlab.documents"; + private static final Uri TREE = DocumentsContract.buildTreeDocumentUri(AUTHORITY, "root:opaque/α"); + private final JSONArray events = new JSONArray(); + private ContentResolver resolver; + private String phase; + + @Override public void onCreate(Bundle args) { + super.onCreate(args); + phase = args.getString("phase", "probe"); + start(); + } + + private void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } + + private void record(String operation, Object value) throws Exception { + events.put(new JSONObject().put("operation", operation).put("result", value)); + } + + private void control(String operation) throws Exception { + CountDownLatch done = new CountDownLatch(1); + Bundle[] reply = new Bundle[1]; + Intent command = new Intent().setComponent(new ComponentName("net.paperclover.snowbound.providerlab", + "net.paperclover.snowbound.providerlab.Control")).putExtra("operation", operation); + getTargetContext().sendOrderedBroadcast(command, null, new BroadcastReceiver() { + @Override public void onReceive(Context context, Intent intent) { + reply[0] = getResultExtras(false); + done.countDown(); + } + }, null, 0, null, null); + require(done.await(10, TimeUnit.SECONDS), "Provider control timed out: " + operation); + require(reply[0] != null && reply[0].getBoolean("ok"), "Provider control failed: " + reply[0]); + require(reply[0].getInt("uid") != android.os.Process.myUid(), "Provider shares client UID"); + record("provider-control", new JSONObject().put("operation", operation) + .put("uid", reply[0].getInt("uid")).put("pid", reply[0].getInt("pid"))); + } + + private void grant(boolean write) throws Exception { + Grant.done = new CountDownLatch(1); + Grant.reply = null; + startActivitySync(new Intent(getTargetContext(), Grant.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).putExtra("write", write)); + require(Grant.done.await(10, TimeUnit.SECONDS), "Activity grant timed out"); + Bundle reply = Grant.reply; + require(reply != null && reply.getBoolean("ok"), "Activity grant failed: " + reply); + require(reply.getInt("providerUid") != android.os.Process.myUid(), "Grant provider shares client UID"); + require(reply.getString("uri").equals(TREE.toString()), "Grant URI changed"); + int expected = Intent.FLAG_GRANT_READ_URI_PERMISSION | (write ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0); + require(reply.getInt("persistedFlags") == expected, "Returned grant flags changed"); + record("activity-result-grant", new JSONObject().put("uri", reply.getString("uri")) + .put("receivedFlags", reply.getInt("receivedFlags")).put("persistedFlags", reply.getInt("persistedFlags")) + .put("providerUid", reply.getInt("providerUid")).put("providerPid", reply.getInt("providerPid"))); + } + + private static Uri document(String id) { + return DocumentsContract.buildDocumentUriUsingTree(TREE, id); + } + + private byte[] read(Uri uri) throws Exception { + try (ParcelFileDescriptor.AutoCloseInputStream input = new ParcelFileDescriptor.AutoCloseInputStream( + java.util.Objects.requireNonNull(resolver.openFileDescriptor(uri, "r")))) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = input.read(buffer)) != -1) bytes.write(buffer, 0, length); + return bytes.toByteArray(); + } + } + + private static JSONObject bytes(byte[] data) throws Exception { + StringBuilder digest = new StringBuilder(); + for (byte b : MessageDigest.getInstance("SHA-256").digest(data)) digest.append(String.format("%02x", b & 255)); + return new JSONObject().put("length", data.length).put("sha256", digest.toString()) + .put("syntheticText", new String(data, StandardCharsets.UTF_8)); + } + + private interface Operation { void run() throws Exception; } + + private void failure(String name, Class expected, Operation operation) throws Exception { + Throwable error = null; + try { operation.run(); } catch (Exception e) { error = e; } + require(error != null && expected.isInstance(error), name + " unexpected result: " + error); + record(name, new JSONObject().put("exception", error.getClass().getName()).put("message", error.toString())); + } + + private void errno(String name, int expected, Operation operation) throws Exception { + android.system.ErrnoException error = null; + try { operation.run(); } catch (android.system.ErrnoException e) { error = e; } + require(error != null && error.errno == expected, name + " unexpected result: " + error); + record(name, new JSONObject().put("errno", error.errno).put("message", error.toString())); + } + + private void probe() throws Exception { + control("revoke"); + control("reset"); + Uri local = document("note:opaque/β"); + failure("ungranted-read", SecurityException.class, () -> read(local)); + grant(false); + resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_READ_URI_PERMISSION); + require(resolver.getPersistedUriPermissions().size() == 1 + && resolver.getPersistedUriPermissions().get(0).isReadPermission() + && !resolver.getPersistedUriPermissions().get(0).isWritePermission(), "Read-only grant widened"); + require(new String(read(local), StandardCharsets.UTF_8).equals("ORIGINAL"), "Read-only content unavailable"); + failure("read-only-persist-write", SecurityException.class, + () -> resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)); + failure("read-only-open-rw", SecurityException.class, + () -> { try (ParcelFileDescriptor ignored = resolver.openFileDescriptor(local, "rw")) { } }); + record("read-only-persisted-grant", new JSONObject().put("uri", TREE).put("read", true).put("write", false)); + control("revoke"); + grant(true); + resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + require(resolver.getPersistedUriPermissions().size() == 1, "Persisted tree missing"); + android.content.UriPermission grant = resolver.getPersistedUriPermissions().get(0); + require(grant.getUri().equals(TREE) && grant.isReadPermission() && grant.isWritePermission(), "Wrong persisted grant"); + record("persisted-grant", new JSONObject().put("uri", TREE).put("read", grant.isReadPermission()).put("write", grant.isWritePermission())); + + try (Cursor cursor = resolver.query(local, null, null, null, null)) { + require(cursor != null && cursor.moveToFirst(), "Metadata missing"); + require(cursor.isNull(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)), "Unknown size became known"); + require(cursor.isNull(cursor.getColumnIndexOrThrow(Document.COLUMN_LAST_MODIFIED)), "Unknown timestamp became known"); + require(DocumentsContract.getDocumentId(local).equals("note:opaque/β"), "Opaque identity changed"); + record("metadata", new JSONObject().put("uri", local).put("id", cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID))) + .put("flags", cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_FLAGS))).put("size", JSONObject.NULL).put("lastModified", JSONObject.NULL)); + } + try (ParcelFileDescriptor fd = resolver.openFileDescriptor(local, "rw")) { + record("local-open", "rw"); + record("local-seek", Os.lseek(fd.getFileDescriptor(), 2, OsConstants.SEEK_SET)); + byte[] patch = "++".getBytes(StandardCharsets.UTF_8); + require(Os.write(fd.getFileDescriptor(), patch, 0, patch.length) == patch.length, "Short write"); + record("local-write", patch.length); + Os.fsync(fd.getFileDescriptor()); + record("local-fsync", "success"); + } + record("local-close", "success"); + byte[] patched = read(local); + require(new String(patched, StandardCharsets.UTF_8).equals("OR++INAL"), "Seek write was not retained"); + record("local-reopen", bytes(patched)); + Uri renamed = DocumentsContract.renameDocument(resolver, local, "renamed.one"); + require(renamed != null && !renamed.equals(local), "Rename did not change URI"); + require(DocumentsContract.getDocumentId(renamed).equals("identity:new/γ"), "Unexpected renamed ID"); + failure("old-uri-after-rename", SecurityException.class, () -> read(local)); + require(java.util.Arrays.equals(read(renamed), patched), "Renamed content changed"); + java.util.List ids = new java.util.ArrayList<>(); + Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(TREE, "root:opaque/α"); + try (Cursor cursor = resolver.query(children, new String[] {Document.COLUMN_DOCUMENT_ID}, null, null, null)) { + require(cursor != null, "Listing null"); + while (cursor.moveToNext()) ids.add(cursor.getString(0)); + } + require(ids.contains("identity:new/γ") && !ids.contains("note:opaque/β"), "Rename listing stale"); + record("rename", new JSONObject().put("old", local).put("returned", renamed).put("freshListing", new JSONArray(ids))); + + Uri pipe = document("stream:opaque/δ"); + failure("pipe-rw", UnsupportedOperationException.class, () -> { try (ParcelFileDescriptor ignored = resolver.openFileDescriptor(pipe, "rw")) { } }); + try (ParcelFileDescriptor fd = resolver.openFileDescriptor(pipe, "r")) { + errno("pipe-read-seek", OsConstants.ESPIPE, + () -> Os.lseek(fd.getFileDescriptor(), 0, OsConstants.SEEK_SET)); + errno("pipe-read-fsync", OsConstants.EINVAL, () -> Os.fsync(fd.getFileDescriptor())); + byte[] buffer = new byte[6]; + require(Os.read(fd.getFileDescriptor(), buffer, 0, buffer.length) == 6, "Short pipe read"); + require(new String(buffer, StandardCharsets.UTF_8).equals("STREAM"), "Wrong stream bytes"); + record("pipe-read", bytes(buffer)); + } + control("await-pipe"); + try (ParcelFileDescriptor fd = resolver.openFileDescriptor(pipe, "w")) { + errno("pipe-write-seek", OsConstants.ESPIPE, + () -> Os.lseek(fd.getFileDescriptor(), 0, OsConstants.SEEK_SET)); + byte[] data = "PIPE-WRITE".getBytes(StandardCharsets.UTF_8); + record("pipe-write", Os.write(fd.getFileDescriptor(), data, 0, data.length)); + errno("pipe-write-fsync", OsConstants.EINVAL, () -> Os.fsync(fd.getFileDescriptor())); + } + record("pipe-write-close", "success"); + control("await-pipe"); + require(new String(read(pipe), StandardCharsets.UTF_8).equals("PIPE-WRITE"), "Pipe content not retained"); + control("await-pipe"); + try (ParcelFileDescriptor fd = resolver.openFileDescriptor(document("failed:opaque/ε"), "r")) { + control("await-pipe"); + failure("reliable-peer-error", java.io.IOException.class, fd::checkError); + } + record("reliable-peer-close", "success"); + + Uri writer = document("view:writer/ζ"); + Uri observer = document("view:observer/η"); + try (ParcelFileDescriptor fd = resolver.openFileDescriptor(writer, "rw")) { + byte[] data = "NEW-VIEW".getBytes(StandardCharsets.UTF_8); + require(Os.write(fd.getFileDescriptor(), data, 0, data.length) == data.length, "Short staged write"); + Os.fsync(fd.getFileDescriptor()); + record("delayed-local-fsync", "success"); + } + record("delayed-local-close", "success"); + require(new String(read(writer), StandardCharsets.UTF_8).equals("NEW-VIEW"), "Writer did not retain bytes"); + byte[] old = read(observer); + require(new String(old, StandardCharsets.UTF_8).equals("OLD-VIEW"), "Observer advanced without publication event"); + record("observer-after-successful-fsync-close", bytes(old)); + control("publish"); + byte[] published = read(observer); + require(new String(published, StandardCharsets.UTF_8).equals("NEW-VIEW"), "Controlled publication failed"); + record("observer-after-publication-event", bytes(published)); + control("offline"); + failure("offline-open", java.io.FileNotFoundException.class, () -> read(writer)); + require(resolver.getPersistedUriPermissions().size() == 1, "Offline availability changed grant"); + control("online"); + require(java.util.Arrays.equals(read(writer), published), "Online restore lost content"); + record("restored-online", bytes(read(writer))); + } + + @Override public void onStart() { + resolver = getTargetContext().getContentResolver(); + JSONObject report = new JSONObject(); + boolean passed = false; + try { + if (phase.equals("probe")) probe(); + else if (phase.equals("reopen")) { + require(resolver.getPersistedUriPermissions().size() == 1, "Grant did not survive client process exit"); + android.content.UriPermission grant = resolver.getPersistedUriPermissions().get(0); + require(grant.getUri().equals(TREE) && grant.isReadPermission() && grant.isWritePermission(), "Reopened grant flags changed"); + byte[] reopened = read(document("identity:new/γ")); + require(new String(reopened, StandardCharsets.UTF_8).equals("OR++INAL"), "Reopened bytes changed"); + record("second-client-process", new JSONObject().put("bytes", bytes(reopened)) + .put("uri", grant.getUri()).put("read", grant.isReadPermission()).put("write", grant.isWritePermission())); + control("revoke"); + failure("revoked-read", SecurityException.class, () -> read(document("identity:new/γ"))); + require(resolver.getPersistedUriPermissions().isEmpty(), "Revoked persisted grant retained"); + record("persisted-after-revoke", 0); + } else throw new IllegalArgumentException(phase); + passed = true; + } catch (Throwable e) { + try { report.put("failure", e.toString()); } catch (Exception ignored) { } + } + try { + report.put("passed", passed).put("phase", phase).put("pid", android.os.Process.myPid()) + .put("uid", android.os.Process.myUid()).put("sdk", android.os.Build.VERSION.SDK_INT) + .put("fingerprint", android.os.Build.FINGERPRINT).put("events", events); + try (FileOutputStream out = new FileOutputStream(new File(getTargetContext().getFilesDir(), phase + ".json"))) { + out.write(report.toString(2).getBytes(StandardCharsets.UTF_8)); + } + } catch (Exception e) { passed = false; } + Bundle result = new Bundle(); + result.putString("report", report.toString()); + finish(passed ? -1 : 0, result); + } +} diff --git a/tools/android/provider-lab/provider/AndroidManifest.xml b/tools/android/provider-lab/provider/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..a367d9ea5d05006d3ebd5fd6ad0801d4bacc5c82 --- /dev/null +++ b/tools/android/provider-lab/provider/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tools/android/provider-lab/provider/Control.java b/tools/android/provider-lab/provider/Control.java new file mode 100644 index 0000000000000000000000000000000000000000..6f4392c467143703fdab103816c03a334f1079af --- /dev/null +++ b/tools/android/provider-lab/provider/Control.java @@ -0,0 +1,37 @@ +package net.paperclover.snowbound.providerlab; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.provider.DocumentsContract; +import java.nio.file.Files; + +public final class Control extends BroadcastReceiver { + @Override public void onReceive(Context context, Intent intent) { + Bundle result = new Bundle(); + try { + // Force provider initialization before touching its disposable backing files. + try (android.content.ContentProviderClient ignored = context.getContentResolver() + .acquireContentProviderClient(LabProvider.AUTHORITY)) { + Uri tree = DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.ROOT); + String action = intent.getStringExtra("operation"); + if (action.equals("reset")) LabProvider.reset(); + else if (action.equals("revoke")) { + context.revokeUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } else if (action.equals("offline")) LabProvider.offline = true; + else if (action.equals("online")) LabProvider.offline = false; + else if (action.equals("publish")) Files.write(LabProvider.file("observer").toPath(), Files.readAllBytes(LabProvider.file("writer").toPath())); + else if (action.equals("await-pipe")) LabProvider.awaitPipe(); + else throw new IllegalArgumentException(action); + } + result.putBoolean("ok", true); + result.putInt("uid", android.os.Process.myUid()); + result.putInt("pid", android.os.Process.myPid()); + } catch (Exception e) { + result.putString("error", e.toString()); + } + setResultExtras(result); + } +} diff --git a/tools/android/provider-lab/provider/LabProvider.java b/tools/android/provider-lab/provider/LabProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..d836eb8c51d2e72530d5ad930da6ba9e2e82bf5b --- /dev/null +++ b/tools/android/provider-lab/provider/LabProvider.java @@ -0,0 +1,167 @@ +package net.paperclover.snowbound.providerlab; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public final class LabProvider extends DocumentsProvider { + static final String AUTHORITY = "net.paperclover.snowbound.providerlab.documents"; + static final String ROOT = "root:opaque/α"; + static final String LOCAL = "note:opaque/β"; + static final String RENAMED = "identity:new/γ"; + static final String PIPE = "stream:opaque/δ"; + static final String FAIL = "failed:opaque/ε"; + static final String WRITER = "view:writer/ζ"; + static final String OBSERVER = "view:observer/η"; + static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; + static volatile boolean offline; + static volatile byte[] pipeBytes; + static volatile CountDownLatch pipeDone = new CountDownLatch(0); + static volatile String pipeError; + static File directory; + + @Override public boolean onCreate() { + directory = new File(getContext().getFilesDir(), "synthetic-documents"); + directory.mkdirs(); + return true; + } + + static File file(String name) { return new File(directory, name); } + + static void reset() throws IOException { + offline = false; + Files.deleteIfExists(file("renamed").toPath()); + pipeBytes = "STREAM".getBytes(StandardCharsets.UTF_8); + pipeError = null; + pipeDone = new CountDownLatch(0); + Files.write(file("local").toPath(), "ORIGINAL".getBytes(StandardCharsets.UTF_8)); + Files.write(file("writer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8)); + Files.write(file("observer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8)); + } + + static boolean renamed() { return file("renamed").exists(); } + + static void check(String id) throws FileNotFoundException { + if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE) + || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) { + throw new FileNotFoundException("Unknown opaque identity"); + } + } + + private static void row(MatrixCursor cursor, String id) { + MatrixCursor.RowBuilder row = cursor.newRow(); + for (String column : cursor.getColumnNames()) { + Object value = null; + if (column.equals(Document.COLUMN_DOCUMENT_ID)) value = id; + if (column.equals(Document.COLUMN_DISPLAY_NAME)) value = id.equals(ROOT) ? "Disposable lab" : id.equals(RENAMED) ? "renamed.one" : "same-name.one"; + if (column.equals(Document.COLUMN_MIME_TYPE)) value = id.equals(ROOT) ? Document.MIME_TYPE_DIR : "application/octet-stream"; + if (column.equals(Document.COLUMN_FLAGS)) value = id.equals(ROOT) ? 0 : Document.FLAG_SUPPORTS_WRITE | (id.equals(LOCAL) ? Document.FLAG_SUPPORTS_RENAME : 0); + row.add(value); + } + } + + @Override public Cursor queryRoots(String[] projection) { + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : new String[] { + Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, Root.COLUMN_FLAGS}); + MatrixCursor.RowBuilder row = cursor.newRow(); + for (String column : cursor.getColumnNames()) { + Object value = null; + if (column.equals(Root.COLUMN_ROOT_ID)) value = "synthetic"; + if (column.equals(Root.COLUMN_DOCUMENT_ID)) value = ROOT; + if (column.equals(Root.COLUMN_TITLE)) value = "Disposable provider lab"; + if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; + row.add(value); + } + return cursor; + } + + @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException { + check(id); + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); + row(cursor, id); + return cursor; + } + + @Override public Cursor queryChildDocuments(String parent, String[] projection, String order) throws FileNotFoundException { + if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory"); + MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); + for (String id : Arrays.asList(renamed() ? RENAMED : LOCAL, PIPE, FAIL, WRITER, OBSERVER)) row(cursor, id); + return cursor; + } + + @Override public boolean isChildDocument(String parent, String child) { + try { check(child); } catch (FileNotFoundException e) { return false; } + return parent.equals(ROOT) && !child.equals(ROOT); + } + + @Override public String renameDocument(String id, String name) throws FileNotFoundException { + check(id); + if (!id.equals(LOCAL)) throw new UnsupportedOperationException("Only local fixture renames"); + if (!name.equals("renamed.one")) throw new UnsupportedOperationException("Fixture rename name"); + if (!file("local").renameTo(file("renamed"))) throw new FileNotFoundException("Rename failed"); + return RENAMED; + } + + @Override public ParcelFileDescriptor openDocument(String id, String mode, CancellationSignal signal) throws FileNotFoundException { + check(id); + if (offline) throw new FileNotFoundException("Injected provider unavailable"); + if (signal != null) signal.throwIfCanceled(); + try { + if (id.equals(PIPE) || id.equals(FAIL)) { + if (!(mode.equals("r") || mode.equals("w"))) throw new UnsupportedOperationException("Streaming fixture rejects seeking modes"); + ParcelFileDescriptor[] ends = ParcelFileDescriptor.createReliablePipe(); + boolean reading = mode.equals("r"); + pipeDone = new CountDownLatch(1); + pipeError = null; + new Thread(() -> { + try { + if (id.equals(FAIL)) { + ends[reading ? 1 : 0].closeWithError("Injected reliable peer failure"); + } else if (reading) { + try (ParcelFileDescriptor.AutoCloseOutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(ends[1])) { + out.write(pipeBytes); + } + } else { + try (ParcelFileDescriptor.AutoCloseInputStream in = new ParcelFileDescriptor.AutoCloseInputStream(ends[0])) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = in.read(buffer)) != -1) bytes.write(buffer, 0, length); + pipeBytes = bytes.toByteArray(); + } + } + } catch (IOException e) { + pipeError = e.toString(); + } finally { + pipeDone.countDown(); + } + }, "provider-lab-pipe").start(); + return ends[reading ? 0 : 1]; + } + if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream"); + File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local"); + return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode)); + } catch (IOException e) { + throw new FileNotFoundException(e.toString()); + } + } + + static void awaitPipe() throws Exception { + if (!pipeDone.await(5, TimeUnit.SECONDS)) throw new IOException("Pipe worker did not finish"); + if (pipeError != null) throw new IOException(pipeError); + } +} diff --git a/tools/android/provider-lab/provider/Pick.java b/tools/android/provider-lab/provider/Pick.java new file mode 100644 index 0000000000000000000000000000000000000000..587a7ad69a1e9f0037f2e4fd1bf0aff6595ddb92 --- /dev/null +++ b/tools/android/provider-lab/provider/Pick.java @@ -0,0 +1,19 @@ +package net.paperclover.snowbound.providerlab; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.provider.DocumentsContract; + +public final class Pick extends Activity { + @Override public void onCreate(Bundle state) { + super.onCreate(state); + setResult(RESULT_OK, new Intent() + .setData(DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.ROOT)) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | (getIntent().getBooleanExtra("write", false) ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0) + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + .putExtra("providerUid", android.os.Process.myUid()).putExtra("providerPid", android.os.Process.myPid())); + finish(); + } +} diff --git a/tools/android/provider-lab/run.py b/tools/android/provider-lab/run.py new file mode 100644 index 0000000000000000000000000000000000000000..0de9b42479f33804f4f3c63e91b17b452f11bcaf --- /dev/null +++ b/tools/android/provider-lab/run.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run the provider lab on an explicitly selected disposable emulator.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--adb", type=Path, required=True) + parser.add_argument("--serial", required=True) + parser.add_argument("--build", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + steps = [] + + def adb(*command, timeout=120): + result = subprocess.run([str(args.adb), "-s", args.serial, *command], + capture_output=True, text=True, timeout=timeout) + steps.append({"command": list(command), "exitCode": result.returncode, + "stdout": result.stdout, "stderr": result.stderr}) + (output / "commands.json").write_text(json.dumps(steps, indent=2) + "\n") + result.check_returncode() + return result.stdout + + if adb("shell", "getprop", "ro.kernel.qemu").strip() != "1": + raise RuntimeError("This lab only runs on a disposable emulator") + manifest = json.loads((args.build / "manifest.json").read_text()) + for name, expected in manifest["apkSha256"].items(): + if hashlib.sha256((args.build / name).read_bytes()).hexdigest() != expected: + raise RuntimeError(f"APK hash changed: {name}") + reports = {} + packages = ("net.paperclover.snowbound.providerlab", "net.paperclover.snowbound.providerprobe") + try: + for name in ("provider", "client"): + adb("install", "-t", str((args.build / (name + ".apk")).resolve())) + for phase in ("probe", "reopen"): + adb("shell", "am", "instrument", "-w", "-r", "-e", "phase", phase, + "net.paperclover.snowbound.providerprobe/.Probe") + report = json.loads(adb("shell", "run-as", packages[1], "cat", "files/" + phase + ".json")) + reports[phase] = report + (output / (phase + ".json")).write_text(json.dumps(report, indent=2) + "\n") + if not report["passed"]: + raise RuntimeError(f"{phase} failed: {report.get('failure')}") + adb("shell", "am", "force-stop", packages[1]) + if reports["probe"]["pid"] == reports["reopen"]["pid"]: + raise RuntimeError("Second phase did not use a fresh client process") + result = {"passed": True, "serial": args.serial, "build": manifest, + "reportsSha256": {name + ".json": hashlib.sha256((output / (name + ".json")).read_bytes()).hexdigest() + for name in reports}, + "scope": "Synthetic local DocumentsProvider; no commercial provider, picker UI, lock/CAS, network upload, OneNote, or production storage integration"} + (output / "verification.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + finally: + errors = [] + for package in reversed(packages): + try: + adb("uninstall", package, timeout=15) + except Exception as error: + errors.append({"package": package, "operation": "uninstall", "error": repr(error)}) + retained = [] + unknown = [] + for package in packages: + try: + if adb("shell", "pm", "list", "packages", package, timeout=10).strip(): + retained.append(package) + except Exception as error: + unknown.append(package) + errors.append({"package": package, "operation": "presence", "error": repr(error)}) + cleanup = {"packagesAbsent": not (retained or unknown), "retained": retained, + "unknown": unknown, "errors": errors} + (output / "cleanup.json").write_text(json.dumps(cleanup, indent=2) + "\n") + if errors or retained or unknown: + raise RuntimeError(f"Lab cleanup failed: {cleanup}") + + +if __name__ == "__main__": + main() -- 2.54.0