Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94d96c596 | ||
|
|
59d7acc324 |
@@ -24,7 +24,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: macos-latest-large
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: setup repo
|
- name: setup repo
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
path: packages/${{ github.event.inputs.package }}/*.tgz
|
path: packages/${{ github.event.inputs.package }}/*.tgz
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-slim
|
runs-on: macos-latest-large
|
||||||
needs: test
|
needs: test
|
||||||
environment: npm-publish
|
environment: npm-publish
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
Generated
+1179
-654
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -16,7 +16,7 @@
|
|||||||
"test": "jest --testTimeout 70000"
|
"test": "jest --testTimeout 70000"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.4",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.1.0",
|
"@types/node": "^24.1.0",
|
||||||
"@types/signale": "^1.4.1",
|
"@types/signale": "^1.4.1",
|
||||||
"concurrently": "^6.1.0",
|
"concurrently": "^6.1.0",
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
"eslint-plugin-jest": "^27.2.3",
|
"eslint-plugin-jest": "^27.2.3",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"flow-bin": "^0.115.0",
|
"flow-bin": "^0.115.0",
|
||||||
"jest": "^29.6.4",
|
"jest": "^30.2.0",
|
||||||
"lerna": "^6.4.1",
|
"lerna": "^6.4.1",
|
||||||
"nx": "16.6.0",
|
"nx": "16.6.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
# @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`
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ describe('artifact-http-client', () => {
|
|||||||
name: 'artifact',
|
name: 'artifact',
|
||||||
version: 4
|
version: 4
|
||||||
})
|
})
|
||||||
}).rejects.toThrowError(
|
}).rejects.toThrow(
|
||||||
'Failed to make request after 5 attempts: Failed request: (500) Internal Server Error'
|
'Failed to make request after 5 attempts: Failed request: (500) Internal Server Error'
|
||||||
)
|
)
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
@@ -227,7 +227,7 @@ describe('artifact-http-client', () => {
|
|||||||
name: 'artifact',
|
name: 'artifact',
|
||||||
version: 4
|
version: 4
|
||||||
})
|
})
|
||||||
}).rejects.toThrowError(
|
}).rejects.toThrow(
|
||||||
'Received non-retryable error: Failed request: (401) Unauthorized'
|
'Received non-retryable error: Failed request: (401) Unauthorized'
|
||||||
)
|
)
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
@@ -271,7 +271,7 @@ describe('artifact-http-client', () => {
|
|||||||
name: 'artifact',
|
name: 'artifact',
|
||||||
version: 4
|
version: 4
|
||||||
})
|
})
|
||||||
}).rejects.toThrowError(
|
}).rejects.toThrow(
|
||||||
'Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run'
|
'Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run'
|
||||||
)
|
)
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
@@ -306,7 +306,7 @@ describe('artifact-http-client', () => {
|
|||||||
name: 'artifact',
|
name: 'artifact',
|
||||||
version: 4
|
version: 4
|
||||||
})
|
})
|
||||||
}).rejects.toThrowError(new NetworkError('ENOTFOUND').message)
|
}).rejects.toThrow(new NetworkError('ENOTFOUND').message)
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
@@ -341,7 +341,7 @@ describe('artifact-http-client', () => {
|
|||||||
name: 'artifact',
|
name: 'artifact',
|
||||||
version: 4
|
version: 4
|
||||||
})
|
})
|
||||||
}).rejects.toThrowError(new UsageError().message)
|
}).rejects.toThrow(new UsageError().message)
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ 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 {
|
||||||
@@ -115,7 +114,6 @@ 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 {
|
||||||
@@ -136,7 +134,6 @@ 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 {
|
||||||
@@ -622,17 +619,10 @@ 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)
|
||||||
|
|
||||||
@@ -651,331 +641,12 @@ describe('download-artifact', () => {
|
|||||||
{timeout: 2}
|
{timeout: 2}
|
||||||
)
|
)
|
||||||
expect(true).toBe(false) // should not be called
|
expect(true).toBe(false) // should not be called
|
||||||
} catch (error: unknown) {
|
} catch (e) {
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ describe('get-artifact', () => {
|
|||||||
fixtures.token
|
fixtures.token
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response).rejects.toThrowError(ArtifactNotFoundError)
|
expect(response).rejects.toThrow(ArtifactNotFoundError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail if non-200 response', async () => {
|
it('should fail if non-200 response', async () => {
|
||||||
@@ -155,7 +155,7 @@ describe('get-artifact', () => {
|
|||||||
fixtures.token
|
fixtures.token
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response).rejects.toThrowError(InvalidResponseError)
|
expect(response).rejects.toThrow(InvalidResponseError)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ describe('get-artifact', () => {
|
|||||||
|
|
||||||
const response = getArtifactInternal(fixtures.artifacts[0].name)
|
const response = getArtifactInternal(fixtures.artifacts[0].name)
|
||||||
|
|
||||||
expect(response).rejects.toThrowError(ArtifactNotFoundError)
|
expect(response).rejects.toThrow(ArtifactNotFoundError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail if non-200 response', async () => {
|
it('should fail if non-200 response', async () => {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ describe('upload-artifact', () => {
|
|||||||
fixtures.inputs.files,
|
fixtures.inputs.files,
|
||||||
fixtures.inputs.rootDirectory
|
fixtures.inputs.rootDirectory
|
||||||
)
|
)
|
||||||
await expect(uploadResp).rejects.toThrowError(FilesNotFoundError)
|
await expect(uploadResp).rejects.toThrow(FilesNotFoundError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should reject if no backend IDs are found', async () => {
|
it('should reject if no backend IDs are found', async () => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('get-backend-ids-from-token', () => {
|
|||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCIsImlhdCI6MTUxNjIzOTAyMn0.K0IEoULZteGevF38G94xiaA8zcZ5UlKWfGfqE6q3dhw'
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCIsImlhdCI6MTUxNjIzOTAyMn0.K0IEoULZteGevF38G94xiaA8zcZ5UlKWfGfqE6q3dhw'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(util.getBackendIdsFromToken).toThrowError(
|
expect(util.getBackendIdsFromToken).toThrow(
|
||||||
'Failed to get backend IDs: The provided JWT token is invalid'
|
'Failed to get backend IDs: The provided JWT token is invalid'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -38,7 +38,7 @@ describe('get-backend-ids-from-token', () => {
|
|||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhIiwiaWF0IjoxNTE2MjM5MDIyfQ.7D0_LRfRFRZFImHQ7GxH2S6ZyFjjZ5U0ujjGCfle1XE'
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhIiwiaWF0IjoxNTE2MjM5MDIyfQ.7D0_LRfRFRZFImHQ7GxH2S6ZyFjjZ5U0ujjGCfle1XE'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(util.getBackendIdsFromToken).toThrowError(
|
expect(util.getBackendIdsFromToken).toThrow(
|
||||||
'Failed to get backend IDs: The provided JWT token is invalid'
|
'Failed to get backend IDs: The provided JWT token is invalid'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -46,7 +46,7 @@ describe('get-backend-ids-from-token', () => {
|
|||||||
it('should throw an error when the token is in an invalid format', () => {
|
it('should throw an error when the token is in an invalid format', () => {
|
||||||
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('token')
|
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('token')
|
||||||
|
|
||||||
expect(util.getBackendIdsFromToken).toThrowError('Invalid token specified')
|
expect(util.getBackendIdsFromToken).toThrow('Invalid token specified')
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should throw an error when the token doesn't have the right field", () => {
|
it("should throw an error when the token doesn't have the right field", () => {
|
||||||
@@ -56,7 +56,7 @@ describe('get-backend-ids-from-token', () => {
|
|||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(util.getBackendIdsFromToken).toThrowError(
|
expect(util.getBackendIdsFromToken).toThrow(
|
||||||
'Failed to get backend IDs: The provided JWT token is invalid'
|
'Failed to get backend IDs: The provided JWT token is invalid'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+6
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "6.1.0",
|
"version": "6.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@actions/artifact",
|
"name": "@actions/artifact",
|
||||||
"version": "6.1.0",
|
"version": "6.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^3.0.0",
|
"@actions/core": "^3.0.0",
|
||||||
@@ -1112,9 +1112,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz",
|
||||||
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
|
"integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1795,6 +1795,7 @@
|
|||||||
"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.1.0",
|
"version": "6.0.0",
|
||||||
"preview": true,
|
"preview": true,
|
||||||
"description": "Actions artifact lib",
|
"description": "Actions artifact lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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'
|
||||||
@@ -45,13 +43,12 @@ 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, {skipDecompress})
|
return await streamExtractExternal(url, directory)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
retryCount++
|
retryCount++
|
||||||
core.debug(
|
core.debug(
|
||||||
@@ -68,9 +65,8 @@ async function streamExtract(
|
|||||||
export async function streamExtractExternal(
|
export async function streamExtractExternal(
|
||||||
url: string,
|
url: string,
|
||||||
directory: string,
|
directory: string,
|
||||||
opts: {timeout?: number; skipDecompress?: boolean} = {}
|
opts: {timeout: number} = {timeout: 30 * 1000}
|
||||||
): 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) {
|
||||||
@@ -79,91 +75,49 @@ 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 ${timeout}ms`
|
`Blob storage chunk did not respond in ${opts.timeout}ms`
|
||||||
)
|
)
|
||||||
response.message.destroy(timeoutError)
|
response.message.destroy(timeoutError)
|
||||||
reject(timeoutError)
|
reject(timeoutError)
|
||||||
}
|
}
|
||||||
const timer = setTimeout(timerFn, timeout)
|
const timer = setTimeout(timerFn, opts.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()
|
||||||
.on('data', () => {
|
|
||||||
timer.refresh()
|
|
||||||
})
|
|
||||||
.on('error', onError)
|
|
||||||
|
|
||||||
response.message.pipe(passThrough)
|
response.message.pipe(passThrough)
|
||||||
passThrough.pipe(hashStream)
|
passThrough.pipe(hashStream)
|
||||||
|
const extractStream = passThrough
|
||||||
|
|
||||||
const onClose = (): void => {
|
extractStream
|
||||||
clearTimeout(timer)
|
.on('data', () => {
|
||||||
if (hashStream) {
|
timer.refresh()
|
||||||
hashStream.end()
|
})
|
||||||
sha256Digest = hashStream.read() as string
|
.on('error', (error: Error) => {
|
||||||
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
|
core.debug(
|
||||||
}
|
`response.message: Artifact download failed: ${error.message}`
|
||||||
resolve({sha256Digest: `sha256:${sha256Digest}`})
|
)
|
||||||
}
|
clearTimeout(timer)
|
||||||
|
reject(error)
|
||||||
if (isZip && !skipDecompress) {
|
})
|
||||||
// Extract zip file
|
.pipe(unzip.Extract({path: directory}))
|
||||||
passThrough
|
.on('close', () => {
|
||||||
.pipe(unzip.Extract({path: directory}))
|
clearTimeout(timer)
|
||||||
.on('close', onClose)
|
if (hashStream) {
|
||||||
.on('error', onError)
|
hashStream.end()
|
||||||
} else {
|
sha256Digest = hashStream.read() as string
|
||||||
// Save raw file without extracting
|
core.info(`SHA256 digest of downloaded artifact is ${sha256Digest}`)
|
||||||
const filePath = path.join(directory, fileName)
|
}
|
||||||
const writeStream = fsSync.createWriteStream(filePath)
|
resolve({sha256Digest: `sha256:${sha256Digest}`})
|
||||||
|
})
|
||||||
core.info(`Downloading raw file (non-zip) to: ${filePath}`)
|
.on('error', (error: Error) => {
|
||||||
passThrough.pipe(writeStream).on('close', onClose).on('error', onError)
|
reject(error)
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +163,7 @@ 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(
|
const extractResponse = await streamExtract(location, downloadPath)
|
||||||
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) {
|
||||||
@@ -274,11 +224,7 @@ 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(
|
const extractResponse = await streamExtract(signedUrl, downloadPath)
|
||||||
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,12 +113,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# @actions/attest Releases
|
# @actions/attest Releases
|
||||||
|
|
||||||
## 3.1.0
|
|
||||||
|
|
||||||
- Add support for `ACTIONS_ORCHESTRATION_ID` in user-agent [#2320](https://github.com/actions/toolkit/pull/2320)
|
|
||||||
|
|
||||||
## 3.0.0
|
## 3.0.0
|
||||||
|
|
||||||
- **Breaking change**: Package is now ESM-only
|
- **Breaking change**: Package is now ESM-only
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||||
|
|
||||||
exports[`buildIntotoStatement returns an intoto statement 1`] = `
|
exports[`buildIntotoStatement returns an intoto statement 1`] = `
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||||
|
|
||||||
exports[`provenance functions buildSLSAProvenancePredicate returns a provenance hydrated from an OIDC token 1`] = `
|
exports[`provenance functions buildSLSAProvenancePredicate returns a provenance hydrated from an OIDC token 1`] = `
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('attest', () => {
|
|||||||
predicate: {bar: 'baz'},
|
predicate: {bar: 'baz'},
|
||||||
token: 'token'
|
token: 'token'
|
||||||
}
|
}
|
||||||
expect(attest(options)).rejects.toThrowError(
|
expect(attest(options)).rejects.toThrow(
|
||||||
'Must provide either subjectName and subjectDigest or subjects'
|
'Must provide either subjectName and subjectDigest or subjects'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/attest",
|
"name": "@actions/attest",
|
||||||
"version": "3.1.0",
|
"version": "3.0.0",
|
||||||
"description": "Actions attestation lib",
|
"description": "Actions attestation lib",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"github",
|
"github",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: run tests from root\" && exit 1",
|
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||||
"tsc": "tsc && cp src/package-version.cjs lib/"
|
"tsc": "tsc"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/actions/toolkit/issues"
|
"url": "https://github.com/actions/toolkit/issues"
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
// This file exists as a CommonJS module to read the version from package.json.
|
|
||||||
// In an ESM package, using `require()` directly in .ts files requires disabling
|
|
||||||
// ESLint rules and doesn't work reliably across all Node.js versions.
|
|
||||||
// By keeping this as a .cjs file, we can use require() naturally and export
|
|
||||||
// the version for the ESM modules to import.
|
|
||||||
const packageJson = require('../package.json')
|
|
||||||
module.exports = {version: packageJson.version}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as github from '@actions/github'
|
import * as github from '@actions/github'
|
||||||
import {retry} from '@octokit/plugin-retry'
|
import {retry} from '@octokit/plugin-retry'
|
||||||
import {RequestHeaders} from '@octokit/types'
|
import {RequestHeaders} from '@octokit/types'
|
||||||
import {version} from './package-version.cjs'
|
|
||||||
|
|
||||||
const CREATE_ATTESTATION_REQUEST = 'POST /repos/{owner}/{repo}/attestations'
|
const CREATE_ATTESTATION_REQUEST = 'POST /repos/{owner}/{repo}/attestations'
|
||||||
const DEFAULT_RETRY_COUNT = 5
|
const DEFAULT_RETRY_COUNT = 5
|
||||||
@@ -25,16 +24,11 @@ export const writeAttestation = async (
|
|||||||
const retries = options.retry ?? DEFAULT_RETRY_COUNT
|
const retries = options.retry ?? DEFAULT_RETRY_COUNT
|
||||||
const octokit = github.getOctokit(token, {retry: {retries}}, retry)
|
const octokit = github.getOctokit(token, {retry: {retries}}, retry)
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'User-Agent': getUserAgent(),
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await octokit.request(CREATE_ATTESTATION_REQUEST, {
|
const response = await octokit.request(CREATE_ATTESTATION_REQUEST, {
|
||||||
owner: github.context.repo.owner,
|
owner: github.context.repo.owner,
|
||||||
repo: github.context.repo.repo,
|
repo: github.context.repo.repo,
|
||||||
headers,
|
headers: options.headers,
|
||||||
bundle: attestation as {
|
bundle: attestation as {
|
||||||
mediaType?: string
|
mediaType?: string
|
||||||
verificationMaterial?: {[key: string]: unknown}
|
verificationMaterial?: {[key: string]: unknown}
|
||||||
@@ -52,17 +46,3 @@ export const writeAttestation = async (
|
|||||||
throw new Error(`Failed to persist attestation: ${message}`)
|
throw new Error(`Failed to persist attestation: ${message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserAgent = (): string => {
|
|
||||||
const baseUserAgent = `@actions/attest-${version}`
|
|
||||||
|
|
||||||
const orchId = process.env['ACTIONS_ORCHESTRATION_ID']
|
|
||||||
if (orchId) {
|
|
||||||
// Sanitize the orchestration ID to ensure it contains only valid characters
|
|
||||||
// Valid characters: 0-9, a-z, _, -, .
|
|
||||||
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_')
|
|
||||||
return `${baseUserAgent} actions_orchestration_id/${sanitizedId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseUserAgent
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ test('unlinkFile unlinks file', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('assertDefined throws if undefined', () => {
|
test('assertDefined throws if undefined', () => {
|
||||||
expect(() => cacheUtils.assertDefined('test', undefined)).toThrowError()
|
expect(() => cacheUtils.assertDefined('test', undefined)).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('assertDefined returns value', () => {
|
test('assertDefined returns value', () => {
|
||||||
|
|||||||
+4
-4
@@ -29,7 +29,7 @@ beforeAll(() => {
|
|||||||
test('restore with no path should fail', async () => {
|
test('restore with no path should fail', async () => {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
await expect(restoreCache(paths, key)).rejects.toThrow(
|
||||||
`Path Validation Error: At least one directory or file path is required`
|
`Path Validation Error: At least one directory or file path is required`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -38,7 +38,7 @@ test('restore with too many keys should fail', async () => {
|
|||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
const restoreKeys = [...Array(20).keys()].map(x => x.toString())
|
const restoreKeys = [...Array(20).keys()].map(x => x.toString())
|
||||||
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrowError(
|
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrow(
|
||||||
`Key Validation Error: Keys are limited to a maximum of 10.`
|
`Key Validation Error: Keys are limited to a maximum of 10.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -46,7 +46,7 @@ test('restore with too many keys should fail', async () => {
|
|||||||
test('restore with large key should fail', async () => {
|
test('restore with large key should fail', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'foo'.repeat(512) // Over the 512 character limit
|
const key = 'foo'.repeat(512) // Over the 512 character limit
|
||||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
await expect(restoreCache(paths, key)).rejects.toThrow(
|
||||||
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -54,7 +54,7 @@ test('restore with large key should fail', async () => {
|
|||||||
test('restore with invalid key should fail', async () => {
|
test('restore with invalid key should fail', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'comma,comma'
|
const key = 'comma,comma'
|
||||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
await expect(restoreCache(paths, key)).rejects.toThrow(
|
||||||
`Key Validation Error: ${key} cannot contain commas.`
|
`Key Validation Error: ${key} cannot contain commas.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+4
-4
@@ -44,7 +44,7 @@ afterEach(() => {
|
|||||||
test('restore with no path should fail', async () => {
|
test('restore with no path should fail', async () => {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
await expect(restoreCache(paths, key)).rejects.toThrow(
|
||||||
`Path Validation Error: At least one directory or file path is required`
|
`Path Validation Error: At least one directory or file path is required`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -53,7 +53,7 @@ test('restore with too many keys should fail', async () => {
|
|||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
const restoreKeys = [...Array(20).keys()].map(x => x.toString())
|
const restoreKeys = [...Array(20).keys()].map(x => x.toString())
|
||||||
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrowError(
|
await expect(restoreCache(paths, key, restoreKeys)).rejects.toThrow(
|
||||||
`Key Validation Error: Keys are limited to a maximum of 10.`
|
`Key Validation Error: Keys are limited to a maximum of 10.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -61,7 +61,7 @@ test('restore with too many keys should fail', async () => {
|
|||||||
test('restore with large key should fail', async () => {
|
test('restore with large key should fail', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'foo'.repeat(512) // Over the 512 character limit
|
const key = 'foo'.repeat(512) // Over the 512 character limit
|
||||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
await expect(restoreCache(paths, key)).rejects.toThrow(
|
||||||
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -69,7 +69,7 @@ test('restore with large key should fail', async () => {
|
|||||||
test('restore with invalid key should fail', async () => {
|
test('restore with invalid key should fail', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'comma,comma'
|
const key = 'comma,comma'
|
||||||
await expect(restoreCache(paths, key)).rejects.toThrowError(
|
await expect(restoreCache(paths, key)).rejects.toThrow(
|
||||||
`Key Validation Error: ${key} cannot contain commas.`
|
`Key Validation Error: ${key} cannot contain commas.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -40,7 +40,7 @@ beforeAll(() => {
|
|||||||
test('save with missing input should fail', async () => {
|
test('save with missing input should fail', async () => {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||||
await expect(saveCache(paths, primaryKey)).rejects.toThrowError(
|
await expect(saveCache(paths, primaryKey)).rejects.toThrow(
|
||||||
`Path Validation Error: At least one directory or file path is required`
|
`Path Validation Error: At least one directory or file path is required`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -342,7 +342,7 @@ test('save with non existing path should not save cache', async () => {
|
|||||||
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async () => {
|
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async () => {
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
await expect(saveCache([path], primaryKey)).rejects.toThrowError(
|
await expect(saveCache([path], primaryKey)).rejects.toThrow(
|
||||||
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
|
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -54,7 +54,7 @@ test('save with missing input should fail', async () => {
|
|||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
const key = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||||
|
|
||||||
await expect(saveCache(paths, key)).rejects.toThrowError(
|
await expect(saveCache(paths, key)).rejects.toThrow(
|
||||||
`Path Validation Error: At least one directory or file path is required`
|
`Path Validation Error: At least one directory or file path is required`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -581,7 +581,7 @@ test('save with non existing path should not save cache using v2 saveCache', asy
|
|||||||
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async () => {
|
jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async () => {
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
await expect(saveCache([path], key)).rejects.toThrowError(
|
await expect(saveCache([path], key)).rejects.toThrow(
|
||||||
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
|
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -677,8 +677,5 @@ describe('oidc-client-tests', () => {
|
|||||||
const http = new HttpClient('actions/oidc-client')
|
const http = new HttpClient('actions/oidc-client')
|
||||||
const res = await http.get(getTokenEndPoint())
|
const res = await http.get(getTokenEndPoint())
|
||||||
expect(res.message.statusCode).toBe(200)
|
expect(res.message.statusCode).toBe(200)
|
||||||
// Consume the response to close the socket
|
|
||||||
await res.readBody()
|
|
||||||
res.message.destroy()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ describe('@actions/exec', () => {
|
|||||||
|
|
||||||
expect(exitCode).toBe(0)
|
expect(exitCode).toBe(0)
|
||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} /c echo hello${os.EOL}`
|
`[command]${toolpath} /c echo hello${os.EOL}`
|
||||||
)
|
)
|
||||||
expect(outstream.write).toBeCalledWith(Buffer.from(`hello${os.EOL}`))
|
expect(outstream.write).toHaveBeenCalledWith(Buffer.from(`hello${os.EOL}`))
|
||||||
} else {
|
} else {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} -l -a${os.EOL}`
|
`[command]${toolpath} -l -a${os.EOL}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,12 +85,12 @@ describe('@actions/exec', () => {
|
|||||||
|
|
||||||
expect(exitCode).toBe(0)
|
expect(exitCode).toBe(0)
|
||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} /c echo hello${os.EOL}`
|
`[command]${toolpath} /c echo hello${os.EOL}`
|
||||||
)
|
)
|
||||||
expect(outstream.write).toBeCalledWith(Buffer.from(`hello${os.EOL}`))
|
expect(outstream.write).toHaveBeenCalledWith(Buffer.from(`hello${os.EOL}`))
|
||||||
} else {
|
} else {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} -l -a${os.EOL}`
|
`[command]${toolpath} -l -a${os.EOL}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -115,12 +115,12 @@ describe('@actions/exec', () => {
|
|||||||
|
|
||||||
expect(exitCode).toBe(0)
|
expect(exitCode).toBe(0)
|
||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} /c echo hello${os.EOL}`
|
`[command]${toolpath} /c echo hello${os.EOL}`
|
||||||
)
|
)
|
||||||
expect(outstream.write).toBeCalledWith(Buffer.from(`hello${os.EOL}`))
|
expect(outstream.write).toHaveBeenCalledWith(Buffer.from(`hello${os.EOL}`))
|
||||||
} else {
|
} else {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} -l -a${os.EOL}`
|
`[command]${toolpath} -l -a${os.EOL}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -182,11 +182,11 @@ describe('@actions/exec', () => {
|
|||||||
|
|
||||||
expect(failed).toBe(true)
|
expect(failed).toBe(true)
|
||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} /c non-existent${os.EOL}`
|
`[command]${toolpath} /c non-existent${os.EOL}`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
`[command]${toolpath} -l non-existent${os.EOL}`
|
`[command]${toolpath} -l non-existent${os.EOL}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -209,7 +209,7 @@ describe('@actions/exec', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(exitCode).toBe(0)
|
expect(exitCode).toBe(0)
|
||||||
expect(outstream.write).toBeCalledWith(
|
expect(outstream.write).toHaveBeenCalledWith(
|
||||||
Buffer.from('this is output to stderr')
|
Buffer.from('this is output to stderr')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -233,7 +233,7 @@ describe('@actions/exec', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(failed).toBe(true)
|
expect(failed).toBe(true)
|
||||||
expect(errstream.write).toBeCalledWith(
|
expect(errstream.write).toHaveBeenCalledWith(
|
||||||
Buffer.from('this is output to stderr')
|
Buffer.from('this is output to stderr')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -524,7 +524,7 @@ describe('@actions/exec', () => {
|
|||||||
const execOptions = getExecOptions()
|
const execOptions = getExecOptions()
|
||||||
execOptions.cwd = 'nonexistent/path'
|
execOptions.cwd = 'nonexistent/path'
|
||||||
|
|
||||||
await expect(exec.exec('ls', ['-all'], execOptions)).rejects.toThrowError(
|
await expect(exec.exec('ls', ['-all'], execOptions)).rejects.toThrow(
|
||||||
`The cwd: ${execOptions.cwd} does not exist!`
|
`The cwd: ${execOptions.cwd} does not exist!`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||||
|
|
||||||
exports[`@actions/context return error for context.repo when repository doesn't exist 1`] = `"context.repo requires a GITHUB_REPOSITORY environment variable like 'owner/repo'"`;
|
exports[`@actions/context return error for context.repo when repository doesn't exist 1`] = `"context.repo requires a GITHUB_REPOSITORY environment variable like 'owner/repo'"`;
|
||||||
|
|||||||
@@ -238,8 +238,6 @@ describe('basics', () => {
|
|||||||
'https://postman-echo.com/get'
|
'https://postman-echo.com/get'
|
||||||
)
|
)
|
||||||
expect(res.message.statusCode).toBe(200)
|
expect(res.message.statusCode).toBe(200)
|
||||||
// Consume the response to close the socket
|
|
||||||
res.message.destroy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does basic http delete request', async () => {
|
it('does basic http delete request', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user