Skip to content

Commit a2e2c11

Browse files
committed
fix: add opaque whiteout support and consistent cross-platform basename handling
- Handle .wh..wh..opq opaque whiteouts per OCI image spec: files in opaque-whiteout directories from older layers are now properly excluded - Replace path.basename with a custom getBasename helper that handles both / and \ separators regardless of platform, keeping isWhitedOutFile and isOpaqueWhiteout consistent with removeWhiteoutPrefix - Add tests for isOpaqueWhiteout
1 parent 9b6deeb commit a2e2c11

File tree

3 files changed

+74
-3
lines changed

3 files changed

+74
-3
lines changed

lib/extractor/index.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,19 @@ function layersWithLatestFileModifications(
232232
): ExtractedLayers {
233233
const extractedLayers: ExtractedLayers = {};
234234
const removedFilesToIgnore: Set<string> = new Set();
235+
const opaqueWhiteoutDirs: Set<string> = new Set();
235236

236237
// TODO: This removes the information about the layer name, maybe we would need it in the future?
237238
for (const layer of layers) {
238239
// go over extracted files products found in this layer
239240
for (const filename of Object.keys(layer)) {
240241
// if finding a deleted file - trimming to its original file name for excluding it from extractedLayers
241242
// + not adding this file
243+
if (isOpaqueWhiteout(filename)) {
244+
// Opaque whiteout: all files in this directory from older layers should be ignored
245+
opaqueWhiteoutDirs.add(path.dirname(filename));
246+
continue;
247+
}
242248
if (isWhitedOutFile(filename)) {
243249
removedFilesToIgnore.add(removeWhiteoutPrefix(filename));
244250
continue;
@@ -251,6 +257,10 @@ function layersWithLatestFileModifications(
251257
if (isFileInARemovedFolder(filename, removedFilesToIgnore)) {
252258
continue;
253259
}
260+
// not adding files in directories covered by an opaque whiteout from a newer layer
261+
if (isFileInOpaqueDir(filename, opaqueWhiteoutDirs)) {
262+
continue;
263+
}
254264
// file not already in extractedLayers
255265
if (!Reflect.has(extractedLayers, filename)) {
256266
extractedLayers[filename] = layer[filename];
@@ -267,7 +277,38 @@ function layersWithLatestFileModifications(
267277
* https://github.com/opencontainers/image-spec/blob/main/layer.md#whiteouts
268278
*/
269279
export function isWhitedOutFile(filename: string): boolean {
270-
return path.basename(filename).startsWith(".wh.");
280+
return getBasename(filename).startsWith(".wh.");
281+
}
282+
283+
/**
284+
* Check if a file is an opaque whiteout (.wh..wh..opq).
285+
* Opaque whiteouts mean "delete everything in this directory from lower layers."
286+
* https://github.com/opencontainers/image-spec/blob/main/layer.md#opaque-whiteout
287+
*/
288+
export function isOpaqueWhiteout(filename: string): boolean {
289+
return getBasename(filename) === ".wh..wh..opq";
290+
}
291+
292+
/**
293+
* Extract the basename from a path, handling both Unix and Windows separators
294+
* regardless of the current platform.
295+
*/
296+
function getBasename(filename: string): string {
297+
const lastSlash = Math.max(filename.lastIndexOf("/"), filename.lastIndexOf("\\"));
298+
return lastSlash === -1 ? filename : filename.substring(lastSlash + 1);
299+
}
300+
301+
function isFileInOpaqueDir(
302+
filename: string,
303+
opaqueWhiteoutDirs: Set<string>,
304+
): boolean {
305+
const dir = path.dirname(filename);
306+
for (const opaqueDir of opaqueWhiteoutDirs) {
307+
if (dir === opaqueDir || dir.startsWith(opaqueDir + path.sep)) {
308+
return true;
309+
}
310+
}
311+
return false;
271312
}
272313

273314
/**

lib/extractor/layer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Debug from "debug";
22
import * as path from "path";
33
import { Readable } from "stream";
44
import { extract, Extract } from "tar-stream";
5-
import { isWhitedOutFile } from ".";
5+
import { isOpaqueWhiteout, isWhitedOutFile } from ".";
66
import { applyCallbacks, isResultEmpty } from "./callbacks";
77
import { decompressMaybe } from "./decompress-maybe";
88
import { ExtractAction, ExtractedLayers } from "./types";
@@ -32,7 +32,10 @@ export async function extractImageLayer(
3232
const matchedActions = extractActions.filter((action) =>
3333
action.filePathMatches(absoluteFileName),
3434
);
35-
if (matchedActions.length > 0) {
35+
if (isOpaqueWhiteout(absoluteFileName)) {
36+
// Opaque whiteout: record it so layersWithLatestFileModifications can process it
37+
result[absoluteFileName] = {};
38+
} else if (matchedActions.length > 0) {
3639
try {
3740
const callbackResult = await applyCallbacks(
3841
matchedActions,

test/lib/extractor/index.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
getContentAsString,
3+
isOpaqueWhiteout,
34
isWhitedOutFile,
45
removeWhiteoutPrefix,
56
} from "../../../lib/extractor";
@@ -60,6 +61,32 @@ describe("isWhitedOutFile", () => {
6061
});
6162
});
6263

64+
describe("isOpaqueWhiteout", () => {
65+
test("should return true for opaque whiteout files", () => {
66+
expect(isOpaqueWhiteout("/etc/.wh..wh..opq")).toBe(true);
67+
expect(isOpaqueWhiteout("/.wh..wh..opq")).toBe(true);
68+
expect(isOpaqueWhiteout(".wh..wh..opq")).toBe(true);
69+
expect(isOpaqueWhiteout("/deeply/nested/path/.wh..wh..opq")).toBe(true);
70+
});
71+
72+
test("should return false for regular whiteout files", () => {
73+
expect(isOpaqueWhiteout("/etc/.wh.hosts")).toBe(false);
74+
expect(isOpaqueWhiteout("/.wh.config")).toBe(false);
75+
});
76+
77+
test("should return false for non-whiteout files", () => {
78+
expect(isOpaqueWhiteout("/etc/hosts")).toBe(false);
79+
expect(isOpaqueWhiteout("")).toBe(false);
80+
expect(isOpaqueWhiteout("/")).toBe(false);
81+
});
82+
83+
test("should return false for similar but incorrect patterns", () => {
84+
expect(isOpaqueWhiteout("/etc/.wh..wh..opq.extra")).toBe(false);
85+
expect(isOpaqueWhiteout("/etc/.wh..opq")).toBe(false);
86+
expect(isOpaqueWhiteout("/etc/.WH..WH..OPQ")).toBe(false);
87+
});
88+
});
89+
6390
describe("removeWhiteoutPrefix", () => {
6491
test("should remove .wh. prefix from filenames without slashes", () => {
6592
expect(removeWhiteoutPrefix(".wh.hosts")).toBe("hosts");

0 commit comments

Comments
 (0)