diff --git a/native/suidhelper/Cargo.toml b/native/suidhelper/Cargo.toml index f5b4b1b7..fbb4524a 100644 --- a/native/suidhelper/Cargo.toml +++ b/native/suidhelper/Cargo.toml @@ -56,6 +56,10 @@ path = "tests/util/confinement.rs" name = "tools_chroot_jail_remove" path = "tests/tools/chroot_jail_remove.rs" +[[test]] +name = "tools_chroot_jail_grant_api" +path = "tests/tools/chroot_jail_grant_api.rs" + [[test]] name = "util_chroot_jail" path = "tests/util/chroot_jail.rs" diff --git a/native/suidhelper/src/tools/chroot_jail/grant_api.rs b/native/suidhelper/src/tools/chroot_jail/grant_api.rs new file mode 100644 index 00000000..4ec94a21 --- /dev/null +++ b/native/suidhelper/src/tools/chroot_jail/grant_api.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! `chroot-jail grant-api`: hand the firecracker API socket to the node user so +//! the unprivileged controller can `connect()` to it. +//! +//! The jailer drops firecracker to a per-VM uid/gid and chroots it; firecracker +//! then creates its API socket at `/root/api.socket` owned by that per-VM +//! id. Connecting a unix socket needs *write* permission on the node, so the +//! node user (a different uid) gets `EACCES`. This op chowns just that one +//! socket to the helper's CALLER — `getuid()`/`getgid()`, which inside the +//! privileged scope are the real (caller) ids while euid is 0 — and chmods it +//! `0660`. The node thus connects as owner, and humans added to the node's +//! group connect via the group bit. +//! +//! That alone is not enough: the jailer leaves `/root` as `0700` owned by +//! the per-VM uid, and connecting needs *search* (`+x`) on every ancestor, so +//! the node cannot even traverse into `root` to reach the (now its own) socket. +//! So this op also opens just that one directory to the caller's group: it keeps +//! the per-VM uid as owner (firecracker still needs it), chgrps `root` to the +//! caller's gid, and chmods it `0710` — owner `rwx`, group `--x` (traverse, not +//! list), other none. Per-VM isolation is otherwise untouched: only this socket +//! and its immediate parent's group/mode move, nothing else in the jail, and +//! unrelated users stay locked out. +//! +//! Security: the socket path is validated as a `SafePath` and reached by an +//! `O_NOFOLLOW` walk from `JAIL_BASE`, so a symlinked component cannot redirect +//! the chown outside the jail, and every op is fd-relative on the pinned `root` +//! dir fd, never by re-resolved name. The leaf must be exactly `api.socket` +//! `//root` below the base, and `fstatat(AT_SYMLINK_NOFOLLOW)` must +//! report a *socket* — a regular file or symlink planted at that name is an +//! attack and is refused, never chmod'd. A missing socket (`ENOENT`, anywhere on +//! the path) is `Pending`, not an error: firecracker has not created it yet, so +//! the controller keeps probing. + +use crate::config::Config; +use crate::tools::IsTool; +use crate::util::safe_dir::{self, SafeDir}; +use crate::util::safe_path::{self, IsAbsolute, SafePath, StrictComponents}; +use clap::Args; +use nix::errno::Errno; +use nix::sys::stat::SFlag; +use nix::unistd::{getgid, getuid}; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use thiserror::Error as ThisError; + +/// The fixed in-jail socket name firecracker opens (mirrors the Elixir +/// `Hyper.Node.FireVMM.Jailer` `@jail_socket`). +const SOCKET_NAME: &str = "api.socket"; + +/// The socket sits at `///root/api.socket`: three parent +/// components (``, ``, `root`) before the leaf. +const SOCKET_PARENT_DEPTH: usize = 3; + +/// Mode handed to the node: owner+group read/write, no world access. +const SOCKET_MODE: u32 = 0o660; + +/// Mode set on the jail `root` dir so the node's group can *traverse* it to +/// reach the socket: owner `rwx` (the per-VM uid, unchanged), group `--x` +/// (traverse, not list), other none. +const JAIL_ROOT_MODE: u32 = 0o710; + +type LexicalPath = SafePath; + +#[derive(Debug, ThisError)] +pub enum Error { + #[error("--socket path: {0}")] + SocketPath(#[source] safe_path::ValidationError), + #[error("--socket must be exactly //root/api.socket below JAIL_BASE: {0:?}")] + SocketShape(PathBuf), + #[error("--socket leaf must be {SOCKET_NAME:?}: {0:?}")] + SocketName(PathBuf), + #[error("walking to the jail root: {0}")] + Walk(#[source] safe_dir::Error), + #[error("api.socket is not a socket (or is a symlink); refusing to touch it")] + NotASocket, + #[error("statting the socket: {0}")] + Stat(#[source] safe_dir::Error), + #[error("chowning the socket to the caller: {0}")] + Chown(#[source] safe_dir::Error), + #[error("chmoding the socket: {0}")] + Chmod(#[source] safe_dir::Error), + #[error("chgrp-ing the jail root dir to the caller: {0}")] + ChgrpRoot(#[source] safe_dir::Error), + #[error("chmoding the jail root dir for traversal: {0}")] + ChmodRoot(#[source] safe_dir::Error), +} + +#[derive(Args)] +pub struct GrantApiArgs { + /// Host path of the firecracker API socket, shape + /// ///root/api.socket. + #[arg(long)] + socket: PathBuf, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum GrantOut { + /// The socket was handed to the caller (chowned + chmoded). + Granted, + /// The socket does not exist yet; the caller should keep waiting. + Pending, +} + +/// Run the `grant-api` op in its own privileged scope (returns its serialized `Value`). +pub fn run(args: GrantApiArgs) -> Result { + GrantApi { args }.run() +} + +struct GrantApi { + args: GrantApiArgs, +} + +impl IsTool for GrantApi { + type Args = GrantApiArgs; + type Output = GrantOut; + type RunT = Result; + + fn run_privileged(&self) -> Self::RunT { + grant_api_under(&Config::get().jail_base(), &self.args.socket) + } + + fn parse(&self, res: Self::RunT) -> Result> { + Ok(res?) + } +} + +/// Hand `socket` (`///root/api.socket`) to the helper's +/// caller, fd-relative after an `O_NOFOLLOW` walk from `jail_base`. Returns +/// `Pending` if any path component or the socket itself is not yet present. +pub fn grant_api_under(jail_base: &Path, socket: &Path) -> Result { + let path: LexicalPath = socket.to_path_buf().try_into().map_err(Error::SocketPath)?; + let (parents, leaf) = path.relative_to(jail_base).map_err(Error::SocketPath)?; + if parents.len() != SOCKET_PARENT_DEPTH { + return Err(Error::SocketShape(socket.to_path_buf())); + } + if leaf != Path::new(SOCKET_NAME) { + return Err(Error::SocketName(socket.to_path_buf())); + } + + let Some(root) = walk(jail_base.to_path_buf(), &parents)? else { + return Ok(GrantOut::Pending); // jail not fully created yet + }; + + let leaf = Path::new(SOCKET_NAME); + let stat = match root.stat(leaf) { + Ok(stat) => stat, + Err(e) if e.errno() == Some(Errno::ENOENT) => return Ok(GrantOut::Pending), + Err(e) => return Err(Error::Stat(e)), + }; + // `stat` used AT_SYMLINK_NOFOLLOW, so a symlink reports as S_IFLNK and fails + // this check too: only a real socket is accepted, anything else is refused. + if stat.st_mode & SFlag::S_IFMT.bits() != SFlag::S_IFSOCK.bits() { + return Err(Error::NotASocket); + } + + root.chown(leaf, getuid().as_raw(), getgid().as_raw()) + .map_err(Error::Chown)?; + root.chmod(leaf, SOCKET_MODE).map_err(Error::Chmod)?; + + // Open `root` itself to the caller's group so the node can traverse into it + // to reach the socket (the jailer leaves it 0700 / per-VM uid). Owner stays + // the per-VM uid; only the group and mode move. Operate on the pinned `root` + // fd (already opened `O_NOFOLLOW`), never by name - TOCTOU-safe. + root.chgrp_self(getgid().as_raw()) + .map_err(Error::ChgrpRoot)?; + root.chmod_self(JAIL_ROOT_MODE).map_err(Error::ChmodRoot)?; + Ok(GrantOut::Granted) +} + +/// Open `base` and walk `parents` from it (`O_NOFOLLOW` each step). Returns +/// `Ok(None)` if `base` or any parent is not yet present (`ENOENT`), so the +/// caller can treat a half-built jail as `Pending` rather than an error. +fn walk(base: PathBuf, parents: &[PathBuf]) -> Result, Error> { + let base_path: LexicalPath = base.try_into().map_err(Error::SocketPath)?; + let anchor = match SafeDir::open(&base_path) { + Ok(dir) => dir, + Err(e) if e.errno() == Some(Errno::ENOENT) => return Ok(None), + Err(e) => return Err(Error::Walk(e)), + }; + match anchor.descend(parents) { + Ok(dir) => Ok(Some(dir)), + Err(e) if e.errno() == Some(Errno::ENOENT) => Ok(None), + Err(e) => Err(Error::Walk(e)), + } +} diff --git a/native/suidhelper/src/tools/chroot_jail/mod.rs b/native/suidhelper/src/tools/chroot_jail/mod.rs index d65bac7b..3a06c35a 100644 --- a/native/suidhelper/src/tools/chroot_jail/mod.rs +++ b/native/suidhelper/src/tools/chroot_jail/mod.rs @@ -1,9 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-only //! `chroot-jail`: per-VM chroot/jail lifecycle. +pub mod grant_api; mod prepare; pub mod remove; +pub use grant_api::GrantApiArgs; pub use prepare::PrepareArgs; pub use remove::RemoveArgs; @@ -15,6 +17,8 @@ pub enum ChrootJailOp { Prepare(PrepareArgs), /// Remove a VM's stale chroot and cgroup leaf before relaunching the jailer. Remove(RemoveArgs), + /// Hand the firecracker API socket to the node user (chown to caller, 0660). + GrantApi(GrantApiArgs), } impl ChrootJailOp { @@ -25,6 +29,7 @@ impl ChrootJailOp { match self { ChrootJailOp::Prepare(args) => prepare::run(args), ChrootJailOp::Remove(args) => remove::run(args), + ChrootJailOp::GrantApi(args) => grant_api::run(args), } } } diff --git a/native/suidhelper/src/util/safe_dir.rs b/native/suidhelper/src/util/safe_dir.rs index 53d687c0..61808d8a 100644 --- a/native/suidhelper/src/util/safe_dir.rs +++ b/native/suidhelper/src/util/safe_dir.rs @@ -17,8 +17,8 @@ use super::safe_path::SafePath; use nix::dir::{Dir, Type}; use nix::fcntl::{openat, AtFlags, OFlag}; use nix::libc::dev_t; -use nix::sys::stat::{mknodat, Mode, SFlag}; -use nix::unistd::{dup, fchownat, linkat, unlinkat, write, Gid, Uid, UnlinkatFlags}; +use nix::sys::stat::{fchmod, fchmodat, fstatat, mknodat, FchmodatFlags, FileStat, Mode, SFlag}; +use nix::unistd::{dup, fchown, fchownat, linkat, unlinkat, write, Gid, Uid, UnlinkatFlags}; use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd}; @@ -39,6 +39,10 @@ pub enum Error { Mknod { name: PathBuf, source: nix::Error }, #[error("fchownat {name:?}: {source}")] Chown { name: PathBuf, source: nix::Error }, + #[error("fchmodat {name:?}: {source}")] + Chmod { name: PathBuf, source: nix::Error }, + #[error("fstatat {name:?}: {source}")] + Stat { name: PathBuf, source: nix::Error }, #[error("linkat -> {name:?}: {source}")] Link { name: PathBuf, source: nix::Error }, #[error("dup: {0}")] @@ -55,6 +59,8 @@ impl Error { | Error::Write { source, .. } | Error::Mknod { source, .. } | Error::Chown { source, .. } + | Error::Chmod { source, .. } + | Error::Stat { source, .. } | Error::Link { source, .. } => Some(*source), Error::ReadDir(source) | Error::Dup(source) => Some(*source), } @@ -216,6 +222,55 @@ impl SafeDir { }) } + /// `fstat` entry `name` relative to this dir's fd without following a final + /// symlink (`AT_SYMLINK_NOFOLLOW`). A symlink stats as itself (`S_IFLNK`), + /// never its target, so a caller inspecting the file type can reject one. + pub fn stat(&self, name: &Path) -> Result { + fstatat(Some(self.0.as_raw_fd()), name, AtFlags::AT_SYMLINK_NOFOLLOW).map_err(|source| { + Error::Stat { + name: name.to_path_buf(), + source, + } + }) + } + + /// `chmod` entry `name` to `mode`. Linux's `fchmodat` has no working + /// no-follow mode (it returns `ENOTSUP`), so this follows a final symlink; + /// call it only after [`stat`](Self::stat) has proven `name` is not a + /// symlink, so the follow is a no-op on a real (non-link) entry. + pub fn chmod(&self, name: &Path, mode: u32) -> Result<(), Error> { + fchmodat( + Some(self.0.as_raw_fd()), + name, + Mode::from_bits_truncate(mode), + FchmodatFlags::FollowSymlink, + ) + .map_err(|source| Error::Chmod { + name: name.to_path_buf(), + source, + }) + } + + /// `fchmod` this directory through its own held fd. Unlike [`chmod`](Self::chmod), + /// which re-resolves a *name*, this targets the fd we already opened + /// `O_NOFOLLOW`, so there is no path component to swap - TOCTOU-safe on the + /// directory itself. + pub fn chmod_self(&self, mode: u32) -> Result<(), Error> { + fchmod(self.0.as_raw_fd(), Mode::from_bits_truncate(mode)).map_err(|source| Error::Chmod { + name: PathBuf::from("."), + source, + }) + } + + /// `fchown` this directory's group through its own held fd, preserving its + /// owner (no uid passed). Same TOCTOU guarantee as [`chmod_self`](Self::chmod_self). + pub fn chgrp_self(&self, gid: u32) -> Result<(), Error> { + fchown(self.0.as_raw_fd(), None, Some(Gid::from_raw(gid))).map_err(|source| Error::Chown { + name: PathBuf::from("."), + source, + }) + } + /// Remove the non-directory entry `name` from this directory. pub fn unlink(&self, name: &Path) -> Result<(), Error> { unlinkat(Some(self.0.as_raw_fd()), name, UnlinkatFlags::NoRemoveDir).map_err(|source| { diff --git a/native/suidhelper/tests/tools/chroot_jail_grant_api.rs b/native/suidhelper/tests/tools/chroot_jail_grant_api.rs new file mode 100644 index 00000000..e55a679b --- /dev/null +++ b/native/suidhelper/tests/tools/chroot_jail_grant_api.rs @@ -0,0 +1,241 @@ +//! Contracts of the `chroot-jail grant-api` op, driven through the base-injected +//! `grant_api_under` seam so they run unprivileged in a tempdir. The promises +//! under test (refusal contracts first — they are the security boundary): +//! * shape — the socket is accepted iff it is exactly +//! `//root/api.socket` below the jail base; any other depth or a +//! leaf that is not `api.socket` is refused before any chown; +//! * lexical — a `.`/`..`/empty component or a relative path is always rejected +//! before any filesystem access; +//! * type — a regular file or a symlink planted at `api.socket` is refused +//! (`NotASocket`) and left untouched, never chmod'd; only a real socket is +//! granted; +//! * confinement — a symlinked path component is never followed, so the chown +//! can never escape the anchored jail tree (the core TOCTOU guarantee); +//! * pending — a not-yet-created socket (or half-built jail) is `Pending`, not +//! an error, so the controller keeps probing; +//! * grant — a real socket is chowned to the caller and left mode 0660, and +//! its parent `root` dir is opened for the caller's group to traverse +//! (chgrp'd to the caller, chmod'd 0710) so the node can reach the socket. + +use hyper_suidhelper::tools::chroot_jail::grant_api::{grant_api_under, Error, GrantOut}; +use hyper_suidhelper::util::safe_path::ValidationError; +use proptest::prelude::*; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::os::unix::net::UnixListener; +use std::path::{Path, PathBuf}; +use std::{fs, os::unix::fs::MetadataExt}; + +/// Build the canonical `/exec/id/root` parent dirs and return that dir. +fn make_root(jail: &Path) -> PathBuf { + let root = jail.join("exec").join("id").join("root"); + fs::create_dir_all(&root).unwrap(); + root +} + +#[test] +fn socket_outside_jail_base_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path().join("jail"); + fs::create_dir(&jail).unwrap(); + let outside = tmp.path().join("elsewhere/exec/id/root/api.socket"); + let err = grant_api_under(&jail, &outside).unwrap_err(); + assert!( + matches!(err, Error::SocketPath(ValidationError::NotUnderBase)), + "got {err:?}", + ); +} + +#[test] +fn wrong_leaf_basename_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let bad = jail.join("exec").join("id").join("root").join("evil.sock"); + let err = grant_api_under(jail, &bad).unwrap_err(); + assert!(matches!(err, Error::SocketName(_)), "got {err:?}"); +} + +#[test] +fn too_shallow_is_shape_error() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let bad = jail.join("exec").join("id").join("api.socket"); // missing root/ + let err = grant_api_under(jail, &bad).unwrap_err(); + assert!(matches!(err, Error::SocketShape(_)), "got {err:?}"); +} + +#[test] +fn too_deep_is_shape_error() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let bad = jail + .join("exec") + .join("id") + .join("root") + .join("extra") + .join("api.socket"); + let err = grant_api_under(jail, &bad).unwrap_err(); + assert!(matches!(err, Error::SocketShape(_)), "got {err:?}"); +} + +#[test] +fn dotdot_traversal_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let bad = PathBuf::from(format!("{}/exec/../id/root/api.socket", jail.display())); + let err = grant_api_under(jail, &bad).unwrap_err(); + assert!( + matches!(err, Error::SocketPath(ValidationError::LooseComponents)), + "got {err:?}", + ); +} + +#[test] +fn relative_socket_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let err = grant_api_under(tmp.path(), Path::new("exec/id/root/api.socket")).unwrap_err(); + assert!( + matches!(err, Error::SocketPath(ValidationError::NotAbsolute)), + "got {err:?}", + ); +} + +#[test] +fn missing_socket_is_pending() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let root = make_root(jail); + let socket = root.join("api.socket"); // never created + let out = grant_api_under(jail, &socket).expect("missing socket must be Ok(Pending)"); + assert!(matches!(out, GrantOut::Pending), "got {out:?}"); +} + +#[test] +fn missing_jail_tree_is_pending() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let socket = jail.join("exec").join("id").join("root").join("api.socket"); // nothing created + let out = grant_api_under(jail, &socket).expect("half-built jail must be Ok(Pending)"); + assert!(matches!(out, GrantOut::Pending), "got {out:?}"); +} + +// A real socket is granted: chowned to the caller (our own uid/gid, which a +// non-root process is allowed to set on a file it owns) and chmod'd 0660. +#[test] +fn real_socket_is_granted_and_chmod_0660() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let root = make_root(jail); + let socket = root.join("api.socket"); + let _listener = UnixListener::bind(&socket).unwrap(); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o755)).unwrap(); + + let out = grant_api_under(jail, &socket).expect("real socket must grant"); + assert!(matches!(out, GrantOut::Granted), "got {out:?}"); + + let meta = fs::symlink_metadata(&socket).unwrap(); + assert_eq!(meta.mode() & 0o777, 0o660, "socket must be chmod'd 0660"); + assert_eq!(meta.uid(), nix::unistd::getuid().as_raw()); + assert_eq!(meta.gid(), nix::unistd::getgid().as_raw()); + + // The parent `root` dir must also be opened for the caller's group to + // traverse, else the node could not reach the socket: chgrp'd to the caller, + // chmod'd 0710 (owner rwx, group --x, other none). + let root_meta = fs::symlink_metadata(&root).unwrap(); + assert_eq!( + root_meta.mode() & 0o777, + 0o710, + "jail root must be chmod'd 0710 for traversal", + ); + assert_eq!( + root_meta.gid(), + nix::unistd::getgid().as_raw(), + "jail root must be chgrp'd to the caller", + ); +} + +// A regular file planted at api.socket is refused and left untouched (not chmod'd). +#[test] +fn regular_file_at_leaf_is_refused_and_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let root = make_root(jail); + let imposter = root.join("api.socket"); + fs::write(&imposter, b"not a socket").unwrap(); + fs::set_permissions(&imposter, fs::Permissions::from_mode(0o600)).unwrap(); + + let err = grant_api_under(jail, &imposter).unwrap_err(); + assert!(matches!(err, Error::NotASocket), "got {err:?}"); + assert_eq!( + fs::symlink_metadata(&imposter).unwrap().mode() & 0o777, + 0o600, + "imposter file must not be chmod'd", + ); +} + +// A symlink planted at api.socket is refused: fstatat(AT_SYMLINK_NOFOLLOW) stats +// the link itself (S_IFLNK), so it is never seen as a socket and never followed. +#[test] +fn symlink_at_leaf_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let root = make_root(jail); + let target = tmp.path().join("real-target"); + fs::write(&target, b"secret").unwrap(); + let link = root.join("api.socket"); + symlink(&target, &link).unwrap(); + + let err = grant_api_under(jail, &link).unwrap_err(); + assert!(matches!(err, Error::NotASocket), "got {err:?}"); +} + +// A symlinked path component must NOT be followed: the walk fails rather than +// reaching through it, so nothing outside the jail is touched. +#[test] +fn symlinked_component_does_not_escape() { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path().join("jail"); + fs::create_dir(&jail).unwrap(); + + let sentinel = tmp.path().join("sentinel"); + fs::create_dir_all(sentinel.join("id").join("root")).unwrap(); + let outside_socket = sentinel.join("id").join("root").join("api.socket"); + let _listener = UnixListener::bind(&outside_socket).unwrap(); + fs::set_permissions(&outside_socket, fs::Permissions::from_mode(0o700)).unwrap(); + + // `/exec` is a symlink to the external sentinel dir. + symlink(&sentinel, jail.join("exec")).unwrap(); + + let socket = jail.join("exec").join("id").join("root").join("api.socket"); + let _ = grant_api_under(&jail, &socket); // O_NOFOLLOW makes the walk refuse + + assert_eq!( + fs::symlink_metadata(&outside_socket).unwrap().mode() & 0o777, + 0o700, + "grant escaped through a symlinked component", + ); +} + +proptest! { + // For a socket `depth` components below the jail base with leaf `api.socket` + // (target never created), grant_api_under returns Ok(Pending) iff depth == 4 + // (i.e. 3 parents), else SocketShape. The generator emits only plain names so + // the lexical gate never fires and the leaf is always `api.socket`. + #[test] + fn shape_classification( + parents in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..6) + ) { + let tmp = tempfile::tempdir().unwrap(); + let jail = tmp.path(); + let mut socket = jail.to_path_buf(); + for c in &parents { + socket.push(c); + } + socket.push("api.socket"); + let res = grant_api_under(jail, &socket); + if parents.len() == 3 { + prop_assert!(matches!(res, Ok(GrantOut::Pending)), "depth 3 must be Pending, got {res:?}"); + } else { + prop_assert!(matches!(res, Err(Error::SocketShape(_))), "got {res:?}"); + } + } +}