pathrs 0.2.4

C-friendly API to make path resolution safer on Linux.
Documentation
#!/usr/bin/bats -t
# SPDX-License-Identifier: MPL-2.0
#
# libpathrs: safe path resolution on Linux
# Copyright (C) 2019-2025 SUSE LLC
# Copyright (C) 2026 Aleksa Sarai <cyphar@cyphar.com>
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

load helpers

function setup() {
	setup_tmpdirs
}

function teardown() {
	teardown_tmpdirs
}

# TODO: All of these tests (especially for --reopen) are very limited because
# we cannot verify anything useful about the opened files. Ideally we would
# instead output something simple like the fdinfo, stat, and/or contents to
# verify against.

@test "root resolve [file]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/etc"
	echo "dummy passwd" >"$ROOT/etc/passwd"
	ln -s /../../../../../../../../etc "$ROOT/bad-passwd"

	pathrs-cmd root --root "$ROOT" resolve /etc/passwd
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/etc/passwd" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"

	pathrs-cmd root --root "$ROOT" resolve bad-passwd/passwd
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/etc/passwd" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
}

@test "root resolve --reopen O_RDONLY [file]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/etc"
	echo "dummy passwd" >"$ROOT/etc/passwd"
	ln -s /../../../../../../../../etc "$ROOT/bad-passwd"

	pathrs-cmd root --root "$ROOT" resolve --reopen O_RDONLY /etc/passwd
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/etc/passwd" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/etc/passwd" <<<"$output"

	pathrs-cmd root --root "$ROOT" resolve --reopen O_RDONLY bad-passwd/passwd
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/etc/passwd" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/etc/passwd" <<<"$output"
}

@test "root open --oflags O_RDONLY [file]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/etc"
	echo "dummy passwd" >"$ROOT/etc/passwd"
	ln -s /../../../../../../../../etc "$ROOT/bad-passwd"

	pathrs-cmd root --root "$ROOT" open --oflags O_RDONLY /etc/passwd
	[ "$status" -eq 0 ]
	! grep -Fx "HANDLE-PATH $ROOT/etc/passwd" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/etc/passwd" <<<"$output"

	pathrs-cmd root --root "$ROOT" open --oflags O_RDONLY bad-passwd/passwd
	[ "$status" -eq 0 ]
	! grep -Fx "HANDLE-PATH $ROOT/etc/passwd" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/etc/passwd" <<<"$output"
}

@test "root resolve --reopen O_DIRECTORY [file]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/etc"
	echo "dummy passwd" >"$ROOT/etc/passwd"

	pathrs-cmd root --root "$ROOT" resolve --reopen O_DIRECTORY /etc/passwd
	check-errno ENOTDIR
}

@test "root open --oflags O_DIRECTORY [file]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/etc"
	echo "dummy passwd" >"$ROOT/etc/passwd"

	pathrs-cmd root --root "$ROOT" open --oflags O_DIRECTORY /etc/passwd
	check-errno ENOTDIR
}

@test "root resolve --reopen O_RDWR|O_TRUNC [file]" {
	ROOT="$(setup_tmpdir)"

	echo "THIS SHOULD BE TRUNCATED" >"$ROOT/to-trunc"
	[ "$(stat -c '%s' "$ROOT/to-trunc")" -ne 0 ]

	pathrs-cmd root --root "$ROOT" resolve --reopen O_RDWR,O_TRUNC to-trunc
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/to-trunc" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/to-trunc" <<<"$output"
	# The file should've been truncated by O_TRUNC.
	[ "$(stat -c '%s' "$ROOT/to-trunc")" -eq 0 ]
}

@test "root open --oflags O_RDWR|O_TRUNC [file]" {
	ROOT="$(setup_tmpdir)"

	echo "THIS SHOULD BE TRUNCATED" >"$ROOT/to-trunc"
	[ "$(stat -c '%s' "$ROOT/to-trunc")" -ne 0 ]

	pathrs-cmd root --root "$ROOT" open --oflags O_RDWR,O_TRUNC to-trunc
	[ "$status" -eq 0 ]
	! grep -Fx "HANDLE-PATH $ROOT/to-trunc" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/to-trunc" <<<"$output"
	# The file should've been truncated by O_TRUNC.
	[ "$(stat -c '%s' "$ROOT/to-trunc")" -eq 0 ]
}

@test "root resolve [directory]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/some/random/dir"

	pathrs-cmd root --root "$ROOT" resolve some/../some/./random/dir/..
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/some/random" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
}

@test "root resolve --reopen [directory]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/some/random/dir"

	pathrs-cmd root --root "$ROOT" resolve --reopen O_DIRECTORY some/random/dir
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/some/random/dir" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/some/random/dir" <<<"$output"

	mkdir -p "$ROOT/some/random/dir"
	pathrs-cmd root --root "$ROOT" resolve --reopen O_WRONLY some
	check-errno EISDIR
}

@test "root open [directory]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/some/random/dir"

	pathrs-cmd root --root "$ROOT" open --oflags O_DIRECTORY some/random/dir
	[ "$status" -eq 0 ]
	! grep -Fx "HANDLE-PATH $ROOT/some/random/dir" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/some/random/dir" <<<"$output"

	mkdir -p "$ROOT/some/random/dir"
	pathrs-cmd root --root "$ROOT" open --oflags O_WRONLY some
	check-errno EISDIR
}

@test "root resolve [device inode]" {
	requires can-mkwhiteout

	ROOT="$(setup_tmpdir)"
	mknod "$ROOT/char-0-0" c 0 0

	pathrs-cmd root --root "$ROOT" resolve char-0-0
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/char-0-0" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
}

@test "root resolve [/dev/full]" {
	pathrs-cmd root --root /dev resolve full
	[ "$status" -eq 0 ]
	grep -Fx 'HANDLE-PATH /dev/full' <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
}

@test "root resolve [fifo]" {
	ROOT="$(setup_tmpdir)"
	mkfifo "$ROOT/fifo"

	pathrs-cmd root --root "$ROOT" resolve fifo
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/fifo" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
}

@test "root resolve --follow [symlinks]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/target/dir"
	echo "TARGET" >"$ROOT/target/dir/file"
	[ "$(stat -c '%s' "$ROOT/target/dir/file")" -ne 0 ]

	ln -s /target/dir "$ROOT/a"
	ln -s /a/../dir "$ROOT/b"
	ln -s ../b/. "$ROOT/c"
	ln -s /../../../../c "$ROOT/d"
	ln -s d "$ROOT/e"
	ln -s e/file "$ROOT/file-link"

	pathrs-cmd root --root "$ROOT" resolve --follow "target/dir"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --follow "a"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --follow "b"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --follow "c"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --follow "d"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --follow "e"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"

	pathrs-cmd root --root "$ROOT" resolve --follow --reopen O_DIRECTORY "e"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/target/dir" <<<"$output"

	pathrs-cmd root --root "$ROOT" resolve --follow "file-link"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir/file" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"

	pathrs-cmd root --root "$ROOT" resolve --follow --reopen O_WRONLY,O_TRUNC "file-link"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir/file" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/target/dir/file" <<<"$output"
	# The file should've been truncated by O_TRUNC.
	[ "$(stat -c '%s' "$ROOT/target/dir/file")" -eq 0 ]
}

@test "root resolve --no-follow [symlinks]" {
	ROOT="$(setup_tmpdir)"

	mkdir -p "$ROOT/target/dir"
	echo "TARGET" >"$ROOT/target/dir/file"
	[ "$(stat -c '%s' "$ROOT/target/dir/file")" -ne 0 ]

	ln -s /target/dir "$ROOT/a"
	ln -s /a/../dir "$ROOT/b"
	ln -s ../b/. "$ROOT/c"
	ln -s /../../../../c "$ROOT/d"
	ln -s d "$ROOT/e"
	ln -s e/file "$ROOT/file-link"

	pathrs-cmd root --root "$ROOT" resolve --follow "target/dir"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --no-follow "a"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/a" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --no-follow "b"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/b" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --no-follow "c"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/c" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --no-follow "d"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/d" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"
	pathrs-cmd root --root "$ROOT" resolve --no-follow "e"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/e" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"

	# You cannot reopen an O_PATH|O_NOFOLLOW handle to a symlink.
	pathrs-cmd root --root "$ROOT" resolve --no-follow --reopen O_RDONLY "e"
	check-errno ELOOP
	pathrs-cmd root --root "$ROOT" resolve --no-follow --reopen O_DIRECTORY "e"
	check-errno ELOOP

	pathrs-cmd root --root "$ROOT" resolve --no-follow "file-link"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/file-link" <<<"$output"
	! grep '^FILE-PATH' <<<"$output"

	pathrs-cmd root --root "$ROOT" resolve --no-follow --reopen O_WRONLY,O_TRUNC "file-link"
	check-errno ELOOP
	# The file should NOT have been truncated by O_TRUNC.
	[ "$(stat -c '%s' "$ROOT/target/dir/file")" -ne 0 ]

	# --no-follow has no impact on non-final components.
	pathrs-cmd root --root "$ROOT" resolve --no-follow --reopen O_WRONLY,O_TRUNC "e/file"
	[ "$status" -eq 0 ]
	grep -Fx "HANDLE-PATH $ROOT/target/dir/file" <<<"$output"
	grep -Fx "FILE-PATH $ROOT/target/dir/file" <<<"$output"
	# The file should've been truncated by O_TRUNC.
	[ "$(stat -c '%s' "$ROOT/target/dir/file")" -eq 0 ]
}