diff --git a/install.sh b/install.sh index cbd8947..024d52e 100755 --- a/install.sh +++ b/install.sh @@ -74,6 +74,19 @@ EOF } ensure_managed_root() { + # `stat` and the test builtins resolve a trailing "/" or "/." through a symlink, so a + # managed root given as "link/" or "link/." is inspected as the link TARGET and slips + # past the ordinary-directory check below. Normalize before any inspection; reordering + # this loop after read_managed_root_metadata silently reopens the symlink escape. + while [ "$INSTALL_ROOT" != / ]; do + case $INSTALL_ROOT in + */.) INSTALL_ROOT=${INSTALL_ROOT%/.} ;; + */) INSTALL_ROOT=${INSTALL_ROOT%/} ;; + *) break ;; + esac + [ -n "$INSTALL_ROOT" ] || INSTALL_ROOT=/ + done + if [ -e "$INSTALL_ROOT" ] || [ -L "$INSTALL_ROOT" ]; then read_managed_root_metadata else diff --git a/server/install-bootstrap.test.ts b/server/install-bootstrap.test.ts index 6fe507c..3d82646 100644 --- a/server/install-bootstrap.test.ts +++ b/server/install-bootstrap.test.ts @@ -108,15 +108,23 @@ test('one-line bootstrap tightens an existing owner-managed root and rejects uns assert.equal(migrated.status, 0, migrated.stderr); assert.equal((await fs.stat(managedRoot)).mode & 0o777, 0o700); - for (const [name, setup, expected] of [ - ['symlink', async (target: string) => fs.symlink(path.join(root, 'missing'), target), /ordinary directory/], - ['non-directory', async (target: string) => fs.writeFile(target, 'not a directory'), /ordinary directory/], + // A trailing "/" or "/." makes the kernel resolve a symlink, so the suffixed cases + // fail closed only while ensure_managed_root normalizes the root before inspecting it. + const symlinkToDirectory = async (target: string): Promise => { + await fs.mkdir(`${target}-target`); + await fs.symlink(`${target}-target`, target); + }; + for (const [name, setup, expected, suffix] of [ + ['symlink', async (target: string) => fs.symlink(path.join(root, 'missing'), target), /ordinary directory/, ''], + ['non-directory', async (target: string) => fs.writeFile(target, 'not a directory'), /ordinary directory/, ''], + ['symlink-trailing-slash', symlinkToDirectory, /ordinary directory/, '/'], + ['symlink-trailing-dot', symlinkToDirectory, /ordinary directory/, '/.'], ] as const) { const unsafeRoot = path.join(root, name); await setup(unsafeRoot); const result = spawnSync('bash', [installerPath, '--local'], { encoding: 'utf8', - env: { ...commonEnv, CHATMUX_INSTALL_ROOT: unsafeRoot }, + env: { ...commonEnv, CHATMUX_INSTALL_ROOT: `${unsafeRoot}${suffix}` }, }); assert.notEqual(result.status, 0, name); assert.match(result.stderr, expected);