Skip to content
Open
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ Running `rv sync` will synchronize the library, lock file, and configuration fil

Additional example projects with more configurations can be found in the [example_projects](example_projects) directory of this repository.

## System library sandboxing

- base R always appends `.Library` to `.libPaths()`
- packages installed in the system library can leak into projects
- `rv` now creates `rv/sandbox/<rver>/<arch>` containing only base+recommended and points `.Library` there during activation
- opt-out: `RV_SANDBOX=0`

## Installation

See the [documentation site](https://a2-ai.github.io/rv-docs/) for installation instructions.
Expand Down
32 changes: 29 additions & 3 deletions src/activate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ const RVR_FILE_NAME: &str = "rv/scripts/rvr.R";
pub fn activate(dir: impl AsRef<Path>, no_r_environment: bool) -> Result<(), ActivateError> {
let dir = dir.as_ref();

let config_path = dir.join("rproject.toml");

// Check if file exists first to provide a better error
if !config_path.exists() {
return Err(ActivateError {
source: ActivateErrorKind::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Config file not found at {}", config_path.display())
)),
});
}

let config = crate::Config::from_file(&config_path).map_err(|e| ActivateError {
source: ActivateErrorKind::Io(std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string())),
})?;

// ensure the directory is a directory and that it exists. If not, activation cannot occur
if !dir.is_dir() {
return Err(ActivateError {
Expand All @@ -24,7 +40,8 @@ pub fn activate(dir: impl AsRef<Path>, no_r_environment: bool) -> Result<(), Act
let is_home = is_home_dir(&dir.canonicalize()?);
let (activate_source_path, rvr_source_path) = scripts_as_paths(is_home);

write_activate_file(dir, is_home)?;
write_activate_file(dir, is_home, config.project.sandbox)?;

add_rprofile_source_call(dir, activate_source_path)?;
write_rvr_file(dir)?;
if !no_r_environment {
Expand Down Expand Up @@ -95,7 +112,7 @@ fn scripts_as_paths(is_home: bool) -> (PathBuf, PathBuf) {
}
}

fn write_activate_file(dir: impl AsRef<Path>, is_home: bool) -> Result<(), ActivateError> {
fn write_activate_file(dir: impl AsRef<Path>, is_home: bool, sandbox_enabled: Option<bool>) -> Result<(), ActivateError> {
let template = ACTIVATE_FILE_TEMPLATE.to_string();
let global_wd_content = if is_home {
r#"
Expand All @@ -108,9 +125,18 @@ fn write_activate_file(dir: impl AsRef<Path>, is_home: bool) -> Result<(), Activ
""
};
let rv_command = if cfg!(windows) { "rv.exe" } else { "rv" };

// Change the mapping to handle Option<bool>
let sandbox_val = match sandbox_enabled {
Some(true) => "TRUE", // Explicitly enabled in TOML
Some(false) => "FALSE", // Explicitly disabled in TOML
None => "NULL", // Missing from TOML (fallback to ENV)
};

let content = template
.replace("%rv command%", rv_command)
.replace("%global wd content%", global_wd_content);
.replace("%global wd content%", global_wd_content)
.replace("%sandbox enabled%", sandbox_val);
// read the file and determine if the content within the activate file matches
// File may exist but needs upgrade if file changes with rv upgrade
let activate_file_name = dir.as_ref().join(ACTIVATE_FILE_NAME);
Expand Down
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,11 @@ pub(crate) struct Project {
)]
r_version: Version,
#[serde(default)]
<<<<<<< HEAD
use_devel: Option<bool>,
=======
pub sandbox: Option<bool>, // None = missing from file
>>>>>>> e451cda (add RV_SANDBOX_ENABLE and in the config file .)
#[serde(default)]
description: String,
license: Option<String>,
Expand Down Expand Up @@ -509,6 +513,10 @@ impl Config {
pub fn configure_args(&self) -> &HashMap<String, Vec<ConfigureArgsRule>> {
&self.project.configure_args
}

pub fn sandbox(&self) -> Option<bool> {
self.project.sandbox
}
}

impl FromStr for Config {
Expand Down
120 changes: 120 additions & 0 deletions src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,105 @@ pub(crate) const ACTIVATE_FILE_TEMPLATE: &str = r#"local({%global wd content%
sub(paste0("^", prefix, ":\\s*"), "", line)
}

# -------------------------------------------------------------------------
# System library sandboxing (renv-style)
#
# base::.libPaths() always appends `.Library` at the end, meaning packages
# installed into the system library can leak into a project.
#
# This creates a per-project sandbox containing ONLY base + recommended
# packages and repoints `.Library` to that sandbox, so `.libPaths()` retains
# base R semantics without leaking extra packages.
#
# Opt-out:
# Sys.setenv(RV_SANDBOX = "0")
# -------------------------------------------------------------------------

# Injected by Rust from rproject.toml, config_val is TRUE|FALSE|NULL
config_val <- %sandbox enabled%
print(paste0("config_val: ", config_val, "\n"))

# Environment variable
env_val <- Sys.getenv("RV_SANDBOX_ENABLE", unset = "")

# PRECEDENCE LOGIC:
# 1. If config is TRUE/FALSE, use it.
# 2. If config is NULL, check if ENV is "1".
should_sandbox <- if (!is.null(config_val)) {
config_val
} else {
env_val %in% c("1", "true", "TRUE")
}

rv_sandbox_system_library <- function(rv_lib, execute = FALSE) {
if (!execute) return(invisible(FALSE))

syslib <- .Library
if (!is.character(syslib) || !nzchar(syslib) || !dir.exists(syslib))
return(invisible(FALSE))

# Derive project rv dir from .../rv/library/<rver>/<arch>
rv_dir <- normalizePath(file.path(rv_lib, "..", "..", ".."), mustWork = FALSE)
r_ver <- basename(dirname(rv_lib)) # e.g. "4.4"
arch <- basename(rv_lib) # e.g. "arm64"

sandbox <- file.path(rv_dir, "sandbox", r_ver, arch)
if (!dir.exists(sandbox))
dir.create(sandbox, recursive = TRUE, showWarnings = FALSE)

# Collect base + recommended packages from system library WITHOUT utils
pkg_dirs <- list.dirs(syslib, full.names = FALSE, recursive = FALSE)

is_base_or_recommended <- function(pkg) {
dcf <- file.path(syslib, pkg, "DESCRIPTION")
if (!file.exists(dcf)) return(FALSE)
# Use base::readLines; avoid dependency on loaded utils if possible
x <- tryCatch(base::readLines(dcf, warn = FALSE), error = function(e) character())
pr <- x[base::grepl("^Priority\\s*:", x)]
if (!length(pr)) return(FALSE)
# Manual trim for extreme safety
val <- base::gsub("^Priority\\s*:\\s*|\\s*$", "", pr[1])
val %in% c("base", "recommended")
}

pkgs <- pkg_dirs[vapply(pkg_dirs, is_base_or_recommended, logical(1))]

# Populate sandbox
for (pkg in pkgs) {
from <- file.path(syslib, pkg)
to <- file.path(sandbox, pkg)

if (!dir.exists(from) || file.exists(to))
next

ok_link <- tryCatch(file.symlink(from, to), error = function(e) FALSE)

if (!isTRUE(ok_link)) {
# Copy whole package dir into sandbox root
tryCatch(
file.copy(from, sandbox, recursive = TRUE, copy.mode = TRUE),
error = function(e) NULL
)
}
}

# Safety: only repoint if core packages are present
required <- c("utils", "stats", "graphics", "grDevices", "datasets", "methods", "compiler")
if (!all(dir.exists(file.path(sandbox, required)))) {
return(invisible(FALSE))
}

# Repoint locked `.Library` binding to sandbox
ok_set <- tryCatch({
if (bindingIsLocked(".Library", baseenv()))
unlockBinding(".Library", baseenv())
assign(".Library", sandbox, envir = baseenv())
lockBinding(".Library", baseenv())
TRUE
}, error = function(e) FALSE)

invisible(isTRUE(ok_set))
}
# Set repos option
repo_str <- get_val("repositories")

Expand Down Expand Up @@ -133,6 +232,12 @@ rv library will not be activated until the issue is resolved. Entering safe mode
dir.create(rv_lib, recursive = TRUE)
}

# Track sandbox existence for user facing message
sandbox_ok <- FALSE
if (r_match && should_sandbox) {
sandbox_ok <- isTRUE(rv_sandbox_system_library(rv_lib, execute = should_sandbox))
}

.libPaths(rv_lib, include.site = FALSE)
Sys.setenv("R_LIBS_USER" = rv_lib)
Sys.setenv("R_LIBS_SITE" = rv_lib)
Expand All @@ -150,6 +255,21 @@ rv library will not be activated until the issue is resolved. Entering safe mode
),
"\n"
)
message(
if (sandbox_ok) {
paste0(
"rv system library sandbox active (base+recommended only):\n ",
.Library,
"\nopt-out: set RV_SANDBOX_ENABLE=0\n"
)
} else {
message("rv system library sandbox disabled.\n",
"To Enable: set `sandbox = true` in `rproject.toml` `[project]` section\n",
" or run `RV_SANDBOX_ENABLE=1`.\n",
"Always run `rv init` after the changes for changes to take place.\n")
}
)

message(
if (r_match) {
"rv libpaths active!\nlibrary paths: \n"
Expand Down