Skip to content

Commit 70fc10c

Browse files
authored
Merge pull request #461 from actions/danwkennedy/digest-mismatch-behavior
Add a setting to specify what to do on hash mismatch and default it to `error`
2 parents ac21fcf + f258da9 commit 70fc10c

6 files changed

Lines changed: 182 additions & 9 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@ See also [upload-artifact](https://github.com/actions/upload-artifact).
2828
> [!IMPORTANT]
2929
> actions/download-artifact@v8 has been migrated to an ESM module. This should be transparent to the caller but forks might need to make significant changes.
3030
31+
> [!IMPORTANT]
32+
> Hash mismatches will now error by default. Users can override this behavior with a setting change (see below).
33+
3134
- Downloads will check the content-type returned to determine if a file can be decompressed and skip the decompression stage if so. This removes previous failures where we were trying to decompress a non-zip file. Since this is making a big change to the default behavior, we're making it opt-in via a version bump.
3235
- Users can also download a zip file without decompressing it with the new `skip-decompress` flag.
3336

37+
- Introduces a new parameter `digest-mismatch` that allows callers to specify what to do when the downloaded hash doesn't match the expected hash (`ignore`, `info`, `warn`, `error`). To ensure security by default, the default value is `error`.
38+
3439
- Chore: we've bumped versions on a lot of our dev packages to get them up to date with the latest bugfixes/security patches.
3540

3641
## v7 - What's new

__tests__/download.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,14 +234,39 @@ describe('download', () => {
234234
)
235235
})
236236

237-
test('warns when digest validation fails', async () => {
237+
test('errors when digest validation fails (default behavior)', async () => {
238238
const mockArtifact = {
239239
id: 123,
240240
name: 'corrupted-artifact',
241241
size: 1024,
242242
digest: 'abc123'
243243
}
244244

245+
jest
246+
.spyOn(artifact.default, 'getArtifact')
247+
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
248+
249+
jest
250+
.spyOn(artifact.default, 'downloadArtifact')
251+
.mockImplementation(() => Promise.resolve({digestMismatch: true}))
252+
253+
await expect(run()).rejects.toThrow(
254+
'Digest validation failed for artifact(s): corrupted-artifact'
255+
)
256+
})
257+
258+
test('warns when digest validation fails with digest-mismatch set to warn', async () => {
259+
const mockArtifact = {
260+
id: 123,
261+
name: 'corrupted-artifact',
262+
size: 1024,
263+
digest: 'abc123'
264+
}
265+
266+
mockInputs({
267+
[Inputs.DigestMismatch]: 'warn'
268+
})
269+
245270
jest
246271
.spyOn(artifact.default, 'getArtifact')
247272
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
@@ -257,6 +282,61 @@ describe('download', () => {
257282
)
258283
})
259284

285+
test('logs info when digest validation fails with digest-mismatch set to info', async () => {
286+
const mockArtifact = {
287+
id: 123,
288+
name: 'corrupted-artifact',
289+
size: 1024,
290+
digest: 'abc123'
291+
}
292+
293+
mockInputs({
294+
[Inputs.DigestMismatch]: 'info'
295+
})
296+
297+
jest
298+
.spyOn(artifact.default, 'getArtifact')
299+
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
300+
301+
jest
302+
.spyOn(artifact.default, 'downloadArtifact')
303+
.mockImplementation(() => Promise.resolve({digestMismatch: true}))
304+
305+
await run()
306+
307+
expect(core.info).toHaveBeenCalledWith(
308+
expect.stringContaining('digest validation failed')
309+
)
310+
})
311+
312+
test('silently continues when digest validation fails with digest-mismatch set to ignore', async () => {
313+
const mockArtifact = {
314+
id: 123,
315+
name: 'corrupted-artifact',
316+
size: 1024,
317+
digest: 'abc123'
318+
}
319+
320+
mockInputs({
321+
[Inputs.DigestMismatch]: 'ignore'
322+
})
323+
324+
jest
325+
.spyOn(artifact.default, 'getArtifact')
326+
.mockImplementation(() => Promise.resolve({artifact: mockArtifact}))
327+
328+
jest
329+
.spyOn(artifact.default, 'downloadArtifact')
330+
.mockImplementation(() => Promise.resolve({digestMismatch: true}))
331+
332+
await run()
333+
334+
expect(core.warning).not.toHaveBeenCalledWith(
335+
expect.stringContaining('digest validation failed')
336+
)
337+
expect(core.info).toHaveBeenCalledWith('Total of 1 artifact(s) downloaded')
338+
})
339+
260340
test('downloads a single artifact by ID', async () => {
261341
const mockArtifact = {
262342
id: 456,

action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ inputs:
4040
This is useful when you want to handle the artifact as-is without extraction.'
4141
required: false
4242
default: 'false'
43+
digest-mismatch:
44+
description: 'The behavior when a downloaded artifact''s digest does not match the expected digest.
45+
Options: ignore, info, warn, error. Default is error which will fail the action.'
46+
required: false
47+
default: 'error'
4348
outputs:
4449
download-path:
4550
description: 'Path of artifact download'

dist/index.js

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129353,7 +129353,15 @@ var Inputs;
129353129353
Inputs["MergeMultiple"] = "merge-multiple";
129354129354
Inputs["ArtifactIds"] = "artifact-ids";
129355129355
Inputs["SkipDecompress"] = "skip-decompress";
129356+
Inputs["DigestMismatch"] = "digest-mismatch";
129356129357
})(Inputs || (Inputs = {}));
129358+
var DigestMismatchBehavior;
129359+
(function (DigestMismatchBehavior) {
129360+
DigestMismatchBehavior["Ignore"] = "ignore";
129361+
DigestMismatchBehavior["Info"] = "info";
129362+
DigestMismatchBehavior["Warn"] = "warn";
129363+
DigestMismatchBehavior["Error"] = "error";
129364+
})(DigestMismatchBehavior || (DigestMismatchBehavior = {}));
129357129365
var Outputs;
129358129366
(function (Outputs) {
129359129367
Outputs["DownloadPath"] = "download-path";
@@ -129386,8 +129394,15 @@ async function run() {
129386129394
artifactIds: getInput(Inputs.ArtifactIds, { required: false }),
129387129395
skipDecompress: getBooleanInput(Inputs.SkipDecompress, {
129388129396
required: false
129389-
})
129397+
}),
129398+
digestMismatch: (getInput(Inputs.DigestMismatch, { required: false }) ||
129399+
DigestMismatchBehavior.Error)
129390129400
};
129401+
// Validate digest-mismatch input
129402+
const validBehaviors = Object.values(DigestMismatchBehavior);
129403+
if (!validBehaviors.includes(inputs.digestMismatch)) {
129404+
throw new Error(`Invalid value for 'digest-mismatch': '${inputs.digestMismatch}'. Valid options are: ${validBehaviors.join(', ')}`);
129405+
}
129391129406
if (!inputs.path) {
129392129407
inputs.path = process.env['GITHUB_WORKSPACE'] || process.cwd();
129393129408
}
@@ -129500,17 +129515,39 @@ async function run() {
129500129515
})
129501129516
}));
129502129517
const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS);
129518+
const digestMismatches = [];
129503129519
for (const chunk of chunkedPromises) {
129504129520
const chunkPromises = chunk.map(item => item.promise);
129505129521
const results = await Promise.all(chunkPromises);
129506129522
for (let i = 0; i < results.length; i++) {
129507129523
const outcome = results[i];
129508129524
const artifactName = chunk[i].name;
129509129525
if (outcome.digestMismatch) {
129510-
warning(`Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.`);
129526+
digestMismatches.push(artifactName);
129527+
const message = `Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.`;
129528+
switch (inputs.digestMismatch) {
129529+
case DigestMismatchBehavior.Ignore:
129530+
// Do nothing
129531+
break;
129532+
case DigestMismatchBehavior.Info:
129533+
info(message);
129534+
break;
129535+
case DigestMismatchBehavior.Warn:
129536+
warning(message);
129537+
break;
129538+
case DigestMismatchBehavior.Error:
129539+
// Collect all errors and fail at the end
129540+
break;
129541+
}
129511129542
}
129512129543
}
129513129544
}
129545+
// If there were digest mismatches and behavior is 'error', fail the action
129546+
if (digestMismatches.length > 0 &&
129547+
inputs.digestMismatch === DigestMismatchBehavior.Error) {
129548+
throw new Error(`Digest validation failed for artifact(s): ${digestMismatches.join(', ')}. ` +
129549+
`Use 'digest-mismatch: warn' to continue on mismatch.`);
129550+
}
129514129551
info(`Total of ${artifacts.length} artifact(s) downloaded`);
129515129552
setOutput(Outputs.DownloadPath, resolvedPath);
129516129553
info('Download artifact has finished successfully');

src/constants.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ export enum Inputs {
77
Pattern = 'pattern',
88
MergeMultiple = 'merge-multiple',
99
ArtifactIds = 'artifact-ids',
10-
SkipDecompress = 'skip-decompress'
10+
SkipDecompress = 'skip-decompress',
11+
DigestMismatch = 'digest-mismatch'
12+
}
13+
14+
export enum DigestMismatchBehavior {
15+
Ignore = 'ignore',
16+
Info = 'info',
17+
Warn = 'warn',
18+
Error = 'error'
1119
}
1220

1321
export enum Outputs {

src/download-artifact.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as core from '@actions/core'
44
import artifactClient from '@actions/artifact'
55
import type {Artifact, FindOptions} from '@actions/artifact'
66
import {Minimatch} from 'minimatch'
7-
import {Inputs, Outputs} from './constants.js'
7+
import {Inputs, Outputs, DigestMismatchBehavior} from './constants.js'
88

99
const PARALLEL_DOWNLOADS = 5
1010

@@ -29,7 +29,17 @@ export async function run(): Promise<void> {
2929
artifactIds: core.getInput(Inputs.ArtifactIds, {required: false}),
3030
skipDecompress: core.getBooleanInput(Inputs.SkipDecompress, {
3131
required: false
32-
})
32+
}),
33+
digestMismatch: (core.getInput(Inputs.DigestMismatch, {required: false}) ||
34+
DigestMismatchBehavior.Error) as DigestMismatchBehavior
35+
}
36+
37+
// Validate digest-mismatch input
38+
const validBehaviors = Object.values(DigestMismatchBehavior)
39+
if (!validBehaviors.includes(inputs.digestMismatch)) {
40+
throw new Error(
41+
`Invalid value for 'digest-mismatch': '${inputs.digestMismatch}'. Valid options are: ${validBehaviors.join(', ')}`
42+
)
3343
}
3444

3545
if (!inputs.path) {
@@ -188,6 +198,8 @@ export async function run(): Promise<void> {
188198
}))
189199

190200
const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS)
201+
const digestMismatches: string[] = []
202+
191203
for (const chunk of chunkedPromises) {
192204
const chunkPromises = chunk.map(item => item.promise)
193205
const results = await Promise.all(chunkPromises)
@@ -197,12 +209,38 @@ export async function run(): Promise<void> {
197209
const artifactName = chunk[i].name
198210

199211
if (outcome.digestMismatch) {
200-
core.warning(
201-
`Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.`
202-
)
212+
digestMismatches.push(artifactName)
213+
const message = `Artifact '${artifactName}' digest validation failed. Please verify the integrity of the artifact.`
214+
215+
switch (inputs.digestMismatch) {
216+
case DigestMismatchBehavior.Ignore:
217+
// Do nothing
218+
break
219+
case DigestMismatchBehavior.Info:
220+
core.info(message)
221+
break
222+
case DigestMismatchBehavior.Warn:
223+
core.warning(message)
224+
break
225+
case DigestMismatchBehavior.Error:
226+
// Collect all errors and fail at the end
227+
break
228+
}
203229
}
204230
}
205231
}
232+
233+
// If there were digest mismatches and behavior is 'error', fail the action
234+
if (
235+
digestMismatches.length > 0 &&
236+
inputs.digestMismatch === DigestMismatchBehavior.Error
237+
) {
238+
throw new Error(
239+
`Digest validation failed for artifact(s): ${digestMismatches.join(', ')}. ` +
240+
`Use 'digest-mismatch: warn' to continue on mismatch.`
241+
)
242+
}
243+
206244
core.info(`Total of ${artifacts.length} artifact(s) downloaded`)
207245
core.setOutput(Outputs.DownloadPath, resolvedPath)
208246
core.info('Download artifact has finished successfully')

0 commit comments

Comments
 (0)