mirror of https://github.com/actions/toolkit
Introduce universal archive extractor into @actions/tool-cache
parent
92695f58da
commit
39f7658b55
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1 @@
|
|||
not an archive
|
Binary file not shown.
|
@ -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'
|
|
@ -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)
|
||||
})
|
||||
)
|
|
@ -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'
|
|
@ -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}`)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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'
|
||||
|
|
Loading…
Reference in New Issue