Add persistent network namespace primitives
Commit 2/6 of the network isolation feature (docs/networking-design.md).
persistent_netns.{h,cpp}: create/verify/remove a network namespace kept
alive with no process in it, the way `ip netns add` does (fork a child,
unshare(CLONE_NEWNET), bind-mount its /proc/self/ns/net onto a
persistent path, exit -- the bind mount keeps it alive). Root-only
(CAP_SYS_ADMIN for the bind mount), best-effort like this project's
other host-state primitives. Not wired into -n/--network yet.
xdg_state_dir() (pid_file.cpp) moved out of its anonymous namespace so
this file can reuse the same $XDG_STATE_HOME resolution rather than a
second, drifting copy.
-t/--test now exercises the create/verify/remove cycle (skipped with a
message, not a failure, when not root) -- confirmed working via doas.
This commit is contained in:
@@ -196,10 +196,17 @@ Source layout (all under `src/`):
|
|||||||
- `self_test.{h,cpp}` — `run_self_tests()` implements `-t/--test`, this
|
- `self_test.{h,cpp}` — `run_self_tests()` implements `-t/--test`, this
|
||||||
project's own built-in self-test mode (distinct from the Meson-driven
|
project's own built-in self-test mode (distinct from the Meson-driven
|
||||||
fixture smoke test under `tests/`, described in "Build & test commands"
|
fixture smoke test under `tests/`, described in "Build & test commands"
|
||||||
below). Currently an empty placeholder (previously reported
|
below; previously reported `detect_bwrap_unshare_args()`'s output —
|
||||||
`detect_bwrap_unshare_args()`'s output — `bwrap.{h,cpp}` — unplugged since
|
`bwrap.{h,cpp}` — unplugged since that's kernel-capability diagnostics, not
|
||||||
that's kernel-capability diagnostics, not a test); deliberately its own
|
a test). Currently exercises `persistent_netns.{h,cpp}`'s (see below)
|
||||||
small file since real tests are expected here soon.
|
create/verify/remove cycle: skipped with a message (not a failure) when not
|
||||||
|
root, since `create_persistent_netns()` requires it for the bind mount.
|
||||||
|
Confirms the namespace is missing before creation, exists right after
|
||||||
|
(checked from this process, *after* the forked child that actually did the
|
||||||
|
`unshare()`/bind-mount has already exited — the actual claim being tested:
|
||||||
|
the namespace outlives its creating process), then gone again after
|
||||||
|
removal. Deliberately its own small file since more real tests are expected
|
||||||
|
here as more of the networking feature lands.
|
||||||
- `env_spec.{h,cpp}` — `resolve_env_specs()` turns an ordered list of
|
- `env_spec.{h,cpp}` — `resolve_env_specs()` turns an ordered list of
|
||||||
`EnvSpec {is_file, value}` (see `cli_args.{h,cpp}` above) into a flat, ordered list of
|
`EnvSpec {is_file, value}` (see `cli_args.{h,cpp}` above) into a flat, ordered list of
|
||||||
`(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the
|
`(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the
|
||||||
@@ -406,7 +413,12 @@ Source layout (all under `src/`):
|
|||||||
back to `"container"` if that leaves nothing) is exported here (not just
|
back to `"container"` if that leaves nothing) is exported here (not just
|
||||||
`.cpp`-local) specifically so `session_cgroup.{h,cpp}` (see below) can reuse
|
`.cpp`-local) specifically so `session_cgroup.{h,cpp}` (see below) can reuse
|
||||||
the exact same `<name>-<pid>` naming rule for its own per-session cgroup
|
the exact same `<name>-<pid>` naming rule for its own per-session cgroup
|
||||||
directory without drifting from this file's own. `session_pid_file_path()` resolves
|
directory without drifting from this file's own. `xdg_state_dir()`
|
||||||
|
(`$XDG_STATE_HOME/slocker-lite`, or the `$HOME/.local/state/...` fallback)
|
||||||
|
is likewise exported (moved out of this file's own anonymous namespace) so
|
||||||
|
`persistent_netns.{h,cpp}` (see below) can resolve its own subdirectory
|
||||||
|
under the same state root without a second, drifting copy of this
|
||||||
|
resolution logic. `session_pid_file_path()` resolves
|
||||||
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to
|
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to
|
||||||
`$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same
|
`$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same
|
||||||
resolution pattern as `config_file_path()` below, for state instead of
|
resolution pattern as `config_file_path()` below, for state instead of
|
||||||
@@ -450,6 +462,29 @@ Source layout (all under `src/`):
|
|||||||
a check and a later removal. Only files it actually removes are reported back
|
a check and a later removal. Only files it actually removes are reported back
|
||||||
(as `SessionInfo`s with `running=false`); still-locked (running) files are
|
(as `SessionInfo`s with `running=false`); still-locked (running) files are
|
||||||
left untouched and not reported.
|
left untouched and not reported.
|
||||||
|
- `persistent_netns.{h,cpp}` — generic, narrow infrastructure for keeping a
|
||||||
|
network namespace alive with no process in it, the way `ip netns add` does;
|
||||||
|
no `intern`/`extern` policy or bridge logic here (that's a later commit,
|
||||||
|
`network_bridge.{h,cpp}`, per `docs/networking-design.md`'s commit
|
||||||
|
sequence), and not yet wired into `-n/--network` at all. `persistent_netns_path()`
|
||||||
|
resolves `xdg_state_dir() / "netns" / sanitize_for_filename(name)`
|
||||||
|
(`pid_file.h`, see above). `persistent_netns_exists()` checks whether that
|
||||||
|
path is actually a live bind-mounted namespace, not just a stale/never-
|
||||||
|
mounted file: `stat()`s the path and its parent directory and compares
|
||||||
|
`st_dev` — a genuine bind mount always has a different device number than
|
||||||
|
its parent, the same "is this a mountpoint" technique used elsewhere. Never
|
||||||
|
needs root itself (just `stat()`). `create_persistent_netns()` forks a
|
||||||
|
child (never touches the caller's own network namespace — `unshare(2)`
|
||||||
|
affects only the calling process) that `unshare(CLONE_NEWNET)`s its own
|
||||||
|
fresh namespace, bind-mounts its `/proc/self/ns/net` onto the target path,
|
||||||
|
then exits immediately — the bind mount itself is what keeps the namespace
|
||||||
|
alive from then on, independent of the now-exited child, exactly `ip netns
|
||||||
|
add`'s own technique. Requires `CAP_SYS_ADMIN` (root) for the bind mount,
|
||||||
|
matching this feature's current root-only scope (see
|
||||||
|
`docs/networking-design.md`) — best-effort like this project's other
|
||||||
|
host-state primitives (session locks, cgroups): logs and returns `false` on
|
||||||
|
any failure (already exists, fork/unshare/mount failure) rather than
|
||||||
|
throwing. `remove_persistent_netns()` unmounts then removes the file.
|
||||||
- `session_cgroup.{h,cpp}` — gives `--kill` (`kill_session.{h,cpp}`, see
|
- `session_cgroup.{h,cpp}` — gives `--kill` (`kill_session.{h,cpp}`, see
|
||||||
below) a reliable way to find every process a session ever started, however
|
below) a reliable way to find every process a session ever started, however
|
||||||
deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2
|
deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ slocker_lite = executable('slocker-lite',
|
|||||||
'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp',
|
'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp',
|
||||||
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp',
|
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp',
|
||||||
'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp',
|
'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp',
|
||||||
'src/network_subnet.cpp'],
|
'src/network_subnet.cpp', 'src/persistent_netns.cpp'],
|
||||||
include_directories : include_directories('.'),
|
include_directories : include_directories('.'),
|
||||||
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
|
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
|
||||||
install : true)
|
install : true)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Copyright (C) 2026 Viorel Munteanu
|
||||||
|
//
|
||||||
|
// This program is free software; you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation; either version 2 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License along
|
||||||
|
// with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
#include "persistent_netns.h"
|
||||||
|
|
||||||
|
#include <sched.h>
|
||||||
|
#include <sys/mount.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstring>
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include "pid_file.h"
|
||||||
|
|
||||||
|
std::filesystem::path persistent_netns_path(std::string_view name) {
|
||||||
|
return xdg_state_dir() / "netns" / sanitize_for_filename(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool persistent_netns_exists(std::string_view name) {
|
||||||
|
auto path = persistent_netns_path(name);
|
||||||
|
|
||||||
|
struct stat file_stat {};
|
||||||
|
struct stat parent_stat {};
|
||||||
|
if (stat(path.c_str(), &file_stat) != 0 || stat(path.parent_path().c_str(), &parent_stat) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return file_stat.st_dev != parent_stat.st_dev;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool create_persistent_netns(std::string_view name) {
|
||||||
|
if (persistent_netns_exists(name)) {
|
||||||
|
spdlog::error("persistent network namespace '{}' already exists", name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto path = persistent_netns_path(name);
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(path.parent_path(), ec);
|
||||||
|
if (ec) {
|
||||||
|
spdlog::warn("failed to create directory {}: {}", path.parent_path().string(), ec.message());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain regular file for the bind mount to land on -- created (or, if a
|
||||||
|
// stale never-mounted leftover from a previous failed attempt, simply
|
||||||
|
// reused) before forking, so the child has a stable target to bind onto.
|
||||||
|
int fd = open(path.c_str(), O_CREAT | O_RDONLY | O_CLOEXEC, 0644);
|
||||||
|
if (fd < 0) {
|
||||||
|
spdlog::warn("failed to create {}: {}", path.string(), strerror(errno));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid < 0) {
|
||||||
|
spdlog::warn("failed to fork while creating persistent network namespace '{}': {}", name, strerror(errno));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pid == 0) {
|
||||||
|
// unshare(2) affects only the calling process -- doing this in a
|
||||||
|
// forked child, never the caller itself, means slocker-lite's own
|
||||||
|
// network namespace is never touched by creating one of these.
|
||||||
|
if (unshare(CLONE_NEWNET) != 0) {
|
||||||
|
_exit(1);
|
||||||
|
}
|
||||||
|
// The bind mount is what actually keeps the namespace alive after
|
||||||
|
// this child exits -- exactly the technique `ip netns add` itself
|
||||||
|
// uses (bind-mounting /proc/self/ns/net onto a persistent path).
|
||||||
|
if (mount("/proc/self/ns/net", path.c_str(), nullptr, MS_BIND, nullptr) != 0) {
|
||||||
|
_exit(1);
|
||||||
|
}
|
||||||
|
_exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int status = 0;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||||
|
spdlog::warn(
|
||||||
|
"failed to create persistent network namespace '{}' (unshare/bind-mount failed -- requires root)",
|
||||||
|
name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool remove_persistent_netns(std::string_view name) {
|
||||||
|
auto path = persistent_netns_path(name);
|
||||||
|
if (umount2(path.c_str(), 0) != 0) {
|
||||||
|
spdlog::warn("failed to unmount persistent network namespace '{}' at {}: {}", name, path.string(),
|
||||||
|
strerror(errno));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove(path, ec);
|
||||||
|
if (ec) {
|
||||||
|
spdlog::warn("failed to remove {}: {}", path.string(), ec.message());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (C) 2026 Viorel Munteanu
|
||||||
|
//
|
||||||
|
// This program is free software; you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation; either version 2 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License along
|
||||||
|
// with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
// Generic infrastructure for keeping a network namespace alive with no
|
||||||
|
// process in it, the way `ip netns add` does -- narrow, reusable primitives
|
||||||
|
// only; no `intern`/`extern` policy or bridge logic here (that's
|
||||||
|
// network_bridge.{h,cpp}, a later commit -- see docs/networking-design.md's
|
||||||
|
// commit sequence). Not yet wired into -n/--network.
|
||||||
|
|
||||||
|
// $XDG_STATE_HOME/slocker-lite/netns/<sanitized-name> (see pid_file.h's
|
||||||
|
// xdg_state_dir()/sanitize_for_filename(), reused here rather than a second,
|
||||||
|
// drifting copy of the same resolution/sanitization rules).
|
||||||
|
std::filesystem::path persistent_netns_path(std::string_view name);
|
||||||
|
|
||||||
|
// True if persistent_netns_path(name) exists and is actually a live
|
||||||
|
// bind-mounted namespace (its st_dev differs from its parent directory's --
|
||||||
|
// the same "is this a mountpoint" check used elsewhere in this project's
|
||||||
|
// spirit of trusting stat() over guessing from a path alone), not just a
|
||||||
|
// stale, never-mounted, or already-unmounted leftover file.
|
||||||
|
bool persistent_netns_exists(std::string_view name);
|
||||||
|
|
||||||
|
// Creates a brand-new persistent network namespace at
|
||||||
|
// persistent_netns_path(name): forks a child that unshare(CLONE_NEWNET)s its
|
||||||
|
// own network namespace, bind-mounts its /proc/self/ns/net onto the target
|
||||||
|
// path, then exits immediately -- the bind mount itself keeps the namespace
|
||||||
|
// alive from then on, independent of the (now-exited) child, exactly like
|
||||||
|
// `ip netns add`. Requires CAP_SYS_ADMIN (root) for the bind mount, matching
|
||||||
|
// this feature's current root-only scope. Best-effort like this project's
|
||||||
|
// other host-state primitives (session locks, cgroups): logs and returns
|
||||||
|
// false on any failure (already exists, can't fork/unshare/mount) rather
|
||||||
|
// than throwing.
|
||||||
|
bool create_persistent_netns(std::string_view name);
|
||||||
|
|
||||||
|
// Removes the persistent network namespace at persistent_netns_path(name)
|
||||||
|
// (unmounts it, then removes the now-plain file). Returns false (logging a
|
||||||
|
// warning) if it doesn't exist or can't be removed.
|
||||||
|
bool remove_persistent_netns(std::string_view name);
|
||||||
+2
-2
@@ -42,8 +42,6 @@ std::string sanitize_for_filename(std::string_view name) {
|
|||||||
return result.empty() ? "container" : result;
|
return result.empty() ? "container" : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
std::filesystem::path xdg_state_dir() {
|
std::filesystem::path xdg_state_dir() {
|
||||||
const char* xdg_state_home = std::getenv("XDG_STATE_HOME");
|
const char* xdg_state_home = std::getenv("XDG_STATE_HOME");
|
||||||
std::filesystem::path state_home;
|
std::filesystem::path state_home;
|
||||||
@@ -56,6 +54,8 @@ std::filesystem::path xdg_state_dir() {
|
|||||||
return state_home / "slocker-lite";
|
return state_home / "slocker-lite";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
std::filesystem::path session_run_dir() {
|
std::filesystem::path session_run_dir() {
|
||||||
return xdg_state_dir() / "run";
|
return xdg_state_dir() / "run";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
// cgroup directory, so the two don't drift apart.
|
// cgroup directory, so the two don't drift apart.
|
||||||
std::string sanitize_for_filename(std::string_view name);
|
std::string sanitize_for_filename(std::string_view name);
|
||||||
|
|
||||||
|
// $XDG_STATE_HOME/slocker-lite, or $HOME/.local/state/slocker-lite if
|
||||||
|
// XDG_STATE_HOME is unset/empty. Exported (not just this file's own internal
|
||||||
|
// helper) so persistent_netns.{h,cpp} can resolve its own subdirectory under
|
||||||
|
// the same state root without a second, drifting copy of this logic.
|
||||||
|
std::filesystem::path xdg_state_dir();
|
||||||
|
|
||||||
// Tracks one running -r/--run session (a live bwrap process) as a PID file with an
|
// Tracks one running -r/--run session (a live bwrap process) as a PID file with an
|
||||||
// advisory flock() held for as long as this process is running it -- so a *different*
|
// advisory flock() held for as long as this process is running it -- so a *different*
|
||||||
// process can tell a stale leftover file apart from a genuinely still-running
|
// process can tell a stale leftover file apart from a genuinely still-running
|
||||||
|
|||||||
@@ -16,6 +16,52 @@
|
|||||||
|
|
||||||
#include "self_test.h"
|
#include "self_test.h"
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <fmt/core.h>
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include "persistent_netns.h"
|
||||||
|
|
||||||
int run_self_tests() {
|
int run_self_tests() {
|
||||||
|
constexpr std::string_view test_netns_name = "selftest";
|
||||||
|
|
||||||
|
// create_persistent_netns() bind-mounts, which requires root -- report
|
||||||
|
// and skip rather than treating a rootless dev machine as a failure.
|
||||||
|
if (geteuid() != 0) {
|
||||||
|
fmt::print("skipping persistent network namespace test (requires root)\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up a leftover from a previous interrupted run, if any, before
|
||||||
|
// starting -- create_persistent_netns() refuses to overwrite an existing
|
||||||
|
// live namespace.
|
||||||
|
if (persistent_netns_exists(test_netns_name)) {
|
||||||
|
remove_persistent_netns(test_netns_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!create_persistent_netns(test_netns_name)) {
|
||||||
|
spdlog::error("self-test: failed to create persistent network namespace");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Checked from this (parent) process, after the child that actually did
|
||||||
|
// the unshare()/bind-mount has already exited -- this is exactly what
|
||||||
|
// confirms the namespace outlives its creating process, the whole point
|
||||||
|
// of the bind-mount technique.
|
||||||
|
if (!persistent_netns_exists(test_netns_name)) {
|
||||||
|
spdlog::error("self-test: persistent network namespace missing right after creating it");
|
||||||
|
remove_persistent_netns(test_netns_name);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!remove_persistent_netns(test_netns_name)) {
|
||||||
|
spdlog::error("self-test: failed to remove persistent network namespace");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (persistent_netns_exists(test_netns_name)) {
|
||||||
|
spdlog::error("self-test: persistent network namespace still exists after removing it");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt::print("persistent network namespace create/verify/remove: OK\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -17,6 +17,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// Implements -t/--test, this project's own built-in self-test mode (distinct
|
// Implements -t/--test, this project's own built-in self-test mode (distinct
|
||||||
// from the Meson-driven fixture smoke test under tests/). Currently an empty
|
// from the Meson-driven fixture smoke test under tests/). Currently exercises
|
||||||
// placeholder -- real tests are expected here soon.
|
// persistent_netns.{h,cpp}'s create/verify/remove cycle (root-only, skipped
|
||||||
|
// with a message otherwise -- see docs/networking-design.md); more real tests
|
||||||
|
// are expected here as more of that feature lands.
|
||||||
int run_self_tests();
|
int run_self_tests();
|
||||||
|
|||||||
Reference in New Issue
Block a user