| 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 | } |