1
0
Fork 0

Introduce universal archive extractor into @actions/tool-cache

pull/1552/head
Nikolai Laevskii 2023-10-09 06:35:51 +02:00
parent 92695f58da
commit 39f7658b55
10 changed files with 433 additions and 0 deletions

View File

@ -39,6 +39,15 @@ else {
}
```
the Archive helper can be used as a shortcut for both download and extraction of an archive:
```js
const {Archive} = require('@actions/tool-cache');
const node12Archive = await Archive.retrieve('https://nodejs.org/dist/v12.7.0/node-v12.7.0-linux-x64.tar.gz')
const node12ExtractedFolder = await node12Archive.extract('path/to/extract/to')
```
#### Cache
Finally, you can cache these directories in our tool-cache. This is useful if you want to switch back and forth between versions of a tool, or save a tool between runs for self-hosted runners.

View File

@ -0,0 +1,122 @@
import {Archive} from '../src/tool-cache'
import * as tc from '../src/tool-cache'
describe('archive-extractor', () => {
describe('getArchiveType', () => {
it('detects 7z', async () => {
const type = await Archive.getArchiveType(
require.resolve('./data/test.7z')
)
expect(type).toEqual('7z')
})
it('detects tar', async () => {
const type1 = await Archive.getArchiveType(
require.resolve('./data/test.tar.gz')
)
expect(type1).toEqual('tar')
const type2 = await Archive.getArchiveType(
require.resolve('./data/test.tar.xz')
)
expect(type2).toEqual('tar')
})
it('detects zip', async () => {
const type = await Archive.getArchiveType(
require.resolve('./data/test.zip')
)
expect(type).toEqual('zip')
})
it('throws on unsupported type', async () => {
await expect(
Archive.getArchiveType(require.resolve('./data/test.notarchive'))
).rejects.toThrow('Unable to determine archive type')
})
it('throws on non-existent file', async () => {
await expect(Archive.getArchiveType('non-existent-file')).rejects.toThrow(
'Unable to open non-existent-file'
)
})
})
describe('retrieveArchive', () => {
it('downloads archive', async () => {
const downloadToolSpy = jest.spyOn(tc, 'downloadTool')
downloadToolSpy.mockImplementation(async () =>
Promise.resolve('dummy-path')
)
await Archive.retrieve('https://test', {
type: 'zip',
downloadPath: 'dummy-download-path'
})
expect(downloadToolSpy).toHaveBeenLastCalledWith(
'https://test',
'dummy-download-path',
undefined,
undefined
)
downloadToolSpy.mockRestore()
})
it('extracts zip', async () => {
const extractZipSpy = jest.spyOn(tc, 'extractZip')
extractZipSpy.mockImplementation(async () =>
Promise.resolve('dummy-path')
)
const archive = new Archive.ZipArchive('dummy-path')
await archive.extract('dummy-dest')
expect(extractZipSpy).toHaveBeenLastCalledWith('dummy-path', 'dummy-dest')
extractZipSpy.mockRestore()
})
it('extracts tar', async () => {
const extractTarSpy = jest.spyOn(tc, 'extractTar')
extractTarSpy.mockImplementation(async () =>
Promise.resolve('dummy-path')
)
const archive = new Archive.TarArchive('dummy-path')
await archive.extract('dummy-dest', ['flag1', 'flag2'])
expect(extractTarSpy).toHaveBeenLastCalledWith(
'dummy-path',
'dummy-dest',
['flag1', 'flag2']
)
extractTarSpy.mockRestore()
})
it('extracts 7z', async () => {
const extract7zSpy = jest.spyOn(tc, 'extract7z')
extract7zSpy.mockImplementation(async () => Promise.resolve('dummy-path'))
const archive = new Archive.SevenZipArchive('dummy-path')
await archive.extract('dummy-dest', 'dummy-7z-path')
expect(extract7zSpy).toHaveBeenLastCalledWith(
'dummy-path',
'dummy-dest',
'dummy-7z-path'
)
extract7zSpy.mockRestore()
})
})
})

View File

@ -0,0 +1 @@
not an archive

Binary file not shown.

View File

@ -0,0 +1,83 @@
import {extractZip, extract7z, extractTar, extractXar} from '../tool-cache'
abstract class ArchiveBase {
abstract type: 'zip' | 'tar' | '7z' | 'xar'
path: string
constructor(path: string) {
this.path = path
}
abstract extract(
dest?: string,
flags?: string | string[] | undefined
): Promise<string>
}
export class ZipArchive extends ArchiveBase {
type: 'zip' = 'zip'
constructor(public path: string) {
super(path)
}
async extract(dest?: string): Promise<string> {
return await extractZip(this.path, dest)
}
}
export class TarArchive extends ArchiveBase {
type: 'tar' = 'tar'
constructor(public path: string) {
super(path)
}
async extract(
dest?: string,
flags?: string | string[] | undefined
): Promise<string> {
return await extractTar(this.path, dest, flags)
}
}
export class SevenZipArchive extends ArchiveBase {
type: '7z' = '7z'
constructor(public path: string) {
super(path)
}
async extract(dest?: string, _7zPath?: string | undefined): Promise<string> {
return await extract7z(this.path, dest, _7zPath)
}
}
export class XarArchive extends ArchiveBase {
type: 'xar' = 'xar'
constructor(public path: string) {
super(path)
}
async extract(
dest?: string,
flags?: string | string[] | undefined
): Promise<string> {
return await extractXar(this.path, dest, flags)
}
}
export type Archive = ZipArchive | TarArchive | SevenZipArchive | XarArchive
// Helpers
export const isZipArchive = (archive: Archive): archive is ZipArchive =>
archive.type === 'zip'
export const isTarArchive = (archive: Archive): archive is TarArchive =>
archive.type === 'tar'
export const isSevenZipArchive = (
archive: Archive
): archive is SevenZipArchive => archive.type === '7z'
export const isXarArchive = (archive: Archive): archive is XarArchive =>
archive.type === 'xar'

View File

@ -0,0 +1,107 @@
import fs from 'fs'
import {ArchiveType} from './types'
const MAX_READ_SIZE = 4096
const MAX_CHUNK_SIZE = 1024
const SIGNATURES = {
zip: '504b0304',
gz: '1f8b08',
bz2: '425a68',
xz: 'fd377a585a00',
'7z': '377abcaf271c',
xar: '78617221', // 'xar!' in hex
tar: '7573746172' // 'ustar' in hex
} as const
const getArchiveTypeFromBuffer = (buffer: Buffer): ArchiveType | null => {
for (const [type, signature] of Object.entries(SIGNATURES)) {
if (!buffer.toString('hex').includes(signature)) {
continue
}
if (['bz2', 'gz', 'tar', 'xz'].includes(type)) {
return 'tar'
}
return type as ArchiveType
}
return null
}
const readStreamFromDescriptor = (fd: number): fs.ReadStream =>
fs.createReadStream('', {
fd,
start: 0,
end: MAX_READ_SIZE,
highWaterMark: MAX_CHUNK_SIZE
})
class LimitedArray<T> {
private _array: T[] = []
constructor(private maxLength: number) {}
push(item: T): void {
if (this._array.length >= this.maxLength) {
this._array.shift()
}
this._array.push(item)
}
get array(): T[] {
return [...this._array]
}
}
export const getArchiveType = async (filePath: string): Promise<ArchiveType> =>
new Promise((resolve, reject) =>
fs.open(filePath, 'r', (error, fd) => {
if (fd === undefined) {
reject(new Error(`Unable to open ${filePath}`))
return
}
if (error) {
fs.close(fd, () => reject(error))
return
}
const buffers = new LimitedArray<Buffer>(2)
const readStream = readStreamFromDescriptor(fd)
const closeEverythingAndResolve = (result: ArchiveType): void => {
readStream.close(() => {
fs.close(fd, () => resolve(result as '7z' | 'zip' | 'xar' | 'tar'))
})
readStream.push(null)
}
const closeEverythingAndReject = (error?: Error): void => {
readStream.close(() => {
fs.close(fd, () =>
reject(
error ?? Error(`Unable to determine archive type of ${filePath}`)
)
)
})
readStream.push(null)
}
setTimeout(closeEverythingAndReject, 100)
readStream
.on('data', chunk => {
if (!(chunk instanceof Buffer)) return closeEverythingAndReject()
buffers.push(chunk)
const type = getArchiveTypeFromBuffer(Buffer.concat(buffers.array))
if (type !== null) {
return closeEverythingAndResolve(type)
}
})
.on('end', () => closeEverythingAndReject())
.on('close', () => closeEverythingAndReject())
.on('error', closeEverythingAndReject)
})
)

View File

@ -0,0 +1,12 @@
export {retrieve} from './retrieve-archive'
export {getArchiveType} from './get-archive-type'
export {
ZipArchive,
TarArchive,
SevenZipArchive,
XarArchive,
isZipArchive,
isTarArchive,
isSevenZipArchive,
isXarArchive
} from './archive-types'

View File

@ -0,0 +1,39 @@
import {
ZipArchive,
TarArchive,
SevenZipArchive,
XarArchive
} from './archive-types'
import {downloadTool} from '../tool-cache'
import {getArchiveType} from './get-archive-type'
import {PredictTypeByOptions, RetrieveArchiveOptions} from './types'
export const retrieve = async <O extends RetrieveArchiveOptions>(
url: string,
options?: O
): Promise<PredictTypeByOptions<O>> => {
const path = await downloadTool(
url,
options?.downloadPath,
options?.auth,
options?.headers
)
const archiveType =
options?.type === 'auto' || !options?.type
? await getArchiveType(path)
: options.type
switch (archiveType) {
case 'zip':
return new ZipArchive(path) as PredictTypeByOptions<O>
case 'tar':
return new TarArchive(path) as PredictTypeByOptions<O>
case '7z':
return new SevenZipArchive(path) as PredictTypeByOptions<O>
case 'xar':
return new XarArchive(path) as PredictTypeByOptions<O>
default:
throw new Error(`Unsupported archive type: ${archiveType}`)
}
}

View File

@ -0,0 +1,55 @@
import {OutgoingHttpHeaders} from 'http'
import {
SevenZipArchive,
TarArchive,
XarArchive,
ZipArchive
} from './archive-types'
export type ArchiveType = 'zip' | 'tar' | '7z' | 'xar'
interface RetrieveArchiveOptionsBase {
downloadPath?: string
type?: ArchiveType | 'auto'
auth?: string | undefined
headers?: OutgoingHttpHeaders | undefined
}
export interface RetrieveZipArchiveOptions extends RetrieveArchiveOptionsBase {
type: 'zip'
}
export interface RetrieveTarArchiveOptions extends RetrieveArchiveOptionsBase {
type: 'tar'
}
export interface Retrieve7zArchiveOptions extends RetrieveArchiveOptionsBase {
type: '7z'
}
export interface RetrieveXarArchiveOptions extends RetrieveArchiveOptionsBase {
type: 'xar'
}
export interface RetrieveUnknownArchiveOptions
extends RetrieveArchiveOptionsBase {
type?: 'auto'
}
export type RetrieveArchiveOptions =
| RetrieveZipArchiveOptions
| RetrieveTarArchiveOptions
| Retrieve7zArchiveOptions
| RetrieveXarArchiveOptions
| RetrieveUnknownArchiveOptions
export type PredictTypeByOptions<O extends RetrieveArchiveOptions> =
O extends RetrieveZipArchiveOptions
? ZipArchive
: O extends Retrieve7zArchiveOptions
? SevenZipArchive
: O extends RetrieveTarArchiveOptions
? TarArchive
: O extends RetrieveXarArchiveOptions
? XarArchive
: ZipArchive | TarArchive | SevenZipArchive | XarArchive

View File

@ -775,3 +775,8 @@ function _getGlobal<T>(key: string, defaultValue: T): T {
function _unique<T>(values: T[]): T[] {
return Array.from(new Set(values))
}
/**
* Archive helper exports
*/
export * as Archive from './archive'