From 26644fcf53a2aa10cd3b7327b4022d104343c7bb Mon Sep 17 00:00:00 2001 From: Steffen Neubauer Date: Thu, 12 Sep 2024 16:55:20 +0200 Subject: [PATCH] fix: allow relative symlinks to directories when using build staging (#6430) * fix: allow relative symlinks to directories when using build staging Allows using relative symlinks to directories and files when using build staging. The goal is to reproduce symlinks in the target directory as-is, without having to resolve them. The user would be responsible for making sure that both the relative symlink as well as the target directory are included in the action. If the symlink points to a file or directory outside the actions's source path, this throws an error. To avoid this becoming a breaking change, we keep the old behaviour if the symlink points to a file. In that case, we resolve the symlink and copy the target file. If the target file is outside the action's source or build directory, log a warning. In a future 0.14 release of Garden, we should remove that branch, only reproduce symlinks (without resolving them recursively) and throw an error if the link is out of bounds. Fixes #2382 Co-authored-by: Thorarinn Sigurdsson * test: add additional test for the error Co-authored-by: vova * refactor: rename param object property To keep more precise naming. * refactor: introduce param object for constructor * refactor: introduce param object for factory method * chore: declare non re-assignable props as readonly * refactor: named type for standard stats callback * refactor: named type for resolve symlink callback stats callback * refactor: param object for resolve symlink callback * fix: restore accidentally deleted condition check Co-authored-by: Steffen Neubauer --------- Co-authored-by: Thorarinn Sigurdsson Co-authored-by: vova Co-authored-by: Vladimir Vagaytsev <10628074+vvagaytsev@users.noreply.github.com> --- core/src/build-staging/build-staging.ts | 4 +- core/src/build-staging/helpers.ts | 209 +++++++++++++++----- core/test/unit/src/build-staging/helpers.ts | 113 +++++++++-- 3 files changed, 257 insertions(+), 69 deletions(-) diff --git a/core/src/build-staging/build-staging.ts b/core/src/build-staging/build-staging.ts index 06d7bfe74a..8d2ab7177f 100644 --- a/core/src/build-staging/build-staging.ts +++ b/core/src/build-staging/build-staging.ts @@ -271,6 +271,8 @@ export class BuildStaging { const to = targetShouldBeDirectory || targetStat?.isDirectory() ? join(targetPath, sourceBasename) : targetPath await syncFileAsync({ + log, + sourceRoot, from: sourceRoot, to, allowDelete: withDelete, @@ -323,7 +325,7 @@ export class BuildStaging { ([fromRelative, toRelative], fileCb) => { const from = joinWithPosix(sourceRoot, fromRelative) const to = joinWithPosix(targetPath, toRelative) - cloneFile({ from, to, allowDelete: withDelete, statsHelper }, fileCb) + cloneFile({ log, sourceRoot, from, to, allowDelete: withDelete, statsHelper }, fileCb) }, cb ) diff --git a/core/src/build-staging/helpers.ts b/core/src/build-staging/helpers.ts index e6680c29f1..e0a2f3ad28 100644 --- a/core/src/build-staging/helpers.ts +++ b/core/src/build-staging/helpers.ts @@ -6,22 +6,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { readlink, copyFile, constants, utimes } from "fs" +import { readlink, copyFile, constants, utimes, symlink } from "fs" import readdir from "@jsdevtools/readdir-enhanced" -import { splitLast } from "../util/string.js" +import { dedent, splitLast } from "../util/string.js" import { Minimatch } from "minimatch" -import { isAbsolute, parse, basename, resolve } from "path" +import { isAbsolute, parse, basename, resolve, join, dirname } from "path" import fsExtra from "fs-extra" -const { ensureDir, Stats, lstat, remove } = fsExtra -import { FilesystemError, InternalError, isErrnoException } from "../exceptions.js" +import { ConfigurationError, FilesystemError, InternalError, isErrnoException } from "../exceptions.js" import type { AsyncResultCallback } from "async" import async from "async" import { round } from "lodash-es" import { promisify } from "util" +import { styles } from "../logger/styles.js" +import { emitNonRepeatableWarning } from "../warnings.js" +import { type Log } from "../logger/log-entry.js" + +const { ensureDir, Stats, lstat, remove } = fsExtra export type MappedPaths = [string, string][] export interface CloneFileParams { + log: Log + sourceRoot: string from: string to: string allowDelete: boolean @@ -37,7 +43,10 @@ type SyncCallback = AsyncResultCallback /** * Synchronizes (clones) a single file in one direction. */ -export function cloneFile({ from, to, allowDelete, statsHelper }: CloneFileParams, done: SyncCallback) { +export function cloneFile( + { log, sourceRoot, from, to, allowDelete, statsHelper }: CloneFileParams, + done: SyncCallback +) { // Stat the files async.parallel( { @@ -57,33 +66,60 @@ export function cloneFile({ from, to, allowDelete, statsHelper }: CloneFileParam return done(null, { skipped: true }) } - // Follow symlink on source, if applicable if (sourceStats.isSymbolicLink()) { - if (sourceStats.target) { - sourceStats = sourceStats.target - } else { - // Symlink couldn't be resolved, so we ignore it + if (!sourceStats.targetPath) { + // This symlink failed validation (e.g. it is absolute, when absolute symlinks are not allowed) return done(null, { skipped: true }) } + const resolved = resolve(dirname(sourceStats.path), sourceStats.targetPath) + const outOfBounds = !resolved.startsWith(join(sourceRoot, "/")) + if (outOfBounds) { + const outOfBoundsMessage = dedent` + The action's source directory (when using ${styles.highlight("staged builds")}), or build directory (when using ${styles.highlight("copyFrom")}), must be self-contained. + + Encountered a symlink at ${styles.highlight(sourceStats.path)} whose target ${styles.highlight(sourceStats.targetPath)} is out of bounds (not inside ${sourceRoot}). + + In case this is not acceptable, you can disable build staging by setting ${styles.highlight("buildAtSource: true")} in the action configuration to disable build staging for this action.` + if (sourceStats.target?.isFile()) { + // For compatibility with older versions of garden that would allow symlink targets outside the root, if the target file existed, we only emit a warning here. + // TODO(0.14): Throw an error here + emitNonRepeatableWarning( + log, + outOfBoundsMessage + `\n\nWARNING: This will become an error in an upcoming release of Garden.` + ) + // For compatibility with older versions of Garden, copy the target file instead of reproducing the symlink + // TODO(0.14): Only reproduce the symlink. The target file will be copied in another call to `cloneFile`. + sourceStats = sourceStats.target + } else { + // Note: If a symlink pointed to a directory, we threw another error "source is neither a symbolic link, nor a file" in previous versions of garden, + // so this is not a breaking change. + return done( + new ConfigurationError({ + message: outOfBoundsMessage, + }) + ) + } + } } - if (!sourceStats.isFile()) { + if (!sourceStats.isFile() && !sourceStats.isSymbolicLink()) { return done( - new FilesystemError({ + // Using internal error here because if this happens, it's a logical error here in this code + new InternalError({ message: `Error while copying from '${from}' to '${to}': Source is neither a symbolic link, nor a file.`, }) ) } if (targetStats) { - // If target is a directory and deletes are allowed, delete the directory before copying, otherwise throw - if (targetStats.isDirectory()) { + // If target is a directory or source is a symbolic link and deletes are allowed, delete the target before copying, otherwise throw + if (targetStats.isDirectory() || sourceStats.isSymbolicLink()) { if (allowDelete) { return remove(to, (removeErr) => { if (removeErr) { return done(removeErr) } else { - return doClone({ from, to, sourceStats: sourceStats!, done, statsHelper }) + return doClone({ from, to, sourceStats: sourceStats!, targetStats, done, statsHelper }) } }) } else { @@ -101,7 +137,7 @@ export function cloneFile({ from, to, allowDelete, statsHelper }: CloneFileParam } } - return doClone({ from, to, sourceStats, done, statsHelper }) + return doClone({ from, to, sourceStats, targetStats, done, statsHelper }) } ) } @@ -112,14 +148,15 @@ export const cloneFileAsync = promisify(cloneFile) as (params: CloneFileParams) interface CopyParams { from: string to: string - sourceStats: fsExtra.Stats + sourceStats: ExtendedStats + targetStats: ExtendedStats | null done: SyncCallback statsHelper: FileStatsHelper resolvedSymlinkPaths?: string[] } function doClone(params: CopyParams) { - const { from, to, done, sourceStats } = params + const { from, to, done, sourceStats, targetStats } = params const dir = parse(to).dir // TODO: take care of this ahead of time to avoid the extra i/o @@ -128,11 +165,7 @@ function doClone(params: CopyParams) { return done(err) } - // COPYFILE_FICLONE instructs the function to use a copy-on-write reflink on platforms/filesystems where available - copyFile(from, to, constants.COPYFILE_FICLONE, (copyErr) => { - if (copyErr) { - return done(copyErr) - } + const setUtimes = () => { // Set the mtime on the cloned file to the same as the source file utimes(to, new Date(), sourceStats.mtimeMs / 1000, (utimesErr) => { if (utimesErr && (!isErrnoException(utimesErr) || utimesErr.code !== "ENOENT")) { @@ -140,7 +173,46 @@ function doClone(params: CopyParams) { } done(null, { skipped: false }) }) - }) + } + + if (sourceStats.isSymbolicLink()) { + // reproduce the symbolic link. Validation happens before. + if (sourceStats.targetPath) { + symlink( + sourceStats.targetPath, + to, + // relevant on windows + // nodejs will auto-detect this on windows, but if the symlink is copied before the target then the auto-detection will get it wrong. + targetStats?.isDirectory() ? "dir" : "file", + (symlinkErr) => { + if (symlinkErr) { + return done(symlinkErr) + } + + setUtimes() + } + ) + } else { + return done( + new InternalError({ + message: "Source is a symbolic link, but targetPath was null or undefined.", + }) + ) + } + } else if (sourceStats.isFile()) { + // COPYFILE_FICLONE instructs the function to use a copy-on-write reflink on platforms/filesystems where available + copyFile(from, to, constants.COPYFILE_FICLONE, (copyErr) => { + if (copyErr) { + return done(copyErr) + } + + setUtimes() + }) + } else { + throw new InternalError({ + message: "Expected doClone source to be a file or a symbolic link.", + }) + } }) } @@ -217,18 +289,34 @@ export async function scanDirectoryForClone(root: string, pattern?: string): Pro return mappedPaths } +type ExtendedStatsCtorParams = { + path: string + target?: ExtendedStats | null + targetPath?: string | null +} + export class ExtendedStats extends Stats { path: string target?: ExtendedStats | null + // original relative or absolute path the symlink points to. This can be defined when target is null when the target does not exist, for instance. + targetPath?: string | null - constructor(path: string, target?: ExtendedStats | null) { + constructor({ path, target, targetPath }: ExtendedStatsCtorParams) { super() this.path = path this.target = target + this.targetPath = targetPath } - static fromStats(stats: fsExtra.Stats, path: string, target?: ExtendedStats | null) { - const o = new ExtendedStats(path, target) + static fromStats({ + stats, + path, + target, + targetPath, + }: { + stats: fsExtra.Stats + } & ExtendedStatsCtorParams) { + const o = new ExtendedStats({ path, target, targetPath }) Object.assign(o, stats) return o } @@ -245,7 +333,13 @@ export interface ResolveSymlinkParams { _resolvedPaths?: string[] } +type StatsCallback = (err: NodeJS.ErrnoException | null, stats: fsExtra.Stats) => void type ExtendedStatsCallback = (err: NodeJS.ErrnoException | null, stats: ExtendedStats | null) => void +type ResolveSymlinkCallback = (params: { + err: NodeJS.ErrnoException | null + target: ExtendedStats | null + targetPath: string | null +}) => void /** * A helper class for getting information about files/dirs, that caches the stats for the lifetime of the class, and @@ -253,8 +347,8 @@ type ExtendedStatsCallback = (err: NodeJS.ErrnoException | null, stats: Extended * The idea is for an instance to be used for the duration of e.g. one sync flow, but not for longer. */ export class FileStatsHelper { - private lstatCache: { [path: string]: fsExtra.Stats } - private extendedStatCache: { [path: string]: ExtendedStats | null } + private readonly lstatCache: { [path: string]: fsExtra.Stats } + private readonly extendedStatCache: { [path: string]: ExtendedStats | null } constructor() { this.lstatCache = {} @@ -264,7 +358,7 @@ export class FileStatsHelper { /** * Calls fs.lstat on the given path, and caches the result. */ - lstat(path: string, cb: (err: NodeJS.ErrnoException | null, stats: fsExtra.Stats) => void) { + lstat(path: string, cb: StatsCallback) { if (this.lstatCache[path]) { cb(null, this.lstatCache[path]) } else { @@ -312,11 +406,14 @@ export class FileStatsHelper { if (lstatErr) { cb(lstatErr, null) } else if (lstats.isSymbolicLink()) { - this.resolveSymlink({ path, allowAbsolute: allowAbsoluteSymlinks }, (symlinkErr, target) => { - cb(symlinkErr, ExtendedStats.fromStats(lstats, path, target)) - }) + this.resolveSymlink( + { path, allowAbsolute: allowAbsoluteSymlinks }, + ({ err: symlinkErr, target, targetPath }) => { + cb(symlinkErr, ExtendedStats.fromStats({ stats: lstats, path, target, targetPath })) + } + ) } else { - cb(null, ExtendedStats.fromStats(lstats, path)) + cb(null, ExtendedStats.fromStats({ stats: lstats, path })) } }) } @@ -351,15 +448,15 @@ export class FileStatsHelper { * fs.Stats for the resolved target, if one can be resolved. If target cannot be found, the callback is resolved * with a null value. * + * The second callback parameter, `targetPath`, is the unresolved target of the symbolic link. The `targetPath` will be undefined + * if the symbolic link is absolute, and absolute symbolic links are not allowed. + * * `path` must be an absolute path (an error is thrown otherwise). * * By default, absolute symlinks are ignored, and if one is encountered the method resolves to null. Set * `allowAbsolute: true` to allow absolute symlinks. */ - resolveSymlink( - params: ResolveSymlinkParams, - cb: (err: NodeJS.ErrnoException | null, target: ExtendedStats | null) => void - ) { + resolveSymlink(params: ResolveSymlinkParams, cb: ResolveSymlinkCallback) { const { path, allowAbsolute } = params const _resolvedPaths = params._resolvedPaths || [path] @@ -370,15 +467,19 @@ export class FileStatsHelper { readlink(path, (readlinkErr, target) => { if (readlinkErr?.code === "ENOENT") { // Symlink target not found, so we ignore it - return cb(null, null) + return cb({ err: null, target: null, targetPath: null }) } else if (readlinkErr) { - return cb(InternalError.wrapError(readlinkErr, "Error reading symlink"), null) + return cb({ + err: InternalError.wrapError(readlinkErr, "Error reading symlink"), + target: null, + targetPath: null, + }) } // Ignore absolute symlinks unless specifically allowed // TODO: also allow limiting links to a certain absolute path if (isAbsolute(target) && !allowAbsolute) { - return cb(null, null) + return cb({ err: null, target: null, targetPath: null }) } // Resolve the symlink path @@ -387,25 +488,37 @@ export class FileStatsHelper { // Stat the final path and return this.lstat(targetPath, (statErr, targetStats) => { if (statErr?.code === "ENOENT") { - // Can't find the final target, so we ignore it - cb(null, null) + // The symlink target does not exist. That's not an error. + cb({ err: null, target: null, targetPath: target }) } else if (statErr) { // Propagate other errors - cb(statErr, null) + cb({ err: statErr, target: null, targetPath: null }) } else if (targetStats.isSymbolicLink()) { // Keep resolving until we get to a real path if (_resolvedPaths.includes(targetPath)) { // We've gone into a symlink loop, so we ignore it - cb(null, null) + cb({ err: null, target: null, targetPath: target }) } else { this.resolveSymlink( { path: targetPath, allowAbsolute, _resolvedPaths: [..._resolvedPaths, targetPath] }, - cb + ({ err: innerResolveErr, target: innerStats, targetPath: _innerTarget }) => { + if (innerResolveErr) { + cb({ err: innerResolveErr, target: null, targetPath: null }) + } else { + // make sure the original symlink target is not overridden by the recursive search here + // TODO(0.14): In a future version of garden it would be better to simply reproduce relative symlinks, instead of resolving them and copying the target directories. + cb({ err: null, target: innerStats, targetPath: target }) + } + } ) } } else { // Return with path and stats for final symlink target - cb(null, ExtendedStats.fromStats(targetStats, targetPath)) + cb({ + err: null, + target: ExtendedStats.fromStats({ stats: targetStats, path: targetPath }), + targetPath: target, + }) } }) }) diff --git a/core/test/unit/src/build-staging/helpers.ts b/core/test/unit/src/build-staging/helpers.ts index 0074d40f37..8831393cc2 100644 --- a/core/test/unit/src/build-staging/helpers.ts +++ b/core/test/unit/src/build-staging/helpers.ts @@ -12,12 +12,15 @@ import { cloneFileAsync, FileStatsHelper, scanDirectoryForClone } from "../../.. import type { TempDirectory } from "../../../../src/util/fs.js" import { makeTempDir } from "../../../../src/util/fs.js" import fsExtra from "fs-extra" + const { realpath, symlink, writeFile, readFile, mkdir, ensureFile, ensureDir } = fsExtra import { expect } from "chai" import { expectError } from "../../../helpers.js" import { sleep } from "../../../../src/util/util.js" import { sortBy } from "lodash-es" import { equalWithPrecision } from "../../../../src/util/testing.js" +import { getRootLogger } from "../../../../src/logger/logger.js" +import { readlink } from "fs/promises" describe("build staging helpers", () => { let statsHelper: FileStatsHelper @@ -40,11 +43,14 @@ describe("build staging helpers", () => { } describe("cloneFile", () => { + const logger = getRootLogger() + const log = logger.createLog() + it("clones a file", async () => { const a = join(tmpPath, "a") const b = join(tmpPath, "b") await writeFile(a, "foo") - const res = await cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }) + const res = await cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }) const data = await readFileStr(b) expect(res.skipped).to.be.false expect(data).to.equal("foo") @@ -55,7 +61,7 @@ describe("build staging helpers", () => { const b = join(tmpPath, "b") await writeFile(a, "foo") await mkdir(b) - const res = await cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: true }) + const res = await cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: true }) const data = await readFileStr(b) expect(res.skipped).to.be.false expect(data).to.equal("foo") @@ -67,7 +73,7 @@ describe("build staging helpers", () => { await writeFile(a, "foo") await mkdir(b) - await expectError(() => cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }), { + await expectError(() => cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }), { contains: `Build staging: Failed copying file from '${a}' to '${b}' because a directory exists at the target path`, }) }) @@ -78,7 +84,7 @@ describe("build staging helpers", () => { await writeFile(a, "foo") await sleep(100) - await cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }) + await cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }) const statA = await statsHelper.extendedStat({ path: a }) const statB = await statsHelper.extendedStat({ path: b }) @@ -91,9 +97,9 @@ describe("build staging helpers", () => { const b = join(tmpPath, "b") await writeFile(a, "foo") - await cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }) + await cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }) - const res = await cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }) + const res = await cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }) expect(res.skipped).to.be.true }) @@ -102,7 +108,7 @@ describe("build staging helpers", () => { const b = join(tmpPath, "subdir", "b") await writeFile(a, "foo") - await cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }) + await cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }) const data = await readFileStr(b) expect(data).to.equal("foo") @@ -114,12 +120,65 @@ describe("build staging helpers", () => { const c = join(tmpPath, "c") await writeFile(a, "foo") await symlink("a", b) - const res = await cloneFileAsync({ from: b, to: c, statsHelper, allowDelete: false }) + const res = await cloneFileAsync({ log, sourceRoot: b, from: b, to: c, statsHelper, allowDelete: false }) const data = await readFileStr(c) expect(res.skipped).to.be.false expect(data).to.equal("foo") }) + it("throws an error if symlink is out of bounds", async () => { + const b = join(tmpPath, "b") + const a = join(tmpPath, "a") + const symlPath = "symlink" + const symlTarget = ".." + await mkdir(a) + await symlink(symlTarget, join(a, symlPath)) + await expectError( + () => + cloneFileAsync({ + log, + sourceRoot: a, + from: join(a, symlPath), + to: join(b, symlPath), + statsHelper, + allowDelete: false, + }), + { + contains: ["Encountered a symlink", "whose target .. is out of bounds (not inside"], + } + ) + }) + + it("reproduces the symlink if it points to a directory", async () => { + const b = join(tmpPath, "b") + const a = join(tmpPath, "a") + const syml = "symlink" + const symlBroken = "broken" + const dir = "dir" + const file = join(dir, "fruit") + await mkdir(a) + await mkdir(join(a, dir)) + await writeFile(join(a, file), "banana") + await symlink("dir", join(a, syml)) + await symlink("target_does_not_exist", join(a, symlBroken)) + const filesToClone = [syml, symlBroken, file] + for (const f of filesToClone) { + const res = await cloneFileAsync({ + log, + sourceRoot: a, + from: join(a, f), + to: join(b, f), + statsHelper, + allowDelete: false, + }) + expect(res.skipped).to.be.false + } + expect(await readlink(join(b, syml))).to.equal("dir") + expect(await readlink(join(b, symlBroken))).to.equal("target_does_not_exist") + expect(await readFileStr(join(b, file))).to.equal("banana") + expect(await readFileStr(join(b, syml, "fruit"))).to.equal("banana") + }) + it("clones a file that's within a symlinked directory", async () => { const dirLink = join(tmpPath, "dir-link") const dir = join(tmpPath, "dir") @@ -130,7 +189,7 @@ describe("build staging helpers", () => { await symlink("dir", dirLink) await writeFile(a, "foo") - const res = await cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }) + const res = await cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }) const data = await readFileStr(b) expect(res.skipped).to.be.false expect(data).to.equal("foo") @@ -141,7 +200,7 @@ describe("build staging helpers", () => { const b = join(tmpPath, "b") await ensureDir(a) - await expectError(() => cloneFileAsync({ from: a, to: b, statsHelper, allowDelete: false }), { + await expectError(() => cloneFileAsync({ log, sourceRoot: a, from: a, to: b, statsHelper, allowDelete: false }), { contains: `Error while copying from '${a}' to '${b}': Source is neither a symbolic link, nor a file`, }) }) @@ -403,12 +462,18 @@ describe("build staging helpers", () => { describe("resolveSymlink", () => { // A promisified version to simplify tests async function resolveSymlink(params: ResolveSymlinkParams) { - return new Promise((resolve, reject) => { - statsHelper.resolveSymlink(params, (err, target) => { + return new Promise<{ + target: ExtendedStats | null + targetPath: string | null + }>((resolve, reject) => { + statsHelper.resolveSymlink(params, ({ err, target, targetPath }) => { if (err) { reject(err) } else { - resolve(target) + resolve({ + target, + targetPath, + }) } }) }) @@ -420,7 +485,8 @@ describe("build staging helpers", () => { await writeFile(a, "foo") await symlink("a", b) const res = await resolveSymlink({ path: b }) - expect(res?.path).to.equal(a) + expect(res?.target?.path).to.equal(a) + expect(res?.targetPath).to.equal("a") }) it("resolves a symlink recursively", async () => { @@ -431,7 +497,8 @@ describe("build staging helpers", () => { await symlink("a", b) await symlink("b", c) const res = await resolveSymlink({ path: c }) - expect(res?.path).to.equal(a) + expect(res?.target?.path).to.equal(a) + expect(res?.targetPath).to.equal("b") }) it("returns null for an absolute symlink", async () => { @@ -440,7 +507,8 @@ describe("build staging helpers", () => { await writeFile(a, "foo") await symlink(a, b) // <- absolute link const res = await resolveSymlink({ path: b }) - expect(res).to.equal(null) + expect(res.target).to.equal(null) + expect(res.targetPath).to.equal(null) }) it("returns null for a recursive absolute symlink", async () => { @@ -451,7 +519,9 @@ describe("build staging helpers", () => { await symlink(a, b) // <- absolute link await symlink("b", c) const res = await resolveSymlink({ path: c }) - expect(res).to.equal(null) + expect(res.target).to.equal(null) + // target path can be resolved; If the build staging logic were only to reproduce relative symlinks, broken or not, it would all be much easier to understand. + expect(res.targetPath).to.equal("b") }) it("resolves an absolute symlink if allowAbsolute=true", async () => { @@ -460,7 +530,8 @@ describe("build staging helpers", () => { await writeFile(a, "foo") await symlink(a, b) // <- absolute link const res = await resolveSymlink({ path: b, allowAbsolute: true }) - expect(res?.path).to.equal(a) + expect(res?.target?.path).to.equal(a) + expect(res?.targetPath).to.equal(a) }) it("throws if a relative path is given", async () => { @@ -479,7 +550,8 @@ describe("build staging helpers", () => { await symlink("a", b) await symlink("b", a) const res = await resolveSymlink({ path: b }) - expect(res).to.equal(null) + expect(res.target).to.equal(null) + expect(res.targetPath).to.equal("a") }) it("returns null if resolving a two-step circular symlink", async () => { @@ -490,7 +562,8 @@ describe("build staging helpers", () => { await symlink("a", b) await symlink("b", c) const res = await resolveSymlink({ path: c }) - expect(res).to.equal(null) + expect(res.target).to.equal(null) + expect(res.targetPath).to.equal("b") }) }) })