authorclover caruso <git@paperclover.net> 2026-10-11 19:26:56-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 19:26:56-07:00
log849fdb2dfd099502ca37d1e55ec80762b65dcd96
tree621c8f7c7eb2de3a8fc8ce8973ffb4b9b4b450a8
parent3b673de430e5bec20ebf20c4ba79dd1572469f7e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

10 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
3Two **test-only** APKs: a disposable `DocumentsProvider` and a separate-UID
4instrumentation client. No product app, notebook backend, JNI, Gradle, or product UI.
5Install only on an owned emulator; the exported controller intentionally grants,
6revokes, and changes synthetic fixtures without authentication.
7
8Build with an installed SDK and JDK (no downloads or global changes):
9
10```sh
11nice -n 10 python3 tools/android/provider-lab/build.py \
12 --sdk /path/to/sdk --java-home /path/to/jdk \
13 --output /absolute/private/build
14python3 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
19Defaults are SDK/build-tools 36, with API 26 as the lab APK minimum. This does
20not decide Snowbound's minimum Android version. Output directories must be new.
21The runner refuses physical devices, installs the two packages, runs bounded
22instrumentation, force-stops the client between phases, then uninstalls both.
23The caller owns emulator creation, launch, and cleanup. Evidence stays private.
24
25A test-only provider Activity returns an Intent with prefix and persistable URI
26grant flags; the client Activity persists only its actual returned read/write
27flags. 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
41The fixture assigns duplicate display names; the probe does not compare their
42display-name columns.
43
44Provider events are explicit barriers, not timing guesses. Pipe workers have a
45bounded completion wait; every external build/runtime command has a timeout.
46Reports contain actual operations, exceptions, fixture byte hashes, emulator
47fingerprint, process IDs, and APK/source hashes. A successful local fsync or
48close is **not a remote upload receipt**. This synthetic provider does not prove
49commercial-provider behavior, remote durability, file locking, atomicity, CAS,
50cloud-conflict recovery, or `.snowbound/` protocol correctness.
51
52Android 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."""
3import argparse
4import hashlib
5import json
6from pathlib import Path
7import subprocess
8
9
10def 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
70if __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 @@
1package net.paperclover.snowbound.providerprobe;
2
3import android.app.Activity;
4import android.content.ComponentName;
5import android.content.Intent;
6import android.os.Bundle;
7import java.util.concurrent.CountDownLatch;
8
9public 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 @@
1package net.paperclover.snowbound.providerprobe;
2
3import android.app.Instrumentation;
4import android.content.BroadcastReceiver;
5import android.content.ComponentName;
6import android.content.ContentResolver;
7import android.content.Context;
8import android.content.Intent;
9import android.database.Cursor;
10import android.net.Uri;
11import android.os.Bundle;
12import android.os.ParcelFileDescriptor;
13import android.provider.DocumentsContract;
14import android.provider.DocumentsContract.Document;
15import android.system.Os;
16import android.system.OsConstants;
17import java.io.ByteArrayOutputStream;
18import java.io.File;
19import java.io.FileOutputStream;
20import java.nio.charset.StandardCharsets;
21import java.security.MessageDigest;
22import java.util.concurrent.CountDownLatch;
23import java.util.concurrent.TimeUnit;
24import org.json.JSONArray;
25import org.json.JSONObject;
26
27public 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 @@
1package net.paperclover.snowbound.providerlab;
2
3import android.content.BroadcastReceiver;
4import android.content.Context;
5import android.content.Intent;
6import android.net.Uri;
7import android.os.Bundle;
8import android.provider.DocumentsContract;
9import java.nio.file.Files;
10
11public 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 @@
1package net.paperclover.snowbound.providerlab;
2
3import android.database.Cursor;
4import android.database.MatrixCursor;
5import android.os.CancellationSignal;
6import android.os.ParcelFileDescriptor;
7import android.provider.DocumentsContract.Document;
8import android.provider.DocumentsContract.Root;
9import android.provider.DocumentsProvider;
10import java.io.ByteArrayOutputStream;
11import java.io.File;
12import java.io.FileNotFoundException;
13import java.io.IOException;
14import java.nio.charset.StandardCharsets;
15import java.nio.file.Files;
16import java.util.Arrays;
17import java.util.concurrent.CountDownLatch;
18import java.util.concurrent.TimeUnit;
19
20public 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 @@
1package net.paperclover.snowbound.providerlab;
2
3import android.app.Activity;
4import android.content.Intent;
5import android.os.Bundle;
6import android.provider.DocumentsContract;
7
8public 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."""
3import argparse
4import hashlib
5import json
6from pathlib import Path
7import subprocess
8
9
10def 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
81if __name__ == "__main__":
82 main()