mirror of
https://github.com/actions/toolkit.git
synced 2026-08-20 00:00:14 +02:00
Artifact download: don't unzip non-zip artifacts (#2253)
* Download artifact: don't extract the downloaded file if the content-type isn't a zip * Remove unused `import` * Add support for specifying whether to skip decompressing * Prevent path traversal attacks * Fix indenting * Update packages/artifact/__tests__/download-artifact.test.ts Co-authored-by: Copilot <[email protected]> * Parse the mime type out of the content-type header Co-authored-by: Copilot <[email protected]> * Fix some linting issues * Swap `zip` for `application/zip-compressed` * Test: negative check for malicious paths * Increase the timeout on one of the tests * Check the URL path for `.zip` to see if we can auto-decompress * Fix linting issue * Bump the package version and add release notes * Remove `launch.json` --------- Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Copilot
parent
ffae274475
commit
975fcbd402
@@ -1,5 +1,14 @@
|
|||||||
# @actions/artifact Releases
|
# @actions/artifact Releases
|
||||||
|
|
||||||
|
## 6.1.0
|
||||||
|
|
||||||
|
- Support downloading non-zip artifacts. Zipped artifacts will be decompressed automatically (with an optional override). Un-zipped artifacts will be downloaded as-is.
|
||||||
|
|
||||||
|
## 6.0.0
|
||||||
|
|
||||||
|
- **Breaking change**: Package is now ESM-only
|
||||||
|
- CommonJS consumers must use dynamic `import()` instead of `require()`
|
||||||
|
|
||||||
## 5.0.3
|
## 5.0.3
|
||||||
|
|
||||||
- Bump `@actions/http-client` to `3.0.2`
|
- Bump `@actions/http-client` to `3.0.2`
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ 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'] = 'application/zip'
|
||||||
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
|
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
|
||||||
message.push(null)
|
message.push(null)
|
||||||
return {
|
return {
|
||||||
@@ -114,6 +115,7 @@ const mockGetArtifactSuccess = jest.fn(() => {
|
|||||||
const mockGetArtifactHung = jest.fn(() => {
|
const mockGetArtifactHung = 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'] = 'application/zip'
|
||||||
// Don't push any data or call push(null) to end the stream
|
// Don't push any data or call push(null) to end the stream
|
||||||
// This creates a stream that hangs and never completes
|
// This creates a stream that hangs and never completes
|
||||||
return {
|
return {
|
||||||
@@ -134,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'] = 'application/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 {
|
||||||
@@ -619,10 +622,17 @@ describe('download-artifact', () => {
|
|||||||
...fixtures.backendIds,
|
...fixtures.backendIds,
|
||||||
name: fixtures.artifactName
|
name: fixtures.artifactName
|
||||||
})
|
})
|
||||||
})
|
}, 38000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('streamExtractExternal', () => {
|
describe('streamExtractExternal', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await setup()
|
||||||
|
// Create workspace directory for streamExtractExternal tests
|
||||||
|
await fs.promises.mkdir(fixtures.workspaceDir, {recursive: true})
|
||||||
|
})
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
it('should fail if the timeout is exceeded', async () => {
|
it('should fail if the timeout is exceeded', async () => {
|
||||||
const mockSlowGetArtifact = jest.fn(mockGetArtifactHung)
|
const mockSlowGetArtifact = jest.fn(mockGetArtifactHung)
|
||||||
|
|
||||||
@@ -641,12 +651,331 @@ describe('download-artifact', () => {
|
|||||||
{timeout: 2}
|
{timeout: 2}
|
||||||
)
|
)
|
||||||
expect(true).toBe(false) // should not be called
|
expect(true).toBe(false) // should not be called
|
||||||
} catch (e) {
|
} catch (error: unknown) {
|
||||||
|
const e = error as Error
|
||||||
expect(e).toBeInstanceOf(Error)
|
expect(e).toBeInstanceOf(Error)
|
||||||
expect(e.message).toContain('did not respond in 2ms')
|
expect(e.message).toContain('did not respond in 2ms')
|
||||||
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
expect(mockSlowGetArtifact).toHaveBeenCalledTimes(1)
|
expect(mockSlowGetArtifact).toHaveBeenCalledTimes(1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should extract zip file when content-type is application/zip', async () => {
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetArtifactSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify files were extracted (not saved as a single file)
|
||||||
|
await expectExtractedArchive(fixtures.workspaceDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save raw file without extracting when content-type is not a zip', async () => {
|
||||||
|
const rawFileContent = 'This is a raw text file, not a zip'
|
||||||
|
const rawFileName = 'my-artifact.txt'
|
||||||
|
|
||||||
|
const mockGetRawFile = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'text/plain'
|
||||||
|
message.headers['content-disposition'] =
|
||||||
|
`attachment; filename="${rawFileName}"`
|
||||||
|
message.push(Buffer.from(rawFileContent))
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetRawFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify file was saved as-is, not extracted
|
||||||
|
const savedFilePath = path.join(fixtures.workspaceDir, rawFileName)
|
||||||
|
expect(fs.existsSync(savedFilePath)).toBe(true)
|
||||||
|
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save raw file with default name when content-disposition is missing', async () => {
|
||||||
|
const rawFileContent = 'Binary content here'
|
||||||
|
|
||||||
|
const mockGetRawFileNoDisposition = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'application/octet-stream'
|
||||||
|
// No content-disposition header
|
||||||
|
message.push(Buffer.from(rawFileContent))
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetRawFileNoDisposition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify file was saved with default name 'artifact'
|
||||||
|
const savedFilePath = path.join(fixtures.workspaceDir, 'artifact')
|
||||||
|
expect(fs.existsSync(savedFilePath)).toBe(true)
|
||||||
|
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not attempt to unzip when content-type is image/png', async () => {
|
||||||
|
const pngFileName = 'screenshot.png'
|
||||||
|
// Simple PNG header bytes for testing
|
||||||
|
const pngContent = Buffer.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockGetPngFile = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'image/png'
|
||||||
|
message.headers['content-disposition'] =
|
||||||
|
`attachment; filename="${pngFileName}"`
|
||||||
|
message.push(pngContent)
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetPngFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify PNG was saved as-is
|
||||||
|
const savedFilePath = path.join(fixtures.workspaceDir, pngFileName)
|
||||||
|
expect(fs.existsSync(savedFilePath)).toBe(true)
|
||||||
|
expect(fs.readFileSync(savedFilePath)).toEqual(pngContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract when content-type is application/x-zip-compressed', async () => {
|
||||||
|
const mockGetZipCompressed = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'application/x-zip-compressed'
|
||||||
|
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetZipCompressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify files were extracted
|
||||||
|
await expectExtractedArchive(fixtures.workspaceDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract zip when URL ends with .zip even if content-type is not application/zip', async () => {
|
||||||
|
const blobUrlWithZipExtension =
|
||||||
|
'https://blob-storage.local/artifact.zip?sig=abc123'
|
||||||
|
|
||||||
|
const mockGetZipByUrl = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
// Azure Blob Storage may return a generic content-type
|
||||||
|
message.headers['content-type'] = 'application/octet-stream'
|
||||||
|
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetZipByUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
blobUrlWithZipExtension,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify files were extracted based on URL .zip extension
|
||||||
|
await expectExtractedArchive(fixtures.workspaceDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip decompression when skipDecompress option is true even for zip content-type', async () => {
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetArtifactSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir,
|
||||||
|
{skipDecompress: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify zip was saved as-is, not extracted
|
||||||
|
// When skipDecompress is true, the file should be saved with default name 'artifact'
|
||||||
|
const savedFilePath = path.join(fixtures.workspaceDir, 'artifact')
|
||||||
|
expect(fs.existsSync(savedFilePath)).toBe(true)
|
||||||
|
// The saved file should be the raw zip content
|
||||||
|
const savedContent = fs.readFileSync(savedFilePath)
|
||||||
|
const originalZipContent = fs.readFileSync(fixtures.exampleArtifact.path)
|
||||||
|
expect(savedContent).toEqual(originalZipContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sanitize path traversal attempts in Content-Disposition filename', async () => {
|
||||||
|
const rawFileContent = 'malicious content'
|
||||||
|
const maliciousFileName = '../../../etc/passwd'
|
||||||
|
|
||||||
|
const mockGetMaliciousFile = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'text/plain'
|
||||||
|
message.headers['content-disposition'] =
|
||||||
|
`attachment; filename="${maliciousFileName}"`
|
||||||
|
message.push(Buffer.from(rawFileContent))
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetMaliciousFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// Verify file was saved with sanitized name (just 'passwd', not the full path)
|
||||||
|
const sanitizedFileName = 'passwd'
|
||||||
|
const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName)
|
||||||
|
expect(fs.existsSync(savedFilePath)).toBe(true)
|
||||||
|
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
|
||||||
|
|
||||||
|
// Verify the file was NOT written outside the workspace directory
|
||||||
|
const maliciousPath = path.resolve(
|
||||||
|
fixtures.workspaceDir,
|
||||||
|
maliciousFileName
|
||||||
|
)
|
||||||
|
expect(fs.existsSync(maliciousPath)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle encoded path traversal attempts in Content-Disposition filename', async () => {
|
||||||
|
const rawFileContent = 'encoded malicious content'
|
||||||
|
// URL encoded version of ../../../etc/passwd
|
||||||
|
const encodedMaliciousFileName = '..%2F..%2F..%2Fetc%2Fpasswd'
|
||||||
|
|
||||||
|
const mockGetEncodedMaliciousFile = jest.fn(() => {
|
||||||
|
const message = new http.IncomingMessage(new net.Socket())
|
||||||
|
message.statusCode = 200
|
||||||
|
message.headers['content-type'] = 'application/octet-stream'
|
||||||
|
message.headers['content-disposition'] =
|
||||||
|
`attachment; filename="${encodedMaliciousFileName}"`
|
||||||
|
message.push(Buffer.from(rawFileContent))
|
||||||
|
message.push(null)
|
||||||
|
return {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
get: mockGetEncodedMaliciousFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamExtractExternal(
|
||||||
|
fixtures.blobStorageUrl,
|
||||||
|
fixtures.workspaceDir
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
|
||||||
|
// After decoding and sanitizing, should just be 'passwd'
|
||||||
|
const sanitizedFileName = 'passwd'
|
||||||
|
const savedFilePath = path.join(fixtures.workspaceDir, sanitizedFileName)
|
||||||
|
expect(fs.existsSync(savedFilePath)).toBe(true)
|
||||||
|
expect(fs.readFileSync(savedFilePath, 'utf8')).toBe(rawFileContent)
|
||||||
|
|
||||||
|
// Verify the file was NOT written outside the workspace directory
|
||||||
|
const maliciousPathEncoded = path.resolve(
|
||||||
|
fixtures.workspaceDir,
|
||||||
|
encodedMaliciousFileName
|
||||||
|
)
|
||||||
|
expect(fs.existsSync(maliciousPathEncoded)).toBe(false)
|
||||||
|
|
||||||
|
const maliciousPath = path.resolve(
|
||||||
|
fixtures.workspaceDir,
|
||||||
|
'../../../etc/passwd'
|
||||||
|
)
|
||||||
|
expect(fs.existsSync(maliciousPath)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+2
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "6.0.0",
|
"version": "6.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "6.0.0",
|
"version": "6.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^3.0.0",
|
"@actions/core": "^3.0.0",
|
||||||
@@ -1795,7 +1795,6 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "6.0.0",
|
"version": "6.1.0",
|
||||||
"preview": true,
|
"preview": true,
|
||||||
"description": "Actions artifact lib",
|
"description": "Actions artifact lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
|
import * as fsSync from 'fs'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import * as stream from 'stream'
|
import * as stream from 'stream'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
import * as github from '@actions/github'
|
import * as github from '@actions/github'
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
@@ -43,12 +45,13 @@ async function exists(path: string): Promise<boolean> {
|
|||||||
|
|
||||||
async function streamExtract(
|
async function streamExtract(
|
||||||
url: string,
|
url: string,
|
||||||
directory: string
|
directory: string,
|
||||||
|
skipDecompress?: boolean
|
||||||
): Promise<StreamExtractResponse> {
|
): Promise<StreamExtractResponse> {
|
||||||
let retryCount = 0
|
let retryCount = 0
|
||||||
while (retryCount < 5) {
|
while (retryCount < 5) {
|
||||||
try {
|
try {
|
||||||
return await streamExtractExternal(url, directory)
|
return await streamExtractExternal(url, directory, {skipDecompress})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
retryCount++
|
retryCount++
|
||||||
core.debug(
|
core.debug(
|
||||||
@@ -65,8 +68,9 @@ async function streamExtract(
|
|||||||
export async function streamExtractExternal(
|
export async function streamExtractExternal(
|
||||||
url: string,
|
url: string,
|
||||||
directory: string,
|
directory: string,
|
||||||
opts: {timeout: number} = {timeout: 30 * 1000}
|
opts: {timeout?: number; skipDecompress?: boolean} = {}
|
||||||
): Promise<StreamExtractResponse> {
|
): Promise<StreamExtractResponse> {
|
||||||
|
const {timeout = 30 * 1000, skipDecompress = false} = opts
|
||||||
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) {
|
||||||
@@ -75,49 +79,91 @@ export async function streamExtractExternal(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentType = response.message.headers['content-type'] || ''
|
||||||
|
const mimeType = contentType.split(';', 1)[0].trim().toLowerCase()
|
||||||
|
|
||||||
|
// Check if the URL path ends with .zip (ignoring query parameters)
|
||||||
|
const urlPath = new URL(url).pathname.toLowerCase()
|
||||||
|
const urlEndsWithZip = urlPath.endsWith('.zip')
|
||||||
|
|
||||||
|
const isZip =
|
||||||
|
mimeType === 'application/zip' ||
|
||||||
|
mimeType === 'application/x-zip-compressed' ||
|
||||||
|
mimeType === 'application/zip-compressed' ||
|
||||||
|
urlEndsWithZip
|
||||||
|
|
||||||
|
// Extract filename from Content-Disposition header
|
||||||
|
const contentDisposition =
|
||||||
|
response.message.headers['content-disposition'] || ''
|
||||||
|
let fileName = 'artifact'
|
||||||
|
const filenameMatch = contentDisposition.match(
|
||||||
|
/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i
|
||||||
|
)
|
||||||
|
if (filenameMatch && filenameMatch[1]) {
|
||||||
|
// Sanitize fileName to prevent path traversal attacks
|
||||||
|
// Use path.basename to extract only the filename component
|
||||||
|
fileName = path.basename(decodeURIComponent(filenameMatch[1].trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
core.debug(
|
||||||
|
`Content-Type: ${contentType}, mimeType: ${mimeType}, urlEndsWithZip: ${urlEndsWithZip}, isZip: ${isZip}, skipDecompress: ${skipDecompress}`
|
||||||
|
)
|
||||||
|
core.debug(
|
||||||
|
`Content-Disposition: ${contentDisposition}, fileName: ${fileName}`
|
||||||
|
)
|
||||||
|
|
||||||
let sha256Digest: string | undefined = undefined
|
let sha256Digest: string | undefined = undefined
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timerFn = (): void => {
|
const timerFn = (): void => {
|
||||||
const timeoutError = new Error(
|
const timeoutError = new Error(
|
||||||
`Blob storage chunk did not respond in ${opts.timeout}ms`
|
`Blob storage chunk did not respond in ${timeout}ms`
|
||||||
)
|
)
|
||||||
response.message.destroy(timeoutError)
|
response.message.destroy(timeoutError)
|
||||||
reject(timeoutError)
|
reject(timeoutError)
|
||||||
}
|
}
|
||||||
const timer = setTimeout(timerFn, opts.timeout)
|
const timer = setTimeout(timerFn, timeout)
|
||||||
|
|
||||||
|
const onError = (error: Error): void => {
|
||||||
|
core.debug(`response.message: Artifact download failed: ${error.message}`)
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
const hashStream = crypto.createHash('sha256').setEncoding('hex')
|
const hashStream = crypto.createHash('sha256').setEncoding('hex')
|
||||||
const passThrough = new stream.PassThrough()
|
const passThrough = new stream.PassThrough()
|
||||||
|
|
||||||
response.message.pipe(passThrough)
|
|
||||||
passThrough.pipe(hashStream)
|
|
||||||
const extractStream = passThrough
|
|
||||||
|
|
||||||
extractStream
|
|
||||||
.on('data', () => {
|
.on('data', () => {
|
||||||
timer.refresh()
|
timer.refresh()
|
||||||
})
|
})
|
||||||
.on('error', (error: Error) => {
|
.on('error', onError)
|
||||||
core.debug(
|
|
||||||
`response.message: Artifact download failed: ${error.message}`
|
response.message.pipe(passThrough)
|
||||||
)
|
passThrough.pipe(hashStream)
|
||||||
clearTimeout(timer)
|
|
||||||
reject(error)
|
const onClose = (): void => {
|
||||||
})
|
clearTimeout(timer)
|
||||||
.pipe(unzip.Extract({path: directory}))
|
if (hashStream) {
|
||||||
.on('close', () => {
|
hashStream.end()
|
||||||
clearTimeout(timer)
|
sha256Digest = hashStream.read() as string
|
||||||
if (hashStream) {
|
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
|
||||||
hashStream.end()
|
}
|
||||||
sha256Digest = hashStream.read() as string
|
resolve({sha256Digest: `sha256:${sha256Digest}`})
|
||||||
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
|
}
|
||||||
}
|
|
||||||
resolve({sha256Digest: `sha256:${sha256Digest}`})
|
if (isZip && !skipDecompress) {
|
||||||
})
|
// Extract zip file
|
||||||
.on('error', (error: Error) => {
|
passThrough
|
||||||
reject(error)
|
.pipe(unzip.Extract({path: directory}))
|
||||||
})
|
.on('close', onClose)
|
||||||
|
.on('error', onError)
|
||||||
|
} else {
|
||||||
|
// Save raw file without extracting
|
||||||
|
const filePath = path.join(directory, fileName)
|
||||||
|
const writeStream = fsSync.createWriteStream(filePath)
|
||||||
|
|
||||||
|
core.info(`Downloading raw file (non-zip) to: ${filePath}`)
|
||||||
|
passThrough.pipe(writeStream).on('close', onClose).on('error', onError)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +209,11 @@ export async function downloadArtifactPublic(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
core.info(`Starting download of artifact to: ${downloadPath}`)
|
core.info(`Starting download of artifact to: ${downloadPath}`)
|
||||||
const extractResponse = await streamExtract(location, downloadPath)
|
const extractResponse = await streamExtract(
|
||||||
|
location,
|
||||||
|
downloadPath,
|
||||||
|
options?.skipDecompress
|
||||||
|
)
|
||||||
core.info(`Artifact download completed successfully.`)
|
core.info(`Artifact download completed successfully.`)
|
||||||
if (options?.expectedHash) {
|
if (options?.expectedHash) {
|
||||||
if (options?.expectedHash !== extractResponse.sha256Digest) {
|
if (options?.expectedHash !== extractResponse.sha256Digest) {
|
||||||
@@ -224,7 +274,11 @@ export async function downloadArtifactInternal(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
core.info(`Starting download of artifact to: ${downloadPath}`)
|
core.info(`Starting download of artifact to: ${downloadPath}`)
|
||||||
const extractResponse = await streamExtract(signedUrl, downloadPath)
|
const extractResponse = await streamExtract(
|
||||||
|
signedUrl,
|
||||||
|
downloadPath,
|
||||||
|
options?.skipDecompress
|
||||||
|
)
|
||||||
core.info(`Artifact download completed successfully.`)
|
core.info(`Artifact download completed successfully.`)
|
||||||
if (options?.expectedHash) {
|
if (options?.expectedHash) {
|
||||||
if (options?.expectedHash !== extractResponse.sha256Digest) {
|
if (options?.expectedHash !== extractResponse.sha256Digest) {
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ export interface DownloadArtifactOptions {
|
|||||||
* matches the expected hash.
|
* matches the expected hash.
|
||||||
*/
|
*/
|
||||||
expectedHash?: string
|
expectedHash?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the downloaded artifact will not be automatically extracted/decompressed.
|
||||||
|
* The artifact will be saved as-is to the destination path.
|
||||||
|
*/
|
||||||
|
skipDecompress?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamExtractResponse {
|
export interface StreamExtractResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user