sane-sandboxed: introduce a symlink cache to reduce readlink calls even more

it's all a bit silly. i still do a bunch of -L tests: i just avoid the costly readlink fork :|
This commit is contained in:
Colin 2024-05-13 01:31:30 +00:00
parent 11ddce043d
commit 660ba94c7c
3 changed files with 139 additions and 30 deletions

View File

@ -43,6 +43,7 @@ let
makeProfile = pkgs.callPackage ./make-sandbox-profile.nix { };
makeSandboxed = pkgs.callPackage ./make-sandboxed.nix { sane-sandboxed = config.sane.programs.sane-sandboxed.package; };
# TODO: much of this can be simplified now that the sandbox helper uses a symlink cache
# removeStorePaths: [ str ] -> [ str ], but remove store paths, because nix evals aren't allowed to contain any (for purity reasons?)
removeStorePaths = paths: lib.filter (p: !(lib.hasPrefix "/nix/store" p)) paths;
@ -54,6 +55,14 @@ let
expandSymlinksOnce = paths: lib.unique (paths ++ removeStorePaths (makeCanonical (derefSymlinks paths)));
expandSymlinks = paths: lib.converge expandSymlinksOnce paths;
# given some paths, walk all of these and keep only the paths/ancestors which are symlinks
keepOnlySymlinks = paths: lib.filter
(p: ((config.sane.fs."${p}" or {}).symlink or null) != null)
(lib.concatMap (p: path-lib.walk "/" p) paths)
;
# symlinkToAttrs: [ str ] -> Attrs such that `attrs."${symlink}" = symlinkTarget`.
symlinksToAttrs = paths: lib.genAttrs paths (p: config.sane.fs."${p}".symlink.target);
vpn = lib.findSingle (v: v.default) null null (builtins.attrValues config.sane.vpn);
sandboxProfilesFor = userName: let
@ -101,7 +110,35 @@ let
vpn.dns
else
null;
allowedPaths = expandSymlinks allowedPaths;
inherit allowedPaths;
symlinkCache = {
"/bin/sh" = config.environment.binsh;
"${builtins.unsafeDiscardStringContext config.environment.binsh}" = "bash";
"/usr/bin/env" = config.environment.usrbinenv;
"${builtins.unsafeDiscardStringContext config.environment.usrbinenv}" = "coreutils";
# "/run/current-system" = "${config.system.build.toplevel}";
} // lib.optionalAttrs config.hardware.opengl.enable {
"/run/opengl-driver" = let
gl = config.hardware.opengl;
# from: <repo:nixos/nixpkgs:nixos/modules/hardware/opengl.nix>
package = pkgs.buildEnv {
name = "opengl-drivers";
paths = [ gl.package ] ++ gl.extraPackages;
};
in "${package}";
} // lib.optionalAttrs (config.hardware.opengl.enable && config.hardware.opengl.driSupport32Bit) {
"/run/opengl-driver-32" = let
gl = config.hardware.opengl;
# from: <repo:nixos/nixpkgs:nixos/modules/hardware/opengl.nix>
package = pkgs.buildEnv {
name = "opengl-drivers-32bit";
paths = [ gl.package32 ] ++ gl.extraPackages32;
};
in "${package}";
} // (
symlinksToAttrs (keepOnlySymlinks (expandSymlinks allowedPaths))
);
};
defaultProfile = sandboxProfilesFor config.sane.defaultUser;
makeSandboxedArgs = {

View File

@ -4,6 +4,7 @@
{ pkgName
, method
, allowedPaths ? []
, symlinkCache ? {}
, autodetectCliPaths ? false
, capabilities ? []
, dns ? null
@ -18,6 +19,13 @@ let
];
allowPaths = paths: lib.flatten (builtins.map allowPath paths);
cacheLink = from: to: [
"--sane-sandbox-cache-symlink"
from
to
];
cacheLinks = links: lib.flatten (lib.mapAttrsToList cacheLink links);
capabilityFlags = lib.flatten (builtins.map (c: [ "--sane-sandbox-cap" c ]) capabilities);
netItems = lib.optionals (netDev != null) [
@ -38,6 +46,7 @@ let
++ capabilityFlags
++ lib.optionals (autodetectCliPaths != null) [ "--sane-sandbox-autodetect" autodetectCliPaths ]
++ lib.optionals whitelistPwd [ "--sane-sandbox-add-pwd" ]
++ cacheLinks symlinkCache
++ extraConfig;
in

View File

@ -37,6 +37,9 @@ profileDirs=()
isDisable=
# isDryRun: don't actually execute the program or sandbox: just print the command which would be run (and which the user may run from their own shell)
isDryRun=
# linkCache: associative array mapping canonical symlinks to canonical targets
# used to speed up `readlink` operations
declare -A linkCache
### values derived directly from $argv
# cliArgs: the actual command we'll be running inside the sandbox
@ -120,6 +123,9 @@ usage() {
echo ' shorthand for `--sane-sandbox-path $PWD`'
echo ' --sane-sandbox-profile <profile>'
echo ' --sane-sandbox-profile-dir <dir>'
echo ' --sane-sandbox-cache-symlink <from> <to>'
echo ' assume that <from> is a symlink to <to>'
echo ' performance optimization to avoid spawning a readlink subshell'
echo
echo 'the following environment variables are also considered and propagated to children:'
echo ' SANE_SANDBOX_DISABLE=1'
@ -148,6 +154,30 @@ relativeToPwd() {
fi
}
# `splitHead <headVar> <tailVar> <input>`: write the top-level directory to `headVar` and set `tailVar` to the remaining path.
# input is assumed to be a full path.
# both outputs inherit the leading slash (except if the path has only one item, in which case `tailVar=`.
# example: splitHead myHead myTail /path/to/thing
# myHead=/path
# myTail=/to/thing
# example: splitHead myHead myTail /top
# myHead=/top
# myTail=
splitHead() {
local outHead="$1"
local outTail="$2"
local path="$3"
# chomp leading `/`
path="${path:1}"
local leadingComp="${path%%/*}"
local compLen="${#leadingComp}"
local tail="${path:$compLen}"
declare -g "$outHead"="/$leadingComp"
declare -g "$outTail"="$tail"
}
# `normPath outVar "$path"`
# remove duplicate //, reduce '.' and '..' (naively).
# expects a full path as input
@ -155,26 +185,18 @@ relativeToPwd() {
# does not resolve symlinks, nor check for existence of any component of the path.
normPath() {
local npOut="$1"
local npUnparsed="$2"
_npUnparsed="$2"
local npComps=()
while [ -n "$npUnparsed" ]; do
# chomp leading `/`
npUnparsed="${npUnparsed:1}"
# split into <first></re/st/...>
# in the case of `//`, or more, npThisComp is empty,
# and we push nothing to npComps,
# but we're guaranteed to make progress.
npThisComp="${npUnparsed%%/*}"
npThisLen="${#npThisComp}"
npUnparsed="${npUnparsed:$npThisLen}"
if [ "$npThisComp" = ".." ]; then
while [ -n "$_npUnparsed" ]; do
splitHead _npThisComp _npUnparsed "$_npUnparsed"
if [ "$_npThisComp" = "/.." ]; then
# "go up" path component => delete the leaf dir (if any)
if [ ${#npComps[@]} -ne 0 ]; then
unset npComps[-1]
fi
elif [ "$npThisComp" != "." ] && [ -n "$npThisComp" ]; then
elif [ "$_npThisComp" != "/." ] && [ "$_npThisComp" != "/" ] && [ "$_npThisComp" != "" ]; then
# normal, non-empty path component => append it
npComps+=("$npThisComp")
npComps+=("$_npThisComp")
fi
done
@ -184,7 +206,7 @@ normPath() {
else
local npJoined=
for npComp in "${npComps[@]}"; do
npJoined="$npJoined/$npComp"
npJoined="$npJoined$npComp"
done
declare -g "$npOut"="$npJoined"
fi
@ -252,15 +274,44 @@ contains() {
return 1
}
# `derefOnce outvar path`: assume `path` is a symlink and set `outvar` to its target.
# the result is always an absolute path (relative symlinks are transformed).
# `derefOnce outVar path`: walks from `/` to `path` and derefs the first symlink it encounters.
# the dereferenced equivalent of `path` is written to `outVar`.
# the dereferenced path may yet contain more unresolved symlinks.
# if no links are encountered, then `outVar` is set empty.
derefOnce() {
local outVar="$1"
local source="$2"
local target="$(readlink "$source")"
local flag="$3"
local target=
local walked=
_unwalked="$source"
while [ -n "$_unwalked" ]; do
splitHead _head _unwalked "$_unwalked"
walked="$walked$_head"
local linkTarget="${linkCache["$walked"]}"
if [ -z "$linkTarget" ] && [ -L "$walked" ]; then
# path is a link, but not in the cache
linkTarget="$(readlink "$walked")"
# insert it into the cache, in case we traverse it again
linkCache["$walked"]="$linkTarget"
fi
if [ -n "$linkTarget" ]; then
target="$linkTarget$_unwalked"
break
fi
done
# make absolute
if [ "${target:0:1}" != "/" ]; then
target="$(dirname "$source")/$target"
if [ "$flag" != '--no-canon' ] && [ -n "$target" ]; then
if [ "${target:0:1}" != "/" ]; then
# `walked` is a relative link.
# then, the link is relative to the parent directory of `walked`
target="$walked/../$target"
fi
# canonicalize
normPath _normTarget "$target"
target="$_normTarget"
fi
declare -g "$outVar"="$target"
}
@ -490,6 +541,14 @@ parseArgs() {
shift
profileDirs=("$dir" "${profileDirs[@]}")
;;
(--sane-sandbox-cache-symlink)
local from="$1"
shift
local to="$1"
shift
relativeToPwd _absFrom "$from"
linkCache["$_absFrom"]="$to"
;;
(*)
parseArgsExtra+=("$arg")
;;
@ -579,8 +638,10 @@ bwrapIngestPath() {
# or maybe configure remote mounts to somehow never hang.
# test -r "$1" && bwrapFlags+=("--dev-bind-try" "$1" "$1")
if [ -L "$1" ]; then
local target="$(readlink "$1")"
bwrapFlags+=("--symlink" "$target" "$1")
# N.B.: test specifically whether this path is a link, not whether it's a non-symlink under a symlink'd dir.
# this way, the filetype of this path is *always* the same both inside and outside the sandbox.
derefOnce _target "$1" '--no-canon'
bwrapFlags+=("--symlink" "$_target" "$1")
else
bwrapFlags+=("--dev-bind-try" "$1" "$1")
fi
@ -809,19 +870,20 @@ maybeAutodetectPaths() {
# for more sophisticated (i.e. complex) backends like firejail, this may break subpaths which were blacklisted earlier.
canonicalizePaths() {
# remove '//' and simplify '.', '..' paths, into canonical absolute logical paths.
local canonPaths=()
for path in "${paths[@]}"; do
normPath _canonPath "$path"
paths+=("$_canonPath")
canonPaths+=("$_canonPath")
done
paths=("${canonPaths[@]}")
}
expandLink() {
if [ -L "$1" ]; then
derefOnce _linkTarget "$1"
derefOnce _linkTarget "$1"
if [ -n "$_linkTarget" ]; then
# add + expand the symlink further, but take care to avoid infinite recursion
normPath _canonTarget "$_linkTarget"
if ! contains "$_canonTarget" "${paths[@]}"; then
paths+=("$_canonTarget")
expandLink "$_canonTarget"
if ! contains "$_linkTarget" "${paths[@]}"; then
paths+=("$_linkTarget")
expandLink "$_linkTarget"
fi
fi
}
@ -833,6 +895,7 @@ expandLinks() {
}
removeSubpaths() {
# remove subpaths, but the result might include duplicates.
# TODO: make this not be O(n^2)!
local toplevelPaths=()
for path in "${paths[@]}"; do
local isSubpath=