-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbinary-download.ts
More file actions
243 lines (225 loc) · 7.65 KB
/
binary-download.ts
File metadata and controls
243 lines (225 loc) · 7.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
/**
* @fileoverview Download helpers for dlx binaries — fetch tarballs from
* URLs, verify integrity, cache to disk with concurrency locking.
*
* - `downloadBinary` — high-level URL→cached-binary flow
* - `downloadBinaryFile` — low-level fetch+verify with processLock
*
* Split out of `dlx/binary.ts` for size hygiene.
*/
import process from 'node:process'
import { getArch, WIN32 } from '../constants/platform'
import { DLX_BINARY_CACHE_TTL } from '../constants/time'
import { hash } from '../crypto/hash'
import { safeDelete, safeMkdir } from '../fs/safe'
import { httpDownload } from '../http-request/download'
import { normalizePath } from '../paths/normalize'
import { processLock } from '../process/lock-instance'
import { generateCacheKey } from './cache'
import { normalizeHash } from './integrity'
import { ErrorCtor } from '../primordials/error'
import { getNodeCrypto } from '../node/crypto'
import { getNodeFs } from '../node/fs'
import { getNodePath } from '../node/path'
import {
getDlxCachePath,
isBinaryCacheValid,
writeBinaryCacheMetadata,
} from './binary-cache'
import type { DlxBinaryOptions } from './binary-types'
/**
* Download a binary from a URL with caching (without execution).
* Similar to downloadPackage from dlx/package.
*
* @returns Object containing the path to the cached binary and whether it was downloaded
*
* @example
* ```typescript
* const { binaryPath, downloaded } = await downloadBinary({
* url: 'https://example.com/tool-linux-x64',
* name: 'tool',
* })
* console.log(`Binary at: ${binaryPath}, fresh: ${downloaded}`)
* ```
*/
export async function downloadBinary(
options: Omit<DlxBinaryOptions, 'spawnOptions'>,
): Promise<{ binaryPath: string; downloaded: boolean }> {
const {
cacheTtl = DLX_BINARY_CACHE_TTL,
force = false,
hash: hashSpec,
integrity: rawIntegrity,
name,
sha256: rawSha256,
url,
} = { __proto__: null, ...options } as DlxBinaryOptions
let integrity = rawIntegrity
let sha256 = rawSha256
if (hashSpec !== undefined) {
const normalized = normalizeHash(hashSpec)
if (normalized.type === 'integrity') {
integrity = normalized.value
} else {
sha256 = normalized.value
}
}
const fs = getNodeFs()
const path = getNodePath()
// Generate cache paths similar to pnpm/npx structure.
const cacheDir = getDlxCachePath()
const binaryName = name || `binary-${process.platform}-${getArch()}`
// Create spec from URL and binary name for unique cache identity.
const spec = `${url}:${binaryName}`
const cacheKey = generateCacheKey(spec)
const cacheEntryDir = path.join(cacheDir, cacheKey)
const binaryPath = normalizePath(path.join(cacheEntryDir, binaryName))
let downloaded = false
// Check if we need to download.
if (
!force &&
fs.existsSync(cacheEntryDir) &&
(await isBinaryCacheValid(cacheEntryDir, cacheTtl))
) {
// Binary is cached and valid.
downloaded = false
} else {
// Ensure cache directory exists before downloading.
try {
await safeMkdir(cacheEntryDir)
} catch (e) {
const code = (e as NodeJS.ErrnoException).code
if (code === 'EACCES' || code === 'EPERM') {
throw new ErrorCtor(
`Permission denied creating binary cache directory: ${cacheEntryDir}\n` +
'Please check directory permissions or run with appropriate access.',
{ cause: e },
)
}
if (code === 'EROFS') {
throw new ErrorCtor(
`Cannot create binary cache directory on read-only filesystem: ${cacheEntryDir}\n` +
'Ensure the filesystem is writable or set SOCKET_DLX_DIR to a writable location.',
{ cause: e },
)
}
throw new ErrorCtor(
`Failed to create binary cache directory: ${cacheEntryDir}`,
{ cause: e },
)
}
// Download the binary.
const computedIntegrity = await downloadBinaryFile(
url,
binaryPath,
integrity,
sha256,
)
// Get file size for metadata (intentional: need stats.size, not just existence).
// oxlint-disable-next-line socket/prefer-exists-sync
const stats = await fs.promises.stat(binaryPath)
await writeBinaryCacheMetadata(
cacheEntryDir,
cacheKey,
url,
computedIntegrity || '',
stats.size,
)
downloaded = true
}
return {
binaryPath,
downloaded,
}
}
/**
* Download a file from a URL with integrity checking and concurrent download protection.
* Uses processLock to prevent multiple processes from downloading the same binary simultaneously.
*
* Supports two integrity verification methods:
* - sha256: Hex SHA-256 checksum (verified inline during download via httpDownload)
* - integrity: SRI format sha512-<base64> (verified post-download)
*
* The sha256 option is preferred as it fails early during download if the checksum doesn't match.
*
* @example
* ```typescript
* const integrity = await downloadBinaryFile(
* 'https://example.com/tool-linux-x64',
* '/tmp/dlx-cache/tool'
* )
* console.log(`Integrity: ${integrity}`)
* ```
*/
export async function downloadBinaryFile(
url: string,
destPath: string,
integrity?: string | undefined,
sha256?: string | undefined,
): Promise<string> {
// Use process lock to prevent concurrent downloads.
// Lock is placed in the cache entry directory as 'concurrency.lock'.
const crypto = getNodeCrypto()
const fs = getNodeFs()
const path = getNodePath()
const cacheEntryDir = path.dirname(destPath)
const lockPath = path.join(cacheEntryDir, 'concurrency.lock')
return await processLock.withLock(
lockPath,
async () => {
// Check if file was downloaded while waiting for lock.
if (fs.existsSync(destPath)) {
// Need stats.size to validate file is non-empty before reuse.
// oxlint-disable-next-line socket/prefer-exists-sync
const stats = await fs.promises.stat(destPath)
if (stats.size > 0) {
// File exists, compute and return SRI integrity hash.
const fileBuffer = await fs.promises.readFile(destPath)
return `sha512-${hash('sha512', fileBuffer, 'base64')}`
}
}
// Download the file with optional SHA-256 verification.
// The sha256 option enables inline verification during download,
// which is more secure as it fails early if the checksum doesn't match.
try {
await httpDownload(url, destPath, sha256 ? { sha256 } : undefined)
} catch (e) {
throw new ErrorCtor(
`Failed to download binary from ${url}\n` +
`Destination: ${destPath}\n` +
'Check your internet connection or verify the URL is accessible.',
{ cause: e },
)
}
// Compute SRI integrity hash of downloaded file.
const fileBuffer = await fs.promises.readFile(destPath)
const actualIntegrity = `sha512-${hash('sha512', fileBuffer, 'base64')}`
// Verify integrity if provided (constant-time comparison).
if (integrity) {
const integrityMatch =
actualIntegrity.length === integrity.length &&
crypto.timingSafeEqual(
Buffer.from(actualIntegrity),
Buffer.from(integrity),
)
if (!integrityMatch) {
// Clean up invalid file.
await safeDelete(destPath)
throw new ErrorCtor(
`Integrity mismatch: expected ${integrity}, got ${actualIntegrity}`,
)
}
}
// Make executable on POSIX systems.
if (!WIN32) {
await fs.promises.chmod(destPath, 0o755)
}
return actualIntegrity
},
{
// Align with npm npx locking strategy.
staleMs: 5000,
touchIntervalMs: 2000,
},
)
}