diff --git a/README.md b/README.md index 12cd9a695..60bb85487 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,7 @@ Filesystem restrictions are enforced at the OS level: - A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target. - A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either. -**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points, and nothing removes them later: they cannot be told from an empty file or directory of your own, or from the mount points of another process's sandbox that is still running. A later command finds a path that exists, binds it read-only like any other, and leaves it on the host until you remove it. +**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points, and nothing removes them later: they cannot be told from an empty file or directory of your own, or from the mount points of another process's sandbox that is still running. A later command finds a path that exists, binds it read-only like any other, and leaves it on the host until you remove it. Where a file lies above the path (`denyWrite: ["f/x"]`, and `f` is a file), no mount point can be made below it: the file itself is bound read-only, which keeps it there. - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. - On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. @@ -941,7 +941,7 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `.git/hooks/`, `.git/config` +- Git hooks and config: `.git/hooks/`, `.git/config`. Where the working directory is a worktree or a submodule, its `.git` is a file: on Linux that file is blocked, so it can be read but not changed, moved or removed. These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index d8e147142..bc47779da 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -254,13 +254,11 @@ function resolveSymlinkedDenyPath( } /** - * Check if any existing component in the path is a file (not a directory). - * If so, the target path can never be created because you can't mkdir under a file. - * - * This handles the git worktree case: .git is a file, so .git/hooks can never - * exist and there's nothing to deny. + * The first existing component of the path that is not a directory, if there + * is one. Nothing can be created below it for as long as it is there: a git + * worktree's `.git` is a file, so there is no `.git/hooks`. */ -function hasFileAncestor(targetPath: string): boolean { +function fileOnTheWay(targetPath: string): string | undefined { const parts = targetPath.split(path.sep) let currentPath = '' @@ -268,11 +266,7 @@ function hasFileAncestor(targetPath: string): boolean { if (!part) continue // Skip empty parts (leading /) const nextPath = currentPath + path.sep + part try { - const stat = fs.statSync(nextPath) - if (stat.isFile()) { - // This component exists as a file — nothing below it can be created - return true - } + if (!fs.statSync(nextPath).isDirectory()) return nextPath } catch { // Path doesn't exist — stop checking break @@ -280,7 +274,7 @@ function hasFileAncestor(targetPath: string): boolean { currentPath = nextPath } - return false + return undefined } /** @@ -307,8 +301,19 @@ function findFirstNonExistentComponent(targetPath: string): string { return targetPath // Shouldn't reach here if called correctly } -/** What bubblewrap makes on the host to mount a placeholder on. */ -type MountPointKind = 'file' | 'directory' +/** What the placeholder for an absent deny path is mounted on. */ +enum MountPointKind { + /** A file made on the host for it, which /dev/null goes on. */ + File = 'file', + /** A directory made on the host for it, which an empty one goes on. */ + Directory = 'directory', + /** The file above the path, which goes on itself. Nothing is made. */ + Held = 'held', + /** None can be made. Nothing is mounted; what lies above is still pinned. */ + Unmakeable = 'unmakeable', +} +/** The kinds that are made on the host. */ +type MadeMountPointKind = MountPointKind.File | MountPointKind.Directory /** open(2)'s O_PATH on x86-64 and arm64; node:fs does not name it. Where the * bit means something else the open is an ordinary one or fails, and a failure @@ -320,16 +325,17 @@ const V9FS_MAGIC = 0x01021997 /** * The kind of mount point that can be made at `dest`, an absent path whose * parent is there: `wanted`, a directory where only a file is refused, or - * none. bubblewrap makes it on the host as this user, before the command runs, - * and exits if it cannot. + * unmakeable. bubblewrap makes it on the host as this user, before the command + * runs, and exits if it cannot. * - * None only where the refusal is not this user's to lift, so that the command - * cannot create `dest` either; `wanted` on any doubt (see mountPointFor). + * Unmakeable only where the refusal is not this user's to lift, so that the + * command cannot create `dest` either; `wanted` on any doubt (see + * mountPointFor). */ function makeableMountPoint( dest: string, - wanted: MountPointKind, -): MountPointKind | undefined { + wanted: MadeMountPointKind, +): MadeMountPointKind | MountPointKind.Unmakeable { /** The errno `attempt` is refused with. Any other failure is a doubt. */ const refusalOf = (attempt: () => void): string | undefined => { try { @@ -366,7 +372,7 @@ function makeableMountPoint( return refusal === undefined || (refusal === 'EACCES' && uid === process.geteuid?.()) ? wanted - : undefined + : MountPointKind.Unmakeable } // On a Windows drive the Windows ACL decides, and nothing on this side @@ -376,21 +382,21 @@ function makeableMountPoint( if (refusal === undefined) { const entry = `${parent}/${path.basename(dest)}` if ( - wanted === 'file' && + wanted === MountPointKind.File && refusalOf(() => fs.closeSync(fs.openSync(entry, 'wx', 0o444))) === undefined ) { - return 'file' + return MountPointKind.File } if (refusalOf(() => fs.mkdirSync(entry)) === undefined) { - return 'directory' + return MountPointKind.Directory } } // The name may be refused for a reason that passes. The folder is beyond // this user when it may not even be given the mode it has. return refusalOf(() => fs.chmodSync(parent, mode & 0o7777)) === undefined ? wanted - : undefined + : MountPointKind.Unmakeable } catch { return wanted } finally { @@ -398,10 +404,35 @@ function makeableMountPoint( } } +/** What the `.git` of a directory is, and so what is denied there. */ +enum DotGitKind { + /** A repository: its hooks and config are denied. */ + Directory = 'directory', + /** + * The pointer of a worktree or a submodule ("gitdir: /path/..."). There are + * no .git/hooks for as long as it is a file, so the file is denied, which + * keeps it one. + */ + File = 'file', + /** Nothing is denied: a mount at .git would keep `git init` from making it. */ + Absent = 'absent', +} + +function dotGitKindAt(dotGitPath: string): DotGitKind { + try { + return fs.statSync(dotGitPath).isDirectory() + ? DotGitKind.Directory + : DotGitKind.File + } catch { + return DotGitKind.Absent + } +} + /** * The part of the mandatory deny set that follows from the cwd alone: the * dangerous files and directories resolved against it, plus `.git/hooks` and - * (unless the caller allows git config) `.git/config`. + * (unless the caller allows git config) `.git/config`, or a `.git` that is a + * file. * {@link linuxGetMandatoryDenyPaths} adds the nested matches its ripgrep scan * finds on top of these. Split out so a consumer that must not scan — the * violation monitor, which needs the same denies to judge a write bwrap @@ -419,20 +450,15 @@ export function linuxGetCwdMandatoryDenyPaths( ...getDangerousDirectories().map(d => path.resolve(cwd, d)), ] - // Git hooks and config are only denied when .git exists as a directory. - // In git worktrees, .git is a file (e.g., "gitdir: /path/..."), so - // .git/hooks can never exist — denying it would cause bwrap to fail. - // When .git doesn't exist at all, mounting at .git would block its - // creation and break git init. + // Only the cwd's own: a .git file below it is git's to rewrite and remove + // (a checkout that recurses into submodules does). const dotGitPath = path.resolve(cwd, '.git') - let dotGitIsDirectory = false - try { - dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory() - } catch { - // .git doesn't exist - } + const dotGit = dotGitKindAt(dotGitPath) - if (dotGitIsDirectory) { + if (dotGit === DotGitKind.File) { + denyPaths.push(dotGitPath) + } + if (dotGit === DotGitKind.Directory) { // Git hooks always blocked for security denyPaths.push(path.resolve(cwd, '.git/hooks')) @@ -2774,20 +2800,18 @@ async function generateFilesystemArgs( const decidedMountPoints = new Set() const decidedMountPointOver = (p: string): string | undefined => [p, ...properAncestors(p)].find(at => decidedMountPoints.has(at)) + type Placeholder = { dest: string; kind: MountPointKind } /** * The placeholder an absent deny path adds to the plan: where, which is - * its first missing component, and the kind of mount point bubblewrap - * makes for it on the host. `kind` is undefined where none can be made, - * and the whole answer where the path adds nothing. + * its first missing component or the file above it, and on what kind of + * mount point. Undefined where the path adds nothing. * * INVARIANT: a placeholder is left out only where the sandboxed command * cannot create the path either; each branch says why it cannot. On any * doubt the placeholder stays: at worst bubblewrap cannot make its mount * point and nothing starts, which loses no deny. */ - const mountPointFor = ( - absentPath: string, - ): { dest: string; kind: MountPointKind | undefined } | undefined => { + const mountPointFor = (absentPath: string): Placeholder | undefined => { const none = (why: string): undefined => { logForDebugging( `[Sandbox Linux] No placeholder for non-existent deny path ${absentPath}: ${why}`, @@ -2819,12 +2843,27 @@ async function generateFilesystemArgs( ) { return none('it is read-only there') } - // Nothing can be created under a file: a git worktree's .git is one. + // Nothing can be created under a file (a git worktree's .git is one), + // for as long as the file is there. So it is kept there, where the + // command may write the directory that holds it. Where it may write the + // file alone, that stays where it is and writable. It is not tracked + // for removal: it is the caller's own, or another sandbox's mount point. // Asked of what lies above the path, not of the path: another sandbox's // bubblewrap may have made a mount point at it since it was found // absent, and a file there is no reason to leave it unbound. - if (hasFileAncestor(path.dirname(absentPath))) { - return none('a file lies above it') + const file = fileOnTheWay(path.dirname(absentPath)) + if (file !== undefined) { + if (seenDenyWrite.has(file)) return none(`${file} is denied itself`) + if ( + !uncoveredWritePaths.some(allowedPath => + isAtOrUnder(path.dirname(file), allowedPath), + ) + ) { + return none(`${file} is a file, in a directory that is read-only`) + } + seenDenyWrite.add(file) + decidedMountPoints.add(file) + return { dest: file, kind: MountPointKind.Held } } // A component with a deny path beneath it is covered by an empty @@ -2835,9 +2874,11 @@ async function generateFilesystemArgs( decidedMountPoints.add(dest) const kind = makeableMountPoint( dest, - denyPathAncestors.has(dest) ? 'directory' : 'file', + denyPathAncestors.has(dest) + ? MountPointKind.Directory + : MountPointKind.File, ) - if (kind === undefined) { + if (kind === MountPointKind.Unmakeable) { // The directories above it are pinned all the same: a command that // may write one of THEM could otherwise move the refusing one aside. unmakeableDenyDests.add(dest) @@ -2851,6 +2892,20 @@ async function generateFilesystemArgs( } return { dest, kind } } + /** What goes on it. The empty directory is made when first asked for. */ + const mountSourceFor = ({ dest, kind }: Placeholder): string => { + switch (kind) { + case MountPointKind.Held: + return dest + case MountPointKind.Directory: + return (emptySource ??= ensureEmptyMountSourceDir()) + case MountPointKind.File: + return '/dev/null' + case MountPointKind.Unmakeable: + // Never mounted: unmakeableDenyDests keeps it out of what is emitted. + return '/dev/null' + } + } for (const [index, pathPattern] of denyPaths.entries()) { if (index === configuredDenyPaths.length) { mandatoryDenyArgsStart = denyWriteArgs.length @@ -2937,24 +2992,17 @@ async function generateFilesystemArgs( const placeholder = mountPointFor(normalizedPath) if (placeholder !== undefined) { const { dest, kind } = placeholder - denyWriteArgs.push( - '--ro-bind', - kind === 'directory' - ? (emptySource ??= ensureEmptyMountSourceDir()) - : '/dev/null', - dest, - ) + const source = mountSourceFor(placeholder) + denyWriteArgs.push('--ro-bind', source, dest) // The first of the denies that share the destination. The record is // purely additive: it only gives the tmpfs and mask comparisons // below a second spelling to test, and `dest` itself is always // tested. denyWriteRawDests.set(dest, rawPath) logForDebugging( - kind === undefined + kind === MountPointKind.Unmakeable ? `[Sandbox Linux] No mount point at ${dest}, which neither bubblewrap nor the command can create, for ${normalizedPath}` - : `[Sandbox Linux] Mounted ${ - kind === 'directory' ? 'empty dir' : '/dev/null' - } at ${dest} to block creation of ${normalizedPath}`, + : `[Sandbox Linux] Mounted ${source} at ${dest} to block creation of ${normalizedPath}`, ) } continue diff --git a/test/sandbox/deny-path-made-meanwhile.test.ts b/test/sandbox/deny-path-made-meanwhile.test.ts index 30eb6693a..4936c1358 100644 --- a/test/sandbox/deny-path-made-meanwhile.test.ts +++ b/test/sandbox/deny-path-made-meanwhile.test.ts @@ -76,12 +76,15 @@ describe.if(isLinux)('A deny path made while the wrap is looking', () => { expect(countMounts(wrapped, '--ro-bind', '/dev/null', denied)).toBe(1) }) - it('still leaves alone a path beneath a file, which can never be created', async () => { + it('still lays nothing at a path beneath a file, and keeps the file', async () => { writeFileSync(join(area, '.git'), 'gitdir: elsewhere\n') const denied = join(area, '.git', 'hooks') const wrapped = await wrap(denied) expect(wrapped).not.toContain(denied) + expect( + countMounts(wrapped, '--ro-bind', join(area, '.git'), join(area, '.git')), + ).toBe(1) }) }) diff --git a/test/sandbox/linux-file-above-deny.test.ts b/test/sandbox/linux-file-above-deny.test.ts new file mode 100644 index 000000000..2589674ed --- /dev/null +++ b/test/sandbox/linux-file-above-deny.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + wrapCommandWithSandboxLinux, + cleanupBwrapMountPoints, + linuxGetCwdMandatoryDenyPaths, +} from '../../src/sandbox/linux-sandbox-utils.js' +import { isLinux } from '../helpers/platform.js' +import { countMounts, lastMountAt } from '../helpers/bwrap-argv.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' + +/** + * Nothing can be created below a file, for as long as the file is there. So a + * file above a write deny is kept there, in a directory the command may write. + */ +describe.if(isLinux)('A file above a write deny', () => { + const POINTER = 'gitdir: elsewhere\n' + let base: string + let area: string + const savedCwd = process.cwd() + + beforeEach(() => { + cleanupBwrapMountPoints({ force: true }) + base = realpathSync(mkdtempSync(join(tmpdir(), 'file-above-deny-'))) + area = join(base, 'area') + mkdirSync(area) + process.chdir(area) + }) + + afterEach(() => { + process.chdir(savedCwd) + cleanupBwrapMountPoints({ force: true }) + rmSync(base, { recursive: true, force: true }) + }) + + const wrap = ( + command = 'true', + denyWithinAllow: string[] = [], + allowOnly = [area], + ): Promise => + wrapCommandWithSandboxLinux({ + command, + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly, denyWithinAllow }, + }) + const held = (wrapped: string, file: string): number => + countMounts(wrapped, '--ro-bind', file, file) + + describe('in the plan', () => { + it('a .git that is a file is bound onto itself, once, and nothing below it', async () => { + writeFileSync(join(area, '.git'), POINTER) + const wrapped = await wrap() + expect(held(wrapped, join(area, '.git'))).toBe(1) + expect(wrapped).not.toContain(join(area, '.git', 'hooks')) + expect(wrapped).not.toContain(join(area, '.git', 'config')) + }) + + // git rewrites and removes it: a checkout that recurses into submodules. + it('one below the working directory is left to git', async () => { + mkdirSync(join(area, 'sub')) + writeFileSync(join(area, 'sub', '.git'), POINTER) + expect(await wrap()).not.toContain(join(area, 'sub')) + }) + + it('a .git that is a directory is denied as before', async () => { + mkdirSync(join(area, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(area, '.git', 'config'), '') + const wrapped = await wrap() + expect(held(wrapped, join(area, '.git', 'hooks'))).toBe(1) + expect(held(wrapped, join(area, '.git', 'config'))).toBe(1) + }) + + it('no .git at all: nothing is laid there', async () => { + const wrapped = await wrap() + expect(lastMountAt(wrapped, join(area, '.git'))).toBeUndefined() + expect(wrapped).not.toContain(`${join(area, '.git')}/`) + }) + + // Read without the scan too, which would find the file as well. + it.each([ + ['a file', ['.git']], + ['a directory', ['.git/hooks', '.git/config']], + ['not there', []], + ])('what follows from the cwd alone, .git being %s', (what, denied) => { + if (what === 'a file') writeFileSync(join(area, '.git'), POINTER) + if (what === 'a directory') mkdirSync(join(area, '.git')) + expect( + linuxGetCwdMandatoryDenyPaths().filter(p => + /\/\.git(\/|$)/.test(p.slice(area.length)), + ), + ).toEqual(denied.map(name => join(area, name))) + }) + + it('two denies below one file: one bind', async () => { + writeFileSync(join(area, 'f'), 'mine') + const wrapped = await wrap('true', [ + join(area, 'f', 'x'), + join(area, 'f', 'y', 'z'), + ]) + expect(held(wrapped, join(area, 'f'))).toBe(1) + }) + + it('a file that is denied itself too: one bind', async () => { + writeFileSync(join(area, 'f'), 'mine') + for (const denies of [ + [join(area, 'f'), join(area, 'f', 'x')], + [join(area, 'f', 'x'), join(area, 'f')], + ]) { + expect(held(await wrap('true', denies), join(area, 'f'))).toBe(1) + } + }) + + it('where the command may not write, nothing is laid', async () => { + writeFileSync(join(base, 'f'), 'mine') + expect(await wrap('true', [join(base, 'f', 'x')])).not.toContain( + join(base, 'f'), + ) + }) + + it('where it may write the file and not its directory, the file is not held', async () => { + const f = join(base, 'f') + writeFileSync(f, 'mine') + const wrapped = await wrap('true', [join(f, 'x')], [f]) + expect(held(wrapped, f)).toBe(0) + expect(lastMountAt(wrapped, f)).toBe(`--bind ${f} ${f}`) + }) + }) + + describe.if(bwrapCanNamespace())('in the sandbox', () => { + const run = async ( + command: string, + denies: string[] = [], + allowOnly?: string[], + ): Promise => { + const r = spawnSync(await wrap(command, denies, allowOnly), { + shell: true, + encoding: 'utf8', + timeout: 20_000, + }) + cleanupBwrapMountPoints() + expect(r.stderr).not.toContain('bwrap:') + return r.stdout.trim() + } + // Each way of putting something else at the name, and what it is after. + const REPLACE = (name: string): string => + `mv ${name} aside 2>/dev/null; echo mv=$?; rm -rf ${name} 2>/dev/null; echo rm=$?; ` + + `mkdir -p ${name}/hooks 2>/dev/null; echo mkdir=$?; echo x > ${name} 2>/dev/null; echo write=$?` + const REFUSED = 'mv=1\nrm=1\nmkdir=1\nwrite=1' + + it('a .git that is a file stays one', async () => { + writeFileSync(join(area, '.git'), POINTER) + expect(await run(REPLACE('.git'))).toBe(REFUSED) + expect(readFileSync(join(area, '.git'), 'utf8')).toBe(POINTER) + }) + + it('a command beside it runs, and writes what it may', async () => { + writeFileSync(join(area, '.git'), POINTER) + expect(await run('cat .git; echo mine > new; cat new')).toBe( + `${POINTER}mine`, + ) + }) + + it('a configured deny below a file: the file stays', async () => { + writeFileSync(join(area, 'f'), 'mine') + expect(await run(REPLACE('f'), [join(area, 'f', 'x')])).toBe(REFUSED) + expect(readFileSync(join(area, 'f'), 'utf8')).toBe('mine') + }) + + it('a file that alone may be written is written, and stays one', async () => { + const f = join(base, 'f') + writeFileSync(f, 'mine') + expect( + await run( + `echo more >> ${f}; echo append=$?; cd ${base}; ${REPLACE('f')}`, + [join(f, 'x')], + [f], + ), + ).toBe('append=0\nmv=1\nrm=1\nmkdir=1\nwrite=0') + expect(readFileSync(f, 'utf8')).toBe('x\n') + }) + + it('a protected name that leads through a file: the file stays', async () => { + writeFileSync(join(area, 'f'), 'mine') + symlinkSync('f/x', join(area, '.bashrc')) + expect(await run(REPLACE('f'))).toBe(REFUSED) + expect(readFileSync(join(area, 'f'), 'utf8')).toBe('mine') + }) + + // An empty one looks like a mount point bubblewrap has made. + it.each([ + ['.git', POINTER, []], + ['f', 'mine', ['f/x']], + ['f', '', ['f/x']], + ])( + "%s holding %p is the caller's own afterwards: there, as it was, and writable", + async (name, content, denies) => { + writeFileSync(join(area, name), content, { mode: 0o644 }) + await run( + 'true', + denies.map(d => join(area, d)), + ) + cleanupBwrapMountPoints({ force: true }) + expect(readFileSync(join(area, name), 'utf8')).toBe(content) + expect(lstatSync(join(area, name)).mode & 0o777).toBe(0o644) + writeFileSync(join(area, name), 'changed') + }, + ) + }) +})