diff --git a/CHANGELOG.md b/CHANGELOG.md index 03442911..d59e1fb0 100644 Binary files a/CHANGELOG.md and b/CHANGELOG.md differ diff --git a/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md b/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md index a5f93052..54b85b9d 100644 --- a/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md +++ b/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md @@ -2,7 +2,7 @@ 由 scripts/audit_nvs_access.mjs 扫描 ESP、共享 UI 和仓库 third_party 的 C/C++ 文件。只记录 NVS 函数、namespace/key/handle 实参和位置,不读取设备、不记录值/默认值/缓冲/分区名。不把最近的 nvs_open 自动推断为后续调用的 namespace;跨作用域句柄、宏和条件编译仍需人工核对。 -扫描 1522 个源文件,找到 106 个候选调用,分布于 8 个文件。显式打开 15,键访问 45,其余为提交/关闭/清域。调用数不是独立配置数。 +扫描 1527 个源文件,找到 106 个候选调用,分布于 8 个文件。显式打开 15,键访问 45,其余为提交/关闭/清域。调用数不是独立配置数。 这是源码入口清单,不是全部配置或硬件生效证明。系统 SDK、构建环境依赖、其他平台、函数指针/包装器实参以及复杂原始字符串不由此表自动覆盖。依赖库可单独调用 collectNvsAccesses 指定根目录审计;输出应单独记录其版本,不能写进与机器无关的仓库清单。 diff --git a/docs/releases/1.0.0.md b/docs/releases/1.0.0.md index 409d4d67..a2c0db2b 100644 --- a/docs/releases/1.0.0.md +++ b/docs/releases/1.0.0.md @@ -19,10 +19,15 @@ On 2026-10-14 the user reserved the remaining Wio upload opportunity for final release acceptance and became unavailable for manual device operations. Complete remaining development and host-side verification before using it. Do not flash intermediate or diagnostic candidates, or reset the device just to resume tests. -The last flashed source is `4be4f572`; retain the original identity, configuration -and pending publication fixture. An upload is permitted only when a release-ready -candidate actually needs the final physical acceptance run. Do not infer that an -available serial port is authorization to consume this reserved opportunity. +That reserved opportunity was subsequently consumed by combined TMAP firmware +`fbcdd8bb`, with application SHA-256 +`6ab701588dca89739c4a796c39f366fc49f234f08b2bf449ed8d553460734ddc`. +The original author/RF identities and all 309 configuration fields were verified. +The later navigation/Map/POI candidate built successfully but remains unflashed; +Tracker title/card changes and the staged-draft SD contention fix are newer still. +Retain the original identities, configuration and publication fixtures. A further +upload requires a new device opportunity; an available application port is not +permission to reset or replace the running firmware. The existing browser/native-directory track lifecycle has an opt-in physical author mode. It requires an explicit application port, serial Python executable, @@ -32,8 +37,12 @@ clients and retains the exact public confirmation checkpoint before writing. Its default synthetic mode and synthetic API-bridge mode both pass locally with two real native Reticulum directories, three signed versions, KML/attachment reads and archived history after restart. The API-bridge check does not prove -physical USB signatures or device durability. The physical mode remains unused -until the reserved final acceptance run. +physical USB signatures or device durability. Physical mode has verified trail v1 +signing, publication, directory proof and durable device confirmation. Subsequent +versions remain blocked by retained upload-slot capacity; the original checkpoint +must be preserved. Physical Geocaching v1 confirmation also passed. The same-record +update attempt returned a storage failure during draft save, so update/archive is +still unaccepted. ## Requested release deliverables diff --git a/modules/core_geocaching/tests/test_browse_runtime.cpp b/modules/core_geocaching/tests/test_browse_runtime.cpp index c6da6465..e2949526 100644 --- a/modules/core_geocaching/tests/test_browse_runtime.cpp +++ b/modules/core_geocaching/tests/test_browse_runtime.cpp @@ -1124,6 +1124,24 @@ int main(int argc, char** argv) }; require(staged(staged_id, 7) == rt::WebReadResult::AlreadySaved, "web file was not saved through the existing editor transaction"); require(permission(staged_id.data(), 1, router.identity.public_key.data()) == rt::WebReadResult::Ready, "web file save generation was not persisted"); + // Ordinary SD contention during any selected-input operation must + // preserve the staged edit and resume at its last complete slice. + for (unsigned operation = 0; operation < 4; ++operation) + { + staged_id.fill(static_cast(20 + operation)); + test::file_busy_cycles = 1; + test::file_busy_remaining.fill(1); + test::file_busy_remaining[operation] = 0; + const auto hits = test::file_busy_hits[operation]; + require(rt::queueWebDraftFile(staged_id.data(), static_cast(edit_size), 0, edit_hash.data(), router.identity.public_key.data(), 7) == rt::WebReadResult::Pending, + "busy file test could not queue its edit"); + until([&] + { return test::file_busy_hits[operation] > hits; }, + "staged input did not exercise SD contention"); + test::file_busy_cycles = 0; + require(staged(staged_id, 7) == rt::WebReadResult::AlreadySaved, "SD contention rejected the staged web edit"); + require(permission(staged_id.data(), 1, router.identity.public_key.data()) == rt::WebReadResult::Ready, "busy web edit was not persisted"); + } staged_id.fill(10); test::files[rt::kWebDraftCandidatePath][0] ^= 1; require(staged(staged_id, 7) == rt::WebReadResult::HashMismatch, "corrupted web candidate was committed"); diff --git a/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp b/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp index 2a40449b..d727ece0 100644 --- a/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp +++ b/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp @@ -2945,6 +2945,13 @@ static bool advanceWebDraftFile(Session& s) if (!file.open(kWebDraftCandidatePath, "r") || file.size() != total || !file.seek(job.size) || file.read(job.bytes + job.size, count) != static_cast(count)) { + // Map/other SD readers can own the filesystem briefly. Keep the edit + // and retry this same slice; only a real read failure rejects it. + if (file.read_busy()) + { + next_step.store(millis() + 20); + return true; + } failWebDraftSave(job, WebReadResult::Unavailable); return true; } diff --git a/tests/site/geocaching-network-maintenance.browser.mjs b/tests/site/geocaching-network-maintenance.browser.mjs index 443ee4f9..c552b6b5 100644 --- a/tests/site/geocaching-network-maintenance.browser.mjs +++ b/tests/site/geocaching-network-maintenance.browser.mjs @@ -15,6 +15,8 @@ import {GeocachingHardwareExchange} from '../../tools/hostlink/geocaching_hardwa const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1],hardwarePort=args.includes('--hardware-port')?option('--hardware-port'):null; if(hardwarePort&&(!/^COM\d+$/.test(hardwarePort)||!args.includes('--serial-python')||!args.includes('--expected-author')||!/^[a-f0-9]{64}$/.test(option('--expected-author'))))throw Error('Hardware mode requires --hardware-port COMxx --serial-python --expected-author '); const resumeDraftId=args.includes('--resume-draft')?option('--resume-draft'):null; +const resumeConfirmed=args.includes('--resume-confirmed-record'); +if(resumeConfirmed&&(!resumeDraftId||!args.includes('--resume-confirmed-receipt')))throw Error('Confirmed resume requires the exact draft, signed v1 record and directory receipt'); if(resumeDraftId&&(!hardwarePort||!args.includes('--resume-name')||!/^Wio网页验收-[a-z0-9]+$/.test(option('--resume-name'))||!/^[a-f0-9]{32}$/.test(resumeDraftId)))throw Error('Resume requires explicit hardware, isolated acceptance name and ID'); const testName=hardwarePort?(resumeDraftId?option('--resume-name'):'Wio网页验收-'+Date.now().toString(36)):'真实网络网页藏宝点',updatedName=hardwarePort?testName+'-v2':'第二版网络藏宝点'; const python=process.env.TRAIL_MATE_TRACK_PYTHON??path.resolve('.codex-build/reticulum-directory/venv/'+(process.platform==='win32'?'Scripts/python.exe':'bin/python')); @@ -26,6 +28,14 @@ async function freePort(){const socket=net.createServer();await new Promise(reso const server=http.createServer(async(req,res)=>{try{const name=new URL(req.url,'http://localhost').pathname;if(name==='/trail-mate/geocaching/network.json'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({endpoint,discoverySeeds:seeds}));return;}if(!name.startsWith('/trail-mate/'))throw Error('prefix');const file=path.resolve(root,name.slice(12)+(name.endsWith('/')?'index.html':''));if(!file.startsWith(root+path.sep))throw Error('path');res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2','.ttf':'font/ttf'})[path.extname(file)]??'application/octet-stream');res.end(await fs.readFile(file));}catch{res.writeHead(404).end();}});await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port; const author=await Identity.generate(),publicKey=await author.getPublicKey(),nameHash=(await Identity.fullHash(new TextEncoder().encode('lxmf.delivery'))).slice(0,10),destination=await Identity.truncatedHash(Uint8Array.from([...nameHash,...author.identityHash])),info=[publicKey,author.identityHash,destination,7]; const drafts=new Map(),transfers=new Map(),signatures=new Map(),confirmations=new Map(),signedVersions=[],calls=[];let catalog=0,signCount=0,confirmCount=0; +let resumeBaseHash=null; +if(resumeConfirmed){ + const signed=JSON.parse(await fs.readFile(option('--resume-confirmed-record'),'utf8')).map(value=>Uint8Array.from(value)),verified=await verifySigned(signed),record=verified.record; + assert.ok(equal(record[2],Uint8Array.from(Buffer.from(resumeDraftId,'hex'))));assert.equal(record[3],1);assert.equal(record[5],0);assert.equal(record[8],testName);assert.equal(record[6],310000000);assert.equal(record[7],1210000000); + assert.equal(Buffer.from(await crypto.subtle.digest('SHA-256',record[1])).toString('hex'),option('--expected-author')); + const receipt=Object.fromEntries(Object.entries(JSON.parse(await fs.readFile(option('--resume-confirmed-receipt'),'utf8'))).map(([key,value])=>[key,Uint8Array.from(value)]));await verifyPublicationReceipt([receipt.raw,receipt.signature],signed,receipt); + resumeBaseHash=verified.summary[2].slice();signedVersions.push(signed); +} const countSignatures=()=>hardware?.signCount??signCount,countConfirmations=()=>hardware?.confirmCount??confirmCount; const hex=bytes=>Buffer.from(bytes).toString('hex'); async function usbReply(raw){const frame=new FrameDecoder().push(Uint8Array.from(raw))[0];assert.ok(frame);if(frame.type===FRAME.HELLO)return [...encodeFrame(FRAME.HELLO_ACK,frame.sequence,new Uint8Array([1,0,0,2,0,4,0,0,6,...new TextEncoder().encode('T-Deck'),4,...new TextEncoder().encode('test')]))];const request=decodeManagement(frame.payload),body=request.body;calls.push(request.code);let response; @@ -48,18 +58,18 @@ async function usbReply(raw){const frame=new FrameDecoder().push(Uint8Array.from else assert.fail('Unexpected USB operation '+request.code); return [...encodeFrame(FRAME.MANAGEMENT_RESPONSE,frame.sequence,encodeManagement(request.session,request.request,0,response))];} try{ - if(hardwarePort)hardware=new GeocachingHardwareExchange({port:hardwarePort,python:option('--serial-python'),fingerprint:option('--expected-author'),namePrefix:testName,resumeDraftId,calls,signedVersions,checkpointPath:path.join(work,'signature-recovery.json')}); + if(hardwarePort)hardware=new GeocachingHardwareExchange({port:hardwarePort,python:option('--serial-python'),fingerprint:option('--expected-author'),namePrefix:testName,resumeDraftId,resumeBaseHash,calls,signedVersions,checkpointPath:path.join(work,'signature-recovery.json')}); await fs.rm(path.join(work,'result.json'),{force:true}); await fs.rm(path.join(state,'stop'),{force:true});const tcp=await freePort();service=start('directory',['tools/geocaching/browser_test_service.py','--state',state,'--port',String(tcp),'--empty','--startup-delay','0','--run-seconds','600']);const directory=await ready(service,'ready');seeds=[directory.discovery];bridge=start('bridge',['tools/geocaching/packet_bridge.py','--port','0','--tcp-port',String(tcp),'--origin',origin,'--run-seconds','600']);endpoint='ws://127.0.0.1:'+(await ready(bridge,'bridge_ready')).port; browser=await chromium.launch({channel:process.env.TRAIL_MATE_TEST_BROWSER_CHANNEL,headless:true});const context=await browser.newContext({viewport:{width:1440,height:960}});await context.exposeFunction('deviceReply',hardware?raw=>hardware.exchange(raw):usbReply);await context.addInitScript(()=>{let input;const port={async open(){this.readable=new ReadableStream({start(controller){input=controller;}});this.writable=new WritableStream({async write(bytes){input.enqueue(Uint8Array.from(await window.deviceReply([...bytes])));}});},async setSignals(){},async close(){}};Object.defineProperty(navigator,'serial',{configurable:true,value:{requestPort:async()=>port}});});await context.route('https://tile.openstreetmap.org/**',route=>route.fulfill({contentType:'image/svg+xml',body:''})); let page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(String(error)));await page.goto(origin+'/trail-mate/geocaching/?lang=zh-Hans');await page.waitForFunction(()=>document.querySelector('#search')&&!document.querySelector('#search').disabled,{},{timeout:60000});await page.getByRole('button',{name:'草稿',exact:true}).click();await page.getByRole('button',{name:'连接设备',exact:true}).click();await page.getByText('USB 作者已验证',{exact:true}).waitFor(); - if(resumeDraftId){await page.getByRole('button',{name:'刷新设备列表',exact:true}).click();await openTestDraft(testName);assert.ok(hardware.drafts.has(resumeDraftId));} + if(resumeDraftId){if(resumeConfirmed)await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await page.getByRole('button',{name:'刷新设备列表',exact:true}).click();await openTestDraft(testName);assert.ok(hardware.drafts.has(resumeDraftId));} else{await page.getByRole('button',{name:'新建草稿',exact:true}).click();await page.getByLabel('名称',{exact:true}).fill(testName);await page.getByLabel('正文',{exact:true}).fill('网页中文正文 \n'.repeat(25));await page.getByLabel('提示',{exact:true}).fill('&< >'.repeat(80));await page.getByLabel('纬度',{exact:true}).fill('31');await page.getByLabel('经度',{exact:true}).fill('121');await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor({timeout:45000});} async function publishAndSync(){await page.getByRole('button',{name:'刷新 IP 目录',exact:true}).click();await page.getByRole('button',{name:'检查并确认发布',exact:true}).click();assert.equal(await page.locator('dialog[open] script').count(),0);await page.locator('dialog[open]').getByRole('button',{name:'通过 IP 网络发布',exact:true}).click();await page.locator('.geo-publication-task').getByText('发布内容与目录签名证明已归档到设备。',{exact:true}).waitFor({timeout:60000});} async function openTestDraft(name){for(let index=0;index<128;index++){await page.waitForFunction(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='刷新设备列表'&&!button.disabled),{},{timeout:45000});const row=page.locator('.geo-draft-row').filter({hasText:name});if(await row.count()){await row.click();await page.getByLabel('名称',{exact:true}).waitFor();return;}const more=page.getByRole('button',{name:'加载更多设备草稿',exact:true});if(!await more.count())throw Error('Isolated acceptance draft not present in current device catalogue');await more.click();}throw Error('Device catalogue acceptance search exceeded bounded page count');} - await publishAndSync();assert.equal(countSignatures(),1);assert.equal(countConfirmations(),1);await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await openTestDraft(testName);await page.getByLabel('名称',{exact:true}).fill(updatedName);await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();await openTestDraft(updatedName);await page.getByLabel('状态',{exact:true}).selectOption('2');await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();assert.equal(countSignatures(),3);assert.equal(countConfirmations(),3);assert.equal((await verifySigned(signedVersions[2])).record[5],2);if(hardware)await page.locator('.geo-publication-panel').screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-wio-network-archive.png'});else await page.screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-real-network-archive.png',fullPage:true}); + if(!resumeConfirmed){await publishAndSync();assert.equal(countSignatures(),1);assert.equal(countConfirmations(),1);}await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await openTestDraft(testName);await page.getByLabel('名称',{exact:true}).fill(updatedName);await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();await openTestDraft(updatedName);await page.getByLabel('状态',{exact:true}).selectOption('2');await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();assert.equal(countSignatures(),resumeConfirmed?2:3);assert.equal(countConfirmations(),resumeConfirmed?2:3);assert.equal((await verifySigned(signedVersions[2])).record[5],2);if(hardware)await page.locator('.geo-publication-panel').screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-wio-network-archive.png'});else await page.screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-real-network-archive.png',fullPage:true}); await context.grantPermissions(['clipboard-read','clipboard-write'],{origin});const expectedId=(await verifySigned(signedVersions[2])).cacheId;await page.getByRole('button',{name:'复制藏宝点 ID',exact:true}).click();assert.equal(await page.evaluate(()=>navigator.clipboard.readText()),expectedId); const priorCalls=calls.length;for(const {id} of locales){await page.locator('.management-language select').selectOption(id);await page.locator('.geo-publication-task').getByText(geocachingIpPublicationText(id).confirmed,{exact:true}).waitFor();await page.locator('.geo-publication-task').getByText(geocachingDeviceSyncText(id).confirmed,{exact:true}).waitFor();await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1),false);assert.equal(calls.length,priorCalls);}await page.locator('.management-language select').selectOption('zh-Hans');await page.setViewportSize({width:1440,height:960}); const native=spawnSync(python,['tools/geocaching/verify_browser_publication.py','--database',path.join(state,'service','directory.sqlite')],{input:JSON.stringify(signedVersions.map(item=>item.map(part=>[...part]))),windowsHide:true,maxBuffer:1048576});assert.equal(native.status,0,String(native.stderr));const nativeCheck=JSON.parse(native.stdout);assert.equal(nativeCheck.transportDiffersFromAuthor,true); - await page.reload();await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await page.getByRole('button',{name:'连接设备',exact:true}).click();await page.getByText('USB 作者已验证',{exact:true}).waitFor();await page.locator('.geo-publication-task').getByText('发布内容与目录签名证明已归档到设备。',{exact:true}).waitFor();assert.equal(countSignatures(),3);assert.equal(countConfirmations(),3);assert.equal(await page.evaluate(()=>window.__geoNetworkInjection),undefined);assert.ok(signedVersions.every(item=>item[0].length>512));assert.ok(!calls.some(code=>[7,16,17,20,21,67,68,70].includes(code)));assert.deepEqual(errors,[]);await fs.writeFile(path.join(work,'result.json'),JSON.stringify({passed:true,actualPage:true,actualWorker:true,nativeNetwork:true,nativeWebSerial:false,usb:hardware?'physical Wio L2 via restricted browser serial API test bridge':'synthetic independent identity with real signatures',author:hardware?{fingerprint:hardware.author.fingerprint,generation:hardware.author.generation}:undefined,cacheId:expectedId,created:true,updated:true,archived:true,directoryProof:true,deviceConfirmation:true,refreshRecovery:true,signatures:countSignatures(),confirmations:countConfirmations(),rfRequests:0,configWrites:0,keyExports:0,locales:locales.length,mobileWidth:390,clipboardId:true,resourceSizedContent:true,nativeCheck},null,2));console.log('Actual Geocaching page + Worker + native network: create/update/archive/proof/device confirmation and refresh passed. USB: '+(hardware?'physical Wio L2':'synthetic')); + await page.reload();await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await page.getByRole('button',{name:'连接设备',exact:true}).click();await page.getByText('USB 作者已验证',{exact:true}).waitFor();await page.locator('.geo-publication-task').getByText('发布内容与目录签名证明已归档到设备。',{exact:true}).waitFor();assert.equal(countSignatures(),resumeConfirmed?2:3);assert.equal(countConfirmations(),resumeConfirmed?2:3);assert.equal(await page.evaluate(()=>window.__geoNetworkInjection),undefined);assert.ok(signedVersions.every(item=>item[0].length>512));assert.ok(!calls.some(code=>[7,16,17,20,21,67,68,70].includes(code)));assert.deepEqual(errors,[]);await fs.writeFile(path.join(work,'result.json'),JSON.stringify({passed:true,actualPage:true,actualWorker:true,nativeNetwork:true,nativeWebSerial:false,usb:hardware?'physical Wio L2 via restricted browser serial API test bridge':'synthetic independent identity with real signatures',author:hardware?{fingerprint:hardware.author.fingerprint,generation:hardware.author.generation}:undefined,cacheId:expectedId,created:true,resumedVerifiedV1:resumeConfirmed,updated:true,archived:true,directoryProof:true,deviceConfirmation:true,refreshRecovery:true,signatures:countSignatures(),confirmations:countConfirmations(),rfRequests:0,configWrites:0,keyExports:0,locales:locales.length,mobileWidth:390,clipboardId:true,resourceSizedContent:true,nativeCheck},null,2));console.log('Actual Geocaching page + Worker + native network: create/update/archive/proof/device confirmation and refresh passed. USB: '+(hardware?'physical Wio L2':'synthetic')); }finally{try{if(browser){const pages=browser.contexts().flatMap(context=>context.pages());for(const page of pages){await fs.writeFile(path.join(work,'page-state.json'),JSON.stringify({signatures:countSignatures(),confirmations:countConfirmations(),operations:calls,responses:hardware?.responses,status:await page.locator('.geo-maintenance-list p').first().textContent().catch(()=>null),publication:await page.locator('.geo-publication-panel').textContent().catch(()=>null)},null,2));if(hardware)await page.locator('.geo-publication-panel').screenshot({path:path.join(work,'page.png')}).catch(()=>{});else await page.screenshot({path:path.join(work,'page.png'),fullPage:true}).catch(()=>{});}}}finally{await browser?.close();try{await hardware?.close();}finally{if(service&&service.child.exitCode===null){await fs.writeFile(path.join(state,'stop'),'stop');await Promise.race([service.done,sleep(15000)]);}for(const entry of children){if(entry.child.exitCode===null)entry.child.kill();await entry.done;await fs.writeFile(path.join(work,entry.label+'.log'),entry.output);}await new Promise(resolve=>server.close(resolve));}}} diff --git a/tools/hostlink/geocaching_hardware_exchange.mjs b/tools/hostlink/geocaching_hardware_exchange.mjs index 2df0df78..7db88944 100644 --- a/tools/hostlink/geocaching_hardware_exchange.mjs +++ b/tools/hostlink/geocaching_hardware_exchange.mjs @@ -6,10 +6,12 @@ import path from 'node:path'; import {FrameDecoder,FRAME} from '../../site/device/src/hostlink.js';import {decodeManagement} from '../../site/device/src/management-protocol.js';import {decode,verifySigned,equal} from '../../site/geocaching/src/protocol.js';import {validateAuthorInfo} from '../../site/device/src/author-identity.js'; const allowed=new Set([0,1,2,3,32,33,34,35,36,37,38,64,65,66,71,72,73,74,75,76,77]),hex=value=>Buffer.from(value).toString('hex'); export class GeocachingHardwareExchange{ - constructor({port,python,fingerprint,namePrefix,resumeDraftId=null,calls=[],signedVersions=[],checkpointPath=null}){ + constructor({port,python,fingerprint,namePrefix,resumeDraftId=null,resumeBaseHash=null,calls=[],signedVersions=[],checkpointPath=null}){ if(!/^COM\d+$/.test(port)||!/^[a-f0-9]{64}$/.test(fingerprint)||!namePrefix)throw Error('Explicit port, expected author and isolated test-name prefix required'); this.fingerprint=fingerprint;this.namePrefix=namePrefix;this.calls=calls;this.signedVersions=signedVersions;this.signatures=new Set();this.confirmations=new Set();this.confirmed=new Set();this.drafts=new Set();this.transfers=new Map();this.outputs=new Map();this.completed=new Set();this.pending=null;this.queue=Promise.resolve();this.broken=null; if(resumeDraftId!==null&&(!/^[a-f0-9]{32}$/.test(resumeDraftId)||!/^Wio网页验收-[a-z0-9]+$/.test(namePrefix)))throw Error('Resume requires an explicit isolated acceptance ID and name');this.resumeDraftId=resumeDraftId; + if(resumeBaseHash!==null)assert.ok(resumeDraftId&&resumeBaseHash instanceof Uint8Array&&resumeBaseHash.length===32,'Confirmed resume requires an exact signed base'); + this.resumeBaseHash=resumeBaseHash?.slice()??null; this.checkpointPath=checkpointPath; this.child=spawn(python,[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),port],{windowsHide:true,stdio:['pipe','pipe','pipe']});this.stderr='';this.child.stderr.on('data',bytes=>{this.stderr=(this.stderr+bytes).slice(-4096);}); this.done=new Promise((resolve,reject)=>{this.child.on('error',error=>{this.broken=error;this.pending?.reject(error);this.pending=null;reject(error);});this.child.on('exit',code=>{if(this.pending){this.pending.reject(Error('Hardware serial process exited: '+code));this.pending=null;}resolve(code);});});this.done.catch(()=>{}); @@ -61,7 +63,7 @@ export class GeocachingHardwareExchange{ if(code===33){const row=this.transfers.get(hex(body[0]));row.content.set(body[2],body[1]);row.offset=Math.max(row.offset,body[1]+body[2].length);} if(code===35){const row=this.transfers.get(hex(body[0]));assert.equal(row.offset,row.total);assert.ok(equal(new Uint8Array(await crypto.subtle.digest('SHA-256',row.content)),row.hash));row.sealed=true;} if(code===66)this.drafts.add(hex(body[1])); - if(code===64&&value[3]&&hex(value[3][0])===this.resumeDraftId&&!this.drafts.has(this.resumeDraftId)){const row=value[3];assert.equal(row[3],this.namePrefix);assert.equal(row[5],310000000);assert.equal(row[6],1210000000);assert.equal(row[4],0);assert.equal(row[8][0],0);assert.ok(row[2]===null||equal(row[2],this.author.publicKey));this.drafts.add(this.resumeDraftId);} + if(code===64&&value[3]&&hex(value[3][0])===this.resumeDraftId&&!this.drafts.has(this.resumeDraftId)){const row=value[3];assert.equal(row[3],this.namePrefix);assert.equal(row[5],310000000);assert.equal(row[6],1210000000);assert.equal(row[4],0);assert.equal(row[8][0],this.resumeBaseHash?1:0);if(this.resumeBaseHash){assert.equal(row[8][1],1);assert.equal(row[8][2]&7,0);assert.ok(equal(row[7],this.resumeBaseHash));}assert.ok(row[2]===null||equal(row[2],this.author.publicKey));this.drafts.add(this.resumeDraftId);} if(code===71)this.signatures.add(hex(body[4])); if(code===75)this.confirmations.add(hex(body[0])); if([75,76].includes(code)&&value[3]===1&&value[7]===1)this.confirmed.add(hex(body[0]));