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}