From f585327a3330ee614fe489ec151fa93a6efa82a1 Mon Sep 17 00:00:00 2001 From: Selim Date: Thu, 9 Jul 2026 19:42:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(vehicle):=20ara=C3=A7=20parmak=20izi=20tem?= =?UTF-8?q?el=20katman=C4=B1=20(PR-25,=20foundation-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bilinmeyen aftermarket araçları deterministik, tekrar-üretilebilir bir kimlikle tanımanın temeli: aynı araç tekrar bağlandığında (VIN olsun olmasın) güvenle tanınabilsin. YALNIZ FOUNDATION — OBD poll/hot-path, useVidStore subscribe, Discovery Pipeline, native ve SQL/Supabase'e DOKUNULMADI. - vehicleFingerprintService.ts (yeni, SAF + safeStorage): * Normalize: VIN · ECU adresi · PID bitmap · trailing-zero dolgu temizliği * Builder: VIN + protocol + (sıralı/tekil) ECU + PID bitmap + metadata * Deterministik 16-hane hash (FNV-1a çift-tohum; metadata/zaman DAHİL DEĞİL) * Bounded(8) LRU kalıcı depo: save/load/list/remove/clear * Matcher temeli: aynı VIN=1.0 · imza(protocol+ECU+bitmap)=0.80 · adaptör MAC=0.30 - safeStorage: car-vehicle-fingerprints → LRU_PROTECTED (kota dolunca silinmez) - 23 yeni test: aynı/farklı hash · VIN'siz · ECU sıra-bağımsız · PID trailing-zero · max 8 LRU evict · upsert recency · kalıcılık · matcher confidence değerleri tsc temiz · eslint temiz · 2404 test yeşil (yalnız 23 bilinen env-gated hata, ilgisiz) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Fauew13VP3vbfKqnocPdfX --- src/__tests__/vehicleFingerprint.test.ts | 234 ++++++++++++++ src/platform/vehicleFingerprintService.ts | 357 ++++++++++++++++++++++ src/utils/safeStorage.ts | 1 + 3 files changed, 592 insertions(+) create mode 100644 src/__tests__/vehicleFingerprint.test.ts create mode 100644 src/platform/vehicleFingerprintService.ts diff --git a/src/__tests__/vehicleFingerprint.test.ts b/src/__tests__/vehicleFingerprint.test.ts new file mode 100644 index 00000000..a3b30534 --- /dev/null +++ b/src/__tests__/vehicleFingerprint.test.ts @@ -0,0 +1,234 @@ +/** + * vehicleFingerprint.test.ts — Araç Parmak İzi TEMEL katmanı (PR-25, foundation-only). + * + * Kilitlenen davranışlar: deterministik hash (aynı araç=aynı, farklı=farklı, VIN'siz çalışır, + * ECU sırası ve PID trailing-zero dolgusu kimliği DEĞİŞTİRMEZ), bounded (max 8) LRU depo, + * matcher güven değerleri (1.0 / 0.80 / 0.30 / 0). + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + normalizeVin, + normalizeEcuAddress, + normalizeEcuAddresses, + normalizePidBitmap, + stripTrailingZeroBytes, + canonicalFingerprintKey, + buildFingerprint, + matchFingerprint, + findBestMatch, + VehicleFingerprintStore, + MAX_FINGERPRINTS, + type VehicleFingerprintInput, +} from '../platform/vehicleFingerprintService'; + +beforeEach(() => { + try { localStorage.clear(); } catch { /* jsdom */ } +}); + +/* ── Normalize ────────────────────────────────────────────────────────────── */ +describe('normalize yardımcıları', () => { + it('VIN: trim + büyük harf + boşluksuz', () => { + expect(normalizeVin(' vf1 abc 123 ')).toBe('VF1ABC123'); + expect(normalizeVin(undefined)).toBe(''); + expect(normalizeVin(null)).toBe(''); + }); + it('ECU adresi: büyük harf hex, 0x öneki temizli', () => { + expect(normalizeEcuAddress(' 0x7e8 ')).toBe('7E8'); + expect(normalizeEcuAddress('18daf110')).toBe('18DAF110'); + }); + it('ECU listesi: normalize + tekil + sıralı + boşları at', () => { + expect(normalizeEcuAddresses(['7E8', '7e0', '', '7E8', ' 7DF '])).toEqual(['7DF', '7E0', '7E8']); + }); + it('stripTrailingZeroBytes: yalnız sondaki tam sıfır baytı kırpar', () => { + expect(stripTrailingZeroBytes('BE1FA81300')).toBe('BE1FA813'); + expect(stripTrailingZeroBytes('BE1FA8130000')).toBe('BE1FA813'); + expect(stripTrailingZeroBytes('00BE00')).toBe('00BE'); // ortadaki 00 KORUNUR + expect(stripTrailingZeroBytes('0000')).toBe(''); + }); + it('PID bitmap: hex-dışı at, bayt-hizala, trailing-zero temizle', () => { + expect(normalizePidBitmap('be 1f a8 13')).toBe('BE1FA813'); + expect(normalizePidBitmap('BE1FA81300')).toBe('BE1FA813'); + expect(normalizePidBitmap('F')).toBe('0F'); // tek hane → bayt-hizalı + expect(normalizePidBitmap('')).toBe(''); + }); +}); + +/* ── Kanonik anahtar + hash ───────────────────────────────────────────────── */ +describe('deterministik hash', () => { + const base: VehicleFingerprintInput = { + vin: 'VF1ABC0000001234', + protocol: 'ISO15765-4', + ecuAddresses: ['7E8', '7E9'], + supportedPidBitmap: 'BE1FA813', + }; + + it('aynı araç → aynı hash (zaman/metadata kimliği etkilemez)', () => { + const a = buildFingerprint(base, 1000); + const b = buildFingerprint({ ...base, metadata: { adapterMac: 'AA:BB' } }, 9999); + expect(a.hash).toBe(b.hash); + expect(a.hash).toHaveLength(16); + }); + + it('farklı araç → farklı hash', () => { + const a = buildFingerprint(base); + const b = buildFingerprint({ ...base, vin: 'VF1ZZZ0000009999' }); + const c = buildFingerprint({ ...base, supportedPidBitmap: 'BE1FA800' }); + expect(a.hash).not.toBe(b.hash); + expect(a.hash).not.toBe(c.hash); + }); + + it('VIN YOKken çalışır ve deterministiktir', () => { + const a = buildFingerprint({ ...base, vin: undefined }); + const b = buildFingerprint({ ...base, vin: '' }); + expect(a.vin).toBe(''); + expect(a.hash).toBe(b.hash); + expect(a.hash).toHaveLength(16); + }); + + it('ECU SIRASI hash\'i DEĞİŞTİRMEZ (sıra-bağımsız kimlik)', () => { + const a = buildFingerprint({ ...base, ecuAddresses: ['7E8', '7E9', '7EA'] }); + const b = buildFingerprint({ ...base, ecuAddresses: ['7EA', '7E8', '7E9'] }); + const c = buildFingerprint({ ...base, ecuAddresses: ['7e9', '7ea', '7E8'] }); // + case + expect(a.hash).toBe(b.hash); + expect(a.hash).toBe(c.hash); + }); + + it('PID bitmap trailing-zero DOLGUSU aynı fingerprint verir', () => { + const a = buildFingerprint({ ...base, supportedPidBitmap: 'BE1FA813' }); + const b = buildFingerprint({ ...base, supportedPidBitmap: 'BE1FA81300' }); + const c = buildFingerprint({ ...base, supportedPidBitmap: 'BE 1F A8 13 00 00' }); + expect(a.hash).toBe(b.hash); + expect(a.hash).toBe(c.hash); + }); + + it('canonicalFingerprintKey: metadata/zaman DAHİL DEĞİL', () => { + const key = canonicalFingerprintKey({ + vin: 'X', protocol: 'iso', ecuAddresses: ['7E8'], supportedPidBitmap: 'AB', + }); + expect(key).toBe('V:X|P:ISO|E:7E8|B:AB'); + }); +}); + +/* ── Matcher ──────────────────────────────────────────────────────────────── */ +describe('matchFingerprint — güven değerleri', () => { + const sig = { protocol: 'ISO15765-4', ecuAddresses: ['7E8', '7E9'], supportedPidBitmap: 'BE1FA813' }; + + it('aynı VIN → 1.0', () => { + const a = buildFingerprint({ ...sig, vin: 'VF1ABC0000001234' }); + const b = buildFingerprint({ ...sig, vin: 'VF1ABC0000001234', ecuAddresses: ['7E8'] }); // imza farklı ama VIN aynı + const m = matchFingerprint(a, b); + expect(m.confidence).toBe(1.0); + expect(m.reason).toBe('vin'); + }); + + it('VIN yok ama protocol+ECU+bitmap aynı → 0.80', () => { + const a = buildFingerprint({ ...sig, vin: '' }); + const b = buildFingerprint({ ...sig, vin: '' }); + const m = matchFingerprint(a, b); + expect(m.confidence).toBe(0.8); + expect(m.reason).toBe('signature'); + }); + + it('yalnız OBD MAC eşleşmesi → 0.30', () => { + const a = buildFingerprint({ vin: '', protocol: 'ISO15765-4', ecuAddresses: ['7E8'], supportedPidBitmap: 'AA', metadata: { adapterMac: '11:22:33:44' } }); + const b = buildFingerprint({ vin: '', protocol: 'CAN29', ecuAddresses: ['18DAF110'], supportedPidBitmap: 'FF', metadata: { adapterMac: '11:22:33:44' } }); + const m = matchFingerprint(a, b); + expect(m.confidence).toBe(0.3); + expect(m.reason).toBe('adapter-mac'); + }); + + it('hiçbir eşleşme → 0', () => { + const a = buildFingerprint({ vin: 'AAA', protocol: 'ISO15765-4', ecuAddresses: ['7E8'], supportedPidBitmap: 'AA' }); + const b = buildFingerprint({ vin: 'BBB', protocol: 'CAN29', ecuAddresses: ['7E0'], supportedPidBitmap: 'BB' }); + const m = matchFingerprint(a, b); + expect(m.confidence).toBe(0); + expect(m.reason).toBe('none'); + }); + + it('farklı VIN ama aynı imza → VIN çakışması aynı araç saymaz (0)', () => { + const a = buildFingerprint({ ...sig, vin: 'VIN-AAA' }); + const b = buildFingerprint({ ...sig, vin: 'VIN-BBB' }); + expect(matchFingerprint(a, b).confidence).toBe(0); + }); + + it('boş imza (bitmap yok) 0.80 vermez', () => { + const a = buildFingerprint({ vin: '', protocol: 'ISO15765-4', ecuAddresses: ['7E8'], supportedPidBitmap: '' }); + const b = buildFingerprint({ vin: '', protocol: 'ISO15765-4', ecuAddresses: ['7E8'], supportedPidBitmap: '' }); + expect(matchFingerprint(a, b).confidence).toBe(0); + }); + + it('findBestMatch: en yüksek güveni seçer', () => { + const cand = buildFingerprint({ ...sig, vin: 'VF1ABC0000001234', metadata: { adapterMac: 'MAC1' } }); + const macOnly = buildFingerprint({ vin: '', protocol: 'X', ecuAddresses: ['1'], supportedPidBitmap: 'A', metadata: { adapterMac: 'MAC1' } }); + const exact = buildFingerprint({ ...sig, vin: 'VF1ABC0000001234' }); + const best = findBestMatch(cand, [macOnly, exact]); + expect(best.confidence).toBe(1.0); + }); +}); + +/* ── Bounded LRU depo ─────────────────────────────────────────────────────── */ +describe('VehicleFingerprintStore — bounded(8) LRU + kalıcılık', () => { + let _n = 0; + function store() { return new VehicleFingerprintStore(`vfp-test-${_n++}`); } + + function makeFp(i: number) { + return buildFingerprint({ vin: `VIN-${i}`, protocol: 'ISO15765-4', ecuAddresses: ['7E8'], supportedPidBitmap: 'AB' }, 1000 + i); + } + + it('save/load/list/remove/clear', () => { + const s = store(); + const fp = makeFp(1); + s.save(fp); + expect(s.size).toBe(1); + expect(s.load(fp.hash)?.vin).toBe('VIN-1'); + expect(s.list()).toHaveLength(1); + expect(s.remove(fp.hash)).toBe(true); + expect(s.size).toBe(0); + s.save(makeFp(2)); + s.clear(); + expect(s.size).toBe(0); + expect(s.list()).toEqual([]); + }); + + it('MAX 8 araç tutar; taşınca en eski-görülen düşer', () => { + const s = store(); + const fps = Array.from({ length: 10 }, (_, i) => makeFp(i)); + fps.forEach((f) => s.save(f)); + expect(s.size).toBe(MAX_FINGERPRINTS); // 8 + // İlk iki eklenen (en eski-görülen) düşmüş olmalı. + expect(s.load(fps[0].hash)).toBeNull(); + expect(s.load(fps[1].hash)).toBeNull(); + expect(s.load(fps[9].hash)).not.toBeNull(); + // En yeni-görülen başta. + expect(s.list()[0].hash).toBe(fps[9].hash); + }); + + it('aynı hash upsert: firstSeen korunur, kayıt öne taşınır (recency)', () => { + const s = store(); + const first = buildFingerprint({ vin: 'VIN-A', protocol: 'P', ecuAddresses: ['7E8'], supportedPidBitmap: 'AB' }, 1000); + s.save(first); + s.save(makeFp(5)); // araya başka araç + const again = buildFingerprint({ vin: 'VIN-A', protocol: 'P', ecuAddresses: ['7E8'], supportedPidBitmap: 'AB' }, 5000); + const stored = s.save(again); + expect(s.size).toBe(2); // upsert — çift kayıt YOK + expect(stored.firstSeen).toBe(1000); // ilk görülme korundu + expect(stored.lastSeen).toBe(5000); // son görülme güncellendi + expect(s.list()[0].hash).toBe(again.hash); // recency: öne taşındı + }); + + it('kalıcılık: aynı storageKey ile yeni örnek diskten yükler', () => { + const key = `vfp-persist-${_n++}`; + const a = new VehicleFingerprintStore(key); + a.save(makeFp(42)); + const b = new VehicleFingerprintStore(key); + expect(b.size).toBe(1); + expect(b.load(makeFp(42).hash)?.vin).toBe('VIN-42'); + }); + + it('bozuk disk verisi → fail-soft boş liste', () => { + const key = `vfp-corrupt-${_n++}`; + try { localStorage.setItem(key, '{bozuk-json'); } catch { /* jsdom */ } + const s = new VehicleFingerprintStore(key); + expect(s.size).toBe(0); + }); +}); diff --git a/src/platform/vehicleFingerprintService.ts b/src/platform/vehicleFingerprintService.ts new file mode 100644 index 00000000..19ca137b --- /dev/null +++ b/src/platform/vehicleFingerprintService.ts @@ -0,0 +1,357 @@ +/** + * vehicleFingerprintService — Araç Parmak İzi TEMEL katmanı (PR-25, foundation-only). + * + * AMAÇ (VİZYON — "aracın ikinci beyni"): bilinmeyen aftermarket araçları ÖĞRENMEK için + * her aracı deterministik, tekrar-üretilebilir bir kimlikle (fingerprint) tanımak. Aynı + * araç tekrar bağlandığında — VIN olsun olmasın — onu güvenle tanıyıp önceki bağlamı + * (profil/keşif/ayar) geri getirebilmenin temeli budur. + * + * KAPSAM (SADECE FOUNDATION): + * - SAF normalize + builder + deterministik hash (React/native/DOM importu YOK). + * - safeStorage tabanlı bounded (max 8) LRU kalıcı önbellek. + * - Matcher TEMELİ (confidence 1.0 / 0.80 / 0.30). + * + * BU PR'DA YAPILMAYANLAR (bilinçli): + * - OBD poll / hot-path'e DOKUNULMAZ (bu servis hiçbir polling'e abone olmaz). + * - useVidStore / herhangi bir store'a subscribe BAĞLANMAZ (yalnız saf API + kalıcılık). + * - Discovery Pipeline / native / SQL-Supabase mantığı DEĞİŞMEZ. + * + * ZERO-TRUST: girdi telemetrisi güvenilmez (aftermarket) → tüm alanlar normalize edilir, + * eksik alanlar güvenli boşa (''/[]/{}) düşer, "imkânsız" değer hash'i patlatmaz. + * V8/JIT: template-literal tam-şekilli objeler (hidden-class kararlılığı), delete YOK. + */ + +import { safeGetRaw, safeSetRaw, safeRemoveRaw } from '../utils/safeStorage'; + +/* ══════════════════════════════════════════════════════════════════════════ + * Tipler + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Serbest metadata — hash'e GİRMEZ (yalnız görüntü/eşleşme ipuçları). adapterMac düşük-güven + * (0.30) matcher tarafından kullanılır; firmware/label salt-bilgi. + */ +export interface VehicleFingerprintMetadata { + /** OBD adaptörünün Bluetooth MAC'i — yalnız düşük-güven (0.30) eşleşme ipucu. */ + adapterMac?: string; + /** ECU firmware/kalibrasyon sürümü (biliniyorsa). */ + firmwareVersion?: string; + /** Kullanıcı/otomatik etiket (görüntü). */ + label?: string; +} + +/** buildFingerprint girdisi — bilinen alanlar geçilir, gerisi güvenli boşa düşer. */ +export interface VehicleFingerprintInput { + vin?: string; + protocol?: string; + ecuAddresses?: readonly string[]; + /** Desteklenen PID bitmap'i (hex string, ör. Mode 01 PID 00/20/40… birleşimi). */ + supportedPidBitmap?: string; + metadata?: VehicleFingerprintMetadata; +} + +/** Kalıcı araç parmak izi kaydı — hash kimliktir; zaman alanları yalnız LRU/gösterim. */ +export interface VehicleFingerprint { + /** Deterministik kimlik (VIN+protocol+ECU+bitmap türevi; metadata/zaman DAHİL DEĞİL). */ + hash: string; + vin: string; + protocol: string; + /** Normalize + tekilleştirilmiş + SIRALI (sıra hash'i değiştirmez). */ + ecuAddresses: string[]; + /** Normalize + trailing-zero temizlenmiş PID bitmap. */ + supportedPidBitmap: string; + metadata: VehicleFingerprintMetadata; + firstSeen: number; + lastSeen: number; +} + +/** Matcher sonucu — dürüst güven + gerekçe. */ +export type VehicleMatchReason = 'vin' | 'signature' | 'adapter-mac' | 'none'; + +export interface VehicleMatchResult { + confidence: number; // 1.0 | 0.80 | 0.30 | 0 + reason: VehicleMatchReason; + /** Eşleşen kayıt hash'i (varsa). */ + hash: string | null; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Normalize edilebilir yardımcılar (SAF) + * ════════════════════════════════════════════════════════════════════════ */ + +/** VIN: trim + büyük harf + tüm boşlukları kaldır. Bilinmiyorsa ''. */ +export function normalizeVin(vin: string | undefined | null): string { + return (vin ?? '').trim().toUpperCase().replace(/\s+/g, ''); +} + +/** + * ECU adresi: büyük harf hex, boşluksuz, '0X' öneki temizlenir. Ör. ' 0x7e8 ' → '7E8'. + * Baştaki anlamlı sıfırlar KORUNUR (11-bit '7E8' vs 29-bit '18DAF110' ayrımı). + */ +export function normalizeEcuAddress(addr: string | undefined | null): string { + return (addr ?? '').trim().toUpperCase().replace(/\s+/g, '').replace(/^0X/, ''); +} + +/** + * Bir hex string'in SONUNDAKİ tam sıfır bayt ('00') dolgusunu temizler. + * '00' dolgusu farklı uzunlukta bitmap'lerin AYNI parmak izini vermesini sağlar + * (aftermarket adaptörler bitmap'i değişken uzunlukta sıfır-dolgulu döndürebilir). + * Örn. 'BE1FA813' ↔ 'BE1FA81300' ↔ 'BE1FA8130000' → hepsi 'BE1FA813'. + */ +export function stripTrailingZeroBytes(hex: string): string { + let out = hex; + // Çift-haneli tam sıfır bayt olduğu sürece sondan kırp. + while (out.length >= 2 && out.slice(-2) === '00') { + out = out.slice(0, -2); + } + return out; +} + +/** + * PID bitmap: hex-dışı karakterleri at, büyük harf, tek-hane kalırsa başa sıfır ekleyip + * bayt-hizala, sonra trailing '00' bayt dolgusunu temizle. Boş/hex-yoksa ''. + */ +export function normalizePidBitmap(bitmap: string | undefined | null): string { + let hex = (bitmap ?? '').toUpperCase().replace(/[^0-9A-F]/g, ''); + if (hex.length === 0) return ''; + if (hex.length % 2 === 1) hex = `0${hex}`; // bayt-hizala (tek hane → 0X) + return stripTrailingZeroBytes(hex); +} + +/** ECU adres listesini normalize + boşları at + tekilleştir + SIRALA (sıra-bağımsız kimlik). */ +export function normalizeEcuAddresses(addresses: readonly string[] | undefined | null): string[] { + const seen = new Set(); + for (const a of addresses ?? []) { + const n = normalizeEcuAddress(a); + if (n) seen.add(n); + } + return [...seen].sort(); +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Deterministik hash (SAF — harici bağımlılık yok) + * ════════════════════════════════════════════════════════════════════════ */ + +/** FNV-1a 32-bit — taşma-güvenli (Math.imul); tohumla varyant üretilebilir. */ +function fnv1a32(str: string, seed = 0x811c9dc5): number { + let h = seed >>> 0; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** + * Bir parmak izinin KANONİK kimlik metni — hash yalnız bundan türer. + * DAHİL: VIN, protocol, SIRALI ECU adresleri, normalize PID bitmap. + * HARİÇ: metadata (adapterMac/firmware/label) ve zaman alanları (kimlik oynamasın). + */ +export function canonicalFingerprintKey(fp: { + vin: string; protocol: string; ecuAddresses: readonly string[]; supportedPidBitmap: string; +}): string { + return `V:${fp.vin}|P:${fp.protocol.toUpperCase()}|E:${fp.ecuAddresses.join(',')}|B:${fp.supportedPidBitmap}`; +} + +/** + * Kanonik metinden deterministik 16-haneli hex kimlik. İki farklı tohumla FNV-1a + * birleştirilerek çakışma olasılığı düşürülür (kriptografik değil — yalnız kimlik). + */ +export function fingerprintHash(key: string): string { + const a = fnv1a32(key, 0x811c9dc5); + const b = fnv1a32(key, 0x01000193); + return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0'); +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Builder + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Ham girdiden TAM-şekilli, normalize edilmiş, deterministik-hash'li parmak izi üretir. + * @param now zaman damgası (test için enjekte edilebilir) — kimliğe DAHİL DEĞİL. + */ +export function buildFingerprint(input: VehicleFingerprintInput, now: number = Date.now()): VehicleFingerprint { + const vin = normalizeVin(input.vin); + const protocol = (input.protocol ?? '').trim().toUpperCase(); + const ecuAddresses = normalizeEcuAddresses(input.ecuAddresses); + const supportedPidBitmap = normalizePidBitmap(input.supportedPidBitmap); + const metadata: VehicleFingerprintMetadata = { + adapterMac: input.metadata?.adapterMac ? input.metadata.adapterMac.trim().toUpperCase() : undefined, + firmwareVersion: input.metadata?.firmwareVersion ?? undefined, + label: input.metadata?.label ?? undefined, + }; + const hash = fingerprintHash( + canonicalFingerprintKey({ vin, protocol, ecuAddresses, supportedPidBitmap }), + ); + return { + hash, + vin, + protocol, + ecuAddresses, + supportedPidBitmap, + metadata, + firstSeen: now, + lastSeen: now, + }; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Matcher TEMELİ + * ════════════════════════════════════════════════════════════════════════ */ + +/** İki parmak izinin imza (protocol + ECU + bitmap) çekirdeği aynı mı. */ +function signatureEquals(a: VehicleFingerprint, b: VehicleFingerprint): boolean { + return a.protocol === b.protocol && + a.supportedPidBitmap === b.supportedPidBitmap && + a.ecuAddresses.length === b.ecuAddresses.length && + a.ecuAddresses.every((e, i) => e === b.ecuAddresses[i]); // ikisi de sıralı +} + +/** + * İki parmak izini eşleştirir (dürüst güven): + * - Aynı (boş-olmayan) VIN → 1.00 ('vin') + * - VIN yok (birinde) ama protocol+ECU+bitmap aynı → 0.80 ('signature') + * - Yalnız OBD adaptör MAC'i aynı → 0.30 ('adapter-mac') + * - Aksi halde → 0 ('none') + */ +export function matchFingerprint(a: VehicleFingerprint, b: VehicleFingerprint): VehicleMatchResult { + if (a.vin && b.vin && a.vin === b.vin) { + return { confidence: 1.0, reason: 'vin', hash: b.hash }; + } + // İmza eşleşmesi: en az birinde VIN yoksa (VIN varsa ve farklıysa aynı araç sayılmaz). + if ((!a.vin || !b.vin) && signatureEquals(a, b) && a.supportedPidBitmap !== '') { + return { confidence: 0.8, reason: 'signature', hash: b.hash }; + } + const macA = a.metadata.adapterMac; + const macB = b.metadata.adapterMac; + if (macA && macB && macA === macB) { + return { confidence: 0.3, reason: 'adapter-mac', hash: b.hash }; + } + return { confidence: 0, reason: 'none', hash: null }; +} + +/** Aday parmak izine bir liste içindeki EN YÜKSEK güvenli eşleşmeyi döndürür. */ +export function findBestMatch( + candidate: VehicleFingerprint, + known: readonly VehicleFingerprint[], +): VehicleMatchResult { + let best: VehicleMatchResult = { confidence: 0, reason: 'none', hash: null }; + for (const k of known) { + const m = matchFingerprint(candidate, k); + if (m.confidence > best.confidence) best = m; + if (best.confidence >= 1.0) break; // daha iyisi yok + } + return best; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Bounded (max 8) LRU kalıcı depo — safeStorage + * ════════════════════════════════════════════════════════════════════════ */ + +/** safeStorage LRU_PROTECTED anahtarı (kota dolunca SİLİNMEZ — bkz. safeStorage.ts). */ +export const VEHICLE_FINGERPRINT_STORAGE_KEY = 'car-vehicle-fingerprints'; +/** En fazla bu kadar araç saklanır; taşınca en az-yakın-zamanda-görülen düşer. */ +export const MAX_FINGERPRINTS = 8; + +/** + * Araç parmak izi deposu — bounded (max 8) LRU, safeStorage ile kalıcı, ağ YOK. + * En yeni-görülen BAŞTA tutulur; save() kaydı öne taşır (LRU recency); taşınca kuyruğun + * SONU (en eski görülen) düşer. Bozuk veri → fail-soft boş liste (zero-trust disk). + */ +export class VehicleFingerprintStore { + private _items: VehicleFingerprint[] = []; + private _loaded = false; + private readonly storageKey: string; + private readonly maxItems: number; + + constructor(storageKey = VEHICLE_FINGERPRINT_STORAGE_KEY, maxItems = MAX_FINGERPRINTS) { + this.storageKey = storageKey; + this.maxItems = maxItems; + } + + private _ensureLoaded(): void { + if (this._loaded) return; + this._loaded = true; + try { + const raw = safeGetRaw(this.storageKey); + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) this._items = parsed as VehicleFingerprint[]; + } + } catch { + this._items = []; // bozuk JSON → dürüstçe boş + } + } + + private _persist(): void { + try { + safeSetRaw(this.storageKey, JSON.stringify(this._items)); + } catch { + /* kota/serileştirme — bellek listesi korunur, fail-soft */ + } + } + + /** + * Kaydı ekler/günceller (hash kimliğiyle upsert) ve LRU önüne taşır. Var olan kaydın + * firstSeen'i korunur, lastSeen güncellenir. Taşınca en eski-görülen düşer. + * @returns saklanan (güncel) kayıt. + */ + save(fp: VehicleFingerprint): VehicleFingerprint { + this._ensureLoaded(); + const idx = this._items.findIndex((x) => x.hash === fp.hash); + let stored: VehicleFingerprint; + if (idx >= 0) { + const prev = this._items[idx]; + stored = { ...fp, firstSeen: prev.firstSeen, lastSeen: fp.lastSeen }; + this._items.splice(idx, 1); // eskiyi çıkar, öne taşınacak + } else { + stored = fp; + } + this._items.unshift(stored); // en yeni-görülen başta + if (this._items.length > this.maxItems) { + this._items.length = this.maxItems; // en eski-görülen(ler) düşer (LRU evict) + } + this._persist(); + return stored; + } + + /** hash ile kaydı getirir (kopya) veya null. */ + load(hash: string): VehicleFingerprint | null { + this._ensureLoaded(); + const found = this._items.find((x) => x.hash === hash); + return found ? { ...found } : null; + } + + /** Tüm kayıtların kopyası (en yeni-görülen başta). */ + list(): VehicleFingerprint[] { + this._ensureLoaded(); + return this._items.map((x) => ({ ...x })); + } + + /** hash ile kaydı siler. @returns silindi mi. */ + remove(hash: string): boolean { + this._ensureLoaded(); + const idx = this._items.findIndex((x) => x.hash === hash); + if (idx < 0) return false; + this._items.splice(idx, 1); + this._persist(); + return true; + } + + /** Depoyu ve kalıcı kaydı tamamen temizler. */ + clear(): void { + this._items = []; + this._loaded = true; + try { safeRemoveRaw(this.storageKey); } catch { /* yoksay */ } + } + + /** Saklanan araç sayısı. */ + get size(): number { + this._ensureLoaded(); + return this._items.length; + } +} + +/** Uygulama geneli tekil depo (foundation wiring). Testler kendi örneğini kurar. */ +export const vehicleFingerprintStore = new VehicleFingerprintStore(); diff --git a/src/utils/safeStorage.ts b/src/utils/safeStorage.ts index e37405f9..28c96f57 100644 --- a/src/utils/safeStorage.ts +++ b/src/utils/safeStorage.ts @@ -85,6 +85,7 @@ const LRU_PROTECTED = new Set([ 'car-expert-trust-store', // Expert Trust mühürlü durum 'car-expert-trust-hmac-seed', // Expert Trust HMAC tohumu 'car-safety-brain-v1', // Safety Brain VIN profilleri + 'car-vehicle-fingerprints', // Araç parmak izi bounded LRU cache (max 8 araç kimliği) ]); /**