mirror of https://github.com/actions/toolkit
artifact(download): skip non-zip files
parent
bb2278e5cf
commit
4ec46bb673
|
@ -104,6 +104,18 @@ const cleanup = async (): Promise<void> => {
|
||||||
const mockGetArtifactSuccess = jest.fn(() => {
|
const mockGetArtifactSuccess = jest.fn(() => {
|
||||||
const message = new http.IncomingMessage(new net.Socket())
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
message.statusCode = 200
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'zip'
|
||||||
|
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockGetArtifactGzip = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'application/gzip'
|
||||||
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
|
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
|
||||||
message.push(null)
|
message.push(null)
|
||||||
return {
|
return {
|
||||||
|
@ -124,6 +136,7 @@ const mockGetArtifactFailure = jest.fn(() => {
|
||||||
const mockGetArtifactMalicious = jest.fn(() => {
|
const mockGetArtifactMalicious = jest.fn(() => {
|
||||||
const message = new http.IncomingMessage(new net.Socket())
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
message.statusCode = 200
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'zip'
|
||||||
message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts
|
message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts
|
||||||
message.push(null)
|
message.push(null)
|
||||||
return {
|
return {
|
||||||
|
@ -178,6 +191,7 @@ describe('download-artifact', () => {
|
||||||
)
|
)
|
||||||
expectExtractedArchive(fixtures.workspaceDir)
|
expectExtractedArchive(fixtures.workspaceDir)
|
||||||
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
||||||
|
expect(response.skipped).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not allow path traversal from malicious artifacts', async () => {
|
it('should not allow path traversal from malicious artifacts', async () => {
|
||||||
|
@ -231,6 +245,7 @@ describe('download-artifact', () => {
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
||||||
|
expect(response.skipped).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should successfully download an artifact to user defined path', async () => {
|
it('should successfully download an artifact to user defined path', async () => {
|
||||||
|
@ -280,6 +295,7 @@ describe('download-artifact', () => {
|
||||||
)
|
)
|
||||||
expectExtractedArchive(customPath)
|
expectExtractedArchive(customPath)
|
||||||
expect(response.downloadPath).toBe(customPath)
|
expect(response.downloadPath).toBe(customPath)
|
||||||
|
expect(response.skipped).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail if download artifact API does not respond with location', async () => {
|
it('should fail if download artifact API does not respond with location', async () => {
|
||||||
|
@ -316,6 +332,7 @@ describe('download-artifact', () => {
|
||||||
// mock http client to delay response data by 30s
|
// mock http client to delay response data by 30s
|
||||||
const msg = new http.IncomingMessage(new net.Socket())
|
const msg = new http.IncomingMessage(new net.Socket())
|
||||||
msg.statusCode = 200
|
msg.statusCode = 200
|
||||||
|
msg.headers['content-type'] = 'zip'
|
||||||
|
|
||||||
const mockGet = jest.fn(async () => {
|
const mockGet = jest.fn(async () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
@ -444,7 +461,39 @@ describe('download-artifact', () => {
|
||||||
)
|
)
|
||||||
expect(mockGetArtifactSuccess).toHaveBeenCalledTimes(1)
|
expect(mockGetArtifactSuccess).toHaveBeenCalledTimes(1)
|
||||||
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
||||||
|
expect(response.skipped).toBe(false)
|
||||||
}, 28000)
|
}, 28000)
|
||||||
|
|
||||||
|
it('should skip if artifact does not have the right content type', async () => {
|
||||||
|
const downloadArtifactMock = github.getOctokit(fixtures.token).rest
|
||||||
|
.actions.downloadArtifact as MockedDownloadArtifact
|
||||||
|
downloadArtifactMock.mockResolvedValueOnce({
|
||||||
|
headers: {
|
||||||
|
location: fixtures.blobStorageUrl
|
||||||
|
},
|
||||||
|
status: 302,
|
||||||
|
url: '',
|
||||||
|
data: Buffer.from('')
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetArtifactGzip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await downloadArtifactPublic(
|
||||||
|
fixtures.artifactID,
|
||||||
|
fixtures.repositoryOwner,
|
||||||
|
fixtures.repositoryName,
|
||||||
|
fixtures.token
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
expect(response.skipped).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('internal', () => {
|
describe('internal', () => {
|
||||||
|
@ -499,6 +548,7 @@ describe('download-artifact', () => {
|
||||||
|
|
||||||
expectExtractedArchive(fixtures.workspaceDir)
|
expectExtractedArchive(fixtures.workspaceDir)
|
||||||
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
expect(response.downloadPath).toBe(fixtures.workspaceDir)
|
||||||
|
expect(response.skipped).toBe(false)
|
||||||
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
expect(mockListArtifacts).toHaveBeenCalledWith({
|
expect(mockListArtifacts).toHaveBeenCalledWith({
|
||||||
idFilter: {
|
idFilter: {
|
||||||
|
@ -550,6 +600,7 @@ describe('download-artifact', () => {
|
||||||
|
|
||||||
expectExtractedArchive(customPath)
|
expectExtractedArchive(customPath)
|
||||||
expect(response.downloadPath).toBe(customPath)
|
expect(response.downloadPath).toBe(customPath)
|
||||||
|
expect(response.skipped).toBe(false)
|
||||||
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
expect(mockListArtifacts).toHaveBeenCalledWith({
|
expect(mockListArtifacts).toHaveBeenCalledWith({
|
||||||
idFilter: {
|
idFilter: {
|
||||||
|
|
|
@ -37,12 +37,11 @@ async function exists(path: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamExtract(url: string, directory: string): Promise<void> {
|
async function streamExtract(url: string, directory: string): Promise<boolean> {
|
||||||
let retryCount = 0
|
let retryCount = 0
|
||||||
while (retryCount < 5) {
|
while (retryCount < 5) {
|
||||||
try {
|
try {
|
||||||
await streamExtractExternal(url, directory)
|
return await streamExtractExternal(url, directory)
|
||||||
return
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
retryCount++
|
retryCount++
|
||||||
core.debug(
|
core.debug(
|
||||||
|
@ -59,18 +58,23 @@ async function streamExtract(url: string, directory: string): Promise<void> {
|
||||||
export async function streamExtractExternal(
|
export async function streamExtractExternal(
|
||||||
url: string,
|
url: string,
|
||||||
directory: string
|
directory: string
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
const client = new httpClient.HttpClient(getUserAgentString())
|
const client = new httpClient.HttpClient(getUserAgentString())
|
||||||
const response = await client.get(url)
|
const response = await client.get(url)
|
||||||
if (response.message.statusCode !== 200) {
|
if (response.message.statusCode !== 200) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}`
|
`Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}`
|
||||||
)
|
)
|
||||||
|
} else if (response.message.headers['content-type'] !== 'zip') {
|
||||||
|
core.debug(
|
||||||
|
`Invalid content-type: ${response.message.headers['content-type']}, skipping download`
|
||||||
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = 30 * 1000 // 30 seconds
|
const timeout = 30 * 1000 // 30 seconds
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<boolean>((resolve, reject) => {
|
||||||
const timerFn = (): void => {
|
const timerFn = (): void => {
|
||||||
response.message.destroy(
|
response.message.destroy(
|
||||||
new Error(`Blob storage chunk did not respond in ${timeout}ms`)
|
new Error(`Blob storage chunk did not respond in ${timeout}ms`)
|
||||||
|
@ -92,7 +96,7 @@ export async function streamExtractExternal(
|
||||||
.pipe(unzip.Extract({path: directory}))
|
.pipe(unzip.Extract({path: directory}))
|
||||||
.on('close', () => {
|
.on('close', () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
resolve()
|
resolve(true)
|
||||||
})
|
})
|
||||||
.on('error', (error: Error) => {
|
.on('error', (error: Error) => {
|
||||||
reject(error)
|
reject(error)
|
||||||
|
@ -140,13 +144,16 @@ export async function downloadArtifactPublic(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
core.info(`Starting download of artifact to: ${downloadPath}`)
|
core.info(`Starting download of artifact to: ${downloadPath}`)
|
||||||
await streamExtract(location, downloadPath)
|
if (await streamExtract(location, downloadPath)) {
|
||||||
core.info(`Artifact download completed successfully.`)
|
core.info(`Artifact download completed successfully.`)
|
||||||
|
return {downloadPath, skipped: false}
|
||||||
|
} else {
|
||||||
|
core.info(`Artifact download skipped.`)
|
||||||
|
return {downloadPath, skipped: true}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Unable to download and extract artifact: ${error.message}`)
|
throw new Error(`Unable to download and extract artifact: ${error.message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {downloadPath}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadArtifactInternal(
|
export async function downloadArtifactInternal(
|
||||||
|
@ -192,13 +199,16 @@ export async function downloadArtifactInternal(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
core.info(`Starting download of artifact to: ${downloadPath}`)
|
core.info(`Starting download of artifact to: ${downloadPath}`)
|
||||||
await streamExtract(signedUrl, downloadPath)
|
if (await streamExtract(signedUrl, downloadPath)) {
|
||||||
core.info(`Artifact download completed successfully.`)
|
core.info(`Artifact download completed successfully.`)
|
||||||
|
return {downloadPath, skipped: false}
|
||||||
|
} else {
|
||||||
|
core.info(`Artifact download skipped.`)
|
||||||
|
return {downloadPath, skipped: true}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Unable to download and extract artifact: ${error.message}`)
|
throw new Error(`Unable to download and extract artifact: ${error.message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {downloadPath}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOrCreateDirectory(
|
async function resolveOrCreateDirectory(
|
||||||
|
|
|
@ -86,6 +86,11 @@ export interface DownloadArtifactResponse {
|
||||||
* The path where the artifact was downloaded to
|
* The path where the artifact was downloaded to
|
||||||
*/
|
*/
|
||||||
downloadPath?: string
|
downloadPath?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the artifact download was skipped
|
||||||
|
*/
|
||||||
|
skipped?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue