Skip to content

Commit 39f7658

Browse files
Introduce universal archive extractor into @actions/tool-cache
1 parent 92695f5 commit 39f7658

File tree

10 files changed

+433
-0
lines changed

10 files changed

+433
-0
lines changed

packages/tool-cache/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ else {
3939
}
4040
```
4141

42+
the Archive helper can be used as a shortcut for both download and extraction of an archive:
43+
44+
```js
45+
const {Archive} = require('@actions/tool-cache');
46+
47+
const node12Archive = await Archive.retrieve('https://nodejs.org/dist/v12.7.0/node-v12.7.0-linux-x64.tar.gz')
48+
const node12ExtractedFolder = await node12Archive.extract('path/to/extract/to')
49+
```
50+
4251
#### Cache
4352

4453
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.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {Archive} from '../src/tool-cache'
2+
import * as tc from '../src/tool-cache'
3+
4+
describe('archive-extractor', () => {
5+
describe('getArchiveType', () => {
6+
it('detects 7z', async () => {
7+
const type = await Archive.getArchiveType(
8+
require.resolve('./data/test.7z')
9+
)
10+
expect(type).toEqual('7z')
11+
})
12+
13+
it('detects tar', async () => {
14+
const type1 = await Archive.getArchiveType(
15+
require.resolve('./data/test.tar.gz')
16+
)
17+
expect(type1).toEqual('tar')
18+
19+
const type2 = await Archive.getArchiveType(
20+
require.resolve('./data/test.tar.xz')
21+
)
22+
23+
expect(type2).toEqual('tar')
24+
})
25+
26+
it('detects zip', async () => {
27+
const type = await Archive.getArchiveType(
28+
require.resolve('./data/test.zip')
29+
)
30+
expect(type).toEqual('zip')
31+
})
32+
33+
it('throws on unsupported type', async () => {
34+
await expect(
35+
Archive.getArchiveType(require.resolve('./data/test.notarchive'))
36+
).rejects.toThrow('Unable to determine archive type')
37+
})
38+
39+
it('throws on non-existent file', async () => {
40+
await expect(Archive.getArchiveType('non-existent-file')).rejects.toThrow(
41+
'Unable to open non-existent-file'
42+
)
43+
})
44+
})
45+
46+
describe('retrieveArchive', () => {
47+
it('downloads archive', async () => {
48+
const downloadToolSpy = jest.spyOn(tc, 'downloadTool')
49+
50+
downloadToolSpy.mockImplementation(async () =>
51+
Promise.resolve('dummy-path')
52+
)
53+
54+
await Archive.retrieve('https://test', {
55+
type: 'zip',
56+
downloadPath: 'dummy-download-path'
57+
})
58+
59+
expect(downloadToolSpy).toHaveBeenLastCalledWith(
60+
'https://test',
61+
'dummy-download-path',
62+
undefined,
63+
undefined
64+
)
65+
66+
downloadToolSpy.mockRestore()
67+
})
68+
69+
it('extracts zip', async () => {
70+
const extractZipSpy = jest.spyOn(tc, 'extractZip')
71+
72+
extractZipSpy.mockImplementation(async () =>
73+
Promise.resolve('dummy-path')
74+
)
75+
76+
const archive = new Archive.ZipArchive('dummy-path')
77+
await archive.extract('dummy-dest')
78+
79+
expect(extractZipSpy).toHaveBeenLastCalledWith('dummy-path', 'dummy-dest')
80+
81+
extractZipSpy.mockRestore()
82+
})
83+
84+
it('extracts tar', async () => {
85+
const extractTarSpy = jest.spyOn(tc, 'extractTar')
86+
87+
extractTarSpy.mockImplementation(async () =>
88+
Promise.resolve('dummy-path')
89+
)
90+
91+
const archive = new Archive.TarArchive('dummy-path')
92+
93+
await archive.extract('dummy-dest', ['flag1', 'flag2'])
94+
95+
expect(extractTarSpy).toHaveBeenLastCalledWith(
96+
'dummy-path',
97+
'dummy-dest',
98+
['flag1', 'flag2']
99+
)
100+
101+
extractTarSpy.mockRestore()
102+
})
103+
104+
it('extracts 7z', async () => {
105+
const extract7zSpy = jest.spyOn(tc, 'extract7z')
106+
107+
extract7zSpy.mockImplementation(async () => Promise.resolve('dummy-path'))
108+
109+
const archive = new Archive.SevenZipArchive('dummy-path')
110+
111+
await archive.extract('dummy-dest', 'dummy-7z-path')
112+
113+
expect(extract7zSpy).toHaveBeenLastCalledWith(
114+
'dummy-path',
115+
'dummy-dest',
116+
'dummy-7z-path'
117+
)
118+
119+
extract7zSpy.mockRestore()
120+
})
121+
})
122+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
not an archive
182 Bytes
Binary file not shown.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {extractZip, extract7z, extractTar, extractXar} from '../tool-cache'
2+
3+
abstract class ArchiveBase {
4+
abstract type: 'zip' | 'tar' | '7z' | 'xar'
5+
path: string
6+
7+
constructor(path: string) {
8+
this.path = path
9+
}
10+
11+
abstract extract(
12+
dest?: string,
13+
flags?: string | string[] | undefined
14+
): Promise<string>
15+
}
16+
17+
export class ZipArchive extends ArchiveBase {
18+
type: 'zip' = 'zip'
19+
20+
constructor(public path: string) {
21+
super(path)
22+
}
23+
24+
async extract(dest?: string): Promise<string> {
25+
return await extractZip(this.path, dest)
26+
}
27+
}
28+
29+
export class TarArchive extends ArchiveBase {
30+
type: 'tar' = 'tar'
31+
32+
constructor(public path: string) {
33+
super(path)
34+
}
35+
36+
async extract(
37+
dest?: string,
38+
flags?: string | string[] | undefined
39+
): Promise<string> {
40+
return await extractTar(this.path, dest, flags)
41+
}
42+
}
43+
44+
export class SevenZipArchive extends ArchiveBase {
45+
type: '7z' = '7z'
46+
47+
constructor(public path: string) {
48+
super(path)
49+
}
50+
51+
async extract(dest?: string, _7zPath?: string | undefined): Promise<string> {
52+
return await extract7z(this.path, dest, _7zPath)
53+
}
54+
}
55+
56+
export class XarArchive extends ArchiveBase {
57+
type: 'xar' = 'xar'
58+
59+
constructor(public path: string) {
60+
super(path)
61+
}
62+
63+
async extract(
64+
dest?: string,
65+
flags?: string | string[] | undefined
66+
): Promise<string> {
67+
return await extractXar(this.path, dest, flags)
68+
}
69+
}
70+
71+
export type Archive = ZipArchive | TarArchive | SevenZipArchive | XarArchive
72+
73+
// Helpers
74+
75+
export const isZipArchive = (archive: Archive): archive is ZipArchive =>
76+
archive.type === 'zip'
77+
export const isTarArchive = (archive: Archive): archive is TarArchive =>
78+
archive.type === 'tar'
79+
export const isSevenZipArchive = (
80+
archive: Archive
81+
): archive is SevenZipArchive => archive.type === '7z'
82+
export const isXarArchive = (archive: Archive): archive is XarArchive =>
83+
archive.type === 'xar'
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import fs from 'fs'
2+
import {ArchiveType} from './types'
3+
4+
const MAX_READ_SIZE = 4096
5+
const MAX_CHUNK_SIZE = 1024
6+
7+
const SIGNATURES = {
8+
zip: '504b0304',
9+
gz: '1f8b08',
10+
bz2: '425a68',
11+
xz: 'fd377a585a00',
12+
'7z': '377abcaf271c',
13+
xar: '78617221', // 'xar!' in hex
14+
tar: '7573746172' // 'ustar' in hex
15+
} as const
16+
17+
const getArchiveTypeFromBuffer = (buffer: Buffer): ArchiveType | null => {
18+
for (const [type, signature] of Object.entries(SIGNATURES)) {
19+
if (!buffer.toString('hex').includes(signature)) {
20+
continue
21+
}
22+
23+
if (['bz2', 'gz', 'tar', 'xz'].includes(type)) {
24+
return 'tar'
25+
}
26+
27+
return type as ArchiveType
28+
}
29+
30+
return null
31+
}
32+
33+
const readStreamFromDescriptor = (fd: number): fs.ReadStream =>
34+
fs.createReadStream('', {
35+
fd,
36+
start: 0,
37+
end: MAX_READ_SIZE,
38+
highWaterMark: MAX_CHUNK_SIZE
39+
})
40+
41+
class LimitedArray<T> {
42+
private _array: T[] = []
43+
constructor(private maxLength: number) {}
44+
push(item: T): void {
45+
if (this._array.length >= this.maxLength) {
46+
this._array.shift()
47+
}
48+
49+
this._array.push(item)
50+
}
51+
get array(): T[] {
52+
return [...this._array]
53+
}
54+
}
55+
56+
export const getArchiveType = async (filePath: string): Promise<ArchiveType> =>
57+
new Promise((resolve, reject) =>
58+
fs.open(filePath, 'r', (error, fd) => {
59+
if (fd === undefined) {
60+
reject(new Error(`Unable to open ${filePath}`))
61+
return
62+
}
63+
64+
if (error) {
65+
fs.close(fd, () => reject(error))
66+
return
67+
}
68+
69+
const buffers = new LimitedArray<Buffer>(2)
70+
const readStream = readStreamFromDescriptor(fd)
71+
72+
const closeEverythingAndResolve = (result: ArchiveType): void => {
73+
readStream.close(() => {
74+
fs.close(fd, () => resolve(result as '7z' | 'zip' | 'xar' | 'tar'))
75+
})
76+
readStream.push(null)
77+
}
78+
79+
const closeEverythingAndReject = (error?: Error): void => {
80+
readStream.close(() => {
81+
fs.close(fd, () =>
82+
reject(
83+
error ?? Error(`Unable to determine archive type of ${filePath}`)
84+
)
85+
)
86+
})
87+
readStream.push(null)
88+
}
89+
90+
setTimeout(closeEverythingAndReject, 100)
91+
92+
readStream
93+
.on('data', chunk => {
94+
if (!(chunk instanceof Buffer)) return closeEverythingAndReject()
95+
96+
buffers.push(chunk)
97+
const type = getArchiveTypeFromBuffer(Buffer.concat(buffers.array))
98+
99+
if (type !== null) {
100+
return closeEverythingAndResolve(type)
101+
}
102+
})
103+
.on('end', () => closeEverythingAndReject())
104+
.on('close', () => closeEverythingAndReject())
105+
.on('error', closeEverythingAndReject)
106+
})
107+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export {retrieve} from './retrieve-archive'
2+
export {getArchiveType} from './get-archive-type'
3+
export {
4+
ZipArchive,
5+
TarArchive,
6+
SevenZipArchive,
7+
XarArchive,
8+
isZipArchive,
9+
isTarArchive,
10+
isSevenZipArchive,
11+
isXarArchive
12+
} from './archive-types'
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
ZipArchive,
3+
TarArchive,
4+
SevenZipArchive,
5+
XarArchive
6+
} from './archive-types'
7+
import {downloadTool} from '../tool-cache'
8+
import {getArchiveType} from './get-archive-type'
9+
import {PredictTypeByOptions, RetrieveArchiveOptions} from './types'
10+
11+
export const retrieve = async <O extends RetrieveArchiveOptions>(
12+
url: string,
13+
options?: O
14+
): Promise<PredictTypeByOptions<O>> => {
15+
const path = await downloadTool(
16+
url,
17+
options?.downloadPath,
18+
options?.auth,
19+
options?.headers
20+
)
21+
22+
const archiveType =
23+
options?.type === 'auto' || !options?.type
24+
? await getArchiveType(path)
25+
: options.type
26+
27+
switch (archiveType) {
28+
case 'zip':
29+
return new ZipArchive(path) as PredictTypeByOptions<O>
30+
case 'tar':
31+
return new TarArchive(path) as PredictTypeByOptions<O>
32+
case '7z':
33+
return new SevenZipArchive(path) as PredictTypeByOptions<O>
34+
case 'xar':
35+
return new XarArchive(path) as PredictTypeByOptions<O>
36+
default:
37+
throw new Error(`Unsupported archive type: ${archiveType}`)
38+
}
39+
}

0 commit comments

Comments
 (0)