publish-board-local.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  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. const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  6. const manifestFile=path.join(root,'deploy/board-local-20261111.json');
  7. const releaseManifest=JSON.parse(fs.readFileSync(manifestFile,'utf8'));
  8. export const FILES=Object.freeze(releaseManifest.files.map(f=>f.path));
  9. const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
  10. const normalized=bytes=>sha(bytes.includes(0)?bytes:Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
  11. const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
  12. const plain=file=>{const s=fs.lstatSync(file);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE',file);return s;};
  13. function safePath(root,relative){
  14. const target=path.resolve(root,relative);if(!target.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET');
  15. let current=root;
  16. for(const part of relative.split('/')){
  17. current=path.join(current,part);
  18. if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())fail('SYMLINK_PATH',relative);
  19. }
  20. return target;
  21. }
  22. function unchanged(file,before){
  23. if(before===null){if(fs.existsSync(file))fail('CONCURRENT_CHANGE');}
  24. else if(!fs.existsSync(file)||sha(fs.readFileSync(file))!==before)fail('CONCURRENT_CHANGE');
  25. }
  26. function atomicWrite(file,bytes,mode){
  27. const temp=file+'.ksf-update-'+crypto.randomUUID();let fd;
  28. try{fd=fs.openSync(temp,'wx',mode);fs.fchmodSync(fd,mode);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temp,file);}
  29. finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);}
  30. }
  31. /** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
  32. export function publishFiles({sourceDir,manifestPath,targetDir,apply=false,releaseDefinition=releaseManifest},dependencies={}){
  33. if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
  34. sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
  35. if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
  36. if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
  37. const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
  38. if(manifest.version!=='ksf.board-local.publish.v1'||manifest.release!==releaseDefinition.release||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
  39. const expectedFiles=releaseDefinition.files.map(f=>f.path);
  40. if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...expectedFiles].sort())||new Set(expectedFiles).size!==expectedFiles.length||expectedFiles.some(p=>!(/^(?:src\/(?!dev\/)[a-zA-Z0-9_./-]+\.(?:js|woff2|json)|lib\/vendor\/[a-zA-Z0-9_@./-]+\.js|skills\/ksf-workshop-runtime\/scripts\/report-contract\.mjs)$/.test(p))))fail('UNEXPECTED_FILE_LIST');
  41. const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
  42. if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
  43. const byName=new Map(manifest.files.map(f=>[f.path,f]));
  44. const entries=expectedFiles.map(relative=>{
  45. const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
  46. if(!/^[a-f0-9]{64}$/.test(def.sha256)||!Array.isArray(def.priorNormalizedSha256)||def.priorNormalizedSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('BAD_MANIFEST');
  47. const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
  48. const stat=fs.existsSync(target)?plain(target):null,before=stat?fs.readFileSync(target):null;
  49. const same=before!==null&&normalized(before)===normalized(bytes);
  50. if(before&&!same&&!def.priorNormalizedSha256.includes(normalized(before)))fail('UNKNOWN_TARGET_CONTENT',relative);
  51. if(!stat&&!def.allowMissing)fail('BASELINE_FILE_MISSING',relative);
  52. return{relative,target,bytes,before,beforeHash:before===null?null:sha(before),mode:0o644,same};
  53. });
  54. const modeRepairs=entries.filter(e=>e.same&&fs.existsSync(e.target)&&(fs.statSync(e.target).mode&0o777)!==0o644);
  55. const changes=entries.filter(e=>!e.same);
  56. const base={operation:apply?'filesystem-update':'read-only-plan',readOnly:!apply,targetDir,release:manifest.release,filesChecked:expectedFiles.length,changes:changes.map(e=>e.relative),unchanged:entries.filter(e=>e.same).map(e=>e.relative),homepageSha256:indexHash,homepageModified:false,networkRequests:0,cdnCacheRefreshed:false,publicSiteVerified:false};
  57. if(!apply||!changes.length){if(apply)for(const e of modeRepairs)fs.chmodSync(e.target,0o644);return{ok:true,...base,modeRepairs:modeRepairs.map(e=>e.relative),backup:null,filesystemVerified:apply};}
  58. const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-industry-ui.lock');
  59. const lockFd=fs.openSync(lock,'wx',0o600);let backup=null;const done=[],createdDirs=[];
  60. try{
  61. backup=fs.mkdtempSync(path.join(path.dirname(targetDir),'ksf-industry-ui-backup-'));
  62. const journal={at:new Date().toISOString(),targetDir,release:manifest.release,homepageSha256:indexHash,status:'prepared',files:changes.map(e=>({path:e.relative,beforeSha256:e.beforeHash,afterSha256:sha(e.bytes)}))};
  63. const writeJournal=()=>fs.writeFileSync(path.join(backup,'journal.json'),JSON.stringify(journal,null,2),{mode:0o600});
  64. // Save every prior file before replacing the first one.
  65. for(const e of changes)if(e.before!==null){const p=path.join(backup,e.relative);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,e.before,{flag:'wx',mode:0o600});}
  66. writeJournal();
  67. for(const e of changes){
  68. unchanged(index,indexHash);safePath(targetDir,e.relative);unchanged(e.target,e.beforeHash);
  69. const absent=[];let parent=path.dirname(e.target);while(!fs.existsSync(parent)){absent.push(parent);parent=path.dirname(parent);}
  70. for(const dir of absent.reverse()){fs.mkdirSync(dir);createdDirs.push(dir);}
  71. dependencies.beforeWrite?.(e.relative);
  72. atomicWrite(e.target,e.bytes,e.mode);done.push(e);
  73. }
  74. for(const e of modeRepairs)fs.chmodSync(e.target,0o644);
  75. for(const e of entries){plain(e.target);if(normalized(fs.readFileSync(e.target))!==normalized(e.bytes))fail('TARGET_VERIFY_FAILED',e.relative);}
  76. unchanged(index,indexHash);journal.status='verified';writeJournal();
  77. return{ok:true,...base,backup,filesystemVerified:true,publicVerificationRequired:true};
  78. }catch(error){
  79. let rollbackComplete=true;
  80. for(const e of [...done].reverse())try{
  81. // Never overwrite a concurrent publisher's version while rolling back.
  82. unchanged(e.target,sha(e.bytes));
  83. if(e.before===null)fs.unlinkSync(e.target);else atomicWrite(e.target,e.before,e.mode);
  84. }catch{rollbackComplete=false;}
  85. for(const dir of [...createdDirs].reverse())try{fs.rmdirSync(dir);}catch{}
  86. if(backup)try{const p=path.join(backup,'journal.json');const j=JSON.parse(fs.readFileSync(p));fs.writeFileSync(p,JSON.stringify({...j,status:'stopped',rollbackComplete},null,2));}catch{}
  87. error.backup=backup;error.rollbackComplete=rollbackComplete;throw error;
  88. }finally{fs.closeSync(lockFd);fs.unlinkSync(lock);}
  89. }
  90. const self=fileURLToPath(import.meta.url);
  91. if(process.argv[1]&&path.resolve(process.argv[1])===self){
  92. const stamp={invocationId:crypto.randomUUID(),at:new Date().toISOString()};
  93. try{
  94. const argv=process.argv.slice(2);let targetDir,apply=false;
  95. for(let i=0;i<argv.length;i++){if(argv[i]==='--apply'&&!apply)apply=true;else if(argv[i]==='--target-dir'&&!targetDir&&argv[i+1]&&!argv[i+1].startsWith('--'))targetDir=argv[++i];else fail('USAGE');}
  96. if(!targetDir)fail('USAGE');const root=path.resolve(path.dirname(self),'..');
  97. console.log(JSON.stringify({...stamp,...publishFiles({sourceDir:root,manifestPath:manifestFile,targetDir,apply})},null,2));
  98. }catch(e){console.error(JSON.stringify({...stamp,ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'PUBLISH_STOPPED',...(FILES.includes(e.file)||e.file==='index.html'?{file:e.file}:{}),message:'发布停止;核对源站目录、首页和白名单文件,不自动修改校验基准。'},...(e.backup?{backup:e.backup,rollbackComplete:e.rollbackComplete}:{})},null,2));process.exitCode=1;}
  99. }