99 * 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
1010 * 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
1111 * legacy fallback to skill.tar.br when the entry has no valid object field
12- * 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
12+ * 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
13+ * then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
1314 * 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
1415 * 5. Write ~/.bailian/wiki-sync-state.json
1516 * 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
2021 * - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
2122 * - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
2223 */
24+ import { createHash } from "node:crypto" ;
2325import {
2426 createWriteStream ,
2527 existsSync ,
2628 mkdirSync ,
29+ readdirSync ,
2730 readFileSync ,
2831 renameSync ,
2932 rmSync ,
@@ -106,6 +109,9 @@ async function downloadBuffer(url) {
106109
107110/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
108111function isSafeEntryName ( name ) {
112+ // Symmetric with core skills/extract.ts: backslashes can escape the extraction
113+ // dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
114+ if ( name . includes ( "\\" ) || name . includes ( "\0" ) ) return false ;
109115 if ( name . startsWith ( "/" ) || / ^ [ a - z A - Z ] : [ \\ / ] / . test ( name ) ) return false ;
110116 return ! name . split ( "/" ) . includes ( ".." ) ;
111117}
@@ -140,6 +146,30 @@ async function extractTarBr(tarBrBuffer, destDir) {
140146 await pipeline ( Readable . from ( tarBrBuffer ) , createBrotliDecompress ( ) , extract ) ;
141147}
142148
149+ /**
150+ * Recompute the publisher's deterministic content hash over an extracted directory
151+ * (same accumulation as core skills/extract.ts computeDirContentHash): regular files
152+ * sorted by "/"-separated relative path, sha256 over relPath + bytes.
153+ */
154+ function computeDirContentHash ( dir ) {
155+ const relPaths = [ ] ;
156+ const walk = ( sub ) => {
157+ for ( const dirent of readdirSync ( sub ? join ( dir , sub ) : dir , { withFileTypes : true } ) ) {
158+ const rel = sub ? `${ sub } /${ dirent . name } ` : dirent . name ;
159+ if ( dirent . isDirectory ( ) ) walk ( rel ) ;
160+ else if ( dirent . isFile ( ) ) relPaths . push ( rel ) ;
161+ }
162+ } ;
163+ walk ( "" ) ;
164+ relPaths . sort ( ( left , right ) => ( left < right ? - 1 : left > right ? 1 : 0 ) ) ;
165+ const hash = createHash ( "sha256" ) ;
166+ for ( const rel of relPaths ) {
167+ hash . update ( rel ) ;
168+ hash . update ( readFileSync ( join ( dir , rel ) ) ) ;
169+ }
170+ return `sha256:${ hash . digest ( "hex" ) } ` ;
171+ }
172+
143173/** Atomic swap: tmpDir (same volume) → catalogDir. */
144174function atomicSwap ( tmpDir , catalogDir ) {
145175 mkdirSync ( dirname ( catalogDir ) , { recursive : true } ) ;
@@ -166,12 +196,22 @@ async function main() {
166196 entry . object && OBJECT_FILE_RE . test ( entry . object ) ? entry . object : LEGACY_ASSET_NAME ;
167197 const tarBuf = await downloadBuffer ( `${ REGISTRY_BASE_URL } /${ WIKI_SKILL_NAME } /${ assetName } ` ) ;
168198
169- // 3. Extract to same-volume temp dir + atomic swap
199+ // 3. Extract to same-volume temp dir + integrity check + atomic swap
170200 const catalogDir = getCatalogDir ( ) ;
171201 const tmpDir = `${ catalogDir } .tmp-${ process . pid } -${ Date . now ( ) } ` ;
172202 try {
173203 mkdirSync ( tmpDir , { recursive : true } ) ;
174204 await extractTarBr ( tarBuf , tmpDir ) ;
205+ // Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
206+ // before touching the canonical dir
207+ if ( entry . contentHash . startsWith ( "sha256:" ) ) {
208+ const actualContentHash = computeDirContentHash ( tmpDir ) ;
209+ if ( actualContentHash !== entry . contentHash ) {
210+ throw new Error (
211+ `content hash mismatch: index says ${ entry . contentHash } , archive is ${ actualContentHash } ` ,
212+ ) ;
213+ }
214+ }
175215 atomicSwap ( tmpDir , catalogDir ) ;
176216 } catch ( err ) {
177217 if ( existsSync ( tmpDir ) ) rmSync ( tmpDir , { recursive : true , force : true } ) ;
0 commit comments