| author | |
| committer | |
| log | 849fdb2dfd099502ca37d1e55ec80762b65dcd96 |
| tree | 621c8f7c7eb2de3a8fc8ce8973ffb4b9b4b450a8 |
| parent | 3b673de430e5bec20ebf20c4ba79dd1572469f7e |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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-sol10 files changed, 758 insertions(+), 0 deletions(-)
tools/android/provider-lab/README.md created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | # Android document-provider capability lab | |
| 2 | ||
| 3 | Two **test-only** APKs: a disposable `DocumentsProvider` and a separate-UID | |
| 4 | instrumentation client. No product app, notebook backend, JNI, Gradle, or product UI. | |
| 5 | Install only on an owned emulator; the exported controller intentionally grants, | |
| 6 | revokes, and changes synthetic fixtures without authentication. | |
| 7 | ||
| 8 | Build with an installed SDK and JDK (no downloads or global changes): | |
| 9 | ||
| 10 | ```sh | |
| 11 | nice -n 10 python3 tools/android/provider-lab/build.py \ | |
| 12 | --sdk /path/to/sdk --java-home /path/to/jdk \ | |
| 13 | --output /absolute/private/build | |
| 14 | python3 tools/android/provider-lab/run.py \ | |
| 15 | --adb /path/to/sdk/platform-tools/adb --serial emulator-5556 \ | |
| 16 | --build /absolute/private/build --output /absolute/private/run | |
| 17 | ``` | |
| 18 | ||
| 19 | Defaults are SDK/build-tools 36, with API 26 as the lab APK minimum. This does | |
| 20 | not decide Snowbound's minimum Android version. Output directories must be new. | |
| 21 | The runner refuses physical devices, installs the two packages, runs bounded | |
| 22 | instrumentation, force-stops the client between phases, then uninstalls both. | |
| 23 | The caller owns emulator creation, launch, and cleanup. Evidence stays private. | |
| 24 | ||
| 25 | A test-only provider Activity returns an Intent with prefix and persistable URI | |
| 26 | grant flags; the client Activity persists only its actual returned read/write | |
| 27 | flags. This substitutes for a picker result. This does **not** test | |
| 28 | `ACTION_OPEN_DOCUMENT_TREE` UI. All data is synthetic bytes, not `.one` files. | |
| 29 | ||
| 30 | | Experiment | Assertion | | |
| 31 | | --- | --- | | |
| 32 | | 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. | | |
| 33 | | Opaque IDs / metadata | Slash and non-ASCII identities survive contract URI construction; null size and timestamp remain unknown. | | |
| 34 | | Local `rw` descriptor | Actual seek, partial overwrite, fsync, close and reopen retain the expected bytes. | | |
| 35 | | Stream `r` / `w` | Actual pipes reject seek/fsync; `rw` is rejected; bytes round-trip after a bounded provider completion event. | | |
| 36 | | Reliable peer failure | Provider `closeWithError` reaches client `checkError`. | | |
| 37 | | Rename | Returned document identity changes; old URI fails; fresh listing contains the returned identity and bytes remain intact. | | |
| 38 | | 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. | | |
| 39 | | Availability | Injected offline open fails while the persisted grant remains; restoring availability restores the bytes. | | |
| 40 | ||
| 41 | The fixture assigns duplicate display names; the probe does not compare their | |
| 42 | display-name columns. | |
| 43 | ||
| 44 | Provider events are explicit barriers, not timing guesses. Pipe workers have a | |
| 45 | bounded completion wait; every external build/runtime command has a timeout. | |
| 46 | Reports contain actual operations, exceptions, fixture byte hashes, emulator | |
| 47 | fingerprint, process IDs, and APK/source hashes. A successful local fsync or | |
| 48 | close is **not a remote upload receipt**. This synthetic provider does not prove | |
| 49 | commercial-provider behavior, remote durability, file locking, atomicity, CAS, | |
| 50 | cloud-conflict recovery, or `.snowbound/` protocol correctness. | |
| 51 | ||
| 52 | Android contracts: | |
| 53 | [ContentResolver modes](https://developer.android.com/reference/android/content/ContentResolver#openFileDescriptor(android.net.Uri,%20java.lang.String)), | |
| 54 | [DocumentsProvider](https://developer.android.com/reference/android/provider/DocumentsProvider), | |
| 55 | [rename identity](https://developer.android.com/reference/android/provider/DocumentsContract#renameDocument(android.content.ContentResolver,%20android.net.Uri,%20java.lang.String)), | |
| 56 | [descriptor errors](https://developer.android.com/reference/android/os/ParcelFileDescriptor#checkError()). |
tools/android/provider-lab/build.py created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Build two test-only APKs with an explicitly installed SDK/JDK, without Gradle.""" | |
| 3 | import argparse | |
| 4 | import hashlib | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | import subprocess | |
| 8 | ||
| 9 | ||
| 10 | def main(): | |
| 11 | parser = argparse.ArgumentParser(description=__doc__) | |
| 12 | parser.add_argument("--sdk", type=Path, required=True) | |
| 13 | parser.add_argument("--java-home", type=Path, required=True) | |
| 14 | parser.add_argument("--output", type=Path, required=True) | |
| 15 | parser.add_argument("--platform", default="android-36") | |
| 16 | parser.add_argument("--build-tools", default="36.0.0") | |
| 17 | args = parser.parse_args() | |
| 18 | source = Path(__file__).resolve().parent | |
| 19 | output = args.output.resolve() | |
| 20 | output.mkdir(parents=True, exist_ok=False) | |
| 21 | java = args.java_home.resolve() / "bin" | |
| 22 | tools = args.sdk.resolve() / "build-tools" / args.build_tools | |
| 23 | android = args.sdk.resolve() / "platforms" / args.platform / "android.jar" | |
| 24 | steps = [] | |
| 25 | ||
| 26 | def run(command): | |
| 27 | command = [str(item) for item in command] | |
| 28 | result = subprocess.run(command, capture_output=True, text=True, timeout=120) | |
| 29 | steps.append({"command": command, "exitCode": result.returncode, | |
| 30 | "stdout": result.stdout, "stderr": result.stderr}) | |
| 31 | (output / "build.json").write_text(json.dumps(steps, indent=2) + "\n") | |
| 32 | result.check_returncode() | |
| 33 | ||
| 34 | keystore = output / "test-only.jks" | |
| 35 | run([java / "keytool", "-genkeypair", "-keystore", keystore, "-storepass", "android", | |
| 36 | "-keypass", "android", "-alias", "lab", "-keyalg", "RSA", "-validity", "30", | |
| 37 | "-dname", "CN=Disposable Snowbound Provider Lab"]) | |
| 38 | for name in ("provider", "client"): | |
| 39 | classes = output / name / "classes" | |
| 40 | classes.mkdir(parents=True) | |
| 41 | run([java / "javac", "--release", "8", "-classpath", android, | |
| 42 | "-d", classes, *sorted((source / name).glob("*.java"))]) | |
| 43 | dex = output / name / "dex" | |
| 44 | dex.mkdir() | |
| 45 | run([java / "java", "-Xmx512m", "-cp", tools / "lib" / "d8.jar", "com.android.tools.r8.D8", | |
| 46 | "--min-api", "26", "--lib", android, "--output", dex, *sorted(classes.rglob("*.class"))]) | |
| 47 | unsigned = output / name / "unsigned.apk" | |
| 48 | run([tools / "aapt2", "link", "-I", android, "--manifest", source / name / "AndroidManifest.xml", | |
| 49 | "-o", unsigned]) | |
| 50 | import zipfile | |
| 51 | with zipfile.ZipFile(unsigned, "a") as archive: | |
| 52 | archive.write(dex / "classes.dex", "classes.dex") | |
| 53 | aligned = output / name / "aligned.apk" | |
| 54 | run([tools / "zipalign", "-p", "4", unsigned, aligned]) | |
| 55 | apk = output / (name + ".apk") | |
| 56 | run([java / "java", "-Xmx256m", "-jar", tools / "lib" / "apksigner.jar", "sign", | |
| 57 | "--ks", keystore, "--ks-pass", "pass:android", "--out", apk, aligned]) | |
| 58 | run([java / "java", "-jar", tools / "lib" / "apksigner.jar", "verify", "--verbose", apk]) | |
| 59 | manifest = { | |
| 60 | "sourceSha256": {str(p.relative_to(source)): hashlib.sha256(p.read_bytes()).hexdigest() | |
| 61 | for p in sorted(source.rglob("*")) if p.is_file() and "__pycache__" not in p.parts}, | |
| 62 | "apkSha256": {name: hashlib.sha256((output / name).read_bytes()).hexdigest() | |
| 63 | for name in ("provider.apk", "client.apk")}, | |
| 64 | "runtimeTested": False, | |
| 65 | } | |
| 66 | (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") | |
| 67 | print(json.dumps(manifest, indent=2)) | |
| 68 | ||
| 69 | ||
| 70 | if __name__ == "__main__": | |
| 71 | main() |
tools/android/provider-lab/client/AndroidManifest.xml created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.paperclover.snowbound.providerprobe"> | |
| 2 | <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" /> | |
| 3 | <queries><package android:name="net.paperclover.snowbound.providerlab" /></queries> | |
| 4 | <application android:debuggable="true" android:testOnly="true" android:allowBackup="false"> | |
| 5 | <activity android:name=".Grant" android:exported="false" android:theme="@android:style/Theme.Translucent.NoTitleBar" /> | |
| 6 | </application> | |
| 7 | <instrumentation android:name=".Probe" android:targetPackage="net.paperclover.snowbound.providerprobe" /> | |
| 8 | </manifest> |
tools/android/provider-lab/client/Grant.java created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | package net.paperclover.snowbound.providerprobe; | |
| 2 | ||
| 3 | import android.app.Activity; | |
| 4 | import android.content.ComponentName; | |
| 5 | import android.content.Intent; | |
| 6 | import android.os.Bundle; | |
| 7 | import java.util.concurrent.CountDownLatch; | |
| 8 | ||
| 9 | public final class Grant extends Activity { | |
| 10 | static volatile Bundle reply; | |
| 11 | static CountDownLatch done; | |
| 12 | ||
| 13 | @Override public void onCreate(Bundle state) { | |
| 14 | super.onCreate(state); | |
| 15 | startActivityForResult(new Intent().setComponent(new ComponentName( | |
| 16 | "net.paperclover.snowbound.providerlab", "net.paperclover.snowbound.providerlab.Pick")) | |
| 17 | .putExtra("write", getIntent().getBooleanExtra("write", false)), 1); | |
| 18 | } | |
| 19 | ||
| 20 | @Override protected void onActivityResult(int request, int result, Intent data) { | |
| 21 | super.onActivityResult(request, result, data); | |
| 22 | reply = new Bundle(); | |
| 23 | try { | |
| 24 | if (request != 1 || result != RESULT_OK || data == null) throw new IllegalStateException("Grant result missing"); | |
| 25 | int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); | |
| 26 | getContentResolver().takePersistableUriPermission(data.getData(), flags); | |
| 27 | reply.putBoolean("ok", true); | |
| 28 | reply.putInt("receivedFlags", data.getFlags()); | |
| 29 | reply.putInt("persistedFlags", flags); | |
| 30 | reply.putString("uri", data.getData().toString()); | |
| 31 | reply.putInt("providerUid", data.getIntExtra("providerUid", -1)); | |
| 32 | reply.putInt("providerPid", data.getIntExtra("providerPid", -1)); | |
| 33 | } catch (Exception e) { | |
| 34 | reply.putString("error", e.toString()); | |
| 35 | } | |
| 36 | done.countDown(); | |
| 37 | finish(); | |
| 38 | } | |
| 39 | } |
tools/android/provider-lab/client/Probe.java created+269| ... | ... | @@ -0,0 +1,269 @@ |
| 1 | package net.paperclover.snowbound.providerprobe; | |
| 2 | ||
| 3 | import android.app.Instrumentation; | |
| 4 | import android.content.BroadcastReceiver; | |
| 5 | import android.content.ComponentName; | |
| 6 | import android.content.ContentResolver; | |
| 7 | import android.content.Context; | |
| 8 | import android.content.Intent; | |
| 9 | import android.database.Cursor; | |
| 10 | import android.net.Uri; | |
| 11 | import android.os.Bundle; | |
| 12 | import android.os.ParcelFileDescriptor; | |
| 13 | import android.provider.DocumentsContract; | |
| 14 | import android.provider.DocumentsContract.Document; | |
| 15 | import android.system.Os; | |
| 16 | import android.system.OsConstants; | |
| 17 | import java.io.ByteArrayOutputStream; | |
| 18 | import java.io.File; | |
| 19 | import java.io.FileOutputStream; | |
| 20 | import java.nio.charset.StandardCharsets; | |
| 21 | import java.security.MessageDigest; | |
| 22 | import java.util.concurrent.CountDownLatch; | |
| 23 | import java.util.concurrent.TimeUnit; | |
| 24 | import org.json.JSONArray; | |
| 25 | import org.json.JSONObject; | |
| 26 | ||
| 27 | public final class Probe extends Instrumentation { | |
| 28 | private static final String AUTHORITY = "net.paperclover.snowbound.providerlab.documents"; | |
| 29 | private static final Uri TREE = DocumentsContract.buildTreeDocumentUri(AUTHORITY, "root:opaque/α"); | |
| 30 | private final JSONArray events = new JSONArray(); | |
| 31 | private ContentResolver resolver; | |
| 32 | private String phase; | |
| 33 | ||
| 34 | @Override public void onCreate(Bundle args) { | |
| 35 | super.onCreate(args); | |
| 36 | phase = args.getString("phase", "probe"); | |
| 37 | start(); | |
| 38 | } | |
| 39 | ||
| 40 | private void require(boolean value, String message) { | |
| 41 | if (!value) throw new AssertionError(message); | |
| 42 | } | |
| 43 | ||
| 44 | private void record(String operation, Object value) throws Exception { | |
| 45 | events.put(new JSONObject().put("operation", operation).put("result", value)); | |
| 46 | } | |
| 47 | ||
| 48 | private void control(String operation) throws Exception { | |
| 49 | CountDownLatch done = new CountDownLatch(1); | |
| 50 | Bundle[] reply = new Bundle[1]; | |
| 51 | Intent command = new Intent().setComponent(new ComponentName("net.paperclover.snowbound.providerlab", | |
| 52 | "net.paperclover.snowbound.providerlab.Control")).putExtra("operation", operation); | |
| 53 | getTargetContext().sendOrderedBroadcast(command, null, new BroadcastReceiver() { | |
| 54 | @Override public void onReceive(Context context, Intent intent) { | |
| 55 | reply[0] = getResultExtras(false); | |
| 56 | done.countDown(); | |
| 57 | } | |
| 58 | }, null, 0, null, null); | |
| 59 | require(done.await(10, TimeUnit.SECONDS), "Provider control timed out: " + operation); | |
| 60 | require(reply[0] != null && reply[0].getBoolean("ok"), "Provider control failed: " + reply[0]); | |
| 61 | require(reply[0].getInt("uid") != android.os.Process.myUid(), "Provider shares client UID"); | |
| 62 | record("provider-control", new JSONObject().put("operation", operation) | |
| 63 | .put("uid", reply[0].getInt("uid")).put("pid", reply[0].getInt("pid"))); | |
| 64 | } | |
| 65 | ||
| 66 | private void grant(boolean write) throws Exception { | |
| 67 | Grant.done = new CountDownLatch(1); | |
| 68 | Grant.reply = null; | |
| 69 | startActivitySync(new Intent(getTargetContext(), Grant.class) | |
| 70 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).putExtra("write", write)); | |
| 71 | require(Grant.done.await(10, TimeUnit.SECONDS), "Activity grant timed out"); | |
| 72 | Bundle reply = Grant.reply; | |
| 73 | require(reply != null && reply.getBoolean("ok"), "Activity grant failed: " + reply); | |
| 74 | require(reply.getInt("providerUid") != android.os.Process.myUid(), "Grant provider shares client UID"); | |
| 75 | require(reply.getString("uri").equals(TREE.toString()), "Grant URI changed"); | |
| 76 | int expected = Intent.FLAG_GRANT_READ_URI_PERMISSION | (write ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0); | |
| 77 | require(reply.getInt("persistedFlags") == expected, "Returned grant flags changed"); | |
| 78 | record("activity-result-grant", new JSONObject().put("uri", reply.getString("uri")) | |
| 79 | .put("receivedFlags", reply.getInt("receivedFlags")).put("persistedFlags", reply.getInt("persistedFlags")) | |
| 80 | .put("providerUid", reply.getInt("providerUid")).put("providerPid", reply.getInt("providerPid"))); | |
| 81 | } | |
| 82 | ||
| 83 | private static Uri document(String id) { | |
| 84 | return DocumentsContract.buildDocumentUriUsingTree(TREE, id); | |
| 85 | } | |
| 86 | ||
| 87 | private byte[] read(Uri uri) throws Exception { | |
| 88 | try (ParcelFileDescriptor.AutoCloseInputStream input = new ParcelFileDescriptor.AutoCloseInputStream( | |
| 89 | java.util.Objects.requireNonNull(resolver.openFileDescriptor(uri, "r")))) { | |
| 90 | ByteArrayOutputStream bytes = new ByteArrayOutputStream(); | |
| 91 | byte[] buffer = new byte[1024]; | |
| 92 | int length; | |
| 93 | while ((length = input.read(buffer)) != -1) bytes.write(buffer, 0, length); | |
| 94 | return bytes.toByteArray(); | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | private static JSONObject bytes(byte[] data) throws Exception { | |
| 99 | StringBuilder digest = new StringBuilder(); | |
| 100 | for (byte b : MessageDigest.getInstance("SHA-256").digest(data)) digest.append(String.format("%02x", b & 255)); | |
| 101 | return new JSONObject().put("length", data.length).put("sha256", digest.toString()) | |
| 102 | .put("syntheticText", new String(data, StandardCharsets.UTF_8)); | |
| 103 | } | |
| 104 | ||
| 105 | private interface Operation { void run() throws Exception; } | |
| 106 | ||
| 107 | private void failure(String name, Class<?> expected, Operation operation) throws Exception { | |
| 108 | Throwable error = null; | |
| 109 | try { operation.run(); } catch (Exception e) { error = e; } | |
| 110 | require(error != null && expected.isInstance(error), name + " unexpected result: " + error); | |
| 111 | record(name, new JSONObject().put("exception", error.getClass().getName()).put("message", error.toString())); | |
| 112 | } | |
| 113 | ||
| 114 | private void errno(String name, int expected, Operation operation) throws Exception { | |
| 115 | android.system.ErrnoException error = null; | |
| 116 | try { operation.run(); } catch (android.system.ErrnoException e) { error = e; } | |
| 117 | require(error != null && error.errno == expected, name + " unexpected result: " + error); | |
| 118 | record(name, new JSONObject().put("errno", error.errno).put("message", error.toString())); | |
| 119 | } | |
| 120 | ||
| 121 | private void probe() throws Exception { | |
| 122 | control("revoke"); | |
| 123 | control("reset"); | |
| 124 | Uri local = document("note:opaque/β"); | |
| 125 | failure("ungranted-read", SecurityException.class, () -> read(local)); | |
| 126 | grant(false); | |
| 127 | resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_READ_URI_PERMISSION); | |
| 128 | require(resolver.getPersistedUriPermissions().size() == 1 | |
| 129 | && resolver.getPersistedUriPermissions().get(0).isReadPermission() | |
| 130 | && !resolver.getPersistedUriPermissions().get(0).isWritePermission(), "Read-only grant widened"); | |
| 131 | require(new String(read(local), StandardCharsets.UTF_8).equals("ORIGINAL"), "Read-only content unavailable"); | |
| 132 | failure("read-only-persist-write", SecurityException.class, | |
| 133 | () -> resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)); | |
| 134 | failure("read-only-open-rw", SecurityException.class, | |
| 135 | () -> { try (ParcelFileDescriptor ignored = resolver.openFileDescriptor(local, "rw")) { } }); | |
| 136 | record("read-only-persisted-grant", new JSONObject().put("uri", TREE).put("read", true).put("write", false)); | |
| 137 | control("revoke"); | |
| 138 | grant(true); | |
| 139 | resolver.takePersistableUriPermission(TREE, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); | |
| 140 | require(resolver.getPersistedUriPermissions().size() == 1, "Persisted tree missing"); | |
| 141 | android.content.UriPermission grant = resolver.getPersistedUriPermissions().get(0); | |
| 142 | require(grant.getUri().equals(TREE) && grant.isReadPermission() && grant.isWritePermission(), "Wrong persisted grant"); | |
| 143 | record("persisted-grant", new JSONObject().put("uri", TREE).put("read", grant.isReadPermission()).put("write", grant.isWritePermission())); | |
| 144 | ||
| 145 | try (Cursor cursor = resolver.query(local, null, null, null, null)) { | |
| 146 | require(cursor != null && cursor.moveToFirst(), "Metadata missing"); | |
| 147 | require(cursor.isNull(cursor.getColumnIndexOrThrow(Document.COLUMN_SIZE)), "Unknown size became known"); | |
| 148 | require(cursor.isNull(cursor.getColumnIndexOrThrow(Document.COLUMN_LAST_MODIFIED)), "Unknown timestamp became known"); | |
| 149 | require(DocumentsContract.getDocumentId(local).equals("note:opaque/β"), "Opaque identity changed"); | |
| 150 | record("metadata", new JSONObject().put("uri", local).put("id", cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID))) | |
| 151 | .put("flags", cursor.getInt(cursor.getColumnIndexOrThrow(Document.COLUMN_FLAGS))).put("size", JSONObject.NULL).put("lastModified", JSONObject.NULL)); | |
| 152 | } | |
| 153 | try (ParcelFileDescriptor fd = resolver.openFileDescriptor(local, "rw")) { | |
| 154 | record("local-open", "rw"); | |
| 155 | record("local-seek", Os.lseek(fd.getFileDescriptor(), 2, OsConstants.SEEK_SET)); | |
| 156 | byte[] patch = "++".getBytes(StandardCharsets.UTF_8); | |
| 157 | require(Os.write(fd.getFileDescriptor(), patch, 0, patch.length) == patch.length, "Short write"); | |
| 158 | record("local-write", patch.length); | |
| 159 | Os.fsync(fd.getFileDescriptor()); | |
| 160 | record("local-fsync", "success"); | |
| 161 | } | |
| 162 | record("local-close", "success"); | |
| 163 | byte[] patched = read(local); | |
| 164 | require(new String(patched, StandardCharsets.UTF_8).equals("OR++INAL"), "Seek write was not retained"); | |
| 165 | record("local-reopen", bytes(patched)); | |
| 166 | Uri renamed = DocumentsContract.renameDocument(resolver, local, "renamed.one"); | |
| 167 | require(renamed != null && !renamed.equals(local), "Rename did not change URI"); | |
| 168 | require(DocumentsContract.getDocumentId(renamed).equals("identity:new/γ"), "Unexpected renamed ID"); | |
| 169 | failure("old-uri-after-rename", SecurityException.class, () -> read(local)); | |
| 170 | require(java.util.Arrays.equals(read(renamed), patched), "Renamed content changed"); | |
| 171 | java.util.List<String> ids = new java.util.ArrayList<>(); | |
| 172 | Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(TREE, "root:opaque/α"); | |
| 173 | try (Cursor cursor = resolver.query(children, new String[] {Document.COLUMN_DOCUMENT_ID}, null, null, null)) { | |
| 174 | require(cursor != null, "Listing null"); | |
| 175 | while (cursor.moveToNext()) ids.add(cursor.getString(0)); | |
| 176 | } | |
| 177 | require(ids.contains("identity:new/γ") && !ids.contains("note:opaque/β"), "Rename listing stale"); | |
| 178 | record("rename", new JSONObject().put("old", local).put("returned", renamed).put("freshListing", new JSONArray(ids))); | |
| 179 | ||
| 180 | Uri pipe = document("stream:opaque/δ"); | |
| 181 | failure("pipe-rw", UnsupportedOperationException.class, () -> { try (ParcelFileDescriptor ignored = resolver.openFileDescriptor(pipe, "rw")) { } }); | |
| 182 | try (ParcelFileDescriptor fd = resolver.openFileDescriptor(pipe, "r")) { | |
| 183 | errno("pipe-read-seek", OsConstants.ESPIPE, | |
| 184 | () -> Os.lseek(fd.getFileDescriptor(), 0, OsConstants.SEEK_SET)); | |
| 185 | errno("pipe-read-fsync", OsConstants.EINVAL, () -> Os.fsync(fd.getFileDescriptor())); | |
| 186 | byte[] buffer = new byte[6]; | |
| 187 | require(Os.read(fd.getFileDescriptor(), buffer, 0, buffer.length) == 6, "Short pipe read"); | |
| 188 | require(new String(buffer, StandardCharsets.UTF_8).equals("STREAM"), "Wrong stream bytes"); | |
| 189 | record("pipe-read", bytes(buffer)); | |
| 190 | } | |
| 191 | control("await-pipe"); | |
| 192 | try (ParcelFileDescriptor fd = resolver.openFileDescriptor(pipe, "w")) { | |
| 193 | errno("pipe-write-seek", OsConstants.ESPIPE, | |
| 194 | () -> Os.lseek(fd.getFileDescriptor(), 0, OsConstants.SEEK_SET)); | |
| 195 | byte[] data = "PIPE-WRITE".getBytes(StandardCharsets.UTF_8); | |
| 196 | record("pipe-write", Os.write(fd.getFileDescriptor(), data, 0, data.length)); | |
| 197 | errno("pipe-write-fsync", OsConstants.EINVAL, () -> Os.fsync(fd.getFileDescriptor())); | |
| 198 | } | |
| 199 | record("pipe-write-close", "success"); | |
| 200 | control("await-pipe"); | |
| 201 | require(new String(read(pipe), StandardCharsets.UTF_8).equals("PIPE-WRITE"), "Pipe content not retained"); | |
| 202 | control("await-pipe"); | |
| 203 | try (ParcelFileDescriptor fd = resolver.openFileDescriptor(document("failed:opaque/ε"), "r")) { | |
| 204 | control("await-pipe"); | |
| 205 | failure("reliable-peer-error", java.io.IOException.class, fd::checkError); | |
| 206 | } | |
| 207 | record("reliable-peer-close", "success"); | |
| 208 | ||
| 209 | Uri writer = document("view:writer/ζ"); | |
| 210 | Uri observer = document("view:observer/η"); | |
| 211 | try (ParcelFileDescriptor fd = resolver.openFileDescriptor(writer, "rw")) { | |
| 212 | byte[] data = "NEW-VIEW".getBytes(StandardCharsets.UTF_8); | |
| 213 | require(Os.write(fd.getFileDescriptor(), data, 0, data.length) == data.length, "Short staged write"); | |
| 214 | Os.fsync(fd.getFileDescriptor()); | |
| 215 | record("delayed-local-fsync", "success"); | |
| 216 | } | |
| 217 | record("delayed-local-close", "success"); | |
| 218 | require(new String(read(writer), StandardCharsets.UTF_8).equals("NEW-VIEW"), "Writer did not retain bytes"); | |
| 219 | byte[] old = read(observer); | |
| 220 | require(new String(old, StandardCharsets.UTF_8).equals("OLD-VIEW"), "Observer advanced without publication event"); | |
| 221 | record("observer-after-successful-fsync-close", bytes(old)); | |
| 222 | control("publish"); | |
| 223 | byte[] published = read(observer); | |
| 224 | require(new String(published, StandardCharsets.UTF_8).equals("NEW-VIEW"), "Controlled publication failed"); | |
| 225 | record("observer-after-publication-event", bytes(published)); | |
| 226 | control("offline"); | |
| 227 | failure("offline-open", java.io.FileNotFoundException.class, () -> read(writer)); | |
| 228 | require(resolver.getPersistedUriPermissions().size() == 1, "Offline availability changed grant"); | |
| 229 | control("online"); | |
| 230 | require(java.util.Arrays.equals(read(writer), published), "Online restore lost content"); | |
| 231 | record("restored-online", bytes(read(writer))); | |
| 232 | } | |
| 233 | ||
| 234 | @Override public void onStart() { | |
| 235 | resolver = getTargetContext().getContentResolver(); | |
| 236 | JSONObject report = new JSONObject(); | |
| 237 | boolean passed = false; | |
| 238 | try { | |
| 239 | if (phase.equals("probe")) probe(); | |
| 240 | else if (phase.equals("reopen")) { | |
| 241 | require(resolver.getPersistedUriPermissions().size() == 1, "Grant did not survive client process exit"); | |
| 242 | android.content.UriPermission grant = resolver.getPersistedUriPermissions().get(0); | |
| 243 | require(grant.getUri().equals(TREE) && grant.isReadPermission() && grant.isWritePermission(), "Reopened grant flags changed"); | |
| 244 | byte[] reopened = read(document("identity:new/γ")); | |
| 245 | require(new String(reopened, StandardCharsets.UTF_8).equals("OR++INAL"), "Reopened bytes changed"); | |
| 246 | record("second-client-process", new JSONObject().put("bytes", bytes(reopened)) | |
| 247 | .put("uri", grant.getUri()).put("read", grant.isReadPermission()).put("write", grant.isWritePermission())); | |
| 248 | control("revoke"); | |
| 249 | failure("revoked-read", SecurityException.class, () -> read(document("identity:new/γ"))); | |
| 250 | require(resolver.getPersistedUriPermissions().isEmpty(), "Revoked persisted grant retained"); | |
| 251 | record("persisted-after-revoke", 0); | |
| 252 | } else throw new IllegalArgumentException(phase); | |
| 253 | passed = true; | |
| 254 | } catch (Throwable e) { | |
| 255 | try { report.put("failure", e.toString()); } catch (Exception ignored) { } | |
| 256 | } | |
| 257 | try { | |
| 258 | report.put("passed", passed).put("phase", phase).put("pid", android.os.Process.myPid()) | |
| 259 | .put("uid", android.os.Process.myUid()).put("sdk", android.os.Build.VERSION.SDK_INT) | |
| 260 | .put("fingerprint", android.os.Build.FINGERPRINT).put("events", events); | |
| 261 | try (FileOutputStream out = new FileOutputStream(new File(getTargetContext().getFilesDir(), phase + ".json"))) { | |
| 262 | out.write(report.toString(2).getBytes(StandardCharsets.UTF_8)); | |
| 263 | } | |
| 264 | } catch (Exception e) { passed = false; } | |
| 265 | Bundle result = new Bundle(); | |
| 266 | result.putString("report", report.toString()); | |
| 267 | finish(passed ? -1 : 0, result); | |
| 268 | } | |
| 269 | } |
tools/android/provider-lab/provider/AndroidManifest.xml created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="net.paperclover.snowbound.providerlab"> | |
| 2 | <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" /> | |
| 3 | <application android:debuggable="true" android:testOnly="true" android:allowBackup="false"> | |
| 4 | <provider android:name=".LabProvider" android:authorities="net.paperclover.snowbound.providerlab.documents" android:exported="true" android:grantUriPermissions="true" android:permission="android.permission.MANAGE_DOCUMENTS"> | |
| 5 | <intent-filter><action android:name="android.content.action.DOCUMENTS_PROVIDER" /></intent-filter> | |
| 6 | </provider> | |
| 7 | <receiver android:name=".Control" android:exported="true" /> | |
| 8 | <activity android:name=".Pick" android:exported="true" android:theme="@android:style/Theme.NoDisplay" /> | |
| 9 | </application> | |
| 10 | </manifest> |
tools/android/provider-lab/provider/Control.java created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | package net.paperclover.snowbound.providerlab; | |
| 2 | ||
| 3 | import android.content.BroadcastReceiver; | |
| 4 | import android.content.Context; | |
| 5 | import android.content.Intent; | |
| 6 | import android.net.Uri; | |
| 7 | import android.os.Bundle; | |
| 8 | import android.provider.DocumentsContract; | |
| 9 | import java.nio.file.Files; | |
| 10 | ||
| 11 | public final class Control extends BroadcastReceiver { | |
| 12 | @Override public void onReceive(Context context, Intent intent) { | |
| 13 | Bundle result = new Bundle(); | |
| 14 | try { | |
| 15 | // Force provider initialization before touching its disposable backing files. | |
| 16 | try (android.content.ContentProviderClient ignored = context.getContentResolver() | |
| 17 | .acquireContentProviderClient(LabProvider.AUTHORITY)) { | |
| 18 | Uri tree = DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.ROOT); | |
| 19 | String action = intent.getStringExtra("operation"); | |
| 20 | if (action.equals("reset")) LabProvider.reset(); | |
| 21 | else if (action.equals("revoke")) { | |
| 22 | context.revokeUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); | |
| 23 | } else if (action.equals("offline")) LabProvider.offline = true; | |
| 24 | else if (action.equals("online")) LabProvider.offline = false; | |
| 25 | else if (action.equals("publish")) Files.write(LabProvider.file("observer").toPath(), Files.readAllBytes(LabProvider.file("writer").toPath())); | |
| 26 | else if (action.equals("await-pipe")) LabProvider.awaitPipe(); | |
| 27 | else throw new IllegalArgumentException(action); | |
| 28 | } | |
| 29 | result.putBoolean("ok", true); | |
| 30 | result.putInt("uid", android.os.Process.myUid()); | |
| 31 | result.putInt("pid", android.os.Process.myPid()); | |
| 32 | } catch (Exception e) { | |
| 33 | result.putString("error", e.toString()); | |
| 34 | } | |
| 35 | setResultExtras(result); | |
| 36 | } | |
| 37 | } |
tools/android/provider-lab/provider/LabProvider.java created+167| ... | ... | @@ -0,0 +1,167 @@ |
| 1 | package net.paperclover.snowbound.providerlab; | |
| 2 | ||
| 3 | import android.database.Cursor; | |
| 4 | import android.database.MatrixCursor; | |
| 5 | import android.os.CancellationSignal; | |
| 6 | import android.os.ParcelFileDescriptor; | |
| 7 | import android.provider.DocumentsContract.Document; | |
| 8 | import android.provider.DocumentsContract.Root; | |
| 9 | import android.provider.DocumentsProvider; | |
| 10 | import java.io.ByteArrayOutputStream; | |
| 11 | import java.io.File; | |
| 12 | import java.io.FileNotFoundException; | |
| 13 | import java.io.IOException; | |
| 14 | import java.nio.charset.StandardCharsets; | |
| 15 | import java.nio.file.Files; | |
| 16 | import java.util.Arrays; | |
| 17 | import java.util.concurrent.CountDownLatch; | |
| 18 | import java.util.concurrent.TimeUnit; | |
| 19 | ||
| 20 | public final class LabProvider extends DocumentsProvider { | |
| 21 | static final String AUTHORITY = "net.paperclover.snowbound.providerlab.documents"; | |
| 22 | static final String ROOT = "root:opaque/α"; | |
| 23 | static final String LOCAL = "note:opaque/β"; | |
| 24 | static final String RENAMED = "identity:new/γ"; | |
| 25 | static final String PIPE = "stream:opaque/δ"; | |
| 26 | static final String FAIL = "failed:opaque/ε"; | |
| 27 | static final String WRITER = "view:writer/ζ"; | |
| 28 | static final String OBSERVER = "view:observer/η"; | |
| 29 | static final String[] COLUMNS = {Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, | |
| 30 | Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED}; | |
| 31 | static volatile boolean offline; | |
| 32 | static volatile byte[] pipeBytes; | |
| 33 | static volatile CountDownLatch pipeDone = new CountDownLatch(0); | |
| 34 | static volatile String pipeError; | |
| 35 | static File directory; | |
| 36 | ||
| 37 | @Override public boolean onCreate() { | |
| 38 | directory = new File(getContext().getFilesDir(), "synthetic-documents"); | |
| 39 | directory.mkdirs(); | |
| 40 | return true; | |
| 41 | } | |
| 42 | ||
| 43 | static File file(String name) { return new File(directory, name); } | |
| 44 | ||
| 45 | static void reset() throws IOException { | |
| 46 | offline = false; | |
| 47 | Files.deleteIfExists(file("renamed").toPath()); | |
| 48 | pipeBytes = "STREAM".getBytes(StandardCharsets.UTF_8); | |
| 49 | pipeError = null; | |
| 50 | pipeDone = new CountDownLatch(0); | |
| 51 | Files.write(file("local").toPath(), "ORIGINAL".getBytes(StandardCharsets.UTF_8)); | |
| 52 | Files.write(file("writer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8)); | |
| 53 | Files.write(file("observer").toPath(), "OLD-VIEW".getBytes(StandardCharsets.UTF_8)); | |
| 54 | } | |
| 55 | ||
| 56 | static boolean renamed() { return file("renamed").exists(); } | |
| 57 | ||
| 58 | static void check(String id) throws FileNotFoundException { | |
| 59 | if (!(id.equals(ROOT) || id.equals(renamed() ? RENAMED : LOCAL) || id.equals(PIPE) | |
| 60 | || id.equals(FAIL) || id.equals(WRITER) || id.equals(OBSERVER))) { | |
| 61 | throw new FileNotFoundException("Unknown opaque identity"); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | private static void row(MatrixCursor cursor, String id) { | |
| 66 | MatrixCursor.RowBuilder row = cursor.newRow(); | |
| 67 | for (String column : cursor.getColumnNames()) { | |
| 68 | Object value = null; | |
| 69 | if (column.equals(Document.COLUMN_DOCUMENT_ID)) value = id; | |
| 70 | if (column.equals(Document.COLUMN_DISPLAY_NAME)) value = id.equals(ROOT) ? "Disposable lab" : id.equals(RENAMED) ? "renamed.one" : "same-name.one"; | |
| 71 | if (column.equals(Document.COLUMN_MIME_TYPE)) value = id.equals(ROOT) ? Document.MIME_TYPE_DIR : "application/octet-stream"; | |
| 72 | if (column.equals(Document.COLUMN_FLAGS)) value = id.equals(ROOT) ? 0 : Document.FLAG_SUPPORTS_WRITE | (id.equals(LOCAL) ? Document.FLAG_SUPPORTS_RENAME : 0); | |
| 73 | row.add(value); | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | @Override public Cursor queryRoots(String[] projection) { | |
| 78 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : new String[] { | |
| 79 | Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, Root.COLUMN_FLAGS}); | |
| 80 | MatrixCursor.RowBuilder row = cursor.newRow(); | |
| 81 | for (String column : cursor.getColumnNames()) { | |
| 82 | Object value = null; | |
| 83 | if (column.equals(Root.COLUMN_ROOT_ID)) value = "synthetic"; | |
| 84 | if (column.equals(Root.COLUMN_DOCUMENT_ID)) value = ROOT; | |
| 85 | if (column.equals(Root.COLUMN_TITLE)) value = "Disposable provider lab"; | |
| 86 | if (column.equals(Root.COLUMN_FLAGS)) value = Root.FLAG_SUPPORTS_IS_CHILD; | |
| 87 | row.add(value); | |
| 88 | } | |
| 89 | return cursor; | |
| 90 | } | |
| 91 | ||
| 92 | @Override public Cursor queryDocument(String id, String[] projection) throws FileNotFoundException { | |
| 93 | check(id); | |
| 94 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); | |
| 95 | row(cursor, id); | |
| 96 | return cursor; | |
| 97 | } | |
| 98 | ||
| 99 | @Override public Cursor queryChildDocuments(String parent, String[] projection, String order) throws FileNotFoundException { | |
| 100 | if (!parent.equals(ROOT)) throw new FileNotFoundException("Not a directory"); | |
| 101 | MatrixCursor cursor = new MatrixCursor(projection != null ? projection : COLUMNS); | |
| 102 | for (String id : Arrays.asList(renamed() ? RENAMED : LOCAL, PIPE, FAIL, WRITER, OBSERVER)) row(cursor, id); | |
| 103 | return cursor; | |
| 104 | } | |
| 105 | ||
| 106 | @Override public boolean isChildDocument(String parent, String child) { | |
| 107 | try { check(child); } catch (FileNotFoundException e) { return false; } | |
| 108 | return parent.equals(ROOT) && !child.equals(ROOT); | |
| 109 | } | |
| 110 | ||
| 111 | @Override public String renameDocument(String id, String name) throws FileNotFoundException { | |
| 112 | check(id); | |
| 113 | if (!id.equals(LOCAL)) throw new UnsupportedOperationException("Only local fixture renames"); | |
| 114 | if (!name.equals("renamed.one")) throw new UnsupportedOperationException("Fixture rename name"); | |
| 115 | if (!file("local").renameTo(file("renamed"))) throw new FileNotFoundException("Rename failed"); | |
| 116 | return RENAMED; | |
| 117 | } | |
| 118 | ||
| 119 | @Override public ParcelFileDescriptor openDocument(String id, String mode, CancellationSignal signal) throws FileNotFoundException { | |
| 120 | check(id); | |
| 121 | if (offline) throw new FileNotFoundException("Injected provider unavailable"); | |
| 122 | if (signal != null) signal.throwIfCanceled(); | |
| 123 | try { | |
| 124 | if (id.equals(PIPE) || id.equals(FAIL)) { | |
| 125 | if (!(mode.equals("r") || mode.equals("w"))) throw new UnsupportedOperationException("Streaming fixture rejects seeking modes"); | |
| 126 | ParcelFileDescriptor[] ends = ParcelFileDescriptor.createReliablePipe(); | |
| 127 | boolean reading = mode.equals("r"); | |
| 128 | pipeDone = new CountDownLatch(1); | |
| 129 | pipeError = null; | |
| 130 | new Thread(() -> { | |
| 131 | try { | |
| 132 | if (id.equals(FAIL)) { | |
| 133 | ends[reading ? 1 : 0].closeWithError("Injected reliable peer failure"); | |
| 134 | } else if (reading) { | |
| 135 | try (ParcelFileDescriptor.AutoCloseOutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(ends[1])) { | |
| 136 | out.write(pipeBytes); | |
| 137 | } | |
| 138 | } else { | |
| 139 | try (ParcelFileDescriptor.AutoCloseInputStream in = new ParcelFileDescriptor.AutoCloseInputStream(ends[0])) { | |
| 140 | ByteArrayOutputStream bytes = new ByteArrayOutputStream(); | |
| 141 | byte[] buffer = new byte[1024]; | |
| 142 | int length; | |
| 143 | while ((length = in.read(buffer)) != -1) bytes.write(buffer, 0, length); | |
| 144 | pipeBytes = bytes.toByteArray(); | |
| 145 | } | |
| 146 | } | |
| 147 | } catch (IOException e) { | |
| 148 | pipeError = e.toString(); | |
| 149 | } finally { | |
| 150 | pipeDone.countDown(); | |
| 151 | } | |
| 152 | }, "provider-lab-pipe").start(); | |
| 153 | return ends[reading ? 0 : 1]; | |
| 154 | } | |
| 155 | if (id.equals(ROOT)) throw new FileNotFoundException("Directory has no byte stream"); | |
| 156 | File source = file(id.equals(WRITER) ? "writer" : id.equals(OBSERVER) ? "observer" : id.equals(RENAMED) ? "renamed" : "local"); | |
| 157 | return ParcelFileDescriptor.open(source, ParcelFileDescriptor.parseMode(mode)); | |
| 158 | } catch (IOException e) { | |
| 159 | throw new FileNotFoundException(e.toString()); | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | static void awaitPipe() throws Exception { | |
| 164 | if (!pipeDone.await(5, TimeUnit.SECONDS)) throw new IOException("Pipe worker did not finish"); | |
| 165 | if (pipeError != null) throw new IOException(pipeError); | |
| 166 | } | |
| 167 | } |
tools/android/provider-lab/provider/Pick.java created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | package net.paperclover.snowbound.providerlab; | |
| 2 | ||
| 3 | import android.app.Activity; | |
| 4 | import android.content.Intent; | |
| 5 | import android.os.Bundle; | |
| 6 | import android.provider.DocumentsContract; | |
| 7 | ||
| 8 | public final class Pick extends Activity { | |
| 9 | @Override public void onCreate(Bundle state) { | |
| 10 | super.onCreate(state); | |
| 11 | setResult(RESULT_OK, new Intent() | |
| 12 | .setData(DocumentsContract.buildTreeDocumentUri(LabProvider.AUTHORITY, LabProvider.ROOT)) | |
| 13 | .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | |
| 14 | | (getIntent().getBooleanExtra("write", false) ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0) | |
| 15 | | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) | |
| 16 | .putExtra("providerUid", android.os.Process.myUid()).putExtra("providerPid", android.os.Process.myPid())); | |
| 17 | finish(); | |
| 18 | } | |
| 19 | } |
tools/android/provider-lab/run.py created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Run the provider lab on an explicitly selected disposable emulator.""" | |
| 3 | import argparse | |
| 4 | import hashlib | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | import subprocess | |
| 8 | ||
| 9 | ||
| 10 | def main(): | |
| 11 | parser = argparse.ArgumentParser(description=__doc__) | |
| 12 | parser.add_argument("--adb", type=Path, required=True) | |
| 13 | parser.add_argument("--serial", required=True) | |
| 14 | parser.add_argument("--build", type=Path, required=True) | |
| 15 | parser.add_argument("--output", type=Path, required=True) | |
| 16 | args = parser.parse_args() | |
| 17 | output = args.output.resolve() | |
| 18 | output.mkdir(parents=True, exist_ok=False) | |
| 19 | steps = [] | |
| 20 | ||
| 21 | def adb(*command, timeout=120): | |
| 22 | result = subprocess.run([str(args.adb), "-s", args.serial, *command], | |
| 23 | capture_output=True, text=True, timeout=timeout) | |
| 24 | steps.append({"command": list(command), "exitCode": result.returncode, | |
| 25 | "stdout": result.stdout, "stderr": result.stderr}) | |
| 26 | (output / "commands.json").write_text(json.dumps(steps, indent=2) + "\n") | |
| 27 | result.check_returncode() | |
| 28 | return result.stdout | |
| 29 | ||
| 30 | if adb("shell", "getprop", "ro.kernel.qemu").strip() != "1": | |
| 31 | raise RuntimeError("This lab only runs on a disposable emulator") | |
| 32 | manifest = json.loads((args.build / "manifest.json").read_text()) | |
| 33 | for name, expected in manifest["apkSha256"].items(): | |
| 34 | if hashlib.sha256((args.build / name).read_bytes()).hexdigest() != expected: | |
| 35 | raise RuntimeError(f"APK hash changed: {name}") | |
| 36 | reports = {} | |
| 37 | packages = ("net.paperclover.snowbound.providerlab", "net.paperclover.snowbound.providerprobe") | |
| 38 | try: | |
| 39 | for name in ("provider", "client"): | |
| 40 | adb("install", "-t", str((args.build / (name + ".apk")).resolve())) | |
| 41 | for phase in ("probe", "reopen"): | |
| 42 | adb("shell", "am", "instrument", "-w", "-r", "-e", "phase", phase, | |
| 43 | "net.paperclover.snowbound.providerprobe/.Probe") | |
| 44 | report = json.loads(adb("shell", "run-as", packages[1], "cat", "files/" + phase + ".json")) | |
| 45 | reports[phase] = report | |
| 46 | (output / (phase + ".json")).write_text(json.dumps(report, indent=2) + "\n") | |
| 47 | if not report["passed"]: | |
| 48 | raise RuntimeError(f"{phase} failed: {report.get('failure')}") | |
| 49 | adb("shell", "am", "force-stop", packages[1]) | |
| 50 | if reports["probe"]["pid"] == reports["reopen"]["pid"]: | |
| 51 | raise RuntimeError("Second phase did not use a fresh client process") | |
| 52 | result = {"passed": True, "serial": args.serial, "build": manifest, | |
| 53 | "reportsSha256": {name + ".json": hashlib.sha256((output / (name + ".json")).read_bytes()).hexdigest() | |
| 54 | for name in reports}, | |
| 55 | "scope": "Synthetic local DocumentsProvider; no commercial provider, picker UI, lock/CAS, network upload, OneNote, or production storage integration"} | |
| 56 | (output / "verification.json").write_text(json.dumps(result, indent=2) + "\n") | |
| 57 | print(json.dumps(result, indent=2)) | |
| 58 | finally: | |
| 59 | errors = [] | |
| 60 | for package in reversed(packages): | |
| 61 | try: | |
| 62 | adb("uninstall", package, timeout=15) | |
| 63 | except Exception as error: | |
| 64 | errors.append({"package": package, "operation": "uninstall", "error": repr(error)}) | |
| 65 | retained = [] | |
| 66 | unknown = [] | |
| 67 | for package in packages: | |
| 68 | try: | |
| 69 | if adb("shell", "pm", "list", "packages", package, timeout=10).strip(): | |
| 70 | retained.append(package) | |
| 71 | except Exception as error: | |
| 72 | unknown.append(package) | |
| 73 | errors.append({"package": package, "operation": "presence", "error": repr(error)}) | |
| 74 | cleanup = {"packagesAbsent": not (retained or unknown), "retained": retained, | |
| 75 | "unknown": unknown, "errors": errors} | |
| 76 | (output / "cleanup.json").write_text(json.dumps(cleanup, indent=2) + "\n") | |
| 77 | if errors or retained or unknown: | |
| 78 | raise RuntimeError(f"Lab cleanup failed: {cleanup}") | |
| 79 | ||
| 80 | ||
| 81 | if __name__ == "__main__": | |
| 82 | main() |