Protect STHAL from duplicate UUID model loads

The STHAL aborts when duplicate UUID model loads are received.
To protect against this, we add a handler above both v2, v3 hals,
which rejects duplicate UUID model loads with RESOURCE_CONTENTION.

Fixes: 275058053
Test: atest SoundTriggerDuplicateModelHandlerTest
Test: manual verification of assistant/now playing
Change-Id: I53b249856fba49b3b33d5668660f4beb8611a20c
This commit is contained in:
Atneya Nair
2023-04-03 11:29:29 -07:00
parent ca7c149247
commit 552c327b60
4 changed files with 543 additions and 2 deletions

View File

@@ -0,0 +1,335 @@
/*
* Copyright (C) 2022 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 com.android.server.soundtrigger_middleware;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import android.media.soundtrigger.ModelParameterRange;
import android.media.soundtrigger.Phrase;
import android.media.soundtrigger.PhraseSoundModel;
import android.media.soundtrigger.Properties;
import android.media.soundtrigger.RecognitionConfig;
import android.media.soundtrigger.RecognitionMode;
import android.media.soundtrigger.SoundModel;
import android.media.soundtrigger.SoundModelType;
import android.media.soundtrigger.Status;
import android.os.IBinder;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.InOrder;
import org.mockito.MockitoAnnotations;
@RunWith(JUnit4.class)
public final class SoundTriggerDuplicateModelHandlerTest {
// Component under test
private SoundTriggerDuplicateModelHandler mComponent;
private static final String DUPLICATE_UUID = "abcddead-beef-0123-3210-0123456789ab";
private static final String DIFFERENT_UUID = "0000dead-beef-0123-3210-0123456789ab";
@Mock private ISoundTriggerHal mUnderlying;
@Mock private ISoundTriggerHal.GlobalCallback mGlobalCallback;
@Mock private ISoundTriggerHal.ModelCallback mModelCallback;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mComponent = new SoundTriggerDuplicateModelHandler(mUnderlying);
doNothing().when(mUnderlying).registerCallback(any());
mComponent.registerCallback(mGlobalCallback);
verify(mUnderlying).registerCallback(eq(mGlobalCallback));
}
@Test
public void loadSoundModel_throwsResourceContention_whenDuplicateUuid() {
final var soundModel = createSoundModelOne();
final var soundModelSameUuid = createSoundModelTwo();
// First sound model load should complete successfully
mComponent.loadSoundModel(soundModel, mModelCallback);
verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback));
assertEquals(
assertThrows(
RecoverableException.class,
() -> mComponent.loadSoundModel(soundModelSameUuid, mModelCallback))
.errorCode,
Status.RESOURCE_CONTENTION);
// Model has not been unloaded, so we don't get a callback
verify(mGlobalCallback, never()).onResourcesAvailable();
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
@Test
public void loadSoundModel_doesNotThrowResourceContention_whenDifferentUuid() {
final var soundModel = createSoundModelOne();
// Make all other fields the same
final var soundModelDifferentUuid = createSoundModelOne();
soundModelDifferentUuid.uuid = DIFFERENT_UUID;
InOrder inOrder = Mockito.inOrder(mUnderlying);
// First sound model load should complete successfully
mComponent.loadSoundModel(soundModel, mModelCallback);
inOrder.verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback));
mComponent.loadSoundModel(soundModelDifferentUuid, mModelCallback);
inOrder.verify(mUnderlying).loadSoundModel(eq(soundModelDifferentUuid), eq(mModelCallback));
// No contention, so we don't get a callback
verify(mGlobalCallback, never()).onResourcesAvailable();
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
@Test
public void loadSoundModel_doesNotThrow_afterDuplicateUuidHasBeenUnloaded() {
final var soundModel = createSoundModelOne();
// First sound model load should complete successfully
int handle = mComponent.loadSoundModel(soundModel, mModelCallback);
verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback));
// Unload model should complete successfully
mComponent.unloadSoundModel(handle);
verify(mUnderlying).unloadSoundModel(eq(handle));
// Since the model with the same UUID was unloaded, the subsequent load model
// should succeed.
mComponent.loadSoundModel(soundModel, mModelCallback);
verify(mUnderlying, times(2)).loadSoundModel(eq(soundModel), eq(mModelCallback));
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
@Test
public void unloadSoundModel_triggersResourceCallback_afterDuplicateUuidRejected() {
final var soundModel = createSoundModelOne();
final var soundModelSameUuid = createSoundModelTwo();
// First sound model load should complete successfully
int handle = mComponent.loadSoundModel(soundModel, mModelCallback);
verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback));
assertEquals(
assertThrows(
RecoverableException.class,
() -> mComponent.loadSoundModel(soundModelSameUuid, mModelCallback))
.errorCode,
Status.RESOURCE_CONTENTION);
mComponent.unloadSoundModel(handle);
verify(mUnderlying).unloadSoundModel(eq(handle));
verify(mGlobalCallback).onResourcesAvailable();
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
// Next tests are same as above, but for phrase sound model.
@Test
public void loadPhraseSoundModel_throwsResourceContention_whenDuplicateUuid() {
final var soundModel = createPhraseSoundModelOne();
final var soundModelSameUuid = createPhraseSoundModelTwo();
// First sound model load should complete successfully
mComponent.loadPhraseSoundModel(soundModel, mModelCallback);
verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback));
assertEquals(
assertThrows(
RecoverableException.class,
() ->
mComponent.loadPhraseSoundModel(
soundModelSameUuid, mModelCallback))
.errorCode,
Status.RESOURCE_CONTENTION);
// Model has not been unloaded, so we don't get a callback
verify(mGlobalCallback, never()).onResourcesAvailable();
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
@Test
public void loadPhraseSoundModel_doesNotThrowResourceContention_whenDifferentUuid() {
final var soundModel = createPhraseSoundModelOne();
// Make all other fields the same
final var soundModelDifferentUuid = createPhraseSoundModelOne();
soundModelDifferentUuid.common.uuid = DIFFERENT_UUID;
InOrder inOrder = Mockito.inOrder(mUnderlying);
// First sound model load should complete successfully
mComponent.loadPhraseSoundModel(soundModel, mModelCallback);
inOrder.verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback));
mComponent.loadPhraseSoundModel(soundModelDifferentUuid, mModelCallback);
inOrder.verify(mUnderlying).loadPhraseSoundModel(eq(soundModelDifferentUuid),
eq(mModelCallback));
// No contention, so we don't get a callback
verify(mGlobalCallback, never()).onResourcesAvailable();
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
@Test
public void loadPhraseSoundModel_doesNotThrow_afterDuplicateUuidHasBeenUnloaded() {
final var soundModel = createPhraseSoundModelOne();
// First sound model load should complete successfully
int handle = mComponent.loadPhraseSoundModel(soundModel, mModelCallback);
verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback));
// Unload model should complete successfully
mComponent.unloadSoundModel(handle);
verify(mUnderlying).unloadSoundModel(eq(handle));
// Since the model with the same UUID was unloaded, the subsequent load model
// should succeed.
mComponent.loadPhraseSoundModel(soundModel, mModelCallback);
verify(mUnderlying, times(2)).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback));
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
@Test
public void unloadSoundModel_triggersResourceCallback_afterDuplicateUuidRejectedPhrase() {
final var soundModel = createPhraseSoundModelOne();
final var soundModelSameUuid = createPhraseSoundModelTwo();
// First sound model load should complete successfully
int handle = mComponent.loadPhraseSoundModel(soundModel, mModelCallback);
verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback));
assertEquals(
assertThrows(
RecoverableException.class,
() ->
mComponent.loadPhraseSoundModel(
soundModelSameUuid, mModelCallback))
.errorCode,
Status.RESOURCE_CONTENTION);
mComponent.unloadSoundModel(handle);
verify(mUnderlying).unloadSoundModel(eq(handle));
verify(mGlobalCallback).onResourcesAvailable();
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
@Test
public void testDelegation() {
// Test that the rest of the interface delegates its calls to the underlying object
// appropriately.
// This test method does not test load/unloadSoundModel
var properties = new Properties();
InOrder inOrder = Mockito.inOrder(mUnderlying);
doReturn(properties).when(mUnderlying).getProperties();
assertEquals(mComponent.getProperties(), properties);
inOrder.verify(mUnderlying).getProperties();
var mockGlobalCallback = mock(ISoundTriggerHal.GlobalCallback.class);
mComponent.registerCallback(mockGlobalCallback);
inOrder.verify(mUnderlying).registerCallback(eq(mockGlobalCallback));
int modelId = 5;
int deviceHandle = 2;
int ioHandle = 3;
var config = mock(RecognitionConfig.class);
mComponent.startRecognition(modelId, deviceHandle, ioHandle, config);
inOrder.verify(mUnderlying)
.startRecognition(eq(modelId), eq(deviceHandle), eq(ioHandle), eq(config));
mComponent.stopRecognition(modelId);
inOrder.verify(mUnderlying).stopRecognition(eq(modelId));
mComponent.forceRecognitionEvent(modelId);
inOrder.verify(mUnderlying).forceRecognitionEvent(eq(modelId));
int param = 10;
int value = 50;
var modelParamRange = new ModelParameterRange();
doReturn(modelParamRange).when(mUnderlying).queryParameter(anyInt(), anyInt());
assertEquals(mComponent.queryParameter(param, value), modelParamRange);
inOrder.verify(mUnderlying).queryParameter(param, value);
doReturn(value).when(mUnderlying).getModelParameter(anyInt(), anyInt());
assertEquals(mComponent.getModelParameter(modelId, param), value);
inOrder.verify(mUnderlying).getModelParameter(eq(modelId), eq(param));
mComponent.setModelParameter(modelId, param, value);
inOrder.verify(mUnderlying).setModelParameter(eq(modelId), eq(param), eq(value));
var recipient = mock(IBinder.DeathRecipient.class);
mComponent.linkToDeath(recipient);
inOrder.verify(mUnderlying).linkToDeath(eq(recipient));
mComponent.unlinkToDeath(recipient);
inOrder.verify(mUnderlying).unlinkToDeath(eq(recipient));
mComponent.flushCallbacks();
inOrder.verify(mUnderlying).flushCallbacks();
var token = mock(IBinder.class);
mComponent.clientAttached(token);
inOrder.verify(mUnderlying).clientAttached(eq(token));
mComponent.clientDetached(token);
inOrder.verify(mUnderlying).clientDetached(eq(token));
mComponent.reboot();
inOrder.verify(mUnderlying).reboot();
mComponent.detach();
inOrder.verify(mUnderlying).detach();
verifyNoMoreInteractions(mUnderlying);
verifyNoMoreInteractions(mGlobalCallback);
}
private static SoundModel createSoundModelOne() {
SoundModel model = new SoundModel();
model.type = SoundModelType.GENERIC;
model.uuid = DUPLICATE_UUID;
model.vendorUuid = "87654321-5432-6543-7654-456789fedcba";
byte[] data = new byte[] {91, 92, 93, 94, 95};
model.data = TestUtil.byteArrayToParcelFileDescriptor(data);
model.dataSize = data.length;
return model;
}
// Different except for the same UUID
private static SoundModel createSoundModelTwo() {
SoundModel model = new SoundModel();
model.type = SoundModelType.GENERIC;
model.uuid = DUPLICATE_UUID;
model.vendorUuid = "12345678-9876-5432-1012-345678901234";
byte[] data = new byte[] {19, 18, 17, 16};
model.data = TestUtil.byteArrayToParcelFileDescriptor(data);
model.dataSize = data.length;
return model;
}
private static PhraseSoundModel createPhraseSoundModelOne() {
PhraseSoundModel model = new PhraseSoundModel();
model.common = createSoundModelOne();
model.common.type = SoundModelType.KEYPHRASE;
model.phrases = new Phrase[1];
model.phrases[0] = new Phrase();
model.phrases[0].id = 123;
model.phrases[0].users = new int[] {5, 6, 7};
model.phrases[0].locale = "locale";
model.phrases[0].text = "text";
model.phrases[0].recognitionModes =
RecognitionMode.USER_AUTHENTICATION | RecognitionMode.USER_IDENTIFICATION;
return model;
}
private static PhraseSoundModel createPhraseSoundModelTwo() {
PhraseSoundModel model = new PhraseSoundModel();
model.common = createSoundModelTwo();
model.common.type = SoundModelType.KEYPHRASE;
model.phrases = new Phrase[1];
model.phrases[0] = new Phrase();
model.phrases[0].id = 321;
model.phrases[0].users = new int[] {4, 3, 2, 1};
model.phrases[0].locale = "differentLocale";
model.phrases[0].text = "differentText";
model.phrases[0].recognitionModes = 0;
return model;
}
}

View File

@@ -466,7 +466,7 @@ class TestUtil {
assertEquals(43, event.phraseExtras[0].levels[0].levelPercent);
}
private static ParcelFileDescriptor byteArrayToParcelFileDescriptor(byte[] data) {
static ParcelFileDescriptor byteArrayToParcelFileDescriptor(byte[] data) {
try (SharedMemory shmem = SharedMemory.create("", data.length)) {
ByteBuffer buffer = shmem.mapReadWrite();
buffer.put(data);

View File

@@ -0,0 +1,205 @@
/*
* Copyright (C) 2023 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 com.android.server.soundtrigger_middleware;
import android.annotation.NonNull;
import android.media.soundtrigger.ModelParameterRange;
import android.media.soundtrigger.PhraseSoundModel;
import android.media.soundtrigger.Properties;
import android.media.soundtrigger.RecognitionConfig;
import android.media.soundtrigger.SoundModel;
import android.media.soundtrigger.Status;
import android.os.IBinder;
import java.util.ArrayList;
import java.util.List;
/**
* This wrapper prevents a models with the same UUID from being loaded concurrently. This is used to
* protect STHAL implementations, which don't support concurrent loads of the same model. We reject
* the duplicate load with {@link Status#RESOURCE_CONTENTION}.
*/
public class SoundTriggerDuplicateModelHandler implements ISoundTriggerHal {
private final @NonNull ISoundTriggerHal mDelegate;
private GlobalCallback mGlobalCallback;
// There are rarely more than two models loaded.
private final List<ModelData> mModelList = new ArrayList<>();
private static final class ModelData {
ModelData(int modelId, String uuid) {
mModelId = modelId;
mUuid = uuid;
}
int getModelId() {
return mModelId;
}
String getUuid() {
return mUuid;
}
boolean getWasContended() {
return mWasContended;
}
void setWasContended() {
mWasContended = true;
}
private int mModelId;
private String mUuid;
private boolean mWasContended = false;
}
public SoundTriggerDuplicateModelHandler(@NonNull ISoundTriggerHal delegate) {
mDelegate = delegate;
}
@Override
public void reboot() {
mDelegate.reboot();
}
@Override
public void detach() {
mDelegate.detach();
}
@Override
public Properties getProperties() {
return mDelegate.getProperties();
}
@Override
public void registerCallback(GlobalCallback callback) {
mGlobalCallback = callback;
mDelegate.registerCallback(mGlobalCallback);
}
@Override
public int loadSoundModel(SoundModel soundModel, ModelCallback callback) {
synchronized (this) {
checkDuplicateModelUuid(soundModel.uuid);
var result = mDelegate.loadSoundModel(soundModel, callback);
mModelList.add(new ModelData(result, soundModel.uuid));
return result;
}
}
@Override
public int loadPhraseSoundModel(PhraseSoundModel soundModel, ModelCallback callback) {
synchronized (this) {
checkDuplicateModelUuid(soundModel.common.uuid);
var result = mDelegate.loadPhraseSoundModel(soundModel, callback);
mModelList.add(new ModelData(result, soundModel.common.uuid));
return result;
}
}
@Override
public void unloadSoundModel(int modelHandle) {
mDelegate.unloadSoundModel(modelHandle);
for (int i = 0; i < mModelList.size(); i++) {
if (mModelList.get(i).getModelId() == modelHandle) {
var modelData = mModelList.remove(i);
if (modelData.getWasContended()) {
mGlobalCallback.onResourcesAvailable();
}
// Model ID is unique
return;
}
}
}
// Uninteresting delegation calls to follow.
@Override
public void stopRecognition(int modelHandle) {
mDelegate.stopRecognition(modelHandle);
}
@Override
public void startRecognition(
int modelHandle, int deviceHandle, int ioHandle, RecognitionConfig config) {
mDelegate.startRecognition(modelHandle, deviceHandle, ioHandle, config);
}
@Override
public void forceRecognitionEvent(int modelHandle) {
mDelegate.forceRecognitionEvent(modelHandle);
}
@Override
public int getModelParameter(int modelHandle, int param) {
return mDelegate.getModelParameter(modelHandle, param);
}
@Override
public void setModelParameter(int modelHandle, int param, int value) {
mDelegate.setModelParameter(modelHandle, param, value);
}
@Override
public ModelParameterRange queryParameter(int modelHandle, int param) {
return mDelegate.queryParameter(modelHandle, param);
}
@Override
public void linkToDeath(IBinder.DeathRecipient recipient) {
mDelegate.linkToDeath(recipient);
}
@Override
public void unlinkToDeath(IBinder.DeathRecipient recipient) {
mDelegate.unlinkToDeath(recipient);
}
@Override
public String interfaceDescriptor() {
return mDelegate.interfaceDescriptor();
}
@Override
public void flushCallbacks() {
mDelegate.flushCallbacks();
}
@Override
public void clientAttached(IBinder binder) {
mDelegate.clientAttached(binder);
}
@Override
public void clientDetached(IBinder binder) {
mDelegate.clientDetached(binder);
}
/**
* Helper for handling duplicate model. If there is a load attempt for a model with a UUID which
* is already loaded: 1) Reject with {@link Status.RESOURCE_CONTENTION} 2) Mark the already
* loaded model as contended, as we need to dispatch a resource available callback following the
* original model being unloaded.
*/
private void checkDuplicateModelUuid(String uuid) {
var model = mModelList.stream().filter(x -> x.getUuid().equals(uuid)).findFirst();
if (model.isPresent()) {
model.get().setWasContended();
throw new RecoverableException(Status.RESOURCE_CONTENTION);
}
}
}

View File

@@ -170,7 +170,8 @@ class SoundTriggerModule implements IBinder.DeathRecipient, ISoundTriggerHal.Glo
*/
private void attachToHal() {
mHalService = new SoundTriggerHalEnforcer(
new SoundTriggerHalWatchdog(mHalFactory.create()));
new SoundTriggerHalWatchdog(
new SoundTriggerDuplicateModelHandler(mHalFactory.create())));
mHalService.linkToDeath(this);
mHalService.registerCallback(this);
mProperties = mHalService.getProperties();