Skip to content

Commit 4ec46bb

Browse files
committed
artifact(download): skip non-zip files
1 parent bb2278e commit 4ec46bb

File tree

3 files changed

+80
-14
lines changed

3 files changed

+80
-14
lines changed

packages/artifact/__tests__/download-artifact.test.ts

+51
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ const cleanup = async (): Promise<void> => {
104104
const mockGetArtifactSuccess = jest.fn(() => {
105105
const message = new http.IncomingMessage(new net.Socket())
106106
message.statusCode = 200
107+
message.headers['content-type'] = 'zip'
108+
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
109+
message.push(null)
110+
return {
111+
message
112+
}
113+
})
114+
115+
const mockGetArtifactGzip = jest.fn(() => {
116+
const message = new http.IncomingMessage(new net.Socket())
117+
message.statusCode = 200
118+
message.headers['content-type'] = 'application/gzip'
107119
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
108120
message.push(null)
109121
return {
@@ -124,6 +136,7 @@ const mockGetArtifactFailure = jest.fn(() => {
124136
const mockGetArtifactMalicious = jest.fn(() => {
125137
const message = new http.IncomingMessage(new net.Socket())
126138
message.statusCode = 200
139+
message.headers['content-type'] = 'zip'
127140
message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts
128141
message.push(null)
129142
return {
@@ -178,6 +191,7 @@ describe('download-artifact', () => {
178191
)
179192
expectExtractedArchive(fixtures.workspaceDir)
180193
expect(response.downloadPath).toBe(fixtures.workspaceDir)
194+
expect(response.skipped).toBe(false)
181195
})
182196

183197
it('should not allow path traversal from malicious artifacts', async () => {
@@ -231,6 +245,7 @@ describe('download-artifact', () => {
231245
).toBe(true)
232246

233247
expect(response.downloadPath).toBe(fixtures.workspaceDir)
248+
expect(response.skipped).toBe(false)
234249
})
235250

236251
it('should successfully download an artifact to user defined path', async () => {
@@ -280,6 +295,7 @@ describe('download-artifact', () => {
280295
)
281296
expectExtractedArchive(customPath)
282297
expect(response.downloadPath).toBe(customPath)
298+
expect(response.skipped).toBe(false)
283299
})
284300

285301
it('should fail if download artifact API does not respond with location', async () => {
@@ -316,6 +332,7 @@ describe('download-artifact', () => {
316332
// mock http client to delay response data by 30s
317333
const msg = new http.IncomingMessage(new net.Socket())
318334
msg.statusCode = 200
335+
msg.headers['content-type'] = 'zip'
319336

320337
const mockGet = jest.fn(async () => {
321338
return new Promise((resolve, reject) => {
@@ -444,7 +461,39 @@ describe('download-artifact', () => {
444461
)
445462
expect(mockGetArtifactSuccess).toHaveBeenCalledTimes(1)
446463
expect(response.downloadPath).toBe(fixtures.workspaceDir)
464+
expect(response.skipped).toBe(false)
447465
}, 28000)
466+
467+
it('should skip if artifact does not have the right content type', async () => {
468+
const downloadArtifactMock = github.getOctokit(fixtures.token).rest
469+
.actions.downloadArtifact as MockedDownloadArtifact
470+
downloadArtifactMock.mockResolvedValueOnce({
471+
headers: {
472+
location: fixtures.blobStorageUrl
473+
},
474+
status: 302,
475+
url: '',
476+
data: Buffer.from('')
477+
})
478+
479+
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
480+
() => {
481+
return {
482+
get: mockGetArtifactGzip
483+
}
484+
}
485+
)
486+
487+
const response = await downloadArtifactPublic(
488+
fixtures.artifactID,
489+
fixtures.repositoryOwner,
490+
fixtures.repositoryName,
491+
fixtures.token
492+
)
493+
494+
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
495+
expect(response.skipped).toBe(true)
496+
})
448497
})
449498

450499
describe('internal', () => {
@@ -499,6 +548,7 @@ describe('download-artifact', () => {
499548

500549
expectExtractedArchive(fixtures.workspaceDir)
501550
expect(response.downloadPath).toBe(fixtures.workspaceDir)
551+
expect(response.skipped).toBe(false)
502552
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
503553
expect(mockListArtifacts).toHaveBeenCalledWith({
504554
idFilter: {
@@ -550,6 +600,7 @@ describe('download-artifact', () => {
550600

551601
expectExtractedArchive(customPath)
552602
expect(response.downloadPath).toBe(customPath)
603+
expect(response.skipped).toBe(false)
553604
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
554605
expect(mockListArtifacts).toHaveBeenCalledWith({
555606
idFilter: {

packages/artifact/src/internal/download/download-artifact.ts

+24-14
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,11 @@ async function exists(path: string): Promise<boolean> {
3737
}
3838
}
3939

40-
async function streamExtract(url: string, directory: string): Promise<void> {
40+
async function streamExtract(url: string, directory: string): Promise<boolean> {
4141
let retryCount = 0
4242
while (retryCount < 5) {
4343
try {
44-
await streamExtractExternal(url, directory)
45-
return
44+
return await streamExtractExternal(url, directory)
4645
} catch (error) {
4746
retryCount++
4847
core.debug(
@@ -59,18 +58,23 @@ async function streamExtract(url: string, directory: string): Promise<void> {
5958
export async function streamExtractExternal(
6059
url: string,
6160
directory: string
62-
): Promise<void> {
61+
): Promise<boolean> {
6362
const client = new httpClient.HttpClient(getUserAgentString())
6463
const response = await client.get(url)
6564
if (response.message.statusCode !== 200) {
6665
throw new Error(
6766
`Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}`
6867
)
68+
} else if (response.message.headers['content-type'] !== 'zip') {
69+
core.debug(
70+
`Invalid content-type: ${response.message.headers['content-type']}, skipping download`
71+
)
72+
return false
6973
}
7074

7175
const timeout = 30 * 1000 // 30 seconds
7276

73-
return new Promise((resolve, reject) => {
77+
return new Promise<boolean>((resolve, reject) => {
7478
const timerFn = (): void => {
7579
response.message.destroy(
7680
new Error(`Blob storage chunk did not respond in ${timeout}ms`)
@@ -92,7 +96,7 @@ export async function streamExtractExternal(
9296
.pipe(unzip.Extract({path: directory}))
9397
.on('close', () => {
9498
clearTimeout(timer)
95-
resolve()
99+
resolve(true)
96100
})
97101
.on('error', (error: Error) => {
98102
reject(error)
@@ -140,13 +144,16 @@ export async function downloadArtifactPublic(
140144

141145
try {
142146
core.info(`Starting download of artifact to: ${downloadPath}`)
143-
await streamExtract(location, downloadPath)
144-
core.info(`Artifact download completed successfully.`)
147+
if (await streamExtract(location, downloadPath)) {
148+
core.info(`Artifact download completed successfully.`)
149+
return {downloadPath, skipped: false}
150+
} else {
151+
core.info(`Artifact download skipped.`)
152+
return {downloadPath, skipped: true}
153+
}
145154
} catch (error) {
146155
throw new Error(`Unable to download and extract artifact: ${error.message}`)
147156
}
148-
149-
return {downloadPath}
150157
}
151158

152159
export async function downloadArtifactInternal(
@@ -192,13 +199,16 @@ export async function downloadArtifactInternal(
192199

193200
try {
194201
core.info(`Starting download of artifact to: ${downloadPath}`)
195-
await streamExtract(signedUrl, downloadPath)
196-
core.info(`Artifact download completed successfully.`)
202+
if (await streamExtract(signedUrl, downloadPath)) {
203+
core.info(`Artifact download completed successfully.`)
204+
return {downloadPath, skipped: false}
205+
} else {
206+
core.info(`Artifact download skipped.`)
207+
return {downloadPath, skipped: true}
208+
}
197209
} catch (error) {
198210
throw new Error(`Unable to download and extract artifact: ${error.message}`)
199211
}
200-
201-
return {downloadPath}
202212
}
203213

204214
async function resolveOrCreateDirectory(

packages/artifact/src/internal/shared/interfaces.ts

+5
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export interface DownloadArtifactResponse {
8686
* The path where the artifact was downloaded to
8787
*/
8888
downloadPath?: string
89+
90+
/**
91+
* If the artifact download was skipped
92+
*/
93+
skipped?: boolean
8994
}
9095

9196
/**

0 commit comments

Comments
 (0)