Skip to content

Commit 94cb8bd

Browse files
feat: add file hashes to asar header (#221)
* feat: add file hashes to asar header * feat: add getRawHeader method to public API * chore: fix lint * chore: update docs * refactor: use integrity instead of hash pairs * feat: add block hashes * fix: ensure executables are extracted with executable permission * fix: ensure symlinks are not deeply resolved when packaging * chore: update test files * chore: remove DS_Store * perf: generate block hashes as we parse the stream * docs: update README with new options * revert * chore: update per feedback
1 parent 6fb376b commit 94cb8bd

17 files changed

+137
-4
lines changed

README.md

+27-3
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,24 @@ Structure of `header` is something like this:
153153
"ls": {
154154
"offset": "0",
155155
"size": 100,
156-
"executable": true
156+
"executable": true,
157+
"integrity": {
158+
"algorithm": "SHA256",
159+
"hash": "...",
160+
"blockSize": 1024,
161+
"blocks": ["...", "..."]
162+
}
157163
},
158164
"cd": {
159165
"offset": "100",
160166
"size": 100,
161-
"executable": true
167+
"executable": true,
168+
"integrity": {
169+
"algorithm": "SHA256",
170+
"hash": "...",
171+
"blockSize": 1024,
172+
"blocks": ["...", "..."]
173+
}
162174
}
163175
}
164176
}
@@ -168,7 +180,13 @@ Structure of `header` is something like this:
168180
"files": {
169181
"hosts": {
170182
"offset": "200",
171-
"size": 32
183+
"size": 32,
184+
"integrity": {
185+
"algorithm": "SHA256",
186+
"hash": "...",
187+
"blockSize": 1024,
188+
"blocks": ["...", "..."]
189+
}
172190
}
173191
}
174192
}
@@ -187,6 +205,12 @@ precisely represent UINT64 in JavaScript `Number`. `size` is a JavaScript
187205
because file size in Node.js is represented as `Number` and it is not safe to
188206
convert `Number` to UINT64.
189207

208+
`integrity` is an object consisting of a few keys:
209+
* A hashing `algorithm`, currently only `SHA256` is supported.
210+
* A hex encoded `hash` value representing the hash of the entire file.
211+
* An array of hex encoded hashes for the `blocks` of the file. i.e. for a blockSize of 4KB this array contains the hash of every block if you split the file into N 4KB blocks.
212+
* A integer value `blockSize` representing the size in bytes of each block in the `blocks` hashes above
213+
190214
[pickle]: https://chromium.googlesource.com/chromium/src/+/master/base/pickle.h
191215
[node-pickle]: https://www.npmjs.org/package/chromium-pickle
192216
[grunt-asar]: https://github.com/bwin/grunt-asar

lib/asar.js

+7
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ module.exports.statFile = function (archive, filename, followLinks) {
157157
return filesystem.getFile(filename, followLinks)
158158
}
159159

160+
module.exports.getRawHeader = function (archive) {
161+
return disk.readArchiveHeaderSync(archive)
162+
}
163+
160164
module.exports.listPackage = function (archive, options) {
161165
return disk.readFilesystemSync(archive).listFiles(options)
162166
}
@@ -199,6 +203,9 @@ module.exports.extractAll = function (archive, dest) {
199203
// it's a file, extract it
200204
const content = disk.readFileSync(filesystem, filename, file)
201205
fs.writeFileSync(destFilename, content)
206+
if (file.executable) {
207+
fs.chmodSync(destFilename, '755')
208+
}
202209
}
203210
}
204211
}

lib/crawlfs.js

+10
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,21 @@ module.exports = async function (dir, options) {
2020
const metadata = {}
2121
const crawled = await glob(dir, options)
2222
const results = await Promise.all(crawled.map(async filename => [filename, await determineFileType(filename)]))
23+
const links = []
2324
const filenames = results.map(([filename, type]) => {
2425
if (type) {
2526
metadata[filename] = type
27+
if (type.type === 'link') links.push(filename)
2628
}
2729
return filename
30+
}).filter((filename) => {
31+
// Newer glob can return files inside symlinked directories, to avoid
32+
// those appearing in archives we need to manually exclude theme here
33+
const exactLinkIndex = links.findIndex(link => filename === link)
34+
return links.every((link, index) => {
35+
if (index === exactLinkIndex) return true
36+
return !filename.startsWith(link)
37+
})
2838
})
2939
return [filenames, metadata]
3040
}

lib/disk.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ module.exports.readArchiveHeaderSync = function (archive) {
7676

7777
const headerPickle = pickle.createFromBuffer(headerBuf)
7878
const header = headerPickle.createIterator().readString()
79-
return { header: JSON.parse(header), headerSize: size }
79+
return { headerString: header, header: JSON.parse(header), headerSize: size }
8080
}
8181

8282
module.exports.readFilesystemSync = function (archive) {

lib/filesystem.js

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const os = require('os')
55
const path = require('path')
66
const { promisify } = require('util')
77
const stream = require('stream')
8+
const getFileIntegrity = require('./integrity')
89

910
const UINT32_MAX = 2 ** 32 - 1
1011

@@ -57,6 +58,7 @@ class Filesystem {
5758
if (shouldUnpack || dirNode.unpacked) {
5859
node.size = file.stat.size
5960
node.unpacked = true
61+
node.integrity = await getFileIntegrity(p)
6062
return Promise.resolve()
6163
}
6264

@@ -86,6 +88,7 @@ class Filesystem {
8688

8789
node.size = size
8890
node.offset = this.offset.toString()
91+
node.integrity = await getFileIntegrity(p)
8992
if (process.platform !== 'win32' && (file.stat.mode & 0o100)) {
9093
node.executable = true
9194
}

lib/index.d.ts

+24
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ export type InputMetadata = {
4444
}
4545
};
4646

47+
export type DirectoryRecord = {
48+
files: Record<string, DirectoryRecord | FileRecord>;
49+
};
50+
51+
export type FileRecord = {
52+
offset: string;
53+
size: number;
54+
executable?: boolean;
55+
integrity: {
56+
hash: string;
57+
algorithm: 'SHA256';
58+
blocks: string[];
59+
blockSize: number;
60+
};
61+
}
62+
63+
export type ArchiveHeader = {
64+
// The JSON parsed header string
65+
header: DirectoryRecord;
66+
headerString: string;
67+
headerSize: number;
68+
}
69+
4770
export function createPackage(src: string, dest: string): Promise<void>;
4871
export function createPackageWithOptions(
4972
src: string,
@@ -59,6 +82,7 @@ export function createPackageFromFiles(
5982
): Promise<void>;
6083

6184
export function statFile(archive: string, filename: string, followLinks?: boolean): Metadata;
85+
export function getRawHeader(archive: string): ArchiveHeader;
6286
export function listPackage(archive: string, options?: ListOptions): string[];
6387
export function extractFile(archive: string, filename: string): Buffer;
6488
export function extractAll(archive: string, dest: string): void;

lib/integrity.js

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const crypto = require('crypto')
2+
const fs = require('fs')
3+
const stream = require('stream')
4+
const { promisify } = require('util')
5+
6+
const ALGORITHM = 'SHA256'
7+
// 4MB default block size
8+
const BLOCK_SIZE = 4 * 1024 * 1024
9+
10+
const pipeline = promisify(stream.pipeline)
11+
12+
function hashBlock (block) {
13+
return crypto.createHash(ALGORITHM).update(block).digest('hex')
14+
}
15+
16+
async function getFileIntegrity (path) {
17+
const fileHash = crypto.createHash(ALGORITHM)
18+
19+
const blocks = []
20+
let currentBlockSize = 0
21+
let currentBlock = []
22+
23+
await pipeline(
24+
fs.createReadStream(path),
25+
new stream.PassThrough({
26+
decodeStrings: false,
27+
transform (_chunk, encoding, callback) {
28+
fileHash.update(_chunk)
29+
30+
function handleChunk (chunk) {
31+
const diffToSlice = Math.min(BLOCK_SIZE - currentBlockSize, chunk.byteLength)
32+
currentBlockSize += diffToSlice
33+
currentBlock.push(chunk.slice(0, diffToSlice))
34+
if (currentBlockSize === BLOCK_SIZE) {
35+
blocks.push(hashBlock(Buffer.concat(currentBlock)))
36+
currentBlock = []
37+
currentBlockSize = 0
38+
}
39+
if (diffToSlice < chunk.byteLength) {
40+
handleChunk(chunk.slice(diffToSlice))
41+
}
42+
}
43+
handleChunk(_chunk)
44+
callback()
45+
},
46+
flush (callback) {
47+
blocks.push(hashBlock(Buffer.concat(currentBlock)))
48+
currentBlock = []
49+
callback()
50+
}
51+
})
52+
)
53+
54+
return {
55+
algorithm: ALGORITHM,
56+
hash: fileHash.digest('hex'),
57+
blockSize: BLOCK_SIZE,
58+
blocks: blocks
59+
}
60+
}
61+
62+
module.exports = getFileIntegrity
1.21 KB
Binary file not shown.
1.21 KB
Binary file not shown.
412 Bytes
Binary file not shown.
1.21 KB
Binary file not shown.
1.21 KB
Binary file not shown.
1.01 KB
Binary file not shown.

test/expected/packthis-unpack.asar

1.01 KB
Binary file not shown.
1.01 KB
Binary file not shown.

test/expected/packthis.asar

1.21 KB
Binary file not shown.

test/util/compareFiles.js

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ const assert = require('assert')
44
const fs = require('../../lib/wrapped-fs')
55

66
module.exports = async function (actualFilePath, expectedFilePath) {
7+
if (process.env.ELECTRON_ASAR_SPEC_UPDATE) {
8+
await fs.writeFile(expectedFilePath, await fs.readFile(actualFilePath))
9+
}
710
const [actual, expected] = await Promise.all([fs.readFile(actualFilePath, 'utf8'), fs.readFile(expectedFilePath, 'utf8')])
811
assert.strictEqual(actual, expected)
912
}

0 commit comments

Comments
 (0)