From 9a765389b6a55e5e712eb244a5c6b2ed8575c201 Mon Sep 17 00:00:00 2001 From: Ian Vidal Date: Tue, 7 Apr 2026 11:16:23 -0400 Subject: [PATCH 1/4] chore: refactored docker and kaniko archive extraction --- lib/extractor/docker-archive/index.ts | 29 +-- lib/extractor/docker-archive/layer.ts | 144 +-------------- lib/extractor/generic-archive-extractor.ts | 172 ++++++++++++++++++ lib/extractor/index.ts | 14 +- lib/extractor/kaniko-archive/index.ts | 29 +-- lib/extractor/kaniko-archive/layer.ts | 142 +-------------- lib/extractor/layer.ts | 5 +- lib/extractor/types.ts | 47 ++--- .../generic-archive-extractor.spec.ts | 130 +++++++++++++ 9 files changed, 347 insertions(+), 365 deletions(-) create mode 100644 lib/extractor/generic-archive-extractor.ts create mode 100644 test/lib/extractor/generic-archive-extractor.spec.ts diff --git a/lib/extractor/docker-archive/index.ts b/lib/extractor/docker-archive/index.ts index 83e46005..22c8c026 100644 --- a/lib/extractor/docker-archive/index.ts +++ b/lib/extractor/docker-archive/index.ts @@ -1,24 +1,13 @@ -import { normalize as normalizePath } from "path"; -import { HashAlgorithm } from "../../types"; +import { + createGetImageIdFromManifest, + dockerArchiveConfig, + getManifestLayers, +} from "../generic-archive-extractor"; -import { DockerArchiveManifest } from "../types"; export { extractArchive } from "./layer"; -export function getManifestLayers(manifest: DockerArchiveManifest) { - return manifest.Layers.map((layer) => normalizePath(layer)); -} +export { getManifestLayers }; -export function getImageIdFromManifest( - manifest: DockerArchiveManifest, -): string { - try { - const imageId = manifest.Config.split(".")[0]; - if (imageId.includes(":")) { - // imageId includes the algorithm prefix - return imageId; - } - return `${HashAlgorithm.Sha256}:${imageId}`; - } catch (err) { - throw new Error("Failed to extract image ID from archive manifest"); - } -} +export const getImageIdFromManifest = createGetImageIdFromManifest( + dockerArchiveConfig, +); diff --git a/lib/extractor/docker-archive/layer.ts b/lib/extractor/docker-archive/layer.ts index 2c42e6ca..008c1968 100644 --- a/lib/extractor/docker-archive/layer.ts +++ b/lib/extractor/docker-archive/layer.ts @@ -1,142 +1,6 @@ -import * as Debug from "debug"; -import { createReadStream } from "fs"; -import * as gunzip from "gunzip-maybe"; -import { basename, normalize as normalizePath } from "path"; -import { Readable } from "stream"; -import { extract, Extract } from "tar-stream"; -import { InvalidArchiveError } from ".."; -import { getErrorMessage } from "../../error-utils"; -import { streamToJson } from "../../stream-utils"; -import { PluginOptions } from "../../types"; -import { extractImageLayer } from "../layer"; import { - DockerArchiveManifest, - ExtractAction, - ExtractedLayers, - ExtractedLayersAndManifest, - ImageConfig, -} from "../types"; + createExtractArchive, + dockerArchiveConfig, +} from "../generic-archive-extractor"; -const debug = Debug("snyk"); - -/** - * Retrieve the products of files content from the specified docker-archive. - * @param dockerArchiveFilesystemPath Path to image file saved in docker-archive format. - * @param extractActions Array of pattern-callbacks pairs. - * @param options PluginOptions - * @returns Array of extracted files products sorted by the reverse order of the layers from last to first. - */ -export async function extractArchive( - dockerArchiveFilesystemPath: string, - extractActions: ExtractAction[], - _options: Partial, -): Promise { - return new Promise((resolve, reject) => { - const tarExtractor: Extract = extract(); - const layers: Record = {}; - let manifest: DockerArchiveManifest; - let imageConfig: ImageConfig; - - tarExtractor.on("entry", async (header, stream, next) => { - if (header.type === "file") { - const normalizedName = normalizePath(header.name); - if (isTarFile(normalizedName)) { - try { - layers[normalizedName] = await extractImageLayer( - stream, - extractActions, - ); - } catch (error) { - debug( - `Error extracting layer content from: '${getErrorMessage( - error, - )}'`, - ); - reject(new Error("Error reading tar archive")); - } - } else if (isManifestFile(normalizedName)) { - const manifestArray = await getManifestFile( - stream, - ); - manifest = manifestArray[0]; - } else if (isImageConfigFile(normalizedName)) { - imageConfig = await getManifestFile(stream); - } - } - - stream.resume(); // auto drain the stream - next(); // ready for next entry - }); - - tarExtractor.on("finish", () => { - try { - resolve( - getLayersContentAndArchiveManifest(manifest, imageConfig, layers), - ); - } catch (error) { - debug( - `Error getting layers and manifest content from docker archive: ${getErrorMessage( - error, - )}`, - ); - reject(new InvalidArchiveError("Invalid Docker archive")); - } - }); - - tarExtractor.on("error", (error) => reject(error)); - - createReadStream(dockerArchiveFilesystemPath) - .pipe(gunzip()) - .pipe(tarExtractor); - }); -} - -function getLayersContentAndArchiveManifest( - manifest: DockerArchiveManifest, - imageConfig: ImageConfig, - layers: Record, -): ExtractedLayersAndManifest { - // skip (ignore) non-existent layers - // get the layers content without the name - // reverse layers order from last to first - const layersWithNormalizedNames = manifest.Layers.map((layersName) => - normalizePath(layersName), - ); - const filteredLayers = layersWithNormalizedNames - .filter((layersName) => layers[layersName]) - .map((layerName) => layers[layerName]) - .reverse(); - - if (filteredLayers.length === 0) { - throw new Error("We found no layers in the provided image"); - } - - return { - layers: filteredLayers, - manifest, - imageConfig, - }; -} - -/** - * Note: consumes the stream. - */ -async function getManifestFile(stream: Readable): Promise { - return streamToJson(stream); -} - -function isManifestFile(name: string): boolean { - return name === "manifest.json"; -} - -function isImageConfigFile(name: string): boolean { - const configRegex = new RegExp("[A-Fa-f0-9]{64}\\.json"); - return configRegex.test(name); -} - -function isTarFile(name: string): boolean { - // For both "docker save" and "skopeo copy" style archives the - // layers are represented as tar archives whose names end in .tar. - // For Docker this is "layer.tar", for Skopeo - ".tar". - return basename(name).endsWith(".tar"); -} +export const extractArchive = createExtractArchive(dockerArchiveConfig); diff --git a/lib/extractor/generic-archive-extractor.ts b/lib/extractor/generic-archive-extractor.ts new file mode 100644 index 00000000..b069adda --- /dev/null +++ b/lib/extractor/generic-archive-extractor.ts @@ -0,0 +1,172 @@ +import * as Debug from "debug"; +import { createReadStream } from "fs"; +import * as gunzip from "gunzip-maybe"; +import { basename, normalize as normalizePath } from "path"; +import { Readable } from "stream"; +import { extract, Extract } from "tar-stream"; +import { streamToJson } from "../stream-utils"; + +export class InvalidArchiveError extends Error { + constructor(message: string) { + super(); + this.name = "InvalidArchiveError"; + this.message = message; + } +} +import { HashAlgorithm, PluginOptions } from "../types"; +import { extractImageLayer } from "./layer"; +import { + ExtractAction, + ExtractedLayers, + ExtractedLayersAndManifest, + ImageConfig, + TarArchiveManifest, +} from "./types"; + +const debug = Debug("snyk"); + +export interface ArchiveConfig { + isLayerFile: (name: string) => boolean; + isImageConfigFile: (name: string) => boolean; + formatLabel: string; + layerErrorType: string; + extractImageId: (configValue: string) => string; +} + +export const dockerArchiveConfig: ArchiveConfig = { + isLayerFile: (name) => basename(name).endsWith(".tar"), + isImageConfigFile: (name) => + new RegExp("[A-Fa-f0-9]{64}\\.json").test(name), + formatLabel: "Docker", + layerErrorType: "tar", + extractImageId: (configValue) => configValue.split(".")[0], +}; + +export const kanikoArchiveConfig: ArchiveConfig = { + isLayerFile: (name) => basename(name).endsWith(".tar.gz"), + isImageConfigFile: (name) => + new RegExp("sha256:[A-Fa-f0-9]{64}").test(name), + formatLabel: "Kaniko", + layerErrorType: "tar.gz", + extractImageId: (configValue) => configValue, +}; + +export function createExtractArchive( + config: ArchiveConfig, +): ( + archiveFilesystemPath: string, + extractActions: ExtractAction[], + options: Partial, +) => Promise { + return (archiveFilesystemPath, extractActions, _options) => + new Promise((resolve, reject) => { + const tarExtractor: Extract = extract(); + const layers: Record = {}; + let manifest: TarArchiveManifest; + let imageConfig: ImageConfig; + + tarExtractor.on("entry", async (header, stream, next) => { + if (header.type === "file") { + const normalizedName = normalizePath(header.name); + if (config.isLayerFile(normalizedName)) { + try { + layers[normalizedName] = await extractImageLayer( + stream, + extractActions, + ); + } catch (error) { + debug(`Error extracting layer content from: '${error.message}'`); + reject( + new Error( + `Error reading ${config.layerErrorType} archive`, + ), + ); + } + } else if (isManifestFile(normalizedName)) { + const manifestArray = + await getManifestFile(stream); + manifest = manifestArray[0]; + } else if (config.isImageConfigFile(normalizedName)) { + imageConfig = await getManifestFile(stream); + } + } + + stream.resume(); + next(); + }); + + tarExtractor.on("finish", () => { + try { + resolve( + assembleLayersAndManifest(manifest, imageConfig, layers), + ); + } catch (error) { + debug( + `Error getting layers and manifest content from ${config.formatLabel} archive: ${error.message}`, + ); + reject( + new InvalidArchiveError(`Invalid ${config.formatLabel} archive`), + ); + } + }); + + tarExtractor.on("error", (error) => reject(error)); + + createReadStream(archiveFilesystemPath) + .on("error", (error) => reject(error)) + .pipe(gunzip()) + .pipe(tarExtractor); + }); +} + +function assembleLayersAndManifest( + manifest: TarArchiveManifest, + imageConfig: ImageConfig, + layers: Record, +): ExtractedLayersAndManifest { + const layersWithNormalizedNames = manifest.Layers.map((layerName) => + normalizePath(layerName), + ); + const filteredLayers = layersWithNormalizedNames + .filter((layerName) => layers[layerName]) + .map((layerName) => layers[layerName]) + .reverse(); + + if (filteredLayers.length === 0) { + throw new Error("We found no layers in the provided image"); + } + + return { + layers: filteredLayers, + manifest, + imageConfig, + }; +} + +async function getManifestFile(stream: Readable): Promise { + return streamToJson(stream); +} + +function isManifestFile(name: string): boolean { + return name === "manifest.json"; +} + +export function createGetImageIdFromManifest( + config: ArchiveConfig, +): (manifest: TarArchiveManifest) => string { + return (manifest) => { + try { + const imageId = config.extractImageId(manifest.Config); + if (imageId.includes(":")) { + return imageId; + } + return `${HashAlgorithm.Sha256}:${imageId}`; + } catch (err) { + throw new Error("Failed to extract image ID from archive manifest"); + } + }; +} + +export function getManifestLayers(manifest: TarArchiveManifest): string[] { + return manifest.Layers.map((layer) => normalizePath(layer)); +} diff --git a/lib/extractor/index.ts b/lib/extractor/index.ts index c5cd0a27..0b803459 100644 --- a/lib/extractor/index.ts +++ b/lib/extractor/index.ts @@ -21,16 +21,12 @@ import { ImageConfig, OciArchiveManifest, } from "./types"; +import { isWhitedOutFile } from "./layer"; +import { InvalidArchiveError } from "./generic-archive-extractor"; const debug = Debug("snyk"); -export class InvalidArchiveError extends Error { - constructor(message) { - super(); - this.name = "InvalidArchiveError"; - this.message = message; - } -} +export { InvalidArchiveError } from "./generic-archive-extractor"; class ArchiveExtractor { private extractor: Extractor; private fileSystemPath: string; @@ -263,9 +259,7 @@ function layersWithLatestFileModifications( return extractedLayers; } -export function isWhitedOutFile(filename: string) { - return filename.match(/.wh./gm); -} +export { isWhitedOutFile } from "./layer"; function isBufferType(type: FileContent): type is Buffer { return (type as Buffer).buffer !== undefined; diff --git a/lib/extractor/kaniko-archive/index.ts b/lib/extractor/kaniko-archive/index.ts index 1c2f7cb8..799eff90 100644 --- a/lib/extractor/kaniko-archive/index.ts +++ b/lib/extractor/kaniko-archive/index.ts @@ -1,24 +1,13 @@ -import { normalize as normalizePath } from "path"; -import { HashAlgorithm } from "../../types"; +import { + createGetImageIdFromManifest, + kanikoArchiveConfig, + getManifestLayers, +} from "../generic-archive-extractor"; -import { KanikoArchiveManifest } from "../types"; export { extractArchive } from "./layer"; -export function getManifestLayers(manifest: KanikoArchiveManifest) { - return manifest.Layers.map((layer) => normalizePath(layer)); -} +export { getManifestLayers }; -export function getImageIdFromManifest( - manifest: KanikoArchiveManifest, -): string { - try { - const imageId = manifest.Config; - if (imageId.includes(":")) { - // imageId includes the algorithm prefix - return imageId; - } - return `${HashAlgorithm.Sha256}:${imageId}`; - } catch (err) { - throw new Error("Failed to extract image ID from archive manifest"); - } -} +export const getImageIdFromManifest = createGetImageIdFromManifest( + kanikoArchiveConfig, +); diff --git a/lib/extractor/kaniko-archive/layer.ts b/lib/extractor/kaniko-archive/layer.ts index b71e2bcf..5ed71f30 100644 --- a/lib/extractor/kaniko-archive/layer.ts +++ b/lib/extractor/kaniko-archive/layer.ts @@ -1,140 +1,6 @@ -import * as Debug from "debug"; -import { createReadStream } from "fs"; -import * as gunzip from "gunzip-maybe"; -import { basename, normalize as normalizePath } from "path"; -import { Readable } from "stream"; -import { extract, Extract } from "tar-stream"; -import { InvalidArchiveError } from ".."; -import { getErrorMessage } from "../../error-utils"; -import { streamToJson } from "../../stream-utils"; -import { PluginOptions } from "../../types"; -import { extractImageLayer } from "../layer"; import { - ExtractAction, - ImageConfig, - KanikoArchiveManifest, - KanikoExtractedLayers, - KanikoExtractedLayersAndManifest, -} from "../types"; + createExtractArchive, + kanikoArchiveConfig, +} from "../generic-archive-extractor"; -const debug = Debug("snyk"); - -/** - * Retrieve the products of files content from the specified kaniko-archive. - * @param kanikoArchiveFilesystemPath Path to image file saved in kaniko-archive format. - * @param extractActions Array of pattern-callbacks pairs. - * @param options PluginOptions - * @returns Array of extracted files products sorted by the reverse order of the layers from last to first. - */ -export async function extractArchive( - kanikoArchiveFilesystemPath: string, - extractActions: ExtractAction[], - _options: Partial, -): Promise { - return new Promise((resolve, reject) => { - const tarExtractor: Extract = extract(); - const layers: Record = {}; - let manifest: KanikoArchiveManifest; - let imageConfig: ImageConfig; - - tarExtractor.on("entry", async (header, stream, next) => { - if (header.type === "file") { - const normalizedName = normalizePath(header.name); - if (isTarGzFile(normalizedName)) { - try { - layers[normalizedName] = await extractImageLayer( - stream, - extractActions, - ); - } catch (error) { - debug( - `Error extracting layer content from: '${getErrorMessage( - error, - )}'`, - ); - reject(new Error("Error reading tar.gz archive")); - } - } else if (isManifestFile(normalizedName)) { - const manifestArray = await getManifestFile( - stream, - ); - - manifest = manifestArray[0]; - } else if (isImageConfigFile(normalizedName)) { - imageConfig = await getManifestFile(stream); - } - } - - stream.resume(); // auto drain the stream - next(); // ready for next entry - }); - - tarExtractor.on("finish", () => { - try { - resolve( - getLayersContentAndArchiveManifest(manifest, imageConfig, layers), - ); - } catch (error) { - debug( - `Error getting layers and manifest content from Kaniko archive: ${getErrorMessage( - error, - )}`, - ); - reject(new InvalidArchiveError("Invalid Kaniko archive")); - } - }); - - tarExtractor.on("error", (error) => reject(error)); - - createReadStream(kanikoArchiveFilesystemPath) - .pipe(gunzip()) - .pipe(tarExtractor); - }); -} - -function getLayersContentAndArchiveManifest( - manifest: KanikoArchiveManifest, - imageConfig: ImageConfig, - layers: Record, -): KanikoExtractedLayersAndManifest { - // skip (ignore) non-existent layers - // get the layers content without the name - // reverse layers order from last to first - const layersWithNormalizedNames = manifest.Layers.map((layersName) => - normalizePath(layersName), - ); - const filteredLayers = layersWithNormalizedNames - .filter((layersName) => layers[layersName]) - .map((layerName) => layers[layerName]) - .reverse(); - - if (filteredLayers.length === 0) { - throw new Error("We found no layers in the provided image"); - } - - return { - layers: filteredLayers, - manifest, - imageConfig, - }; -} - -/** - * Note: consumes the stream. - */ -async function getManifestFile(stream: Readable): Promise { - return streamToJson(stream); -} - -function isManifestFile(name: string): boolean { - return name === "manifest.json"; -} - -function isImageConfigFile(name: string): boolean { - const configRegex = new RegExp("sha256:[A-Fa-f0-9]{64}"); - return configRegex.test(name); -} - -function isTarGzFile(name: string): boolean { - return basename(name).endsWith(".tar.gz"); -} +export const extractArchive = createExtractArchive(kanikoArchiveConfig); diff --git a/lib/extractor/layer.ts b/lib/extractor/layer.ts index f46fb9c4..f6fde537 100644 --- a/lib/extractor/layer.ts +++ b/lib/extractor/layer.ts @@ -2,12 +2,15 @@ import * as Debug from "debug"; import * as path from "path"; import { Readable } from "stream"; import { extract, Extract } from "tar-stream"; -import { isWhitedOutFile } from "."; import { getErrorMessage } from "../error-utils"; import { applyCallbacks, isResultEmpty } from "./callbacks"; import { decompressMaybe } from "./decompress-maybe"; import { ExtractAction, ExtractedLayers } from "./types"; +export function isWhitedOutFile(filename: string) { + return filename.match(/.wh./gm); +} + const debug = Debug("snyk"); /** diff --git a/lib/extractor/types.ts b/lib/extractor/types.ts index d07b1ea2..f3c8e270 100644 --- a/lib/extractor/types.ts +++ b/lib/extractor/types.ts @@ -42,13 +42,7 @@ export interface ExtractedLayers { [layerName: string]: FileNameAndContent; } -export interface ExtractedLayersAndManifest { - layers: ExtractedLayers[]; - manifest: DockerArchiveManifest | OciArchiveManifest; - imageConfig: ImageConfig; -} - -export interface DockerArchiveManifest { +export interface TarArchiveManifest { // Usually points to the JSON file in the archive that describes how the image was built. Config: string; RepoTags: string[]; @@ -56,6 +50,16 @@ export interface DockerArchiveManifest { Layers: string[]; } +export interface DockerArchiveManifest extends TarArchiveManifest {} + +export interface KanikoArchiveManifest extends TarArchiveManifest {} + +export interface ExtractedLayersAndManifest { + layers: ExtractedLayers[]; + manifest: TarArchiveManifest | OciArchiveManifest; + imageConfig: ImageConfig; +} + export interface ContainerConfig { User?: string | null; ExposedPorts?: { [port: string]: object } | null; @@ -112,35 +116,6 @@ export interface OciImageIndex { manifests: OciManifestInfo[]; } -export interface KanikoArchiveManifest { - // Usually points to the JSON file in the archive that describes how the image was built. - Config: string; - RepoTags: string[]; - // The names of the layers in this archive, usually in the format ".tar" or "/layer.tar". - Layers: string[]; -} - -export interface KanikoExtractionResult { - imageId: string; - manifestLayers: string[]; - extractedLayers: KanikoExtractedLayers; - rootFsLayers?: string[]; - autoDetectedUserInstructions?: AutoDetectedUserInstructions; - platform?: string; - imageLabels?: { [key: string]: string }; - imageCreationTime?: string; -} - -export interface KanikoExtractedLayers { - [layerName: string]: FileNameAndContent; -} - -export interface KanikoExtractedLayersAndManifest { - layers: KanikoExtractedLayers[]; - manifest: KanikoArchiveManifest; - imageConfig: ImageConfig; -} - export interface ExtractAction { // This name should be unique across all actions used. actionName: string; diff --git a/test/lib/extractor/generic-archive-extractor.spec.ts b/test/lib/extractor/generic-archive-extractor.spec.ts new file mode 100644 index 00000000..ef560fe2 --- /dev/null +++ b/test/lib/extractor/generic-archive-extractor.spec.ts @@ -0,0 +1,130 @@ +import { + createExtractArchive, + createGetImageIdFromManifest, + getManifestLayers, + dockerArchiveConfig, + kanikoArchiveConfig, +} from "../../../lib/extractor/generic-archive-extractor"; +import { DockerArchiveManifest, KanikoArchiveManifest } from "../../../lib/extractor/types"; +import { getFixture } from "../../util/index"; + +describe("generic archive extractor", () => { + describe("createExtractArchive", () => { + describe("with docker archive config", () => { + const extractArchive = createExtractArchive(dockerArchiveConfig); + + it("extracts layers and manifest from a docker archive", async () => { + const fixture = getFixture( + "docker-archives/docker-save/nginx-with-buildinfo.tar", + ); + const result = await extractArchive(fixture, [], {}); + expect(result.layers).toBeDefined(); + expect(result.manifest).toBeDefined(); + expect(result.imageConfig).toBeDefined(); + }); + }); + + describe("with kaniko archive config", () => { + const extractArchive = createExtractArchive(kanikoArchiveConfig); + + it("extracts layers and manifest from a kaniko archive", async () => { + const fixture = getFixture("kaniko-archives/kaniko-busybox.tar"); + const result = await extractArchive(fixture, [], {}); + expect(result.layers).toBeDefined(); + expect(result.manifest).toBeDefined(); + expect(result.imageConfig).toBeDefined(); + }); + }); + + it("rejects with an error when given a non-existent file", async () => { + const extractArchive = createExtractArchive(dockerArchiveConfig); + await expect( + extractArchive("non-existent.tar", [], {}), + ).rejects.toThrow(); + }); + }); + + describe("createGetImageIdFromManifest", () => { + describe("with docker archive config (strips .json extension)", () => { + const getImageIdFromManifest = createGetImageIdFromManifest( + dockerArchiveConfig, + ); + + it("strips .json and returns imageId with sha256: prefix when prefix is present", () => { + const manifest: DockerArchiveManifest = { + Config: + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538.json", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + + it("strips .json and prepends sha256: when prefix is absent", () => { + const manifest: DockerArchiveManifest = { + Config: + "2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538.json", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + }); + + describe("with kaniko archive config (uses Config value directly)", () => { + const getImageIdFromManifest = createGetImageIdFromManifest( + kanikoArchiveConfig, + ); + + it("returns imageId as-is when sha256: prefix is present", () => { + const manifest: KanikoArchiveManifest = { + Config: + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + + it("prepends sha256: when prefix is absent", () => { + const manifest: KanikoArchiveManifest = { + Config: + "2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + }); + + it("throws when Config is missing", () => { + const getImageIdFromManifest = createGetImageIdFromManifest( + dockerArchiveConfig, + ); + const manifest = { Config: undefined, RepoTags: [], Layers: [] } as any; + expect(() => getImageIdFromManifest(manifest)).toThrow( + "Failed to extract image ID from archive manifest", + ); + }); + }); + + describe("getManifestLayers", () => { + it("normalizes layer paths", () => { + const manifest: DockerArchiveManifest = { + Config: "abc.json", + RepoTags: [], + Layers: ["a/b/../c/layer.tar", "d/layer.tar"], + }; + const result = getManifestLayers(manifest); + expect(result).toEqual(["a/c/layer.tar", "d/layer.tar"]); + }); + }); +}); From 5ca45d24072ed6906672f5ffdabd8f89bde040d6 Mon Sep 17 00:00:00 2001 From: Ian Vidal Date: Tue, 7 Apr 2026 11:20:22 -0400 Subject: [PATCH 2/4] fix: resolved linting issues --- lib/extractor/docker-archive/index.ts | 5 ++--- lib/extractor/generic-archive-extractor.ts | 19 ++++++---------- lib/extractor/index.ts | 4 ++-- lib/extractor/kaniko-archive/index.ts | 7 +++--- lib/extractor/types.ts | 2 ++ .../generic-archive-extractor.spec.ts | 22 +++++++++---------- 6 files changed, 27 insertions(+), 32 deletions(-) diff --git a/lib/extractor/docker-archive/index.ts b/lib/extractor/docker-archive/index.ts index 22c8c026..4d22203e 100644 --- a/lib/extractor/docker-archive/index.ts +++ b/lib/extractor/docker-archive/index.ts @@ -8,6 +8,5 @@ export { extractArchive } from "./layer"; export { getManifestLayers }; -export const getImageIdFromManifest = createGetImageIdFromManifest( - dockerArchiveConfig, -); +export const getImageIdFromManifest = + createGetImageIdFromManifest(dockerArchiveConfig); diff --git a/lib/extractor/generic-archive-extractor.ts b/lib/extractor/generic-archive-extractor.ts index b069adda..a6387cfc 100644 --- a/lib/extractor/generic-archive-extractor.ts +++ b/lib/extractor/generic-archive-extractor.ts @@ -35,8 +35,7 @@ export interface ArchiveConfig { export const dockerArchiveConfig: ArchiveConfig = { isLayerFile: (name) => basename(name).endsWith(".tar"), - isImageConfigFile: (name) => - new RegExp("[A-Fa-f0-9]{64}\\.json").test(name), + isImageConfigFile: (name) => new RegExp("[A-Fa-f0-9]{64}\\.json").test(name), formatLabel: "Docker", layerErrorType: "tar", extractImageId: (configValue) => configValue.split(".")[0], @@ -44,8 +43,7 @@ export const dockerArchiveConfig: ArchiveConfig = { export const kanikoArchiveConfig: ArchiveConfig = { isLayerFile: (name) => basename(name).endsWith(".tar.gz"), - isImageConfigFile: (name) => - new RegExp("sha256:[A-Fa-f0-9]{64}").test(name), + isImageConfigFile: (name) => new RegExp("sha256:[A-Fa-f0-9]{64}").test(name), formatLabel: "Kaniko", layerErrorType: "tar.gz", extractImageId: (configValue) => configValue, @@ -77,14 +75,13 @@ export function createExtractArchive( } catch (error) { debug(`Error extracting layer content from: '${error.message}'`); reject( - new Error( - `Error reading ${config.layerErrorType} archive`, - ), + new Error(`Error reading ${config.layerErrorType} archive`), ); } } else if (isManifestFile(normalizedName)) { - const manifestArray = - await getManifestFile(stream); + const manifestArray = await getManifestFile( + stream, + ); manifest = manifestArray[0]; } else if (config.isImageConfigFile(normalizedName)) { imageConfig = await getManifestFile(stream); @@ -97,9 +94,7 @@ export function createExtractArchive( tarExtractor.on("finish", () => { try { - resolve( - assembleLayersAndManifest(manifest, imageConfig, layers), - ); + resolve(assembleLayersAndManifest(manifest, imageConfig, layers)); } catch (error) { debug( `Error getting layers and manifest content from ${config.formatLabel} archive: ${error.message}`, diff --git a/lib/extractor/index.ts b/lib/extractor/index.ts index 0b803459..82fbd88c 100644 --- a/lib/extractor/index.ts +++ b/lib/extractor/index.ts @@ -8,7 +8,9 @@ import { getErrorMessage } from "../error-utils"; import { AutoDetectedUserInstructions, ImageType } from "../types"; import { PluginOptions } from "../types"; import * as dockerExtractor from "./docker-archive"; +import { InvalidArchiveError } from "./generic-archive-extractor"; import * as kanikoExtractor from "./kaniko-archive"; +import { isWhitedOutFile } from "./layer"; import * as ociExtractor from "./oci-archive"; import { DockerArchiveManifest, @@ -21,8 +23,6 @@ import { ImageConfig, OciArchiveManifest, } from "./types"; -import { isWhitedOutFile } from "./layer"; -import { InvalidArchiveError } from "./generic-archive-extractor"; const debug = Debug("snyk"); diff --git a/lib/extractor/kaniko-archive/index.ts b/lib/extractor/kaniko-archive/index.ts index 799eff90..b25a701e 100644 --- a/lib/extractor/kaniko-archive/index.ts +++ b/lib/extractor/kaniko-archive/index.ts @@ -1,13 +1,12 @@ import { createGetImageIdFromManifest, - kanikoArchiveConfig, getManifestLayers, + kanikoArchiveConfig, } from "../generic-archive-extractor"; export { extractArchive } from "./layer"; export { getManifestLayers }; -export const getImageIdFromManifest = createGetImageIdFromManifest( - kanikoArchiveConfig, -); +export const getImageIdFromManifest = + createGetImageIdFromManifest(kanikoArchiveConfig); diff --git a/lib/extractor/types.ts b/lib/extractor/types.ts index f3c8e270..418ac7a2 100644 --- a/lib/extractor/types.ts +++ b/lib/extractor/types.ts @@ -50,8 +50,10 @@ export interface TarArchiveManifest { Layers: string[]; } +// tslint:disable-next-line:no-empty-interface export interface DockerArchiveManifest extends TarArchiveManifest {} +// tslint:disable-next-line:no-empty-interface export interface KanikoArchiveManifest extends TarArchiveManifest {} export interface ExtractedLayersAndManifest { diff --git a/test/lib/extractor/generic-archive-extractor.spec.ts b/test/lib/extractor/generic-archive-extractor.spec.ts index ef560fe2..cd805090 100644 --- a/test/lib/extractor/generic-archive-extractor.spec.ts +++ b/test/lib/extractor/generic-archive-extractor.spec.ts @@ -1,11 +1,14 @@ import { createExtractArchive, createGetImageIdFromManifest, - getManifestLayers, dockerArchiveConfig, + getManifestLayers, kanikoArchiveConfig, } from "../../../lib/extractor/generic-archive-extractor"; -import { DockerArchiveManifest, KanikoArchiveManifest } from "../../../lib/extractor/types"; +import { + DockerArchiveManifest, + KanikoArchiveManifest, +} from "../../../lib/extractor/types"; import { getFixture } from "../../util/index"; describe("generic archive extractor", () => { @@ -46,9 +49,8 @@ describe("generic archive extractor", () => { describe("createGetImageIdFromManifest", () => { describe("with docker archive config (strips .json extension)", () => { - const getImageIdFromManifest = createGetImageIdFromManifest( - dockerArchiveConfig, - ); + const getImageIdFromManifest = + createGetImageIdFromManifest(dockerArchiveConfig); it("strips .json and returns imageId with sha256: prefix when prefix is present", () => { const manifest: DockerArchiveManifest = { @@ -76,9 +78,8 @@ describe("generic archive extractor", () => { }); describe("with kaniko archive config (uses Config value directly)", () => { - const getImageIdFromManifest = createGetImageIdFromManifest( - kanikoArchiveConfig, - ); + const getImageIdFromManifest = + createGetImageIdFromManifest(kanikoArchiveConfig); it("returns imageId as-is when sha256: prefix is present", () => { const manifest: KanikoArchiveManifest = { @@ -106,9 +107,8 @@ describe("generic archive extractor", () => { }); it("throws when Config is missing", () => { - const getImageIdFromManifest = createGetImageIdFromManifest( - dockerArchiveConfig, - ); + const getImageIdFromManifest = + createGetImageIdFromManifest(dockerArchiveConfig); const manifest = { Config: undefined, RepoTags: [], Layers: [] } as any; expect(() => getImageIdFromManifest(manifest)).toThrow( "Failed to extract image ID from archive manifest", From 7a6fb22c2a00cb9b7b558c7309dbdf14e20eaa9b Mon Sep 17 00:00:00 2001 From: Ian Vidal Date: Fri, 17 Apr 2026 13:20:50 -0400 Subject: [PATCH 3/4] fix: replaced error.message with helper --- lib/extractor/generic-archive-extractor.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/extractor/generic-archive-extractor.ts b/lib/extractor/generic-archive-extractor.ts index a6387cfc..d461f7cb 100644 --- a/lib/extractor/generic-archive-extractor.ts +++ b/lib/extractor/generic-archive-extractor.ts @@ -4,6 +4,7 @@ import * as gunzip from "gunzip-maybe"; import { basename, normalize as normalizePath } from "path"; import { Readable } from "stream"; import { extract, Extract } from "tar-stream"; +import { getErrorMessage } from "../error-utils"; import { streamToJson } from "../stream-utils"; export class InvalidArchiveError extends Error { @@ -73,7 +74,7 @@ export function createExtractArchive( extractActions, ); } catch (error) { - debug(`Error extracting layer content from: '${error.message}'`); + debug(`Error extracting layer content from: '${getErrorMessage(error)}'`); reject( new Error(`Error reading ${config.layerErrorType} archive`), ); @@ -97,7 +98,7 @@ export function createExtractArchive( resolve(assembleLayersAndManifest(manifest, imageConfig, layers)); } catch (error) { debug( - `Error getting layers and manifest content from ${config.formatLabel} archive: ${error.message}`, + `Error getting layers and manifest content from ${config.formatLabel} archive: ${getErrorMessage(error)}`, ); reject( new InvalidArchiveError(`Invalid ${config.formatLabel} archive`), From 33017e7cb59a3e8ae8a7b3770cd1dc6c353ccc34 Mon Sep 17 00:00:00 2001 From: Ian Vidal Date: Fri, 17 Apr 2026 13:23:57 -0400 Subject: [PATCH 4/4] chore: lint --- lib/extractor/generic-archive-extractor.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/extractor/generic-archive-extractor.ts b/lib/extractor/generic-archive-extractor.ts index d461f7cb..64851073 100644 --- a/lib/extractor/generic-archive-extractor.ts +++ b/lib/extractor/generic-archive-extractor.ts @@ -74,7 +74,11 @@ export function createExtractArchive( extractActions, ); } catch (error) { - debug(`Error extracting layer content from: '${getErrorMessage(error)}'`); + debug( + `Error extracting layer content from: '${getErrorMessage( + error, + )}'`, + ); reject( new Error(`Error reading ${config.layerErrorType} archive`), ); @@ -98,7 +102,9 @@ export function createExtractArchive( resolve(assembleLayersAndManifest(manifest, imageConfig, layers)); } catch (error) { debug( - `Error getting layers and manifest content from ${config.formatLabel} archive: ${getErrorMessage(error)}`, + `Error getting layers and manifest content from ${ + config.formatLabel + } archive: ${getErrorMessage(error)}`, ); reject( new InvalidArchiveError(`Invalid ${config.formatLabel} archive`),