fix(web): generate sync ids on loopback HTTP

This commit is contained in:
fy59 2026-09-17 16:56:59 +02:00
parent 05c93777d6
commit 99e6c0b073
3 changed files with 34 additions and 2 deletions

View file

@ -17,6 +17,9 @@
- Made relocatable launchers resolve stable user-bin symlinks to their physical
versioned bundle before loading matching binaries and helpers.
- Made the Web sync button generate cryptographic UUIDv4 request IDs on the
loopback HTTP hostname where `crypto.randomUUID()` is unavailable.
- Closed the native candidate warning gate with checked path and HTTP response
bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a
stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android

View file

@ -1,8 +1,17 @@
import { beforeEach,describe,expect,it,vi } from 'vitest'
import { fetchSyncStatus,startSync,syncIsActive } from './sync'
import { fetchSyncStatus, newRequestId, startSync, syncIsActive } from './sync'
const token='b'.repeat(64)
function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}}
describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks())
it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)})
it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)})
it('crée un UUIDv4 sans dépendre de randomUUID', () => {
vi.stubGlobal('crypto', {
getRandomValues: (bytes: Uint8Array) => {
bytes.set(Array.from({ length: 16 }, (_, index) => index))
return bytes
},
})
expect(newRequestId()).toBe('sy_00010203-0405-4607-8809-0a0b0c0d0e0f')
})
})

View file

@ -6,5 +6,25 @@ const phases=new Set<SyncPhase>(['idle','requested','waiting_android_publication
function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial<SyncStatus>;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus }
export async function fetchSyncStatus(signal?:AbortSignal):Promise<SyncStatus>{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())}
export async function startSync(requestId:string):Promise<SyncStatus>{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)}
export function newRequestId():string{return `sy_${crypto.randomUUID()}`}
export function newRequestId(): string {
/*
* WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure
* context, so randomUUID() is legitimately unavailable there.
* CONTRACT: getRandomValues() supplies the UUID entropy; Trainlog sets the
* RFC 4122 version/variant bits and never falls back to Math.random().
*/
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hexadecimal = Array.from(bytes, (value) => value.toString(16).padStart(2, '0'))
const uuid = [
hexadecimal.slice(0, 4).join(''),
hexadecimal.slice(4, 6).join(''),
hexadecimal.slice(6, 8).join(''),
hexadecimal.slice(8, 10).join(''),
hexadecimal.slice(10, 16).join(''),
].join('-')
return `sy_${uuid}`
}
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}