From 6644436a75353ace2560cc8d72b01f4ebb0480f6 Mon Sep 17 00:00:00 2001 From: Jeff Sharkey Date: Thu, 4 Mar 2021 09:50:50 -0700 Subject: [PATCH] General BytesMatcher utility class. As part of an upcoming feature, we're anticipating the need to match arbitrary byte sequences, such as Bluetooth UUIDs or MAC addresses, so this change adds a new BytesMatcher similar in spirit to the existing PatternMatcher. It supports a reasonably compact serialization format which can be used to transport a server-pushed deny-list. The choice to support both "accept" and "reject" rules in an ordered list means that very complex matching rules can be expressed efficiently. Bug: 181812624 Test: atest FrameworksCoreTests:android.os.BytesMatcherTest Change-Id: I55193051354278abaef8bef51413c632b07c7177 --- core/java/android/os/BytesMatcher.java | 248 ++++++++++++++++++ .../src/android/os/BytesMatcherTest.java | 133 ++++++++++ 2 files changed, 381 insertions(+) create mode 100644 core/java/android/os/BytesMatcher.java create mode 100644 core/tests/coretests/src/android/os/BytesMatcherTest.java diff --git a/core/java/android/os/BytesMatcher.java b/core/java/android/os/BytesMatcher.java new file mode 100644 index 0000000000000..8537f47eb575a --- /dev/null +++ b/core/java/android/os/BytesMatcher.java @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.os; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.bluetooth.BluetoothUuid; +import android.net.MacAddress; +import android.util.Log; + +import com.android.internal.util.HexDump; + +import java.util.ArrayList; +import java.util.function.Predicate; + +/** + * Predicate that tests if a given {@code byte[]} value matches a set of + * configured rules. + *

+ * Rules are tested in the order in which they were originally added, which + * means a narrow rule can reject a specific value before a later broader rule + * might accept that same value, or vice versa. + *

+ * Matchers can contain rules of varying lengths, and tested values will only be + * matched against rules of the exact same length. This is designed to support + * {@link BluetoothUuid} style values which can be variable length. + * + * @hide + */ +public class BytesMatcher implements Predicate { + private static final String TAG = "BytesMatcher"; + + private static final char TYPE_ACCEPT = '+'; + private static final char TYPE_REJECT = '-'; + + private final ArrayList mRules = new ArrayList<>(); + + private static class Rule { + public final char type; + public final @NonNull byte[] value; + public final @Nullable byte[] mask; + + public Rule(char type, @NonNull byte[] value, @Nullable byte[] mask) { + if (mask != null && value.length != mask.length) { + throw new IllegalArgumentException( + "Expected length " + value.length + " but found " + mask.length); + } + this.type = type; + this.value = value; + this.mask = mask; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + encode(builder); + return builder.toString(); + } + + public void encode(@NonNull StringBuilder builder) { + builder.append(type); + builder.append(HexDump.toHexString(value)); + if (mask != null) { + builder.append('/'); + builder.append(HexDump.toHexString(mask)); + } + } + + public boolean test(@NonNull byte[] value) { + if (value.length != this.value.length) { + return false; + } + for (int i = 0; i < this.value.length; i++) { + byte local = this.value[i]; + byte remote = value[i]; + if (this.mask != null) { + local &= this.mask[i]; + remote &= this.mask[i]; + } + if (local != remote) { + return false; + } + } + return true; + } + } + + /** + * Add a rule that will result in {@link #test(byte[])} returning + * {@code true} when a value being tested matches it. + *

+ * Rules are tested in the order in which they were originally added, which + * means a narrow rule can reject a specific value before a later broader + * rule might accept that same value, or vice versa. + * + * @param value to be matched + * @param mask to be applied to both values before testing for equality; if + * {@code null} then both values must match exactly + */ + public void addAcceptRule(@NonNull byte[] value, @Nullable byte[] mask) { + mRules.add(new Rule(TYPE_ACCEPT, value, mask)); + } + + /** + * Add a rule that will result in {@link #test(byte[])} returning + * {@code false} when a value being tested matches it. + *

+ * Rules are tested in the order in which they were originally added, which + * means a narrow rule can reject a specific value before a later broader + * rule might accept that same value, or vice versa. + * + * @param value to be matched + * @param mask to be applied to both values before testing for equality; if + * {@code null} then both values must match exactly + */ + public void addRejectRule(@NonNull byte[] value, @Nullable byte[] mask) { + mRules.add(new Rule(TYPE_REJECT, value, mask)); + } + + /** + * Test if the given {@code ParcelUuid} value matches the set of rules + * configured in this matcher. + */ + public boolean testBluetoothUuid(@NonNull ParcelUuid value) { + return test(BluetoothUuid.uuidToBytes(value)); + } + + /** + * Test if the given {@code MacAddress} value matches the set of rules + * configured in this matcher. + */ + public boolean testMacAddress(@NonNull MacAddress value) { + return test(value.toByteArray()); + } + + /** + * Test if the given {@code byte[]} value matches the set of rules + * configured in this matcher. + */ + @Override + public boolean test(@NonNull byte[] value) { + return test(value, false); + } + + /** + * Test if the given {@code byte[]} value matches the set of rules + * configured in this matcher. + */ + public boolean test(@NonNull byte[] value, boolean defaultValue) { + final int size = mRules.size(); + for (int i = 0; i < size; i++) { + final Rule rule = mRules.get(i); + if (rule.test(value)) { + return (rule.type == TYPE_ACCEPT); + } + } + return defaultValue; + } + + /** + * Encode the given matcher into a human-readable {@link String} which can + * be used to transport matchers across device boundaries. + *

+ * The human-readable format is an ordered list separated by commas, where + * each rule is a {@code +} or {@code -} symbol indicating if the match + * should be accepted or rejected, then followed by a hex value and an + * optional hex mask. For example, {@code -caff,+cafe/ff00} is a valid + * encoded matcher. + * + * @see #decode(String) + */ + public static @NonNull String encode(@NonNull BytesMatcher matcher) { + final StringBuilder builder = new StringBuilder(); + final int size = matcher.mRules.size(); + for (int i = 0; i < size; i++) { + final Rule rule = matcher.mRules.get(i); + rule.encode(builder); + builder.append(','); + } + builder.deleteCharAt(builder.length() - 1); + return builder.toString(); + } + + /** + * Decode the given human-readable {@link String} used to transport matchers + * across device boundaries. + *

+ * The human-readable format is an ordered list separated by commas, where + * each rule is a {@code +} or {@code -} symbol indicating if the match + * should be accepted or rejected, then followed by a hex value and an + * optional hex mask. For example, {@code -caff,+cafe/ff00} is a valid + * encoded matcher. + * + * @see #encode(BytesMatcher) + */ + public static @NonNull BytesMatcher decode(@NonNull String value) { + final BytesMatcher matcher = new BytesMatcher(); + final int length = value.length(); + for (int i = 0; i < length;) { + final char type = value.charAt(i); + + int nextRule = value.indexOf(',', i); + int nextMask = value.indexOf('/', i); + + if (nextRule == -1) nextRule = length; + if (nextMask > nextRule) nextMask = -1; + + final byte[] ruleValue; + final byte[] ruleMask; + if (nextMask >= 0) { + ruleValue = HexDump.hexStringToByteArray(value.substring(i + 1, nextMask)); + ruleMask = HexDump.hexStringToByteArray(value.substring(nextMask + 1, nextRule)); + } else { + ruleValue = HexDump.hexStringToByteArray(value.substring(i + 1, nextRule)); + ruleMask = null; + } + + switch (type) { + case TYPE_ACCEPT: + matcher.addAcceptRule(ruleValue, ruleMask); + break; + case TYPE_REJECT: + matcher.addRejectRule(ruleValue, ruleMask); + break; + default: + Log.w(TAG, "Ignoring unknown type " + type); + break; + } + + i = nextRule + 1; + } + return matcher; + } +} diff --git a/core/tests/coretests/src/android/os/BytesMatcherTest.java b/core/tests/coretests/src/android/os/BytesMatcherTest.java new file mode 100644 index 0000000000000..67c1b3c947fb8 --- /dev/null +++ b/core/tests/coretests/src/android/os/BytesMatcherTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.os; + +import static com.android.internal.util.HexDump.hexStringToByteArray; + +import android.bluetooth.BluetoothUuid; +import android.net.MacAddress; + +import androidx.test.filters.SmallTest; + +import junit.framework.TestCase; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +@SmallTest +public class BytesMatcherTest extends TestCase { + @Test + public void testEmpty() throws Exception { + BytesMatcher matcher = BytesMatcher.decode(""); + assertFalse(matcher.test(hexStringToByteArray("cafe"))); + assertFalse(matcher.test(hexStringToByteArray(""))); + } + + @Test + public void testExact() throws Exception { + BytesMatcher matcher = BytesMatcher.decode("+cafe"); + assertTrue(matcher.test(hexStringToByteArray("cafe"))); + assertFalse(matcher.test(hexStringToByteArray("beef"))); + assertFalse(matcher.test(hexStringToByteArray("ca"))); + assertFalse(matcher.test(hexStringToByteArray("cafe00"))); + } + + @Test + public void testMask() throws Exception { + BytesMatcher matcher = BytesMatcher.decode("+cafe/ff00"); + assertTrue(matcher.test(hexStringToByteArray("cafe"))); + assertTrue(matcher.test(hexStringToByteArray("ca88"))); + assertFalse(matcher.test(hexStringToByteArray("beef"))); + assertFalse(matcher.test(hexStringToByteArray("ca"))); + assertFalse(matcher.test(hexStringToByteArray("cafe00"))); + } + + @Test + public void testMacAddress() throws Exception { + BytesMatcher matcher = BytesMatcher.decode("+cafe00112233/ffffff000000"); + assertTrue(matcher.testMacAddress( + MacAddress.fromString("ca:fe:00:00:00:00"))); + assertFalse(matcher.testMacAddress( + MacAddress.fromString("f0:0d:00:00:00:00"))); + } + + @Test + public void testBluetoothUuid() throws Exception { + BytesMatcher matcher = BytesMatcher.decode("+cafe/ff00"); + assertTrue(matcher.testBluetoothUuid( + BluetoothUuid.parseUuidFrom(hexStringToByteArray("cafe")))); + assertFalse(matcher.testBluetoothUuid( + BluetoothUuid.parseUuidFrom(hexStringToByteArray("beef")))); + } + + /** + * Verify that single matcher can be configured to match Bluetooth UUIDs of + * varying lengths. + */ + @Test + public void testBluetoothUuid_Mixed() throws Exception { + BytesMatcher matcher = BytesMatcher.decode("+aaaa/ff00,+bbbbbbbb/ffff0000"); + assertTrue(matcher.testBluetoothUuid( + BluetoothUuid.parseUuidFrom(hexStringToByteArray("aaaa")))); + assertFalse(matcher.testBluetoothUuid( + BluetoothUuid.parseUuidFrom(hexStringToByteArray("bbbb")))); + assertTrue(matcher.testBluetoothUuid( + BluetoothUuid.parseUuidFrom(hexStringToByteArray("bbbbbbbb")))); + assertFalse(matcher.testBluetoothUuid( + BluetoothUuid.parseUuidFrom(hexStringToByteArray("aaaaaaaa")))); + } + + @Test + public void testSerialize() throws Exception { + BytesMatcher matcher = new BytesMatcher(); + matcher.addRejectRule(hexStringToByteArray("cafe00112233"), + hexStringToByteArray("ffffff000000")); + matcher.addRejectRule(hexStringToByteArray("beef00112233"), + null); + matcher.addAcceptRule(hexStringToByteArray("000000000000"), + hexStringToByteArray("000000000000")); + + assertFalse(matcher.test(hexStringToByteArray("cafe00ffffff"))); + assertFalse(matcher.test(hexStringToByteArray("beef00112233"))); + assertTrue(matcher.test(hexStringToByteArray("beef00ffffff"))); + + // Bounce through serialization pass and confirm it still works + matcher = BytesMatcher.decode(BytesMatcher.encode(matcher)); + + assertFalse(matcher.test(hexStringToByteArray("cafe00ffffff"))); + assertFalse(matcher.test(hexStringToByteArray("beef00112233"))); + assertTrue(matcher.test(hexStringToByteArray("beef00ffffff"))); + } + + @Test + public void testOrdering_RejectFirst() throws Exception { + BytesMatcher matcher = BytesMatcher.decode("-ff/0f,+ff/f0"); + assertFalse(matcher.test(hexStringToByteArray("ff"))); + assertTrue(matcher.test(hexStringToByteArray("f0"))); + assertFalse(matcher.test(hexStringToByteArray("0f"))); + } + + @Test + public void testOrdering_AcceptFirst() throws Exception { + BytesMatcher matcher = BytesMatcher.decode("+ff/f0,-ff/0f"); + assertTrue(matcher.test(hexStringToByteArray("ff"))); + assertTrue(matcher.test(hexStringToByteArray("f0"))); + assertFalse(matcher.test(hexStringToByteArray("0f"))); + } +}