Skip to content

Commit c3cad68

Browse files
authored
Merge pull request #22 from WildMeOrg/feat/pack-validator
feat: validate pack integrity and quarantine broken packs
2 parents 9f039dd + 7cd6b9d commit c3cad68

10 files changed

Lines changed: 907 additions & 4 deletions

File tree

App.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,24 @@ function App() {
113113
logger.error('[App] Failed to reconcile MiewID model:', err);
114114
}
115115

116+
// Re-validate persisted packs against their on-disk files; packs that
117+
// fail integrity checks are quarantined rather than silently matched.
118+
try {
119+
const { packs, setPacks } = useWildlifeStore.getState();
120+
if (packs.length > 0) {
121+
const reconciled = await packManager.reconcilePacks(packs);
122+
setPacks(reconciled);
123+
const quarantined = reconciled.filter(
124+
(p) => p.status === 'quarantined',
125+
).length;
126+
logger.log(
127+
`[App] Packs reconciled: ${reconciled.length} total, ${quarantined} quarantined`,
128+
);
129+
}
130+
} catch (err) {
131+
logger.error('[App] Failed to reconcile packs:', err);
132+
}
133+
116134
// Initialize hardware detection
117135
const deviceInfo = await hardwareService.getDeviceInfo();
118136
setDeviceInfo(deviceInfo);

__tests__/rntl/screens/CaptureScreen.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,25 @@ describe('CaptureScreen', () => {
300300
expect(wildlifePipeline.processPhoto).not.toHaveBeenCalled();
301301
});
302302

303+
it('excludes quarantined packs from capture', async () => {
304+
useWildlifeStore.setState({
305+
packs: [
306+
makeTestPack({ id: 'pack-healthy', status: 'ready' }),
307+
makeTestPack({ id: 'pack-broken', status: 'quarantined' }),
308+
],
309+
});
310+
311+
const { getByTestId } = render(<CaptureScreen />);
312+
fireEvent.press(getByTestId('take-photo-button'));
313+
314+
await waitFor(() => {
315+
expect(wildlifePipeline.processPhoto).toHaveBeenCalled();
316+
});
317+
const call = (wildlifePipeline.processPhoto as jest.Mock).mock.calls[0][0];
318+
expect(call.speciesConfigs).toHaveLength(1);
319+
expect(call.speciesConfigs[0].packId).toBe('pack-healthy');
320+
});
321+
303322
it('excludes packs with an incompatible embedding model version', async () => {
304323
useWildlifeStore.setState({
305324
packs: [

__tests__/unit/services/packManager.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ jest.mock('react-native-fs', () => ({
88
stat: jest.fn(),
99
}));
1010

11+
jest.mock('../../../src/services/packManager/validator', () => ({
12+
validatePack: jest.fn(),
13+
}));
14+
1115
import RNFS from 'react-native-fs';
1216
import { packManager } from '../../../src/services/packManager';
1317

@@ -162,6 +166,139 @@ describe('PackManager', () => {
162166
expect(result[1]).toEqual([18, 19, 20]);
163167
expect(result[2]).toEqual([21, 22, 23]);
164168
});
169+
170+
it('should throw a RangeError when the requested range exceeds the buffer', () => {
171+
const embeddingDim = 3;
172+
const allEmbeddings = new Float32Array([0, 1, 2, 3, 4, 5]); // 2 vectors
173+
174+
const individual = {
175+
id: 'WB-BAD',
176+
name: null,
177+
alternateId: null,
178+
sex: null,
179+
lifeStage: null,
180+
firstSeen: null,
181+
lastSeen: null,
182+
encounterCount: 1,
183+
embeddingCount: 2,
184+
embeddingOffset: 1, // (1 + 2) * 3 = 9 > 6
185+
referencePhotos: [],
186+
notes: null,
187+
};
188+
189+
expect(() =>
190+
packManager.getEmbeddingsForIndividual(allEmbeddings, individual, embeddingDim),
191+
).toThrow(RangeError);
192+
});
193+
194+
it('should throw a RangeError for a negative offset', () => {
195+
const allEmbeddings = new Float32Array([0, 1, 2]);
196+
197+
const individual = {
198+
id: 'WB-NEG',
199+
name: null,
200+
alternateId: null,
201+
sex: null,
202+
lifeStage: null,
203+
firstSeen: null,
204+
lastSeen: null,
205+
encounterCount: 1,
206+
embeddingCount: 1,
207+
embeddingOffset: -1,
208+
referencePhotos: [],
209+
notes: null,
210+
};
211+
212+
expect(() =>
213+
packManager.getEmbeddingsForIndividual(allEmbeddings, individual, 3),
214+
).toThrow(RangeError);
215+
});
216+
});
217+
218+
describe('reconcilePacks', () => {
219+
const { validatePack } = require('../../../src/services/packManager/validator');
220+
221+
const makeStoredPack = (overrides: Record<string, unknown> = {}) => ({
222+
id: 'pack-1',
223+
species: 'horse',
224+
featureClass: 'horse_wild+face',
225+
displayName: 'Horses',
226+
wildbookInstanceUrl: 'https://horses.wildbook.org',
227+
exportDate: '2026-04-25T00:00:00Z',
228+
individualCount: 5,
229+
embeddingDim: 2152,
230+
embeddingModelVersion: '4.1.0',
231+
detectorModelFile: '/mock/packs/horse/models/detector.onnx',
232+
embeddingsFile: '/mock/packs/horse/embeddings/embeddings.bin',
233+
indexFile: '/mock/packs/horse/embeddings/index.json',
234+
referencePhotosDir: '/mock/packs/horse/reference_photos',
235+
packDir: '/mock/packs/horse',
236+
downloadedAt: '2026-04-25T12:00:00Z',
237+
sizeBytes: 9_000_000,
238+
...overrides,
239+
});
240+
241+
it('keeps a previously validated intact pack ready using cheap mode', async () => {
242+
validatePack.mockResolvedValue({ ok: true, manifest: {}, individuals: [] });
243+
const pack = makeStoredPack({
244+
status: 'ready',
245+
validatedAt: '2026-04-25T12:00:00Z',
246+
});
247+
248+
const result = await packManager.reconcilePacks([pack] as never[]);
249+
250+
expect(validatePack).toHaveBeenCalledWith('/mock/packs/horse', {
251+
skipChecksums: true,
252+
});
253+
expect(result[0].status).toBe('ready');
254+
});
255+
256+
it('fully validates a pack that was never validated', async () => {
257+
validatePack.mockResolvedValue({ ok: true, manifest: {}, individuals: [] });
258+
const pack = makeStoredPack();
259+
260+
const result = await packManager.reconcilePacks([pack] as never[]);
261+
262+
expect(validatePack).toHaveBeenCalledWith('/mock/packs/horse', {
263+
skipChecksums: false,
264+
});
265+
expect(result[0].status).toBe('ready');
266+
expect(result[0].validatedAt).toBeTruthy();
267+
});
268+
269+
it('quarantines a pack that fails validation and records the errors', async () => {
270+
validatePack.mockResolvedValue({
271+
ok: false,
272+
errors: [
273+
{ code: 'checksum-mismatch', detail: 'embeddings.bin hash differs' },
274+
],
275+
});
276+
const pack = makeStoredPack({ status: 'ready', validatedAt: '2026-04-25T12:00:00Z' });
277+
278+
const result = await packManager.reconcilePacks([pack] as never[]);
279+
280+
expect(result[0].status).toBe('quarantined');
281+
expect(result[0].validationErrors).toEqual([
282+
'checksum-mismatch: embeddings.bin hash differs',
283+
]);
284+
});
285+
286+
it('reconciles each pack independently', async () => {
287+
validatePack
288+
.mockResolvedValueOnce({ ok: true, manifest: {}, individuals: [] })
289+
.mockResolvedValueOnce({
290+
ok: false,
291+
errors: [{ code: 'file-missing', detail: 'embeddings.bin' }],
292+
});
293+
294+
const result = await packManager.reconcilePacks([
295+
makeStoredPack({ id: 'pack-good' }),
296+
makeStoredPack({ id: 'pack-bad', packDir: '/mock/packs/bad' }),
297+
] as never[]);
298+
299+
expect(result[0].status).toBe('ready');
300+
expect(result[1].status).toBe('quarantined');
301+
});
165302
});
166303

167304
describe('deletePack', () => {

0 commit comments

Comments
 (0)