Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased] ##

### Added ###
- capi: We now have a new `pathrs_version` API that provides runtime version
information, which loosely matches other libraries like `libseccomp`. The API
is based on extensible structs, so we can add more information here in the
future.

### Fixed ###
- Containers often have `/proc/sys` overmounted with a read-only mount to avoid
container escapes, this caused:
Expand Down
7 changes: 4 additions & 3 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ fn main() {
// output .so. For more information about getting all of this to
// work nicely, see <https://internals.rust-lang.org/t/23626>.
r#"
LIBPATHRS_0.1 {{ }};
LIBPATHRS_0.2 {{ local: *; }} LIBPATHRS_0.1;
"#
LIBPATHRS_0.1 {{ }};
LIBPATHRS_0.2 {{ }} LIBPATHRS_0.1;
LIBPATHRS_0.2.5 {{ local: *; }} LIBPATHRS_0.2;
"#
)
.expect("write version script");
println!("cargo:rustc-cdylib-link-arg=-Wl,--version-script={version_script_path}");
Expand Down
5 changes: 5 additions & 0 deletions cbindgen.toml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ exclude = [
"CBorrowedFd",
# CReturn is a rust-only typedef.
"CReturn",
# CStringPtr is just const char *.
"CStringPtr",
# Don't export the RESOLVE_* definitions.
"RESOLVE_NO_XDEV",
"RESOLVE_NO_MAGICLINKS",
Expand All @@ -167,10 +169,13 @@ exclude = [
"ProcfsOpenFlags" = "uint64_t"
"ProcfsOpenHow" = "pathrs_procfs_open_how"

"VersionInfo" = "pathrs_version_info_t"

# Error API.
"CError" = "pathrs_error_t"

# The bare return values used for "kernel-like" APIs.
"RawFd" = "int"
"BorrowedFd" = "int"
"CBorrowedFd" = "int"
"CStringPtr" = "const char *"
13 changes: 12 additions & 1 deletion contrib/bindings/python/pathrs/_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _fetch(cls, err_id: int, /) -> Optional[Self]:
return None

err = libpathrs_so.pathrs_errorinfo(err_id)
if err == ffi.NULL: # TODO: Make this check nicer...
if err == ffi.NULL: # type: ignore[comparison-overlap,unused-ignore] # TODO: Make this check nicer...
return None

description = _pystr(err.description)
Expand Down Expand Up @@ -344,3 +344,14 @@ def _convert_mode(mode: str) -> int:

# We don't care about "b" or "t" since that's just a Python thing.
return flags


class SingletonClass(type):
"""Metaclass used to create singleton classes."""

_instances: dict[type, Type[Any]] = {}

def __call__(cls, *args, **kwargs): # type: ignore[no-untyped-def] # TODO: Not clear what annotations to use, and mypy appears to be confused by metaclasses.
if cls not in cls._instances:
cls._instances[cls] = super(SingletonClass, cls).__call__(*args, **kwargs)
return cls._instances[cls]
7 changes: 7 additions & 0 deletions contrib/bindings/python/pathrs/_libpathrs_cffi/lib.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ __PATHRS_MAX_ERR_VALUE: ErrorId
def pathrs_errorinfo(err_id: Union[ErrorId, int]) -> CError: ...
def pathrs_errorinfo_free(err: CError) -> None: ...

# pathrs_version_info_t *
@type_check_only
class VersionInfo:
version_string: CString

def pathrs_version(info: VersionInfo, size: int) -> Union[ErrorId, int]: ...

# uint64_t
ProcfsOpenFlags: TypeAlias = int
PATHRS_PROCFS_NEW_UNMASKED: ProcfsOpenFlags
Expand Down
41 changes: 40 additions & 1 deletion contrib/bindings/python/pathrs/_pathrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@
import os

import typing
from typing import Any, IO, Union
from typing import Any, IO, Union, cast
import warnings

# TODO: Remove this once we only support Python >= 3.11.
from typing_extensions import TypeAlias

from ._internal import (
# Generic helpers.
SingletonClass,
# File type helpers.
FileLike,
WrappedFd,
Expand All @@ -28,6 +31,7 @@
INTERNAL_ERROR,
# CFFI helpers.
_cstr,
_pystr,
_cbuffer,
)
from ._libpathrs_cffi import lib as libpathrs_so
Expand All @@ -51,11 +55,46 @@
# Core api.
"Root",
"Handle",
"library_version",
# Error api (re-export).
"PathrsError",
]


class VersionInfo(metaclass=SingletonClass):
"""Stores information about the runtime version of libpathrs.so."""

_VERSION_INFO_TYPE = "pathrs_version_info_t *"

version_string: str

def __init__(self): # type: ignore[no-untyped-def] # TODO: mypy appears to struggle with metaclasses.
info = cast("libpathrs_so.VersionInfo", ffi.new(self._VERSION_INFO_TYPE))
info_size = ffi.sizeof(cast("Any", info))

size = libpathrs_so.pathrs_version(info, info_size)
if _is_pathrs_err(size):
raise PathrsError._fetch(size) or INTERNAL_ERROR
if size > 0:
# TODO: We should also output a warning if the size of
# pathrs_version_info_t is bigger than the number of fields in the
# python-native VersionInfo. Otherwise a rebuild will mask that
# Python callers cannot see any new fields.
warnings.warn(
f"pathrs_version returned extra data we could not parse (need {size} bytes but buffer was only {info_size})"
)

self.version_string = _pystr(info.version_string)


def library_version() -> VersionInfo:
"""
Returns information about the runtime version of libpathrs.so.
This is just shorthand for VersionInfo().
"""
return VersionInfo() # type: ignore[no-untyped-call] # TODO: mypy appears to struggle with metaclasses.


class Handle(WrappedFd):
"A handle to a filesystem object, usually resolved using Root.resolve()."

Expand Down
2 changes: 1 addition & 1 deletion contrib/bindings/python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def parse_pyproject() -> Dict[str, Any]:
openmode = "rb"
except ImportError:
# TODO: Remove this once we only support Python >= 3.11.
import toml as tomllib # type: ignore
import toml as tomllib # type: ignore # The types are not compatible.

openmode = "r"

Expand Down
15 changes: 15 additions & 0 deletions e2e-tests/cmd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ so tests should only ever test against the numerical `<errno code>`. Tests
against `<error message>` are acceptable but should be used carefully as such
tests could be brittle.

### `version` ###

```
pathrs-cmd version
```

This is a top-level command that returns information about the `libpathrs.so`
binary (corresponding to `pathrs_version()`).

#### Output ####

```
VERSION <version string>
```

### `Root` Operations ###

All of the following operations are subcommands of the `root` subcommand.
Expand Down
16 changes: 16 additions & 0 deletions e2e-tests/cmd/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,23 @@ import (

"github.com/urfave/cli/v3"
"golang.org/x/sys/unix"

"cyphar.com/go-pathrs"
)

var versionCmd = &cli.Command{
Name: "version",
Usage: "output the libpathrs.so version",
Action: func(_ context.Context, _ *cli.Command) error {
version, err := pathrs.LibraryVersion()
if err != nil {
return err
}
fmt.Printf("VERSION %s\n", version.VersionString)
return nil
},
}

func Main(args []string) error {
if dumpableStr := os.Getenv("PR_SET_DUMPABLE"); dumpableStr != "" {
dumpable, err := strconv.ParseUint(dumpableStr, 10, 64)
Expand All @@ -41,6 +56,7 @@ func Main(args []string) error {
"Aleksa Sarai <cyphar@cyphar.com>",
},
Commands: []*cli.Command{
versionCmd,
rootCmd,
procfsCmd,
},
Expand Down
9 changes: 9 additions & 0 deletions e2e-tests/cmd/python/pathrs-cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
from pathrs.procfs import ProcfsHandle, ProcfsBase


def version(args: argparse.Namespace):
version = pathrs.library_version()
print("VERSION", version.version_string)


class FilenoFile(Protocol):
def fileno(self) -> int: ...

Expand Down Expand Up @@ -274,6 +279,10 @@ def add_o_flag(
help=f"{help or name} (comma- or |-separated)",
)

version_parser = top_subparser.add_parser("version", help="version information")
version_parser.set_defaults(func=version)
del version_parser

# root --root <root> ... commands
root_parser = top_subparser.add_parser("root", help="Root::* operations")
root_parser.add_argument(
Expand Down
3 changes: 3 additions & 0 deletions e2e-tests/cmd/rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ use rustix::process::{self as rustix_process, DumpableBehavior};
mod procfs;
mod root;
mod utils;
mod version;

fn cli() -> Command {
Command::new("pathrs-cmd")
.author("Aleksa Sarai <cyphar@cyphar.com>")
.subcommand(version::cli())
.subcommand(root::cli())
.subcommand(procfs::cli())
}
Expand Down Expand Up @@ -86,6 +88,7 @@ fn main() -> ExitCode {
let mut app = cli();

match app.get_matches_mut().subcommand() {
Some(("version", sub_matches)) => version::subcommand(sub_matches),
Some(("root", sub_matches)) => root::subcommand(sub_matches),
Some(("procfs", sub_matches)) => procfs::subcommand(sub_matches),
Some((subcommand, _)) => {
Expand Down
26 changes: 26 additions & 0 deletions e2e-tests/cmd/rust/src/version.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MPL-2.0
/*
* libpathrs: safe path resolution on Linux
* 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/.
*/

use anyhow::{anyhow, Error};
use clap::{ArgMatches, Command};

pub(crate) fn cli() -> Command {
Command::new("version").about("version information")
}

pub(crate) fn subcommand(matches: &ArgMatches) -> Result<(), Error> {
if let Some((subcommand, _)) = matches.subcommand() {
// We should never end up here.
Err(anyhow!("unknown 'version' subcommand '{subcommand}'"))?;
}

println!("VERSION {}", pathrs::VERSION);
Ok(())
}
7 changes: 7 additions & 0 deletions e2e-tests/tests/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

set -u

E2ETEST_ROOT="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
SRC_ROOT="$(readlink -f "$E2ETEST_ROOT/../..")"

function fail() {
echo "FAILURE:" "$@" >&2
false
Expand Down Expand Up @@ -113,3 +116,7 @@ function requires() {
esac
done
}

function crate-version() {
sed -Ene '/^version\>/s/.*= "(.*)"$/\1/p' <"$SRC_ROOT/Cargo.toml"
}
25 changes: 25 additions & 0 deletions e2e-tests/tests/version.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
#
# libpathrs: safe path resolution on Linux
# 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
}

@test "version" {
pathrs-cmd version
[ "$status" -eq 0 ]
grep -Fx "VERSION $(crate-version)" <<<"$output"
}
28 changes: 28 additions & 0 deletions go-pathrs/internal/libpathrs/libpathrs_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,31 @@ func ProcfsOpen(how *ProcfsOpenHow) (uintptr, error) {
fd := C.pathrs_procfs_open((*C.pathrs_procfs_open_how)(how), C.size_t(unsafe.Sizeof(*how)))
return uintptr(fd), fetchError(fd)
}

// VersionInfo is a Go-friendly form of pathrs_version_info_t (struct).
type VersionInfo struct {
VersionString string
}

// versionInfo is pathrs_version_info_t (struct).
type versionInfo C.pathrs_version_info_t

// Version is pathrs_version_info_t (sizeof(version) is passed automatically).
func Version() (*VersionInfo, error) {
var rawVersion versionInfo
size := C.pathrs_version((*C.pathrs_version_info_t)(&rawVersion), C.size_t(unsafe.Sizeof(rawVersion)))
switch {
case size < 0:
return nil, fetchError(size)
case size > 0:
// TODO(log): Logging?
fallthrough
default:
// TODO(log): Add a log statement if sizeof(rawVersion) is bigger than
// the number of fields we store in VersionInfo. Otherwise a rebuild
// will mask that Go callers cannot see any new fields.
return &VersionInfo{
VersionString: C.GoString(rawVersion.version_string),
}, nil
}
}
27 changes: 27 additions & 0 deletions go-pathrs/version_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build linux

// SPDX-License-Identifier: MPL-2.0
/*
* libpathrs: safe path resolution on Linux
* 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/.
*/

package pathrs

import (
"cyphar.com/go-pathrs/internal/libpathrs"
)

// LibraryVersionInfo contains information about the version and features
// supported by the underlying libpathrs.so library at runtime.
type LibraryVersionInfo = libpathrs.VersionInfo

// LibraryVersion returns information about the version and features supported
// by the underlying libpathrs.so library at runtime.
func LibraryVersion() (*LibraryVersionInfo, error) {
return libpathrs.Version()
}
Loading
Loading