Skip to content

Commit 6408f0a

Browse files
committed
test(registry): add integration tests for tarball extraction
Add integration-lite tests that validate real tarball extraction using tar-stream to generate test data. These tests verify the extraction logic works with realistic tar.gz structures without requiring network access or committed fixtures. New dependency: - tar-stream@3.1.7 (dev, pinned) - For generating test tarballs Test coverage (12 tests): - extractBinaryFromTarball (5 tests) - Extract binary with correct permissions - Handle package/ prefix correctly - Preserve executable bit (0o755) - Handle nested directories - Throw error if binary not found - extractTarball (5 tests) - Extract all files from tarball - Sanitize paths to prevent directory traversal - Set correct file permissions (0o644, 0o755) - Create parent directories - Throw error for empty tarball - verifyTarballIntegrity (2 tests) - Verify real tarball with correct SHA-512 - Fail verification with incorrect hash Test approach: - Uses tar-stream to generate real tar.gz files in memory - Tests real extraction logic with realistic tarball structures - No network dependencies, no mocks - Fast execution (<200ms for all 12 tests) - Validates permissions, path sanitization, error handling These tests complement unit tests by validating the integration of multiple components working together with real tar file formats.
1 parent 2fdd5f3 commit 6408f0a

File tree

2 files changed

+379
-1
lines changed

2 files changed

+379
-1
lines changed

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@
8080
"del": "8.0.1",
8181
"ink-text-input": "6.0.0",
8282
"rollup": "4.50.1",
83-
"rollup-plugin-visualizer": "6.0.5"
83+
"rollup-plugin-visualizer": "6.0.5",
84+
"tar-stream": "3.1.7"
8485
},
8586
"engines": {
8687
"node": ">=18",
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
/**
2+
* Integration-lite tests for npm registry utilities.
3+
*
4+
* These tests use real tarball files (generated via tar-stream) to test
5+
* extraction logic with realistic tar.gz structures. No network, no mocks,
6+
* just real tarball parsing and filesystem operations.
7+
*/
8+
9+
import { promises as fs } from 'node:fs'
10+
import os from 'node:os'
11+
import path from 'node:path'
12+
import { createGzip } from 'node:zlib'
13+
14+
import { pack as tarPack } from 'tar-stream'
15+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
16+
17+
import {
18+
extractBinaryFromTarball,
19+
extractTarball,
20+
verifyTarballIntegrity,
21+
} from './npm-registry.mts'
22+
23+
/**
24+
* Helper to create a test tarball in memory.
25+
*/
26+
async function createTestTarball(
27+
files: Array<{ name: string; content: string; mode?: number }>,
28+
): Promise<Buffer> {
29+
return new Promise((resolve, reject) => {
30+
const chunks: Buffer[] = []
31+
const tarStream = tarPack()
32+
const gzipStream = createGzip()
33+
34+
gzipStream.on('data', chunk => chunks.push(chunk))
35+
gzipStream.on('end', () => resolve(Buffer.concat(chunks)))
36+
gzipStream.on('error', reject)
37+
38+
tarStream.pipe(gzipStream)
39+
40+
// Add all files to tarball.
41+
for (const file of files) {
42+
tarStream.entry(
43+
{
44+
name: file.name,
45+
mode: file.mode ?? 0o644,
46+
},
47+
file.content,
48+
)
49+
}
50+
51+
tarStream.finalize()
52+
})
53+
}
54+
55+
describe('integration-lite: tarball extraction', () => {
56+
let tempDir: string
57+
58+
beforeEach(async () => {
59+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'npm-registry-integration-'))
60+
})
61+
62+
afterEach(async () => {
63+
await fs.rm(tempDir, { recursive: true, force: true })
64+
})
65+
66+
describe('extractBinaryFromTarball', () => {
67+
it('should extract binary with correct permissions', async () => {
68+
// Create a tarball with an executable binary.
69+
const tarball = await createTestTarball([
70+
{
71+
name: 'package/package.json',
72+
content: JSON.stringify({ name: 'test', version: '1.0.0' }),
73+
mode: 0o644,
74+
},
75+
{
76+
name: 'package/bin/socket',
77+
content: '#!/usr/bin/env node\nconsole.log("test")',
78+
mode: 0o755, // Executable.
79+
},
80+
])
81+
82+
const tarballPath = path.join(tempDir, 'test.tgz')
83+
await fs.writeFile(tarballPath, tarball)
84+
85+
const outputPath = path.join(tempDir, 'socket')
86+
const result = await extractBinaryFromTarball(
87+
tarballPath,
88+
'bin/socket',
89+
outputPath,
90+
)
91+
92+
expect(result).toBe(outputPath)
93+
expect(await fs.readFile(outputPath, 'utf8')).toContain('console.log')
94+
95+
// Check permissions (executable bit).
96+
const stats = await fs.stat(outputPath)
97+
expect(stats.mode & 0o111).toBeTruthy() // Has execute permission.
98+
})
99+
100+
it('should handle package/ prefix correctly', async () => {
101+
// npm tarballs always have package/ prefix.
102+
const tarball = await createTestTarball([
103+
{
104+
name: 'package/bin/socket',
105+
content: 'binary content',
106+
mode: 0o755,
107+
},
108+
])
109+
110+
const tarballPath = path.join(tempDir, 'test.tgz')
111+
await fs.writeFile(tarballPath, tarball)
112+
113+
const outputPath = path.join(tempDir, 'socket')
114+
const result = await extractBinaryFromTarball(
115+
tarballPath,
116+
'bin/socket', // We pass without package/ prefix.
117+
outputPath,
118+
)
119+
120+
expect(result).toBe(outputPath)
121+
expect(await fs.readFile(outputPath, 'utf8')).toBe('binary content')
122+
})
123+
124+
it('should preserve executable bit', async () => {
125+
const tarball = await createTestTarball([
126+
{
127+
name: 'package/bin/socket',
128+
content: '#!/bin/bash\necho test',
129+
mode: 0o755,
130+
},
131+
])
132+
133+
const tarballPath = path.join(tempDir, 'test.tgz')
134+
await fs.writeFile(tarballPath, tarball)
135+
136+
const outputPath = path.join(tempDir, 'socket')
137+
await extractBinaryFromTarball(tarballPath, 'bin/socket', outputPath)
138+
139+
const stats = await fs.stat(outputPath)
140+
141+
// Check all execute bits (owner, group, others).
142+
expect(stats.mode & 0o100).toBeTruthy() // Owner execute.
143+
expect(stats.mode & 0o010).toBeTruthy() // Group execute.
144+
expect(stats.mode & 0o001).toBeTruthy() // Others execute.
145+
})
146+
147+
it('should handle nested directories', async () => {
148+
const tarball = await createTestTarball([
149+
{
150+
name: 'package/lib/utils/helper.js',
151+
content: 'export const helper = () => {}',
152+
mode: 0o644,
153+
},
154+
{
155+
name: 'package/bin/socket',
156+
content: 'binary',
157+
mode: 0o755,
158+
},
159+
])
160+
161+
const tarballPath = path.join(tempDir, 'test.tgz')
162+
await fs.writeFile(tarballPath, tarball)
163+
164+
const outputPath = path.join(tempDir, 'socket')
165+
await extractBinaryFromTarball(tarballPath, 'bin/socket', outputPath)
166+
167+
expect(await fs.readFile(outputPath, 'utf8')).toBe('binary')
168+
})
169+
170+
it('should throw if binary not found in tarball', async () => {
171+
const tarball = await createTestTarball([
172+
{
173+
name: 'package/other-file.txt',
174+
content: 'not the binary',
175+
mode: 0o644,
176+
},
177+
])
178+
179+
const tarballPath = path.join(tempDir, 'test.tgz')
180+
await fs.writeFile(tarballPath, tarball)
181+
182+
const outputPath = path.join(tempDir, 'socket')
183+
184+
await expect(
185+
extractBinaryFromTarball(tarballPath, 'bin/socket', outputPath),
186+
).rejects.toThrow('not found')
187+
})
188+
})
189+
190+
describe('extractTarball', () => {
191+
it('should extract all files from tarball', async () => {
192+
const tarball = await createTestTarball([
193+
{
194+
name: 'package/package.json',
195+
content: '{"name":"test"}',
196+
mode: 0o644,
197+
},
198+
{
199+
name: 'package/README.md',
200+
content: '# Test',
201+
mode: 0o644,
202+
},
203+
{
204+
name: 'package/bin/socket',
205+
content: 'binary',
206+
mode: 0o755,
207+
},
208+
])
209+
210+
const tarballPath = path.join(tempDir, 'test.tgz')
211+
await fs.writeFile(tarballPath, tarball)
212+
213+
const extractDir = path.join(tempDir, 'extracted')
214+
await fs.mkdir(extractDir, { recursive: true })
215+
216+
const files = await extractTarball(tarballPath, extractDir)
217+
218+
expect(files).toHaveLength(3)
219+
expect(files.map(f => f.name)).toEqual([
220+
'package/package.json',
221+
'package/README.md',
222+
'package/bin/socket',
223+
])
224+
225+
// Verify files exist.
226+
expect(await fs.readFile(path.join(extractDir, 'package.json'), 'utf8')).toBe(
227+
'{"name":"test"}',
228+
)
229+
expect(await fs.readFile(path.join(extractDir, 'README.md'), 'utf8')).toBe(
230+
'# Test',
231+
)
232+
expect(await fs.readFile(path.join(extractDir, 'bin/socket'), 'utf8')).toBe(
233+
'binary',
234+
)
235+
})
236+
237+
it('should sanitize paths to prevent traversal', async () => {
238+
// Try to create a file outside the extract directory.
239+
const tarball = await createTestTarball([
240+
{
241+
name: 'package/../../../etc/passwd',
242+
content: 'hacked',
243+
mode: 0o644,
244+
},
245+
])
246+
247+
const tarballPath = path.join(tempDir, 'test.tgz')
248+
await fs.writeFile(tarballPath, tarball)
249+
250+
const extractDir = path.join(tempDir, 'extracted')
251+
await fs.mkdir(extractDir, { recursive: true })
252+
253+
await extractTarball(tarballPath, extractDir)
254+
255+
// File should be in extractDir/etc/passwd, not /etc/passwd.
256+
const sanitizedPath = path.join(extractDir, 'etc/passwd')
257+
expect(await fs.readFile(sanitizedPath, 'utf8')).toBe('hacked')
258+
259+
// Verify it didn't escape.
260+
expect(sanitizedPath).toContain(extractDir)
261+
})
262+
263+
it('should set correct file permissions', async () => {
264+
const tarball = await createTestTarball([
265+
{
266+
name: 'package/readonly.txt',
267+
content: 'read only',
268+
mode: 0o444, // Read-only.
269+
},
270+
{
271+
name: 'package/executable.sh',
272+
content: '#!/bin/bash',
273+
mode: 0o755, // Executable.
274+
},
275+
])
276+
277+
const tarballPath = path.join(tempDir, 'test.tgz')
278+
await fs.writeFile(tarballPath, tarball)
279+
280+
const extractDir = path.join(tempDir, 'extracted')
281+
await fs.mkdir(extractDir, { recursive: true })
282+
283+
await extractTarball(tarballPath, extractDir)
284+
285+
const readonlyStats = await fs.stat(
286+
path.join(extractDir, 'readonly.txt'),
287+
)
288+
const executableStats = await fs.stat(
289+
path.join(extractDir, 'executable.sh'),
290+
)
291+
292+
// Read-only file.
293+
expect(readonlyStats.mode & 0o200).toBe(0) // No write permission.
294+
295+
// Executable file.
296+
expect(executableStats.mode & 0o111).toBeTruthy() // Execute permission.
297+
})
298+
299+
it('should create parent directories', async () => {
300+
const tarball = await createTestTarball([
301+
{
302+
name: 'package/deep/nested/path/file.txt',
303+
content: 'nested file',
304+
mode: 0o644,
305+
},
306+
])
307+
308+
const tarballPath = path.join(tempDir, 'test.tgz')
309+
await fs.writeFile(tarballPath, tarball)
310+
311+
const extractDir = path.join(tempDir, 'extracted')
312+
await fs.mkdir(extractDir, { recursive: true })
313+
314+
await extractTarball(tarballPath, extractDir)
315+
316+
const nestedFile = path.join(extractDir, 'deep/nested/path/file.txt')
317+
expect(await fs.readFile(nestedFile, 'utf8')).toBe('nested file')
318+
})
319+
320+
it('should throw error for empty tarball', async () => {
321+
const tarball = await createTestTarball([])
322+
323+
const tarballPath = path.join(tempDir, 'test.tgz')
324+
await fs.writeFile(tarballPath, tarball)
325+
326+
const extractDir = path.join(tempDir, 'extracted')
327+
await fs.mkdir(extractDir, { recursive: true })
328+
329+
await expect(extractTarball(tarballPath, extractDir)).rejects.toThrow(
330+
'Downloaded tarball is empty or invalid',
331+
)
332+
})
333+
})
334+
335+
describe('verifyTarballIntegrity', () => {
336+
it('should verify real tarball with correct SHA-512', async () => {
337+
const tarball = await createTestTarball([
338+
{
339+
name: 'package/test.txt',
340+
content: 'test content',
341+
mode: 0o644,
342+
},
343+
])
344+
345+
const tarballPath = path.join(tempDir, 'test.tgz')
346+
await fs.writeFile(tarballPath, tarball)
347+
348+
// Compute real SHA-512.
349+
const crypto = await import('node:crypto')
350+
const hash = crypto.createHash('sha512')
351+
hash.update(tarball)
352+
const expectedHash = hash.digest('base64')
353+
const integrity = `sha512-${expectedHash}`
354+
355+
const isValid = await verifyTarballIntegrity(tarballPath, integrity)
356+
expect(isValid).toBe(true)
357+
})
358+
359+
it('should fail verification with incorrect hash', async () => {
360+
const tarball = await createTestTarball([
361+
{
362+
name: 'package/test.txt',
363+
content: 'test content',
364+
mode: 0o644,
365+
},
366+
])
367+
368+
const tarballPath = path.join(tempDir, 'test.tgz')
369+
await fs.writeFile(tarballPath, tarball)
370+
371+
const integrity = 'sha512-wronghashvalue=='
372+
373+
const isValid = await verifyTarballIntegrity(tarballPath, integrity)
374+
expect(isValid).toBe(false)
375+
})
376+
})
377+
})

0 commit comments

Comments
 (0)