import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import {fileURLToPath} from 'node:url'; import {FILES} from './publish-industry-ui.mjs'; const RELEASE='ksf-board-mobile-filters-20261108-r14'; const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex'); const fail=code=>{throw Object.assign(Error(code),{code});}; function safe(root,name){ const dest=path.resolve(root,name);if(!dest.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET'); let current=root;for(const part of name.split('/')){current=path.join(current,part);if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())fail('SYMLINK_PATH');} return dest; } function read(file){if(!fs.existsSync(file))return null;const s=fs.lstatSync(file);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE');return fs.readFileSync(file);} function digest(file){const bytes=read(file);return bytes===null?null:sha(bytes);} function restore(file,bytes){ const temp=file+'.ksf-rollback-'+crypto.randomUUID();let fd; try{fd=fs.openSync(temp,'wx',0o644);fs.fchmodSync(fd,0o644);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temp,file);} finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);} } export function rollbackFiles({targetDir,backupDir,apply=false}){ for(const dir of [targetDir,backupDir])if(!path.isAbsolute(dir)||fs.realpathSync(dir)!==path.resolve(dir)||dir===path.parse(dir).root)fail('UNSAFE_DIRECTORY'); targetDir=path.resolve(targetDir);backupDir=path.resolve(backupDir); if(backupDir===targetDir||backupDir.startsWith(targetDir+path.sep))fail('BACKUP_INSIDE_TARGET'); const journal=JSON.parse(read(safe(backupDir,'journal.json'))); if(journal.release!==RELEASE||journal.targetDir!==targetDir||!Array.isArray(journal.files)||!['verified','stopped'].includes(journal.status))fail('BAD_JOURNAL'); if(new Set(journal.files.map(f=>f.path)).size!==journal.files.length||journal.files.some(f=>!FILES.includes(f.path)||!/^[a-f0-9]{64}$/.test(f.afterSha256)||f.beforeSha256!==null&&!/^[a-f0-9]{64}$/.test(f.beforeSha256)))fail('BAD_FILE_LIST'); const index=safe(targetDir,'index.html');if(digest(index)!==journal.homepageSha256)fail('HOMEPAGE_MISMATCH'); const plans=journal.files.map(f=>{ const file=safe(targetDir,f.path),current=digest(file),bytes=f.beforeSha256===null?null:read(safe(backupDir,f.path)); if(f.beforeSha256!==null&&(bytes===null||sha(bytes)!==f.beforeSha256))fail('BACKUP_HASH_MISMATCH'); if(current!==f.afterSha256&¤t!==f.beforeSha256)fail('CONCURRENT_CHANGE'); return {...f,file,current,bytes,change:current!==f.beforeSha256}; }); const changes=plans.filter(p=>p.change); const result={ok:true,release:RELEASE,operation:apply?'filesystem-rollback':'read-only-rollback-plan',readOnly:!apply,targetDir,backupDir,changes:changes.map(p=>p.path),homepageModified:false,networkRequests:0}; if(!apply||!changes.length)return {...result,filesystemVerified:apply}; const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-industry-ui.lock'); const fd=fs.openSync(lock,'wx',0o600);const done=[]; try{ for(const p of changes){ safe(targetDir,p.path);if(digest(index)!==journal.homepageSha256||digest(p.file)!==p.current)fail('CONCURRENT_CHANGE'); if(p.bytes===null)fs.unlinkSync(p.file);else restore(p.file,p.bytes); done.push(p.path); } for(const p of plans)if(digest(p.file)!==p.beforeSha256)fail('ROLLBACK_VERIFY_FAILED'); if(digest(index)!==journal.homepageSha256)fail('HOMEPAGE_MISMATCH'); const receipt={...result,at:new Date().toISOString(),filesystemVerified:true,restored:done}; fs.writeFileSync(path.join(backupDir,'rollback.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600});return receipt; }catch(e){e.restored=done;throw e;} finally{fs.closeSync(fd);fs.unlinkSync(lock);} } if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ try{ let targetDir,backupDir,apply=false;const args=process.argv.slice(2); for(let i=0;i