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") }) }) })