Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add archive extractor #6

Closed
wants to merge 4 commits into from
Closed
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
9 changes: 9 additions & 0 deletions packages/tool-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
122 changes: 122 additions & 0 deletions packages/tool-cache/__tests__/archive.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
1 change: 1 addition & 0 deletions packages/tool-cache/__tests__/data/test.notarchive
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
not an archive
Binary file added packages/tool-cache/__tests__/data/test.zip
Binary file not shown.
83 changes: 83 additions & 0 deletions packages/tool-cache/src/archive/archive-types.ts
Original file line number Diff line number Diff line change
@@ -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'
103 changes: 103 additions & 0 deletions packages/tool-cache/src/archive/get-archive-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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'))
)
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)
})
)
12 changes: 12 additions & 0 deletions packages/tool-cache/src/archive/index.ts
Original file line number Diff line number Diff line change
@@ -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'
39 changes: 39 additions & 0 deletions packages/tool-cache/src/archive/retrieve-archive.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}
}
Loading