use std::{collections::HashMap, process::Stdio}; use itertools::Itertools; use tokio::{ fs::{self, File}, io::{AsyncReadExt, AsyncWriteExt}, process::Command, }; use uuid::Uuid; use windmill_common::{ client::AuthedClient, error::Error, utils::calculate_hash, worker::{write_file, Connection, RlangAnnotations}, }; use windmill_parser::Arg; use windmill_parser_r::{parse_r_requirements, parse_r_signature}; use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ check_no_network_jailed, common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_result, resolve_nsjail_tmp_mount_block, script_envars, script_overrides, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::{self}, is_sandboxing_enabled, nsjail_clone_newnet, universal_pkg_installer::{ par_install_language_dependencies_seq, DependencyGraph, InstallDeps, RequiredDependency, }, DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, R_CACHE_DIR, TRACING_PROXY_CA_CERT_PATH, WIN_ENVS, }; use windmill_common::scripts::ScriptLang; lazy_static::lazy_static! { static ref RSCRIPT_PATH: String = std::env::var("RSCRIPT_PATH").unwrap_or_else(|_| "/usr/bin/Rscript".to_string()); static ref R_CONCURRENT_DOWNLOADS: usize = std::env::var("R_CONCURRENT_DOWNLOADS").ok().map(|flag| flag.parse().unwrap_or(5)).unwrap_or(5); static ref R_PROXY_ENVS: Vec<(String, String)> = { PROXY_ENVS .clone() .into_iter() .map(|(k, v)| (k.to_lowercase(), v)) .collect() }; } const NSJAIL_CONFIG_RUN_R_CONTENT: &str = include_str!("../nsjail/run.r.config.proto"); const NSJAIL_CONFIG_INSTALL_R_CONTENT: &str = include_str!("../nsjail/install.r.config.proto"); #[allow(dead_code)] pub(crate) struct JobHandlerInput<'a> { pub base_internal_url: &'a str, pub canceled_by: &'a mut Option, pub client: &'a AuthedClient, pub parent_runnable_path: Option, pub conn: &'a Connection, pub envs: HashMap, pub inner_content: &'a str, pub job: &'a MiniPulledJob, pub job_dir: &'a str, pub mem_peak: &'a mut i32, pub occupancy_metrics: &'a mut OccupancyMetrics, pub requirements_o: Option<&'a String>, pub shared_mount: &'a str, pub worker_name: &'a str, } pub async fn handle_r_job<'a>( mut args: JobHandlerInput<'a>, ) -> Result, Error> { let annotation = RlangAnnotations::parse(args.inner_content); if !std::path::Path::new(RSCRIPT_PATH.as_str()).exists() { return Err(Error::ExecutionErr(format!( "Rscript binary not found at '{}'. R is only available in the windmill-full (CE) or windmill-ee-full (EE) Docker images.", *RSCRIPT_PATH ))); } if annotation.sandbox && NSJAIL_AVAILABLE.is_none() { return Err(Error::ExecutionErr( "Script has #sandbox annotation but nsjail is not available on this worker. \ Please ensure nsjail is installed or remove the #sandbox annotation." .to_string(), )); } let jailed = !cfg!(windows) && (is_sandboxing_enabled() || annotation.sandbox); check_no_network_jailed(annotation.no_network, jailed)?; // --- Prepare --- { prepare(&args).await?; } // --- Resolve lockfile --- let lockfile = resolve( &args.job.id, args.inner_content, args.mem_peak, args.canceled_by, args.job_dir, args.conn, args.worker_name, &args.job.workspace_id, annotation.renv_verbose, ) .await?; // --- Install --- let lib_path = if !lockfile.is_empty() { Some( install( &mut args, &lockfile, annotation.renv_verbose, annotation.renv_install_verbose, ) .await?, ) } else { None }; // --- Execute --- { run( &mut args, lib_path.as_deref(), jailed, annotation.no_network, ) .await?; } // --- Retrieve results --- { read_result(&args.job_dir, None).await } } pub async fn prepare<'a>( JobHandlerInput { job, conn, job_dir, inner_content, client, .. }: &JobHandlerInput<'a>, ) -> Result<(), Error> { create_args_and_out_file(&client, job, job_dir, conn).await?; File::create(format!("{}/main.r", job_dir)) .await? .write_all(&wrap(inner_content)?.into_bytes()) .await?; // Create windmill client library for R let wm_lib_path = format!("{}/r_libs", *R_CACHE_DIR); fs::create_dir_all(&wm_lib_path).await?; { File::create(format!("{}/windmill.r", &wm_lib_path)) .await? .write_all( r##" # Windmill mini client methods for R # Uses base R url() + readLines() to avoid requiring any extra R packages .wm_fetch_raw <- function(url) { token <- Sys.getenv("WM_TOKEN") con <- url(url, headers = c(Authorization = paste("Bearer", token))) on.exit(close(con)) paste(readLines(con, warn = FALSE), collapse = "\n") } get_variable <- function(path) { base_url <- Sys.getenv("BASE_INTERNAL_URL") workspace <- Sys.getenv("WM_WORKSPACE") url <- paste0(base_url, "/api/w/", workspace, "/variables/get_value/", path) jsonlite::fromJSON(.wm_fetch_raw(url)) } get_resource <- function(path) { base_url <- Sys.getenv("BASE_INTERNAL_URL") workspace <- Sys.getenv("WM_WORKSPACE") url <- paste0(base_url, "/api/w/", workspace, "/resources/get_value_interpolated/", path) jsonlite::fromJSON(.wm_fetch_raw(url)) } "## .as_bytes(), ) .await?; } Ok(()) } pub async fn resolve<'a>( job_id: &Uuid, inner_content: &str, mem_peak: &mut i32, canceled_by: &mut Option, job_dir: &str, conn: &Connection, worker_name: &str, w_id: &str, verbose: bool, ) -> Result { let mut packages = parse_r_requirements(inner_content)?; // jsonlite is always needed by the wrapper for JSON arg parsing and result serialization let has_jsonlite = packages.lines().any(|l| l.trim() == "jsonlite"); if !has_jsonlite { if packages.is_empty() { packages = "jsonlite".to_string(); } else { packages.push_str("\njsonlite"); } } // Check cache let req_hash = format!("r-{}", calculate_hash(&packages)); if let Some(db) = conn.as_sql() { if let Some(cached) = sqlx::query_scalar!( "SELECT lockfile FROM pip_resolution_cache WHERE hash = $1", req_hash ) .fetch_optional(db) .await? { return Ok(cached); } } append_logs( job_id, w_id, format!("\n--- RESOLVING R PACKAGES ---\n"), conn, ) .await; // main.r is already written by prepare() and contains the library() calls. // renv will scan it to detect dependencies. // Disable renv's own package cache — Windmill manages its own install cache. let resolve_script = format!( r#"options( repos = c(CRAN = "https://cloud.r-project.org"), renv.verbose = {verbose_r}, renv.config.cache.enabled = FALSE, renv.config.restart.enabled = FALSE, renv.config.synchronized.check = FALSE ) renv::consent(provided = TRUE) suppressMessages(renv::init(bare = TRUE, restart = FALSE)) suppressMessages(renv::install(prompt = FALSE)) suppressMessages(renv::snapshot(type = "implicit", prompt = FALSE)) "#, verbose_r = if verbose { "TRUE" } else { "FALSE" }, ); let mut file = File::create(format!("{}/resolve.r", job_dir)).await?; file.write_all(resolve_script.as_bytes()).await?; let child = { let renv_root = format!("{}/renv", *R_CACHE_DIR); let rscript_executable = if cfg!(windows) { "Rscript.exe" } else { RSCRIPT_PATH.as_str() }; let mut cmd = Command::new(rscript_executable); cmd.current_dir(job_dir) .env("PATH", PATH_ENV.as_str()) .env("RENV_PATHS_ROOT", &renv_root) .arg("resolve.r") .stdout(Stdio::piped()) .stderr(Stdio::piped()); start_child_process(cmd, rscript_executable, false).await? }; handle_child::handle_child( job_id, conn, mem_peak, canceled_by, child, false, worker_name, w_id, "r resolve", None, false, &mut None, None, None, ) .await?; let lock_path = format!("{}/renv.lock", job_dir); let mut lock_file = File::open(&lock_path).await?; let mut lock = String::new(); lock_file.read_to_string(&mut lock).await?; // Cache the lockfile if let Some(db) = conn.as_sql() { sqlx::query!( "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('3 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = EXCLUDED.lockfile", req_hash, lock.clone(), ).fetch_optional(db).await?; } // Log a compact summary instead of the entire renv.lock JSON let pkg_count = serde_json::from_str::(&lock) .ok() .and_then(|v| v.get("Packages")?.as_object().map(|o| o.len())) .unwrap_or(0); append_logs( job_id, w_id, format!("resolved {} packages\n", pkg_count), conn, ) .await; Ok(lock) } struct RenvPackage { name: String, version: String, repo_url: String, /// Package names from Imports + Depends fields dependencies: Vec, } /// Reject renv package names/versions that could break out of the R string /// literal they are interpolated into (`renv::install("name@version", ...)`), /// preventing command injection (CWE-78) via crafted renv.lock content. /// CRAN package names are letters/digits/'.' starting with a letter; versions /// are dotted numerics optionally with '-'/'_'/'+' separators. fn validate_renv_package(name: &str, version: &str) -> Result<(), Error> { let name_ok = name .chars() .next() .map_or(false, |c| c.is_ascii_alphabetic()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.'); let version_ok = !version.is_empty() && version .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')); if !name_ok || !version_ok { return Err(Error::ExecutionErr(format!( "Invalid renv package name '{}' / version '{}': name must be alphanumeric or '.', \ version alphanumeric or '.-_+'", name.chars().take(50).collect::(), version.chars().take(50).collect::(), ))); } Ok(()) } /// Escape a value for safe interpolation into an R double-quoted string literal: /// backslash and double-quote are the only metacharacters inside `"..."`. fn escape_r_double_quoted(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } /// Parse renv.lock JSON and extract package info including dependency edges. fn parse_renv_lock(lockfile: &str) -> Result, Error> { let lock: serde_json::Value = serde_json::from_str(lockfile) .map_err(|e| Error::ExecutionErr(format!("Failed to parse renv.lock: {}", e)))?; // Build repo name -> URL map from R.Repositories let mut repo_urls: HashMap = HashMap::new(); if let Some(repos) = lock .get("R") .and_then(|r| r.get("Repositories")) .and_then(|r| r.as_array()) { for repo in repos { if let (Some(name), Some(url)) = ( repo.get("Name").and_then(|v| v.as_str()), repo.get("URL").and_then(|v| v.as_str()), ) { repo_urls.insert(name.to_string(), url.to_string()); } } } let packages = lock .get("Packages") .and_then(|p| p.as_object()) .ok_or_else(|| Error::ExecutionErr("renv.lock missing Packages field".to_string()))?; let mut result = vec![]; for (_name, pkg) in packages { let pkg_name = pkg .get("Package") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let version = pkg .get("Version") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let repo_name = pkg .get("Repository") .and_then(|v| v.as_str()) .unwrap_or("CRAN"); let repo_url = repo_urls .get(repo_name) .cloned() .unwrap_or_else(|| "https://cloud.r-project.org".to_string()); let mut dependencies = vec![]; if let Some(imports) = pkg.get("Imports").and_then(|v| v.as_array()) { for entry in imports { if let Some(s) = entry.as_str() { // Entries look like "cli (>= 3.6.2)" — take just the name let name = s.split_whitespace().next().unwrap_or(""); if !name.is_empty() && name != "R" { dependencies.push(name.to_string()); } } } } // Skip renv itself — it's already loaded and reinstalling it while // loaded triggers a noisy "Restart your R session" message. if !pkg_name.is_empty() && !version.is_empty() && pkg_name != "renv" { validate_renv_package(&pkg_name, &version)?; result.push(RenvPackage { name: pkg_name, version, repo_url, dependencies }); } } Ok(result) } async fn install<'a>( args: &mut JobHandlerInput<'a>, lockfile: &str, verbose: bool, install_verbose: bool, ) -> Result { let lib_path = format!("{}/r_site_library", *R_CACHE_DIR); fs::create_dir_all(&lib_path).await?; let packages = parse_renv_lock(lockfile)?; if packages.is_empty() { return Ok(lib_path); } #[derive(Clone, Debug)] struct RPackagePayload { pkg: String, version: String, #[allow(dead_code)] repo_url: String, } // Build dependency graph for topological layering let mut graph = DependencyGraph::new(); for renv_pkg in &packages { let handle = format!("{}-{}", renv_pkg.name, renv_pkg.version); // renv uses staged installation: it builds to a temp dir then rename()s onto // the target. If the target is a bind mount point, rename fails with // "target file already exists". We work around this by mounting the parent // (wrapper) dir at /install so renv can freely create /install/{pkg}/ via rename. let pkg_outer = format!("{}/{}_outer", lib_path, renv_pkg.name); let path = format!("{}/{}", pkg_outer, renv_pkg.name); graph.insert( renv_pkg.name.clone(), RequiredDependency { path, _s3_handle: handle, display_name: format!("{} ({})", renv_pkg.name, renv_pkg.version), custom_payload: RPackagePayload { pkg: renv_pkg.name.clone(), version: renv_pkg.version.clone(), repo_url: renv_pkg.repo_url.clone(), }, }, renv_pkg.dependencies.clone(), ); } let jailed = !cfg!(windows) && is_sandboxing_enabled(); let job_dir = args.job_dir.to_owned(); par_install_language_dependencies_seq( InstallDeps::Layered(graph), "r", "Rscript", false, *R_CONCURRENT_DOWNLOADS, move |dependency| { let lib_path_c = lib_path.clone(); let job_dir = job_dir.clone(); let pkg_name = &dependency.custom_payload.pkg; // pkg_outer is the wrapper dir mounted rw at /install inside nsjail. // renv creates /install/{pkg}/ inside it via staged rename. let pkg_outer = format!("{}/{}_outer", lib_path_c, pkg_name); std::fs::create_dir_all(&pkg_outer)?; let mut cmd = if jailed { let nsjail_proto = format!("{}.install.config.proto", Uuid::new_v4()); let config_content = NSJAIL_CONFIG_INSTALL_R_CONTENT .replace("{JOB_DIR}", &job_dir) .replace("{PKG_DIR}", &pkg_outer) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL); let _ = write_file( &job_dir, &nsjail_proto, &config_content, )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.args(vec![ "--config", &nsjail_proto, "--", RSCRIPT_PATH.as_str(), ]); cmd } else { Command::new(if cfg!(windows) { "Rscript.exe" } else { RSCRIPT_PATH.as_str() }) }; let verbose_r = if verbose { "TRUE" } else { "FALSE" }; let install_verbose_r = if install_verbose { "TRUE" } else { "FALSE" }; let install_lib = if jailed { "/install".to_string() } else { pkg_outer.clone() }; cmd.env_clear() .current_dir(&job_dir) .env("PATH", PATH_ENV.as_str()) // On Windows, renv needs SystemRoot (winsock init — without it DNS // and sockets fail) and LOCALAPPDATA (its cache root) to install. .envs(WIN_ENVS.to_vec()) .envs(R_PROXY_ENVS.clone()); cmd .args(&[ "-e", &format!( r#"options(renv.verbose = {verbose_r}, renv.config.install.verbose = {install_verbose_r}, renv.config.restart.enabled = FALSE); renv::install("{pkg}@{version}", library = "{lib}", dependencies = FALSE)"#, verbose_r = verbose_r, install_verbose_r = install_verbose_r, pkg = escape_r_double_quoted(&dependency.custom_payload.pkg), version = escape_r_double_quoted(&dependency.custom_payload.version), lib = escape_r_double_quoted(&install_lib), ), // install.packages fallback (no version pinning): // &format!( // r#"install.packages("{pkg}", lib = "{lib}", repos = "{repo}", dependencies = FALSE, quiet = {quiet}, INSTALL_opts = "--no-test-load --no-lock")"#, // pkg = dependency.custom_payload.pkg, // lib = install_lib, // repo = dependency.custom_payload.repo_url, // quiet = quiet_flag, // ), ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); Ok(cmd) }, None, &args.job.id, &args.job.workspace_id, args.worker_name, jailed, args.conn, ) .await?; Ok(format!("{}/r_site_library", *R_CACHE_DIR)) } /// Build R_LIBS_USER from lib_path by listing *_outer subdirs. /// Each package wrapper dir ({pkg}_outer) is added so R finds {pkg}_outer/{pkg}/DESCRIPTION. fn r_libs_user(lib_path: &str) -> String { std::fs::read_dir(lib_path) .into_iter() .flatten() .filter_map(|e| e.ok()) .filter(|e| { e.file_type().map(|t| t.is_dir()).unwrap_or(false) && e.file_name().to_string_lossy().ends_with("_outer") }) .map(|e| e.path().to_string_lossy().to_string()) .collect::>() .join(":") } async fn run<'a>( JobHandlerInput { occupancy_metrics, mem_peak, canceled_by, worker_name, job, conn, job_dir, shared_mount, client, envs, base_internal_url, parent_runnable_path, .. }: &mut JobHandlerInput<'a>, lib_path: Option<&str>, nsjail: bool, no_network: bool, ) -> Result<(), Error> { let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?; crate::strip_no_network_token(&mut reserved_variables, no_network); let child = if nsjail { append_logs( &job.id, &job.workspace_id, format!( "\n--- R CODE EXECUTION (nsjail) ---\n{}", if no_network { "no network\n" } else { "" } ), conn, ) .await; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .envs(&reserved_variables) .envs(if no_network || !crate::passes_proxy_to_jobs() { vec![] } else { R_PROXY_ENVS.clone() }) .envs( get_run_proxy_envs( &ScriptLang::Rlang, job.kind, &job.id, &job.workspace_id, conn, no_network, ) .await?, ); if let Some(lp) = lib_path { cmd.env("R_LIBS_USER", r_libs_user(lp)); } cmd.args(vec![ "--config", "run.config.proto", "--", RSCRIPT_PATH.as_str(), "main.r", ]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); write_file( job_dir, "run.config.proto", &NSJAIL_CONFIG_RUN_R_CONTENT .replace("{JOB_DIR}", job_dir) .replace("{SHARED_MOUNT}", &shared_mount) .replace("{R_CACHE_DIR}", &*R_CACHE_DIR) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( "{TMP_MOUNT_BLOCK}", &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{CLONE_NEWNET}", nsjail_clone_newnet(no_network)) .replace( "{ENVARS}", &script_envars( &envs, &cmd, &script_overrides(&["PATH", "BASE_INTERNAL_URL"], &reserved_variables), ), ), )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( &job.id, &job.workspace_id, format!("\n--- R CODE EXECUTION ---\n"), conn, ) .await; let rscript_executable = if cfg!(windows) { "Rscript.exe" } else { RSCRIPT_PATH.as_str() }; let args = vec!["main.r"]; let mut cmd = build_command_with_isolation(rscript_executable, &args); cmd.env_clear() .current_dir(job_dir.to_owned()) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .envs(reserved_variables) .envs(if crate::passes_proxy_to_jobs() { R_PROXY_ENVS.clone() } else { vec![] }) .envs( get_proxy_envs_for_lang( &ScriptLang::Rlang, job.kind, &job.id, &job.workspace_id, conn, ) .await?, ) .envs(envs); if let Some(lp) = lib_path { cmd.env("R_LIBS_USER", r_libs_user(lp)); } cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); cmd.envs(WIN_ENVS.to_vec()); start_child_process(cmd, rscript_executable, false).await? }; handle_child::handle_child( &job.id, conn, mem_peak, canceled_by, child, nsjail, worker_name, &job.workspace_id, "r", job.timeout, false, &mut Some(occupancy_metrics), None, None, ) .await?; Ok(()) } fn wrap(inner_content: &str) -> Result { let sig = parse_r_signature(inner_content)?; let spread = sig .args .clone() .into_iter() .map(|Arg { name, .. }| format!("{name} = args${name}", name = name)) .collect_vec() .join(", "); let wm_lib_path = format!("{}/r_libs/windmill.r", *R_CACHE_DIR); Ok(format!( r#"source("{wm_lib_path}") suppressPackageStartupMessages({{ {inner_content} }}) library(jsonlite) args <- fromJSON("args.json") tryCatch({{ res <- main({spread}) write(toJSON(res, auto_unbox = TRUE, null = "null"), "result.json") }}, error = function(e) {{ error_obj <- list( name = class(e)[1], message = conditionMessage(e), stack = paste(capture.output(traceback()), collapse = "\n") ) write(toJSON(error_obj, auto_unbox = TRUE), "result.json") stop(e) }}) "#, wm_lib_path = wm_lib_path, inner_content = inner_content, spread = spread, )) } #[cfg(test)] mod tests { use super::{escape_r_double_quoted, parse_renv_lock, validate_renv_package}; #[test] fn test_validate_renv_package_accepts_real() { for (n, v) in [ ("ggplot2", "3.4.4"), ("data.table", "1.14.8"), ("Rcpp", "1.0.11"), ("renv", "1.0.3"), ("pkg", "1.2-3"), ("x", "0.9.8.9000"), ] { assert!( validate_renv_package(n, v).is_ok(), "{n}@{v} should be valid" ); } } #[test] fn test_validate_renv_package_rejects_injection() { // name breakouts, version breakouts, leading non-letter, empty for (n, v) in [ ("ggplot2\", library=system(\"id\"))#", "1.0"), ("ok", "1.0\"); system(\"id\"); (\""), ("ok", "1.0\\\"x"), ("9pkg", "1.0"), ("pkg name", "1.0"), ("ok", ""), ] { assert!( validate_renv_package(n, v).is_err(), "{n:?}@{v:?} should be rejected" ); } } #[test] fn test_escape_r_double_quoted() { assert_eq!(escape_r_double_quoted(r#"a"b"#), r#"a\"b"#); assert_eq!(escape_r_double_quoted(r"a\b"), r"a\\b"); // backslash escaped before quote so \" cannot be reinterpreted assert_eq!(escape_r_double_quoted(r#"\""#), r#"\\\""#); } #[test] fn test_parse_renv_lock_rejects_unsafe_name() { let lock = r#"{"Packages": {"evil": {"Package": "evil\"); system(\"id\"); (\"", "Version": "1.0"}}}"#; assert!(parse_renv_lock(lock).is_err()); } #[test] fn test_parse_renv_lock_accepts_clean() { let lock = r#"{"Packages": {"ggplot2": {"Package": "ggplot2", "Version": "3.4.4"}}}"#; let pkgs = parse_renv_lock(lock).unwrap(); assert_eq!(pkgs.len(), 1); assert_eq!(pkgs[0].name, "ggplot2"); } }