blob: 4baf8dc00e00f2292ffa7bd655f9624871c52445 [file]
//! What a script application is allowed to do.
//!
//! The default set is empty: a script gets no file, process, network, storage or
//! clipboard access until the host grants it. Grants come from a plugin manifest
//! (§18.1) or directly from the embedding application.
use std::path::{Path, PathBuf};
use cap_std::{ambient_authority, fs::Dir};
/// Whether a script may hand `url` to the system URL opener.
///
/// One rule for every route out: `Link.href`, `cx.open_url`, and a link in
/// `TextView` content. The scheme check is the part that matters. Without it
/// any of them becomes a way to hand an arbitrary URI to whatever handler the
/// desktop has registered for its scheme, which is a considerably larger thing
/// than opening a page.
pub(crate) fn is_openable_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|parsed| {
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some()
})
}
/// A capability grant. Every field is private so adding a capability later is
/// not a breaking change for embedders.
#[derive(Clone, Debug, Default)]
pub struct Capabilities {
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
execute: ExecuteGrant,
network_hosts: Vec<String>,
http_requests: Vec<HttpRequestGrant>,
storage: bool,
clipboard_read: bool,
clipboard_write: bool,
exit: bool,
env_variables: Vec<String>,
}
/// Environment variables every application sees without asking.
///
/// Each one describes the user's setup — who they are, their language and
/// time zone, the tools they prefer, where the platform keeps things — and none
/// carries a secret. Without them every application would list the same names
/// in its manifest, and a grant everyone writes is a grant nobody reads. A
/// variable outside this list still needs `capabilities.process.env`.
///
/// The list lives in `default-env`, one name per line, so adding or
/// reviewing a variable is a change to that file alone.
pub(crate) fn default_env_variables() -> &'static [&'static str] {
static NAMES: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
NAMES.get_or_init(|| {
include_str!("default-env")
.lines()
.map(|line| line.split('#').next().unwrap_or_default().trim())
.filter(|name| !name.is_empty())
.collect()
})
}
/// A scheme-, effective-port-, method-, and path-scoped HTTP grant for one host.
///
/// This is deliberately separate from [`Capabilities::network_hosts`]: a
/// plugin may read one REST resource without gaining TCP or WebSocket access to
/// the same host, or permission to POST to another path on it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpRequestGrant {
scheme: String,
host: String,
port: Option<u16>,
methods: Vec<String>,
paths: Vec<String>,
path_prefixes: Vec<String>,
}
impl HttpRequestGrant {
pub fn new<H, M, P, Q>(host: impl Into<String>, methods: H, paths: M, path_prefixes: P) -> Self
where
H: IntoIterator<Item = Q>,
M: IntoIterator,
M::Item: Into<String>,
P: IntoIterator,
P::Item: Into<String>,
Q: Into<String>,
{
Self {
scheme: "https".to_owned(),
host: host.into().to_ascii_lowercase(),
port: None,
methods: methods
.into_iter()
.map(|method| method.into().to_ascii_uppercase())
.collect(),
paths: paths.into_iter().map(Into::into).collect(),
path_prefixes: path_prefixes.into_iter().map(Into::into).collect(),
}
}
/// Overrides the default HTTPS scheme for this grant.
pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
self.scheme = scheme.into().to_ascii_lowercase();
self
}
/// Restricts this grant to a non-default effective port.
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
fn allows(
&self,
scheme: &str,
host: &str,
port: Option<u16>,
method: &str,
path: &str,
) -> bool {
self.scheme.eq_ignore_ascii_case(scheme)
&& self.host.eq_ignore_ascii_case(host)
&& effective_port(&self.scheme, self.port) == effective_port(scheme, port)
&& self
.methods
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(method))
&& (self.paths.iter().any(|allowed| allowed == path)
|| self.path_prefixes.iter().any(|allowed| {
path == allowed
|| path
.strip_prefix(allowed)
.is_some_and(|suffix| allowed.ends_with('/') || suffix.starts_with('/'))
}))
}
}
fn effective_port(scheme: &str, port: Option<u16>) -> Option<u16> {
port.or_else(|| match scheme {
"http" => Some(80),
"https" => Some(443),
_ => None,
})
}
/// Which external commands a script may run.
#[derive(Clone, Debug, Default, PartialEq)]
pub enum ExecuteGrant {
/// `os.execute` is unavailable.
#[default]
Denied,
/// Only these command names may run.
Allowed(Vec<String>),
/// Any command may run. Shown to the user at the highest severity.
Unrestricted,
}
fn add_roots(granted: &mut Vec<PathBuf>, roots: impl IntoIterator<Item = PathBuf>) {
for root in roots {
if !granted.contains(&root) {
granted.push(root);
}
}
}
impl Capabilities {
/// One line per granted capability, for a host that shows what an
/// application asks for before running it. Empty when nothing is granted.
pub(crate) fn summary(&self) -> Vec<String> {
let mut lines = Vec::new();
let paths = |roots: &[PathBuf]| {
roots
.iter()
.map(|root| root.display().to_string())
.collect::<Vec<_>>()
.join(", ")
};
if !self.read_roots.is_empty() {
lines.push(format!("fs read: {}", paths(&self.read_roots)));
}
if !self.write_roots.is_empty() {
lines.push(format!("fs write: {}", paths(&self.write_roots)));
}
if !self.network_hosts.is_empty() {
lines.push(format!("network: {}", self.network_hosts.join(", ")));
}
for request in &self.http_requests {
lines.push(format!(
"http: {} {}://{}{}",
request.methods.join("|"),
request.scheme,
request.host,
request
.port
.map(|port| format!(":{port}"))
.unwrap_or_default()
));
}
match &self.execute {
ExecuteGrant::Denied => {}
ExecuteGrant::Allowed(commands) => {
lines.push(format!("process: {}", commands.join(", ")));
}
ExecuteGrant::Unrestricted => lines.push("process: any command".to_owned()),
}
if self.storage {
lines.push("storage".to_owned());
}
match (self.clipboard_read, self.clipboard_write) {
(true, true) => lines.push("clipboard: read, write".to_owned()),
(true, false) => lines.push("clipboard: read".to_owned()),
(false, true) => lines.push("clipboard: write".to_owned()),
(false, false) => {}
}
if !self.env_variables.is_empty() {
lines.push(format!("env: {}", self.env_variables.join(", ")));
}
lines
}
pub fn new() -> Self {
Self::default()
}
pub fn read_roots(mut self, roots: impl IntoIterator<Item = PathBuf>) -> Self {
self.read_roots = roots.into_iter().collect();
self
}
pub fn write_roots(mut self, roots: impl IntoIterator<Item = PathBuf>) -> Self {
self.write_roots = roots.into_iter().collect();
self
}
/// Adds read roots to the ones already granted, where [`Self::read_roots`]
/// replaces them. A host that anchors an application to its own directories
/// widens the manifest's grant this way rather than overwriting it.
pub(crate) fn add_read_roots(mut self, roots: impl IntoIterator<Item = PathBuf>) -> Self {
add_roots(&mut self.read_roots, roots);
self
}
/// Adds write roots to the ones already granted. See [`Self::add_read_roots`].
pub(crate) fn add_write_roots(mut self, roots: impl IntoIterator<Item = PathBuf>) -> Self {
add_roots(&mut self.write_roots, roots);
self
}
pub fn execute(mut self, grant: ExecuteGrant) -> Self {
self.execute = grant;
self
}
pub fn network_hosts(mut self, hosts: impl IntoIterator<Item = String>) -> Self {
self.network_hosts = hosts
.into_iter()
.map(|host| host.to_ascii_lowercase())
.collect();
self
}
pub fn http_requests(mut self, requests: impl IntoIterator<Item = HttpRequestGrant>) -> Self {
self.http_requests = requests.into_iter().collect();
self
}
pub fn storage(mut self, allowed: bool) -> Self {
self.storage = allowed;
self
}
pub fn clipboard_read(mut self, allowed: bool) -> Self {
self.clipboard_read = allowed;
self
}
pub fn clipboard_write(mut self, allowed: bool) -> Self {
self.clipboard_write = allowed;
self
}
/// Whether script may ask its host to exit.
pub fn exit(mut self, allowed: bool) -> Self {
self.exit = allowed;
self
}
/// Environment variables `process.env` may show beyond
/// [`default_env_variables`]. Everything else in the host's environment
/// stays out of reach: it routinely holds tokens and keys that were handed
/// to the host, not to every script it runs.
pub fn env_variables(mut self, names: impl IntoIterator<Item = String>) -> Self {
self.env_variables = names.into_iter().collect();
self
}
/// Whether `process.env` may show `name`: one of the defaults or a
/// granted name, compared without regard to case.
///
/// Case is ignored because the same variable is spelled both ways in the
/// wild — most Unix programs read `http_proxy`, others `HTTP_PROXY` — and
/// Windows does not distinguish them at all. One spelling in the list or
/// the manifest covers every one the environment uses.
pub(crate) fn may_show_env_variable(&self, name: &str) -> bool {
default_env_variables()
.iter()
.copied()
.chain(self.env_variables.iter().map(String::as_str))
.any(|visible| visible.eq_ignore_ascii_case(name))
}
#[cfg(test)]
pub(crate) fn granted_env_variables(&self) -> &[String] {
&self.env_variables
}
pub fn may_exit(&self) -> bool {
self.exit
}
pub fn has_storage(&self) -> bool {
self.storage
}
pub fn is_clipboard_readable(&self) -> bool {
self.clipboard_read
}
pub fn is_clipboard_writable(&self) -> bool {
self.clipboard_write
}
pub fn execute_grant(&self) -> &ExecuteGrant {
&self.execute
}
/// Whether any filesystem write is permitted at all. `os.remove` and
/// friends need this before their path is even resolved.
pub fn has_write_access(&self) -> bool {
!self.write_roots.is_empty()
}
pub fn has_read_access(&self) -> bool {
!self.read_roots.is_empty()
}
pub fn may_run(&self, command: &str) -> bool {
match &self.execute {
ExecuteGrant::Denied => false,
ExecuteGrant::Unrestricted => true,
ExecuteGrant::Allowed(names) => names.iter().any(|name| name == command),
}
}
pub fn may_reach(&self, host: &str) -> bool {
self.network_hosts
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(host))
}
/// Whether an HTTP request is permitted by either a legacy unrestricted
/// host grant or a scoped HTTP grant.
pub fn may_request(
&self,
scheme: &str,
host: &str,
port: Option<u16>,
method: &str,
path: &str,
) -> bool {
self.may_reach(host)
|| self
.http_requests
.iter()
.any(|grant| grant.allows(scheme, host, port, method, path))
}
/// Opens the granted directory a path belongs to, and the path within it.
///
/// The caller gets a **capability**, not a string: a directory handle that
/// refuses to resolve outside itself, plus the path to use against it. Every
/// operation then goes through that handle, so no name is resolved twice and
/// there is no window between deciding a path is allowed and using it.
///
/// That is the whole point, and the reason this replaced a resolver that
/// returned a `PathBuf`. The old one compared strings; the one before *that*
/// compared strings and then canonicalized, which caught a link that was
/// already there and not one swapped in afterwards. `std` cannot express the
/// difference — `cap-std` can, because a `Dir` carries the authority instead
/// of describing it.
///
/// The same resolver serves the `fs` modules, the capability-gated `os.*`
/// functions and the asset source, so there is no second path policy to keep
/// in sync.
pub(crate) fn open(&self, path: &Path, access: Access) -> Result<Grant, CapabilityError> {
self.open_resolving(path, access, Final::Follow)
}
/// [`Self::open`] for an operation on a path's final component itself —
/// `lstat`, `readlink`, `unlink`, `rmdir`, either end of `rename` — which
/// must not follow it when it is a symlink.
///
/// The directories leading up to it are still resolved, so a path reached
/// through `/var` on macOS agrees with its root. Only the last component is
/// kept as written, which is what the relative form of the same path already
/// gets from the lexical branch.
pub(crate) fn open_link(&self, path: &Path, access: Access) -> Result<Grant, CapabilityError> {
self.open_resolving(path, access, Final::Keep)
}
fn open_resolving(
&self,
path: &Path,
access: Access,
last: Final,
) -> Result<Grant, CapabilityError> {
let roots = match access {
Access::Read => &self.read_roots,
Access::Write => &self.write_roots,
};
if roots.is_empty() {
return Err(CapabilityError::NotGranted(access));
}
for root in roots {
// Resolved the same way a path is, because a granted directory need
// not exist yet and a root reached through a link — `/var` on macOS
// — must agree with a path that was not.
let Some(resolved) = resolved_root(root) else {
continue;
};
// An absolute path is allowed if it is already inside a root, so it
// becomes relative before `Dir` sees it; `Dir` refuses an absolute
// path outright, and rightly.
let relative = if path.is_absolute() {
// Resolved the same way the root was, or the two disagree about
// a directory neither of them left: on macOS a temporary path
// reaches `/private/var` through `/var`.
//
// Resolving runs `canonicalize`, which follows a symlink in the
// last component too. That is right for reading through the link
// — a stored target that is absolute is one `Dir` will not follow
// — and wrong for an operation on the link, which would land on
// its target instead.
let resolved_path = match (last, path.parent(), path.file_name()) {
(Final::Keep, Some(parent), Some(name)) => {
resolved_root(parent).map(|parent| parent.join(name))
}
_ => resolved_root(path),
};
let normalized = resolved_path.unwrap_or_else(|| normalize(path));
match normalized.strip_prefix(&resolved) {
Ok(relative) => relative.to_path_buf(),
// The root itself, named by a path whose last component is a
// link to it — `~/.claude/skills` pointing into a dotfiles
// checkout. Kept as written it lies outside the resolved
// root, but there is no link *inside* the grant to act on:
// the operation is on the root, as `open` would have it.
Err(_)
if last == Final::Keep
&& resolved_root(path).as_deref() == Some(resolved.as_path()) =>
{
PathBuf::new()
}
Err(_) => continue,
}
} else {
// Lexical first, so the ordinary `../..` is refused with a
// sentence about the grant rather than an `errno` from below.
let normalized = normalize(&resolved.join(path));
match normalized.strip_prefix(&resolved) {
Ok(relative) => relative.to_path_buf(),
Err(_) => continue,
}
};
// A granted directory need not exist yet — an application's data
// directory on first run is the ordinary case — and a write grant is
// the host saying that directory is the application's. Making it is
// honouring the grant, not exceeding it. A *read* grant on a missing
// directory has nothing to offer and stays a denial.
if access == Access::Write && !resolved.exists() {
let _ = std::fs::create_dir_all(&resolved);
}
let Ok(dir) = Dir::open_ambient_dir(&resolved, ambient_authority()) else {
continue;
};
// `.` and the root itself normalize to nothing, which is a name no
// directory has. The root is a legitimate target — listing it is how
// a script finds out what it was given — so it gets the name that
// means it.
let relative = if relative.as_os_str().is_empty() {
PathBuf::from(".")
} else {
relative
};
return Ok(Grant {
dir,
root: resolved,
relative,
});
}
Err(CapabilityError::OutsideRoots {
path: path.to_path_buf(),
access,
})
}
}
/// Whether [`Capabilities::open`] resolves a symlink in a path's last component.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Final {
/// Read or write through the link, to what it names.
Follow,
/// Act on the link itself.
Keep,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Access {
Read,
Write,
}
impl Access {
fn as_str(self) -> &'static str {
match self {
Access::Read => "read",
Access::Write => "write",
}
}
}
#[derive(Debug, PartialEq)]
pub enum CapabilityError {
NotGranted(Access),
OutsideRoots { path: PathBuf, access: Access },
ExecuteDenied(String),
StorageDenied,
}
impl std::fmt::Display for CapabilityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CapabilityError::NotGranted(access) => write!(
f,
"filesystem {} is not granted; declare capabilities.fs.{} in the manifest",
access.as_str(),
access.as_str()
),
CapabilityError::OutsideRoots { path, access } => write!(
f,
"`{}` is outside every granted {} root",
path.display(),
access.as_str()
),
CapabilityError::ExecuteDenied(command) => write!(
f,
"running `{command}` is not granted; add it to capabilities.fs.execute in the manifest"
),
CapabilityError::StorageDenied => {
f.write_str("storage is not granted; set capabilities.storage to true")
}
}
}
}
impl std::error::Error for CapabilityError {}
// ---------------------------------------------------------------------------
// The installed grant
// ---------------------------------------------------------------------------
/// Grants the application that is about to be loaded.
///
/// The grant lives on the default [`Policy`](crate::policy), which is what a
/// call inherits when nothing narrower is in force. A plugin host that runs
/// several applications in one runtime builds a policy for each instead of
/// calling this, so that two plugins can hold two different grants at the same
/// time.
///
/// This is deliberately not a knob on the engine. It used to be one, and an
/// engine constructed any other way — in a test, in a second embedding — would
/// then answer the security question differently from the one the host had
/// configured. A grant is a decision about the *application*, not about the
/// interpreter, and there is now nowhere for an engine to answer it
/// differently.
///
/// The default is [`Capabilities::default`], which allows nothing.
pub(crate) fn install(capabilities: Capabilities) {
crate::policy::update_default(|policy| policy.with_capabilities(capabilities));
}
/// A granted directory, and the path to use against it.
///
/// Holding the directory open is what makes this a capability rather than a
/// claim: the handle cannot be made to name something outside itself, so an
/// operation on it cannot leave the grant however the filesystem changes
/// underneath.
#[derive(Debug)]
pub(crate) struct Grant {
dir: Dir,
/// The root `dir` was opened at, resolved.
root: PathBuf,
relative: PathBuf,
}
impl Grant {
/// The directory every operation goes through.
// Only the symlink tests read the handle apart from the path, and those
// are Unix-only — so on Windows this has no caller and `-D warnings` says
// so.
#[cfg(all(test, unix))]
pub(crate) fn dir(&self) -> &Dir {
&self.dir
}
/// The path within it. Never absolute, never `..`.
#[cfg(test)]
pub(crate) fn path(&self) -> &Path {
&self.relative
}
/// What to call the target in a message. Not a path anything opens — the
/// handle is — but the words a script needs to recognize what it asked for.
pub fn describe(&self) -> String {
self.relative.display().to_string()
}
/// The target as a path another program can open, once the granted
/// handle has confirmed it exists.
///
/// The check goes through the handle rather than the path, so a link that
/// leads out of the grant is refused here as a read through it would be.
/// What comes back is only for handing to the system — `open_with_system`
/// takes a path, not a handle — and nothing in this process opens it.
pub(crate) fn system_path(&self) -> std::io::Result<PathBuf> {
self.dir.metadata(&self.relative)?;
Ok(if self.relative == Path::new(".") {
self.root.clone()
} else {
self.root.join(&self.relative)
})
}
/// Splits into the two halves, for work that moves to another thread.
pub fn into_parts(self) -> (Dir, PathBuf) {
(self.dir, self.relative)
}
}
/// Resolves a root by asking the filesystem, not by trusting the string.
///
/// A granted directory need not exist yet, so this cannot simply canonicalize:
/// it resolves as far as the filesystem goes and keeps the rest verbatim. The
/// deepest existing ancestor is canonicalized, following every link on the way.
/// Whatever lies below it does not exist, so none of those components can yet
/// be a link. A dangling symlink at the boundary is refused rather than guessed
/// at, because its destination cannot be proven.
fn resolved_root(root: &Path) -> Option<PathBuf> {
let (resolved, tail) = deepest_resolvable(root)?;
let mut out = resolved;
out.extend(tail.iter());
Some(out)
}
/// Splits a path into its deepest ancestor that resolves, canonicalized, and
/// the components below it in order.
///
/// `canonicalize` fails for a path that does not exist and for one that dangles,
/// which is exactly the set that has to be walked past — and it follows every
/// link on the part that does resolve, which is the point.
fn deepest_resolvable(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut current = path.to_path_buf();
let resolved = loop {
if let Ok(real) = current.canonicalize() {
break real;
}
let name = current.file_name()?.to_os_string();
if !current.pop() {
return None;
}
tail.push(name);
};
tail.reverse();
Some((resolved, tail))
}
/// Lexical normalization, which is the cheap half of the check. [`contain`] is
/// the half that holds when a component is a symlink.
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A real directory, because a grant is now an open handle rather than a
/// string: there is nothing to hold onto a path that does not exist.
struct Root(PathBuf);
impl Root {
fn new(name: &str) -> Self {
let path = std::env::temp_dir()
.join(format!("gpui-shell-grant-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("a root");
Self(path.canonicalize().expect("a canonical root"))
}
fn granted(&self) -> Capabilities {
Capabilities::new()
.read_roots([self.0.clone()])
.write_roots([self.0.clone()])
}
}
impl Drop for Root {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn network_host_grants_are_dns_case_insensitive() {
let capabilities = Capabilities::new().network_hosts(["API.Example.COM".to_owned()]);
assert!(capabilities.may_reach("api.example.com"));
assert!(capabilities.may_reach("Api.Example.Com"));
}
#[test]
fn traversal_out_of_a_root_is_rejected() {
let root = Root::new("traversal");
let error = root
.granted()
.open(Path::new("../../etc/passwd"), Access::Read)
.unwrap_err();
assert!(matches!(error, CapabilityError::OutsideRoots { .. }));
}
#[test]
fn a_relative_path_resolves_inside_its_root() {
let root = Root::new("relative");
let grant = root
.granted()
.open(Path::new("items.json"), Access::Write)
.expect("a path inside the root");
assert_eq!(grant.path(), Path::new("items.json"));
}
#[test]
fn an_absolute_path_inside_a_root_becomes_relative_to_it() {
let root = Root::new("absolute");
let grant = root
.granted()
.open(&root.0.join("nested/items.json"), Access::Write)
.expect("a path inside the root");
assert_eq!(grant.path(), Path::new("nested/items.json"));
}
/// An application's data directory does not exist before its first run, and
/// a write grant is the host saying that directory is the application's.
#[test]
fn a_write_grant_materializes_a_root_that_is_not_there_yet() {
let root = Root::new("missing");
let inner = root.0.join("not-yet");
let capabilities = Capabilities::new().write_roots([inner.clone()]);
let grant = capabilities
.open(Path::new("settings.json"), Access::Write)
.expect("a write grant should make its own root");
assert_eq!(grant.path(), Path::new("settings.json"));
assert!(inner.is_dir());
// A read grant has nothing to offer a directory that is not there.
let reading = Capabilities::new().read_roots([root.0.join("still-not-there")]);
assert!(reading.open(Path::new("x"), Access::Read).is_err());
}
#[test]
fn nothing_resolves_without_a_grant() {
let error = Capabilities::new()
.open(Path::new("items.json"), Access::Read)
.unwrap_err();
assert_eq!(error, CapabilityError::NotGranted(Access::Read));
}
#[test]
fn execute_is_denied_by_default_and_allowlisted_when_granted() {
assert!(!Capabilities::new().may_run("git"));
let capabilities = Capabilities::new().execute(ExecuteGrant::Allowed(vec!["git".into()]));
assert!(capabilities.may_run("git"));
assert!(!capabilities.may_run("curl"));
}
}
/// Unix-only, and the tests say why rather than the module: creating a symlink
/// on Windows needs Developer Mode or an elevated process, which CI has neither
/// of. A test that quietly does nothing when the link cannot be made would pass
/// while testing nothing, which is worse than not running.
#[cfg(all(test, unix))]
mod symlink_tests {
use super::*;
/// A grant is a promise about a *directory*, and a symlink is the oldest
/// way to make a path that is lexically inside one point somewhere else.
/// These use real files, because the whole question is what the filesystem
/// does rather than what the string looks like.
struct Sandbox {
root: PathBuf,
}
impl Sandbox {
fn new(name: &str) -> Self {
let root = std::env::temp_dir()
.join(format!("gpui-shell-escape-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("inside")).expect("a sandbox");
std::fs::write(root.join("inside/ours.txt"), "ours").expect("a file");
Self {
root: root.canonicalize().expect("a canonical sandbox"),
}
}
/// Somewhere outside the grant, standing in for `/etc`.
fn outside(&self) -> PathBuf {
let outside = self.root.join("outside");
std::fs::create_dir_all(&outside).expect("somewhere outside");
std::fs::write(outside.join("secret.txt"), "secret").expect("a secret");
outside
}
fn granted(&self) -> Capabilities {
Capabilities::new()
.read_roots([self.root.join("inside")])
.write_roots([self.root.join("inside")])
}
fn link(&self, name: &str, target: &Path) {
std::os::unix::fs::symlink(target, self.root.join("inside").join(name))
.expect("a symlink");
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[test]
fn a_symlink_out_of_a_root_cannot_be_read_through() {
let sandbox = Sandbox::new("read");
let outside = sandbox.outside();
sandbox.link("escape", &outside);
let capabilities = sandbox.granted();
// The refusal is at the operation, not at the resolution: the grant
// hands back a directory that cannot be made to name anything outside
// itself, and that is what a read runs against. Testing the resolution
// would be testing the old design.
let grant = capabilities
.open(Path::new("escape/secret.txt"), Access::Read)
.expect("lexically inside the root");
assert!(
grant.dir().read_to_string(grant.path()).is_err(),
"reading through a symlink left the granted root"
);
// And an ordinary path still works, which is the point of the grant.
let ours = capabilities
.open(Path::new("ours.txt"), Access::Read)
.expect("a path inside the root");
assert_eq!(
ours.dir()
.read_to_string(ours.path())
.expect("our own file"),
"ours"
);
}
#[test]
fn a_symlink_out_of_a_root_cannot_be_written_through() {
let sandbox = Sandbox::new("write");
let outside = sandbox.outside();
sandbox.link("escape", &outside);
let capabilities = sandbox.granted();
let planted = capabilities
.open(Path::new("escape/planted.txt"), Access::Write)
.expect("lexically inside the root");
assert!(
planted.dir().write(planted.path(), b"x").is_err(),
"writing through a symlink left the granted root"
);
assert!(!outside.join("planted.txt").exists());
let made = capabilities
.open(Path::new("escape/planted"), Access::Write)
.expect("lexically inside the root");
assert!(made.dir().create_dir_all(made.path()).is_err());
assert!(!outside.join("planted").exists());
// A new file directly in the root is still allowed.
let ours = capabilities
.open(Path::new("new.txt"), Access::Write)
.expect("a path inside the root");
ours.dir()
.write(ours.path(), b"ours")
.expect("our own file");
}
/// The case that check-then-use could never cover: the link appears *after*
/// the path was judged. A handle cannot be talked out of its directory, so
/// the timing stops mattering.
#[test]
fn a_symlink_planted_after_the_check_is_still_refused() {
let sandbox = Sandbox::new("toctou");
let outside = sandbox.outside();
let capabilities = sandbox.granted();
// Judged while `escape` is an ordinary missing name.
let grant = capabilities
.open(Path::new("escape/secret.txt"), Access::Read)
.expect("lexically inside the root");
// Now somebody else makes it a link. Under the old resolver this is the
// window: the check had already passed and the syscall would resolve the
// name a second time.
sandbox.link("escape", &outside);
assert!(
grant.dir().read_to_string(grant.path()).is_err(),
"a link planted after the check was followed out of the root"
);
}
#[test]
fn a_dangling_symlink_is_not_followed() {
let sandbox = Sandbox::new("dangling");
let outside = sandbox.outside();
sandbox.link("dangling", &outside.join("not-there.txt"));
let capabilities = sandbox.granted();
let grant = capabilities
.open(Path::new("dangling"), Access::Write)
.expect("lexically inside the root");
assert!(
grant.dir().write(grant.path(), b"x").is_err(),
"a write followed a dangling symlink out of the root"
);
assert!(!outside.join("not-there.txt").exists());
}
#[test]
fn open_link_names_the_link_itself_given_an_absolute_path() {
let sandbox = Sandbox::new("link-itself");
sandbox.link("alias", Path::new("ours.txt"));
let capabilities = sandbox.granted();
let alias = sandbox.root.join("inside/alias");
let link = capabilities
.open_link(&alias, Access::Read)
.expect("a path inside the root");
assert_eq!(link.path(), Path::new("alias"));
assert!(
link.dir()
.symlink_metadata(link.path())
.expect("the link")
.is_symlink()
);
// Reading through it still resolves to what it names.
let through = capabilities
.open(&alias, Access::Read)
.expect("a path inside the root");
assert_eq!(through.path(), Path::new("ours.txt"));
}
#[test]
fn a_root_that_is_itself_a_link_can_be_named_by_its_own_path() {
let sandbox = Sandbox::new("linked-root");
let alias = sandbox.root.join("alias");
std::os::unix::fs::symlink(sandbox.root.join("inside"), &alias).expect("a linked root");
let capabilities = Capabilities::new()
.read_roots([alias.clone()])
.write_roots([alias.clone()]);
let root = capabilities
.open_link(&alias, Access::Read)
.expect("the root, named through the link it was granted as");
assert_eq!(root.path(), Path::new("."));
assert!(
root.dir()
.symlink_metadata(root.path())
.expect("the root")
.is_dir()
);
let inside = capabilities
.open_link(&alias.join("ours.txt"), Access::Write)
.expect("a path inside the linked root");
assert_eq!(inside.path(), Path::new("ours.txt"));
}
#[test]
fn open_link_keeps_a_dangling_link_and_removes_only_the_link() {
let sandbox = Sandbox::new("link-dangling");
let outside = sandbox.outside();
sandbox.link("dangling", &outside.join("not-there.txt"));
let capabilities = sandbox.granted();
let link = capabilities
.open_link(&sandbox.root.join("inside/dangling"), Access::Write)
.expect("a path inside the root");
assert_eq!(link.path(), Path::new("dangling"));
link.dir()
.remove_file(link.path())
.expect("the link itself is removed");
assert!(
std::fs::symlink_metadata(sandbox.root.join("inside/dangling")).is_err(),
"the link is gone"
);
assert!(!outside.join("not-there.txt").exists());
}
#[test]
fn open_link_names_a_link_out_of_the_root_that_open_refuses_to_follow() {
let sandbox = Sandbox::new("link-outside");
let outside = sandbox.outside();
sandbox.link("secret", &outside.join("secret.txt"));
let capabilities = sandbox.granted();
let secret = sandbox.root.join("inside/secret");
let link = capabilities
.open_link(&secret, Access::Write)
.expect("the link is inside the root");
assert_eq!(link.path(), Path::new("secret"));
assert!(
link.dir()
.symlink_metadata(link.path())
.expect("the link")
.is_symlink()
);
assert!(
capabilities.open(&secret, Access::Read).is_err(),
"following a link out of the root was accepted"
);
assert_eq!(
std::fs::read_to_string(outside.join("secret.txt")).expect("the target"),
"secret"
);
}
#[test]
fn open_link_refuses_a_parent_that_leads_out_of_the_root() {
let sandbox = Sandbox::new("link-parent");
let outside = sandbox.outside();
sandbox.link("escape", &outside);
let capabilities = sandbox.granted();
assert!(
capabilities
.open_link(
&sandbox.root.join("inside/escape/secret.txt"),
Access::Write
)
.is_err(),
"a parent that resolves outside the root was accepted"
);
assert!(outside.join("secret.txt").exists());
}
#[test]
fn the_default_environment_holds_only_plain_unique_names_with_no_secrets() {
let names = default_env_variables();
assert!(names.contains(&"HOME"), "the list must parse: {names:?}");
for (index, name) in names.iter().enumerate() {
assert!(
name.chars().all(|c| {
c.is_ascii_uppercase() || c.is_ascii_digit() || matches!(c, '_' | '(' | ')')
}),
"`{name}` in default-env is not an upper-case variable name; \
one spelling covers every case"
);
assert!(
!names[..index].contains(name),
"`{name}` is listed twice in default-env"
);
for secret in [
"TOKEN",
"SECRET",
"PASSWORD",
"PASSWD",
"CREDENTIAL",
"KEY",
"AUTH",
"COOKIE",
"SESSION_ID",
"BUS_ADDRESS",
] {
assert!(
!name.contains(secret),
"`{name}` looks like it can carry a secret or another program's \
authority; ask for it in the manifest instead"
);
}
}
}
#[test]
fn a_variable_is_shown_under_any_spelling_of_a_listed_name() {
let capabilities = Capabilities::new().env_variables(["my_app_mode".to_owned()]);
assert!(capabilities.may_show_env_variable("HTTP_PROXY"));
assert!(capabilities.may_show_env_variable("http_proxy"));
assert!(capabilities.may_show_env_variable("MY_APP_MODE"));
assert!(!capabilities.may_show_env_variable("GITHUB_TOKEN"));
}
}