Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions server/loader/npm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exec } from 'node:child_process'
import { execFile } from 'node:child_process'
import { rmSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
Expand All @@ -12,7 +12,7 @@ import type {
} from '../../common/types.ts'

const DOWNLOADED_PACKAGES = new Map<string, FilePath>()
const execAsync = promisify(exec)
const execFileAsync = promisify(execFile)

function isGitUrl(version: DependencyVersion): boolean {
return (
Expand Down Expand Up @@ -89,13 +89,22 @@ export async function getNpmContent(

let tarballBuffer = await downloadTarball(tarballUrl)
let tempDir = await mkdtemp(join(tmpdir(), 'multiocular-'))
let tarballPath = join(tempDir, 'package.tgz')

await writeFile(tarballPath, Buffer.from(tarballBuffer))
await execAsync(
`tar -xzf "${tarballPath}" --strip-components=1 -C "${tempDir}"`
)
await rm(tarballPath)
try {
let tarballPath = join(tempDir, 'package.tgz')
await writeFile(tarballPath, Buffer.from(tarballBuffer))

await execFileAsync(
'tar',
['-xzf', 'package.tgz', '--strip-components=1'],
{ cwd: tempDir }
)

await rm(tarballPath)
} catch (error) {
await rm(tempDir, { force: true, recursive: true })
throw error
}

DOWNLOADED_PACKAGES.set(spec, tempDir as FilePath)
}
Expand Down
101 changes: 101 additions & 0 deletions server/test/tar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { deepEqual, equal, match, rejects } from 'node:assert/strict'
import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import {
chmod,
mkdtemp,
readdir,
readFile,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, test } from 'node:test'

import type {
DependencyName,
DependencyVersion,
FilePath
} from '../../common/types.ts'
import { deleteTemporary, getNpmContent } from '../loader/npm.ts'

let shimDir: string | undefined
let originalPath = process.env.PATH

afterEach(async () => {
process.env.PATH = originalPath
deleteTemporary()
if (shimDir) {
await rm(shimDir, { force: true, recursive: true })
shimDir = undefined
}
})

async function forbidAbsolutePathsInTar(): Promise<void> {
let real = execSync('command -v tar').toString().trim()
shimDir = await mkdtemp(join(tmpdir(), 'multiocular-shim-'))
let shim = join(shimDir, 'tar')
await writeFile(
shim,
'#!/bin/sh\n' +
'for arg in "$@"; do\n' +
' case "$arg" in\n' +
' /*)\n' +
' echo "tar: absolute path $arg is not portable" >&2\n' +
' exit 128\n' +
' ;;\n' +
' esac\n' +
'done\n' +
`exec "${real}" "$@"\n`
)
await chmod(shim, 0o755)
process.env.PATH = `${shimDir}:${originalPath}`
}

async function makeTarFail(): Promise<void> {
shimDir = await mkdtemp(join(tmpdir(), 'multiocular-shim-'))
let shim = join(shimDir, 'tar')
await writeFile(
shim,
'#!/bin/sh\necho "tar: simulated extraction failure" >&2\nexit 1\n'
)
await chmod(shim, 0o755)
process.env.PATH = `${shimDir}:${originalPath}`
}

test(
'extracts tarballs without absolute paths in tar arguments',
{ skip: process.platform === 'win32' },
async () => {
await forbidAbsolutePathsInTar()
let dir = await getNpmContent(
'.' as FilePath,
'nanoid' as DependencyName,
'5.1.5' as DependencyVersion
)
match(await readFile(join(dir, 'package.json'), 'utf8'), /"name": "nanoid"/)
equal(existsSync(join(dir, 'package.tgz')), false)
}
)

test(
'removes temporary directory when tar extraction fails',
{ skip: process.platform === 'win32' },
async () => {
await makeTarFail()
let before = await readdir(tmpdir())
await rejects(
getNpmContent(
'.' as FilePath,
'nanoid' as DependencyName,
'5.1.5' as DependencyVersion
)
)
let after = await readdir(tmpdir())
let leaked = after.filter(
name => name.startsWith('multiocular-') && !before.includes(name)
)
deepEqual(leaked, [])
}
)