rollback-ui.mjs 4.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. import {FILES} from './publish-industry-ui.mjs';
  6. const RELEASE='ksf-board-mobile-filters-20261108-r14';
  7. const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
  8. const fail=code=>{throw Object.assign(Error(code),{code});};
  9. function safe(root,name){
  10. const dest=path.resolve(root,name);if(!dest.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET');
  11. 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');}
  12. return dest;
  13. }
  14. 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);}
  15. function digest(file){const bytes=read(file);return bytes===null?null:sha(bytes);}
  16. function restore(file,bytes){
  17. const temp=file+'.ksf-rollback-'+crypto.randomUUID();let fd;
  18. 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);}
  19. finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);}
  20. }
  21. export function rollbackFiles({targetDir,backupDir,apply=false}){
  22. for(const dir of [targetDir,backupDir])if(!path.isAbsolute(dir)||fs.realpathSync(dir)!==path.resolve(dir)||dir===path.parse(dir).root)fail('UNSAFE_DIRECTORY');
  23. targetDir=path.resolve(targetDir);backupDir=path.resolve(backupDir);
  24. if(backupDir===targetDir||backupDir.startsWith(targetDir+path.sep))fail('BACKUP_INSIDE_TARGET');
  25. const journal=JSON.parse(read(safe(backupDir,'journal.json')));
  26. if(journal.release!==RELEASE||journal.targetDir!==targetDir||!Array.isArray(journal.files)||!['verified','stopped'].includes(journal.status))fail('BAD_JOURNAL');
  27. 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');
  28. const index=safe(targetDir,'index.html');if(digest(index)!==journal.homepageSha256)fail('HOMEPAGE_MISMATCH');
  29. const plans=journal.files.map(f=>{
  30. const file=safe(targetDir,f.path),current=digest(file),bytes=f.beforeSha256===null?null:read(safe(backupDir,f.path));
  31. if(f.beforeSha256!==null&&(bytes===null||sha(bytes)!==f.beforeSha256))fail('BACKUP_HASH_MISMATCH');
  32. if(current!==f.afterSha256&&current!==f.beforeSha256)fail('CONCURRENT_CHANGE');
  33. return {...f,file,current,bytes,change:current!==f.beforeSha256};
  34. });
  35. const changes=plans.filter(p=>p.change);
  36. 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};
  37. if(!apply||!changes.length)return {...result,filesystemVerified:apply};
  38. const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-industry-ui.lock');
  39. const fd=fs.openSync(lock,'wx',0o600);const done=[];
  40. try{
  41. for(const p of changes){
  42. safe(targetDir,p.path);if(digest(index)!==journal.homepageSha256||digest(p.file)!==p.current)fail('CONCURRENT_CHANGE');
  43. if(p.bytes===null)fs.unlinkSync(p.file);else restore(p.file,p.bytes);
  44. done.push(p.path);
  45. }
  46. for(const p of plans)if(digest(p.file)!==p.beforeSha256)fail('ROLLBACK_VERIFY_FAILED');
  47. if(digest(index)!==journal.homepageSha256)fail('HOMEPAGE_MISMATCH');
  48. const receipt={...result,at:new Date().toISOString(),filesystemVerified:true,restored:done};
  49. fs.writeFileSync(path.join(backupDir,'rollback.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600});return receipt;
  50. }catch(e){e.restored=done;throw e;}
  51. finally{fs.closeSync(fd);fs.unlinkSync(lock);}
  52. }
  53. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  54. try{
  55. let targetDir,backupDir,apply=false;const args=process.argv.slice(2);
  56. for(let i=0;i<args.length;i++){if(args[i]==='--target-dir'&&!targetDir)targetDir=args[++i];else if(args[i]==='--backup-dir'&&!backupDir)backupDir=args[++i];else if(args[i]==='--apply'&&!apply)apply=true;else fail('USAGE');}
  57. if(!targetDir||!backupDir)fail('USAGE');console.log(JSON.stringify(rollbackFiles({targetDir,backupDir,apply}),null,2));
  58. }catch(e){console.error(JSON.stringify({ok:false,error:{code:e.code||'ROLLBACK_STOPPED'},restored:e.restored||[]},null,2));process.exitCode=1;}
  59. }