Skip to content

Commit 5d4ab85

Browse files
authored
Merge pull request #19 from Point72/tkp/link-scripts
Add an option to run packages' post-link scripts
2 parents db6ae75 + e1a2842 commit 5d4ab85

8 files changed

Lines changed: 147 additions & 27 deletions

File tree

docs/wiki/Install.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ If the environment declares [`activation`](Manifests#activation) hooks, `create`
117117
also materializes them into `etc/conda/activate.d/` (recovered from the manifest
118118
the lock was solved from), so a subsequent [`activate`](#activate) runs them.
119119

120+
Packages' `post-link` scripts are **not** run: they are arbitrary code shipped
121+
inside a package and they make an install non-hermetic. Pass `--link-scripts` to
122+
`create`, `unpack`, or `sync` to execute them anyway, for channels you trust and
123+
packages that depend on them.
124+
120125
### Publish, show, pull
121126

122127
```bash

rust/python/lib.rs

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,10 @@ fn build<'py>(
275275
}
276276

277277
/// Resolve a published lock and install it into `prefix` (no conda required).
278+
/// Set `link_scripts` to run each package's `post-link` script (off by default).
278279
/// Returns an install summary dict.
279280
#[pyfunction]
280-
#[pyo3(signature = (env, registry, prefix, *, platform = None, python = None, variant = None, label = "latest"))]
281+
#[pyo3(signature = (env, registry, prefix, *, platform = None, python = None, variant = None, label = "latest", link_scripts = false))]
281282
#[allow(clippy::too_many_arguments)]
282283
fn create<'py>(
283284
py: Python<'py>,
@@ -288,12 +289,22 @@ fn create<'py>(
288289
python: Option<String>,
289290
variant: Option<String>,
290291
label: &str,
292+
link_scripts: bool,
291293
) -> PyResult<Bound<'py, PyDict>> {
292294
let registry = Registry::new(SpecStore::new(), registry);
293295
let coords = coordinates(env, platform, python, variant);
294296
let label = Label::parse(label);
295-
let summary =
296-
block_on(py, install::create(&registry, &coords, &label, &prefix))?.map_err(err)?;
297+
let summary = block_on(
298+
py,
299+
install::create(
300+
&registry,
301+
&coords,
302+
&label,
303+
&prefix,
304+
install::LinkScripts::from(link_scripts),
305+
),
306+
)?
307+
.map_err(err)?;
297308
summary_dict(py, &summary)
298309
}
299310

@@ -472,16 +483,18 @@ fn pack<'py>(
472483

473484
/// Install an environment from a packed bundle into `prefix`, fully offline.
474485
/// `env` defaults to the bundle's environment and `platform` to the current
475-
/// platform. Returns an install summary dict.
486+
/// platform. Set `link_scripts` to run each package's `post-link` script (off by
487+
/// default). Returns an install summary dict.
476488
#[pyfunction]
477-
#[pyo3(signature = (pack, prefix, *, env = None, platform = None, stage_dir = None))]
489+
#[pyo3(signature = (pack, prefix, *, env = None, platform = None, stage_dir = None, link_scripts = false))]
478490
fn unpack<'py>(
479491
py: Python<'py>,
480492
pack: PathBuf,
481493
prefix: PathBuf,
482494
env: Option<String>,
483495
platform: Option<String>,
484496
stage_dir: Option<PathBuf>,
497+
link_scripts: bool,
485498
) -> PyResult<Bound<'py, PyDict>> {
486499
let summary = block_on(
487500
py,
@@ -491,21 +504,31 @@ fn unpack<'py>(
491504
platform.as_deref(),
492505
&prefix,
493506
stage_dir.as_deref(),
507+
install::LinkScripts::from(link_scripts),
494508
),
495509
)?
496510
.map_err(err)?;
497511
summary_dict(py, &summary)
498512
}
499513

500514
/// Install the environment a project's `pyproject.toml` references in its
501-
/// `[tool.nepenthe]` stanza. `project` defaults to `./pyproject.toml`. Returns
502-
/// an install summary dict.
515+
/// `[tool.nepenthe]` stanza. `project` defaults to `./pyproject.toml`. Set
516+
/// `link_scripts` to run each package's `post-link` script (off by default).
517+
/// Returns an install summary dict.
503518
#[pyfunction]
504-
#[pyo3(signature = (project = None))]
505-
fn sync<'py>(py: Python<'py>, project: Option<PathBuf>) -> PyResult<Bound<'py, PyDict>> {
519+
#[pyo3(signature = (project = None, *, link_scripts = false))]
520+
fn sync<'py>(
521+
py: Python<'py>,
522+
project: Option<PathBuf>,
523+
link_scripts: bool,
524+
) -> PyResult<Bound<'py, PyDict>> {
506525
let path = project.unwrap_or_else(|| PathBuf::from("pyproject.toml"));
507526
let file = project::read(&path).map_err(err)?;
508-
let summary = block_on(py, project::sync(&file))?.map_err(err)?;
527+
let summary = block_on(
528+
py,
529+
project::sync(&file, install::LinkScripts::from(link_scripts)),
530+
)?
531+
.map_err(err)?;
509532
summary_dict(py, &summary)
510533
}
511534

rust/src/cli.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ struct CreateArgs {
204204
/// Directory to install the environment into.
205205
#[arg(long)]
206206
prefix: PathBuf,
207+
/// Run each package's `post-link` script (off by default — link scripts are
208+
/// arbitrary code and make the install non-hermetic).
209+
#[arg(long)]
210+
link_scripts: bool,
207211
}
208212

209213
#[derive(Args)]
@@ -375,13 +379,21 @@ struct UnpackArgs {
375379
/// Directory to extract the bundle into (defaults to a temporary directory).
376380
#[arg(long)]
377381
stage_dir: Option<PathBuf>,
382+
/// Run each package's `post-link` script (off by default — link scripts are
383+
/// arbitrary code and make the install non-hermetic).
384+
#[arg(long)]
385+
link_scripts: bool,
378386
}
379387

380388
#[derive(Args)]
381389
struct SyncArgs {
382390
/// Path to the `pyproject.toml` to read (defaults to `./pyproject.toml`).
383391
#[arg(long, default_value = "pyproject.toml")]
384392
project: PathBuf,
393+
/// Run each package's `post-link` script (off by default — link scripts are
394+
/// arbitrary code and make the install non-hermetic).
395+
#[arg(long)]
396+
link_scripts: bool,
385397
}
386398

387399
#[derive(Args)]
@@ -739,6 +751,7 @@ async fn create(args: CreateArgs) -> CliResult {
739751
.platform
740752
.clone()
741753
.unwrap_or_else(|| Platform::current().to_string());
754+
let link_scripts = install::LinkScripts::from(args.link_scripts);
742755

743756
let summary = if let Some(lock_path) = &args.lock {
744757
// No registry, no solve: install exactly the packages the lock pins,
@@ -749,7 +762,9 @@ async fn create(args: CreateArgs) -> CliResult {
749762
Some(env) => env.clone(),
750763
None => install::sole_environment(&lock)?,
751764
};
752-
let summary = install::install_lock(&lock, &environment, &platform, &args.prefix).await?;
765+
let summary =
766+
install::install_lock(&lock, &environment, &platform, &args.prefix, link_scripts)
767+
.await?;
753768
install::write_hooks_from_lock(
754769
&bytes,
755770
&environment,
@@ -778,7 +793,7 @@ async fn create(args: CreateArgs) -> CliResult {
778793
coords = coords.with_variant(v.clone());
779794
}
780795
let label = Label::parse(&args.label);
781-
install::create(&registry, &coords, &label, &args.prefix).await?
796+
install::create(&registry, &coords, &label, &args.prefix, link_scripts).await?
782797
};
783798

784799
println!(
@@ -999,6 +1014,7 @@ async fn unpack(args: UnpackArgs) -> CliResult {
9991014
args.platform.as_deref(),
10001015
&args.prefix,
10011016
args.stage_dir.as_deref(),
1017+
install::LinkScripts::from(args.link_scripts),
10021018
)
10031019
.await?;
10041020
println!(
@@ -1013,7 +1029,8 @@ async fn unpack(args: UnpackArgs) -> CliResult {
10131029

10141030
async fn sync(args: SyncArgs) -> CliResult {
10151031
let project = crate::project::read(&args.project)?;
1016-
let summary = crate::project::sync(&project).await?;
1032+
let summary =
1033+
crate::project::sync(&project, install::LinkScripts::from(args.link_scripts)).await?;
10171034
println!(
10181035
"synced {} ({}) at {} — {} packages",
10191036
summary.environment,
@@ -1223,7 +1240,14 @@ async fn shell(args: ShellArgs) -> CliResult {
12231240
None => cache_env_prefix(&coords)?,
12241241
};
12251242
if !prefix.join("conda-meta").is_dir() {
1226-
install::create(&registry, &coords, &label, &prefix).await?;
1243+
install::create(
1244+
&registry,
1245+
&coords,
1246+
&label,
1247+
&prefix,
1248+
install::LinkScripts::Skip,
1249+
)
1250+
.await?;
12271251
}
12281252

12291253
let shell_program = args

rust/src/image.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ pub async fn build(
426426
) -> Result<ImageSummary, ImageError> {
427427
// Materialize the environment (self-contained: every package on disk).
428428
if !prefix.join("conda-meta").is_dir() {
429-
install::create(registry, coords, label, prefix).await?;
429+
install::create(registry, coords, label, prefix, install::LinkScripts::Skip).await?;
430430
}
431431

432432
let artifact = match target {

rust/src/install.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,34 @@ pub struct InstallSummary {
330330
pub packages: Vec<PackageId>,
331331
}
332332

333+
/// Whether a package's `post-link` / `pre-unlink` scripts run when it is linked
334+
/// into a prefix.
335+
///
336+
/// Skipped by default. Those scripts are arbitrary code shipped inside a
337+
/// package and run with the installer's privileges, and they are what makes an
338+
/// install non-hermetic — they can reach the network or bake host state into
339+
/// the prefix, so a packed bundle stops being reproducible offline. A few
340+
/// packages (some CUDA and MKL builds, older R builds) do real work there, so a
341+
/// caller that trusts the channels it installs from can opt in.
342+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
343+
pub enum LinkScripts {
344+
/// Link files only; `post-link` / `pre-unlink` scripts are ignored.
345+
#[default]
346+
Skip,
347+
/// Execute each package's `post-link` / `pre-unlink` scripts.
348+
Run,
349+
}
350+
351+
impl From<bool> for LinkScripts {
352+
fn from(run: bool) -> Self {
353+
if run {
354+
Self::Run
355+
} else {
356+
Self::Skip
357+
}
358+
}
359+
}
360+
333361
/// Install the `environment`/`platform` packages from `lock` into `prefix`,
334362
/// using rattler's installer (no conda required). Packages are fetched into the
335363
/// shared package cache and linked into the prefix.
@@ -340,9 +368,10 @@ pub async fn install_lock(
340368
environment: &str,
341369
platform: &str,
342370
prefix: &Path,
371+
link_scripts: LinkScripts,
343372
) -> Result<InstallSummary, InstallError> {
344373
let records = lock_records(lock, environment, platform)?;
345-
install_records(records, environment, platform, prefix).await
374+
install_records(records, environment, platform, prefix, link_scripts).await
346375
}
347376

348377
/// Render `error` and its `source` chain as `outer: cause: root cause`.
@@ -445,6 +474,7 @@ pub async fn install_records(
445474
environment: &str,
446475
platform: &str,
447476
prefix: &Path,
477+
link_scripts: LinkScripts,
448478
) -> Result<InstallSummary, InstallError> {
449479
let target = Platform::from_str(platform)
450480
.map_err(|e| InstallError::Lock(format!("bad platform '{platform}': {e}")))?;
@@ -459,6 +489,7 @@ pub async fn install_records(
459489
.with_download_client(crate::net::authenticated_client().map_err(InstallError::Install)?)
460490
.with_max_concurrent_requests(MAX_CONCURRENT_FETCHES)
461491
.with_target_platform(target)
492+
.with_execute_link_scripts(link_scripts == LinkScripts::Run)
462493
.install(prefix, records)
463494
.await
464495
.map_err(|e| InstallError::Install(error_chain(&e)))?;
@@ -489,10 +520,18 @@ pub async fn create(
489520
coords: &Coordinates,
490521
label: &Label,
491522
prefix: &Path,
523+
link_scripts: LinkScripts,
492524
) -> Result<InstallSummary, InstallError> {
493525
let bytes = registry.pull(coords, label)?;
494526
let lock = parse_lock(&bytes)?;
495-
let summary = install_lock(&lock, &coords.environment, &coords.platform, prefix).await?;
527+
let summary = install_lock(
528+
&lock,
529+
&coords.environment,
530+
&coords.platform,
531+
prefix,
532+
link_scripts,
533+
)
534+
.await?;
496535
// Materialize the environment's activation hooks, recovered from the
497536
// manifest the lock was solved from: the embedded comment band if present,
498537
// else the registry's manifest sidecar. Best-effort: a release with no
@@ -1288,7 +1327,7 @@ mod tests {
12881327
std::env::temp_dir().join(format!("nepenthe-install-capstone-{}", std::process::id()));
12891328
let _ = std::fs::remove_dir_all(&prefix);
12901329

1291-
let summary = install_lock(&lock, "app", &platform, &prefix)
1330+
let summary = install_lock(&lock, "app", &platform, &prefix, LinkScripts::Skip)
12921331
.await
12931332
.expect("install should succeed");
12941333
assert!(!summary.packages.is_empty());
@@ -1369,7 +1408,7 @@ mod tests {
13691408
let prefix =
13701409
std::env::temp_dir().join(format!("nepenthe-install-xplat-{}", std::process::id()));
13711410
let _ = std::fs::remove_dir_all(&prefix);
1372-
let summary = install_lock(&lock, "app", &host, &prefix)
1411+
let summary = install_lock(&lock, "app", &host, &prefix, LinkScripts::Skip)
13731412
.await
13741413
.expect("install should succeed");
13751414
assert!(!summary.packages.is_empty());

rust/src/pack.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
2727
use sha2::{Digest, Sha256};
2828
use url::Url;
2929

30-
use crate::install::{self, InstallError, InstallSummary};
30+
use crate::install::{self, InstallError, InstallSummary, LinkScripts};
3131

3232
/// Bundle format version written into the manifest.
3333
const PACK_FORMAT: u32 = 1;
@@ -290,6 +290,7 @@ pub async fn install_pack(
290290
platform: Option<&str>,
291291
prefix: &Path,
292292
stage_dir: Option<&Path>,
293+
link_scripts: LinkScripts,
293294
) -> Result<InstallSummary, PackError> {
294295
let (staging, created_temp) = match stage_dir {
295296
Some(dir) => (dir.to_path_buf(), false),
@@ -304,7 +305,15 @@ pub async fn install_pack(
304305
};
305306
std::fs::create_dir_all(&staging)?;
306307

307-
let result = install_from_staging(pack_path, &staging, environment, platform, prefix).await;
308+
let result = install_from_staging(
309+
pack_path,
310+
&staging,
311+
environment,
312+
platform,
313+
prefix,
314+
link_scripts,
315+
)
316+
.await;
308317

309318
if created_temp {
310319
let _ = std::fs::remove_dir_all(&staging);
@@ -318,6 +327,7 @@ async fn install_from_staging(
318327
environment: Option<&str>,
319328
platform: Option<&str>,
320329
prefix: &Path,
330+
link_scripts: LinkScripts,
321331
) -> Result<InstallSummary, PackError> {
322332
tar::Archive::new(File::open(pack_path)?).unpack(staging)?;
323333

@@ -379,7 +389,7 @@ async fn install_from_staging(
379389
.map_err(|()| PackError::BadUrl(path.display().to_string()))?;
380390
}
381391

382-
install::install_records(records, environment, &platform, prefix)
392+
install::install_records(records, environment, &platform, prefix, link_scripts)
383393
.await
384394
.map_err(PackError::from)
385395
}
@@ -601,7 +611,7 @@ mod tests {
601611

602612
// 3) install from the bundle into a fresh prefix — offline
603613
let prefix = base.join("env");
604-
let install = install_pack(&bundle, None, None, &prefix, None)
614+
let install = install_pack(&bundle, None, None, &prefix, None, LinkScripts::Skip)
605615
.await
606616
.expect("install from bundle should succeed");
607617
assert!(install.packages.iter().any(|p| p.name == "python"));

rust/src/project.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use rattler_conda_types::{ParseStrictness, Version, VersionSpec};
2525
use serde::Deserialize;
2626

2727
use crate::backend::SpecStore;
28-
use crate::install::{self, InstallError, InstallSummary, PackageId};
28+
use crate::install::{self, InstallError, InstallSummary, LinkScripts, PackageId};
2929
use crate::name_map::{self, normalize_name};
3030
use crate::registry::{Coordinates, Label, Registry, RegistryError};
3131

@@ -230,13 +230,17 @@ pub fn read_dependencies(pyproject: &Path) -> Result<Vec<String>, ProjectError>
230230
/// Install (or update) the environment referenced by a project into its prefix,
231231
/// resolving the version label against the registry. Performs network I/O; await
232232
/// inside a tokio runtime.
233-
pub async fn sync(project: &ProjectFile) -> Result<InstallSummary, ProjectError> {
233+
pub async fn sync(
234+
project: &ProjectFile,
235+
link_scripts: LinkScripts,
236+
) -> Result<InstallSummary, ProjectError> {
234237
let reference = &project.nepenthe;
235238
let summary = install::create(
236239
&reference.registry(),
237240
&reference.coordinates(),
238241
&reference.label(),
239242
&project.resolved_prefix(),
243+
link_scripts,
240244
)
241245
.await?;
242246
Ok(summary)

0 commit comments

Comments
 (0)