Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bd2c845
fix: .test is faster than .match regex
parker-snyk Aug 8, 2025
6064ac0
chore: we don't need to keep looping
parker-snyk Aug 8, 2025
cb14317
chore: unit tests
parker-snyk Aug 8, 2025
4f66915
chore: improve readability / consistency
parker-snyk Aug 8, 2025
008a8ea
chore: formatting
parker-snyk Aug 8, 2025
6af7adf
chore: revert out of scope files
parker-snyk Aug 11, 2025
6982327
fix: update check to follow .wh. specs
parker-snyk Aug 18, 2025
9670836
fix: update replace regex
parker-snyk Aug 18, 2025
84a7096
fix: regex
parker-snyk Aug 18, 2025
dc2517f
chore: linting
parker-snyk Aug 18, 2025
b8a9b38
fix: the snapshots were not expecting any names with wh in them
parker-snyk Aug 18, 2025
ef6a033
chore: tests
parker-snyk Aug 18, 2025
e6aa931
chore: revert
parker-snyk Aug 18, 2025
12c304f
chore: revert
parker-snyk Aug 18, 2025
6a4a4c5
fix: fix the shas for snapshot
parker-snyk Aug 18, 2025
1a0c472
fix: some snapshot changes
parker-snyk Aug 18, 2025
e47f47a
fix: more snapshot changes
parker-snyk Aug 18, 2025
17d541d
chore: update snapshots
parker-snyk Aug 19, 2025
f46d29b
fix: whiteout regex bug (CN-272)
parker-snyk Apr 6, 2026
9b6deeb
fix: support Windows paths in whiteout handlers
parker-snyk Apr 6, 2026
a2e2c11
fix: add opaque whiteout support and consistent cross-platform basena…
parker-snyk Apr 6, 2026
cb73d68
fix: defer opaque whiteout application to older layers only
parker-snyk Apr 6, 2026
40159a2
fix: use isFileInFolder for cross-platform opaque whiteout dir matching
parker-snyk Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions lib/extractor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,15 +232,24 @@ function layersWithLatestFileModifications(
): ExtractedLayers {
const extractedLayers: ExtractedLayers = {};
const removedFilesToIgnore: Set<string> = new Set();
const opaqueWhiteoutDirs: Set<string> = new Set();

// TODO: This removes the information about the layer name, maybe we would need it in the future?
for (const layer of layers) {
// Collect opaque whiteout dirs from this layer, but don't apply yet —
// they only affect older layers, not the current one.
const layerOpaqueDirs: Set<string> = new Set();

// go over extracted files products found in this layer
for (const filename of Object.keys(layer)) {
// if finding a deleted file - trimming to its original file name for excluding it from extractedLayers
// + not adding this file
if (isOpaqueWhiteout(filename)) {
layerOpaqueDirs.add(path.dirname(filename));
continue;
}
if (isWhitedOutFile(filename)) {
removedFilesToIgnore.add(filename.replace(/.wh./, ""));
removedFilesToIgnore.add(removeWhiteoutPrefix(filename));
continue;
}
// not adding previously found to be whited out files to extractedLayers
Expand All @@ -251,17 +260,69 @@ function layersWithLatestFileModifications(
if (isFileInARemovedFolder(filename, removedFilesToIgnore)) {
continue;
}
// not adding files in directories covered by an opaque whiteout from a newer layer
if (isFileInOpaqueDir(filename, opaqueWhiteoutDirs)) {
continue;
}
// file not already in extractedLayers
if (!Reflect.has(extractedLayers, filename)) {
extractedLayers[filename] = layer[filename];
}
}

// Apply this layer's opaque whiteouts to older layers
for (const dir of layerOpaqueDirs) {
opaqueWhiteoutDirs.add(dir);
}
}
return extractedLayers;
}

export function isWhitedOutFile(filename: string) {
return filename.match(/.wh./gm);
/**
* check if a file is 'whited out' (filename starts with .wh.)
* https://www.madebymikal.com/interpreting-whiteout-files-in-docker-image-layers
* https://github.com/opencontainers/image-spec/blob/main/layer.md#whiteouts
*/
export function isWhitedOutFile(filename: string): boolean {
return getBasename(filename).startsWith(".wh.");
}

/**
* Check if a file is an opaque whiteout (.wh..wh..opq).
* Opaque whiteouts mean "delete everything in this directory from lower layers."
* https://github.com/opencontainers/image-spec/blob/main/layer.md#opaque-whiteout
*/
export function isOpaqueWhiteout(filename: string): boolean {
return getBasename(filename) === ".wh..wh..opq";
}

/**
* Extract the basename from a path, handling both / and \ separators cross-platform.
*/
function getBasename(filename: string): string {
const lastSlash = Math.max(
filename.lastIndexOf("/"),
filename.lastIndexOf("\\"),
);
return lastSlash === -1 ? filename : filename.substring(lastSlash + 1);
}

function isFileInOpaqueDir(
filename: string,
opaqueWhiteoutDirs: Set<string>,
): boolean {
return Array.from(opaqueWhiteoutDirs).some((opaqueDir) =>
isFileInFolder(filename, opaqueDir),
);
}

/**
* Remove the .wh. prefix from a whiteout file to get the original filename
*/
export function removeWhiteoutPrefix(filename: string): string {
// Replace .wh. at the start or after the last slash/backslash.
// Don't match if there are slashes after .wh.
return filename.replace(/^(.*[\/\\])?\.wh\.([^\/\\]*)$/, "$1$2");
}

function isBufferType(type: FileContent): type is Buffer {
Expand Down
7 changes: 5 additions & 2 deletions lib/extractor/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Debug from "debug";
import * as path from "path";
import { Readable } from "stream";
import { extract, Extract } from "tar-stream";
import { isWhitedOutFile } from ".";
import { isOpaqueWhiteout, isWhitedOutFile } from ".";
import { applyCallbacks, isResultEmpty } from "./callbacks";
import { decompressMaybe } from "./decompress-maybe";
import { ExtractAction, ExtractedLayers } from "./types";
Expand Down Expand Up @@ -32,7 +32,10 @@ export async function extractImageLayer(
const matchedActions = extractActions.filter((action) =>
action.filePathMatches(absoluteFileName),
);
if (matchedActions.length > 0) {
if (isOpaqueWhiteout(absoluteFileName)) {
// Opaque whiteout: record it so layersWithLatestFileModifications can process it
result[absoluteFileName] = {};
} else if (matchedActions.length > 0) {
try {
const callbackResult = await applyCallbacks(
matchedActions,
Expand Down
119 changes: 118 additions & 1 deletion test/lib/extractor/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getContentAsString } from "../../../lib/extractor";
import {
getContentAsString,
isOpaqueWhiteout,
isWhitedOutFile,
removeWhiteoutPrefix,
} from "../../../lib/extractor";
import { ExtractAction, ExtractedLayers } from "../../../lib/extractor/types";

describe("index", () => {
Expand All @@ -18,3 +23,115 @@ describe("index", () => {
expect(result).toEqual("Hello, world!");
});
});

describe("isWhitedOutFile", () => {
test("should return true for files containing .wh. in their path", () => {
expect(isWhitedOutFile("/etc/.wh.hosts")).toBe(true);
expect(isWhitedOutFile("/var/lib/.wh.data")).toBe(true);
expect(isWhitedOutFile("/.wh.config")).toBe(true);
});

test("should return false for files not containing .wh.", () => {
expect(isWhitedOutFile("/etc/hosts")).toBe(false);
expect(isWhitedOutFile("")).toBe(false);
expect(isWhitedOutFile("/")).toBe(false);
});

test("should return false for similar but different patterns", () => {
// make sure the dots are literal and not match all
expect(isWhitedOutFile("/etc/wh.hosts")).toBe(false);
expect(isWhitedOutFile("/etc/.whosts")).toBe(false);
expect(isWhitedOutFile("/etc/whhosts")).toBe(false);

// dots in wrong places
expect(isWhitedOutFile("/etc/.w.h.hosts")).toBe(false);
expect(isWhitedOutFile("/etc/..wh..hosts")).toBe(false);
Comment thread
parker-snyk marked this conversation as resolved.

// case sensitive
expect(isWhitedOutFile("/etc/.WH.hosts")).toBe(false);
expect(isWhitedOutFile("/etc/.Wh.hosts")).toBe(false);
});

test("should handle .wh. at different positions", () => {
expect(isWhitedOutFile(".wh.start")).toBe(true);
expect(isWhitedOutFile("middle.wh.file")).toBe(false);
expect(isWhitedOutFile("end.wh.")).toBe(false);
expect(isWhitedOutFile("/deeply/nested/path/.wh.present")).toBe(true);
expect(isWhitedOutFile("/the/.wh./in/path/present")).toBe(false);
});
});

describe("isOpaqueWhiteout", () => {
test("should return true for opaque whiteout files", () => {
expect(isOpaqueWhiteout("/etc/.wh..wh..opq")).toBe(true);
expect(isOpaqueWhiteout("/.wh..wh..opq")).toBe(true);
expect(isOpaqueWhiteout(".wh..wh..opq")).toBe(true);
expect(isOpaqueWhiteout("/deeply/nested/path/.wh..wh..opq")).toBe(true);
});

test("should return false for regular whiteout files", () => {
expect(isOpaqueWhiteout("/etc/.wh.hosts")).toBe(false);
expect(isOpaqueWhiteout("/.wh.config")).toBe(false);
});

test("should return false for non-whiteout files", () => {
expect(isOpaqueWhiteout("/etc/hosts")).toBe(false);
expect(isOpaqueWhiteout("")).toBe(false);
expect(isOpaqueWhiteout("/")).toBe(false);
});

test("should return false for similar but incorrect patterns", () => {
expect(isOpaqueWhiteout("/etc/.wh..wh..opq.extra")).toBe(false);
expect(isOpaqueWhiteout("/etc/.wh..opq")).toBe(false);
expect(isOpaqueWhiteout("/etc/.WH..WH..OPQ")).toBe(false);
});
});

describe("removeWhiteoutPrefix", () => {
test("should remove .wh. prefix from filenames without slashes", () => {
expect(removeWhiteoutPrefix(".wh.hosts")).toBe("hosts");
expect(removeWhiteoutPrefix(".wh.data")).toBe("data");
expect(removeWhiteoutPrefix(".wh.config")).toBe("config");
expect(removeWhiteoutPrefix(".wh.")).toBe("");
expect(removeWhiteoutPrefix(".wh.file.txt")).toBe("file.txt");
});

test("should remove .wh. prefix after the last slash in paths", () => {
expect(removeWhiteoutPrefix("/etc/.wh.hosts")).toBe("/etc/hosts");
expect(removeWhiteoutPrefix("/var/lib/.wh.data")).toBe("/var/lib/data");
expect(removeWhiteoutPrefix("/.wh.config")).toBe("/config");
expect(removeWhiteoutPrefix("/deeply/nested/path/.wh.present")).toBe(
"/deeply/nested/path/present",
);
expect(removeWhiteoutPrefix("/path/to/.wh.")).toBe("/path/to/");
});

test("should not modify files that don't have .wh. prefix in the correct position", () => {
expect(removeWhiteoutPrefix("normal.file")).toBe("normal.file");
expect(removeWhiteoutPrefix("/etc/hosts")).toBe("/etc/hosts");
expect(removeWhiteoutPrefix("middle.wh.file")).toBe("middle.wh.file");
expect(removeWhiteoutPrefix("/path/middle.wh.file")).toBe(
"/path/middle.wh.file",
);
expect(removeWhiteoutPrefix(".whfile")).toBe(".whfile");
expect(removeWhiteoutPrefix("/path/.whfile")).toBe("/path/.whfile");
expect(removeWhiteoutPrefix("/xwh.txt")).toBe("/xwh.txt");
});

test("should handle edge cases", () => {
expect(removeWhiteoutPrefix("")).toBe("");
expect(removeWhiteoutPrefix("/")).toBe("/");
expect(removeWhiteoutPrefix("//")).toBe("//");
expect(removeWhiteoutPrefix("/.wh.")).toBe("/");
expect(removeWhiteoutPrefix("//.wh.test")).toBe("//test");
});

test("should not remove .wh. that appears in the middle of paths", () => {
expect(removeWhiteoutPrefix("/the/.wh./in/path/file")).toBe(
"/the/.wh./in/path/file",
);
expect(removeWhiteoutPrefix("/path/.wh.dir/.wh.file")).toBe(
"/path/.wh.dir/file",
);
});
Comment thread
parker-snyk marked this conversation as resolved.
});
Loading
Loading