Skip to content

Commit a9f8e6b

Browse files
authored
Handle pre/post-link/unlink scripts (#4313)
1 parent a7ee392 commit a9f8e6b

5 files changed

Lines changed: 231 additions & 10 deletions

File tree

libmamba/src/core/link.cpp

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ namespace mamba
541541
bool run_script(
542542
const TransactionParams& transaction_params,
543543
const PrefixParams& prefix_params,
544+
const fs::u8path& script_prefix,
544545
const specs::PackageInfo& pkg_info,
545546
const std::string& action = "post-link",
546547
const std::string& env_prefix = "",
@@ -550,12 +551,12 @@ namespace mamba
550551
fs::u8path path;
551552
if (util::on_win)
552553
{
553-
path = prefix_params.target_prefix / get_bin_directory_short_path()
554+
path = script_prefix / get_bin_directory_short_path()
554555
/ util::concat(".", pkg_info.name, "-", action, ".bat");
555556
}
556557
else
557558
{
558-
path = prefix_params.target_prefix / get_bin_directory_short_path()
559+
path = script_prefix / get_bin_directory_short_path()
559560
/ util::concat(".", pkg_info.name, "-", action, ".sh");
560561
}
561562

@@ -566,14 +567,23 @@ namespace mamba
566567
return true;
567568
}
568569

569-
// TODO impl.
570-
std::map<std::string, std::string> envmap; // = env::copy();
571-
570+
std::map<std::string, std::string> envmap;
572571
if (action == "pre-link")
573572
{
574-
throw std::runtime_error("mamba does not support pre-link scripts");
573+
LOG_WARNING
574+
<< "Special Note: Pre-link scripts are particularly high-risk as they can "
575+
<< "modify the package cache, potentially affecting all environments on this system.";
576+
envmap["SOURCE_DIR"] = script_prefix.string();
575577
}
576578

579+
if (action == "post-unlink")
580+
{
581+
LOG_WARNING << "post-unlink scripts are deprecated and therefore won't be executed!";
582+
return true;
583+
}
584+
585+
LOG_WARNING << "Executing " << action << " script for package '" << pkg_info.name << "'.";
586+
577587
// script_caller = None
578588
std::vector<std::string> command_args;
579589
std::unique_ptr<TemporaryFile> script_file;
@@ -799,6 +809,16 @@ namespace mamba
799809
return true;
800810
}
801811

812+
run_script(
813+
m_context->transaction_params(),
814+
m_context->prefix_params(),
815+
m_context->prefix_params().target_prefix,
816+
m_pkg_info,
817+
"pre-unlink",
818+
"",
819+
false
820+
);
821+
802822
std::ifstream json_file = open_ifstream(json);
803823
if (!json_file.good())
804824
{
@@ -848,6 +868,16 @@ namespace mamba
848868

849869
json_file.close();
850870

871+
run_script(
872+
m_context->transaction_params(),
873+
m_context->prefix_params(),
874+
m_context->prefix_params().target_prefix,
875+
m_pkg_info,
876+
"post-unlink",
877+
"",
878+
true
879+
);
880+
851881
fs::remove(json);
852882

853883
return true;
@@ -1167,6 +1197,16 @@ namespace mamba
11671197
nlohmann::json index_json, out_json;
11681198
LOG_TRACE << "Preparing linking from '" << m_source.string() << "'";
11691199

1200+
run_script(
1201+
m_context->transaction_params(),
1202+
m_context->prefix_params(),
1203+
m_source,
1204+
m_pkg_info,
1205+
"pre-link",
1206+
"",
1207+
false
1208+
);
1209+
11701210
LOG_TRACE << "Opening: " << m_source / "info" / "paths.json";
11711211
auto paths_data = read_paths(m_source);
11721212

@@ -1431,6 +1471,7 @@ namespace mamba
14311471
run_script(
14321472
m_context->transaction_params(),
14331473
m_context->prefix_params(),
1474+
m_context->prefix_params().target_prefix,
14341475
m_pkg_info,
14351476
"post-link",
14361477
"",

libmamba/src/core/transaction.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,10 @@ namespace mamba
11721172
return true;
11731173
}
11741174

1175+
LOG_WARNING
1176+
<< "Security Warning: This transaction includes executing package scripts (pre/post-link/unlink) if present. "
1177+
<< "These scripts can contain arbitrary code. Please ensure you trust the package sources.";
1178+
11751179
return Console::prompt("Confirm changes", 'y');
11761180
}
11771181

libmamba/tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ set(
101101
src/core/test_history.cpp
102102
src/core/test_invoke.cpp
103103
src/core/test_link_entry_points.cpp
104+
src/core/test_link_scripts.cpp
104105
src/core/test_lockfile.cpp
105106
src/core/test_output.cpp
106107
src/core/test_package_cache.cpp
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) 2026, QuantStack and Mamba Contributors
2+
//
3+
// Distributed under the terms of the BSD 3-Clause License.
4+
//
5+
// The full license is in the file LICENSE, distributed with this software.
6+
7+
#include <fstream>
8+
9+
#include <catch2/catch_all.hpp>
10+
11+
#include "mamba/core/fsutil.hpp"
12+
#include "mamba/core/util.hpp"
13+
#include "mamba/core/util_os.hpp"
14+
#include "mamba/specs/package_info.hpp"
15+
16+
#include "core/link.hpp"
17+
#include "core/transaction_context.hpp"
18+
19+
#include "mambatests.hpp"
20+
21+
namespace mamba
22+
{
23+
namespace
24+
{
25+
TEST_CASE("run_scripts_in_transaction")
26+
{
27+
// Ensure Console is initialized to avoid singleton access errors
28+
(void) mambatests::context();
29+
30+
const auto tmp_dir = TemporaryDirectory();
31+
const fs::u8path prefix = tmp_dir.path() / "prefix";
32+
const fs::u8path cache_dir = tmp_dir.path() / "cache";
33+
34+
specs::PackageInfo pkg("test_pkg");
35+
pkg.version = "1.0";
36+
pkg.build_string = "0";
37+
38+
const fs::u8path pkg_source = cache_dir / pkg.str();
39+
const fs::u8path bin_dir_relative = get_bin_directory_short_path();
40+
41+
fs::create_directories(prefix / "conda-meta");
42+
fs::create_directories(pkg_source / bin_dir_relative);
43+
fs::create_directories(prefix / bin_dir_relative);
44+
45+
// Provide a dummy conda binary so wrap_call activation wrapper
46+
// succeeds in the test environment
47+
if (util::on_win)
48+
{
49+
fs::create_directories(prefix / "condabin");
50+
auto conda_bat_path = prefix / "condabin"
51+
/ (get_self_exe_path().stem().string() + ".bat");
52+
auto out = open_ofstream(conda_bat_path);
53+
out << "@exit /b 0\n";
54+
}
55+
else
56+
{
57+
fs::create_directories(prefix / "bin");
58+
auto conda_path = prefix / "bin" / "conda";
59+
auto out = open_ofstream(conda_path);
60+
out << "#!/bin/sh\n";
61+
out << "if [ \"$1\" = \"shell.posix\" ] && [ \"$2\" = \"hook\" ]; then\n";
62+
out << " echo 'conda() { :; }'\n"; // Define conda as a no-op function
63+
out << "fi\n";
64+
make_executable(conda_path);
65+
}
66+
67+
const auto make_tx_context = [&]
68+
{
69+
TransactionParams tx_params{
70+
.is_mamba_exe = false,
71+
.json_output = false,
72+
.verbosity = 0,
73+
.shortcuts = false,
74+
.envs_dirs = {},
75+
.platform = "linux-64",
76+
.prefix_params =
77+
PrefixParams{
78+
.target_prefix = prefix,
79+
.root_prefix = prefix,
80+
.conda_prefix = prefix,
81+
.relocate_prefix = prefix,
82+
},
83+
.link_params = {},
84+
.threads_params = {},
85+
};
86+
87+
return TransactionContext(
88+
tx_params,
89+
{ "3.14.4", "3.14.4" },
90+
"lib/python3.14/site-packages",
91+
{}
92+
);
93+
};
94+
95+
std::string script_ext = util::on_win ? ".bat" : ".sh";
96+
std::string pre_link_name = ".test_pkg-pre-link" + script_ext;
97+
std::string post_link_name = ".test_pkg-post-link" + script_ext;
98+
std::string pre_unlink_name = ".test_pkg-pre-unlink" + script_ext;
99+
std::string post_unlink_name = ".test_pkg-post-unlink" + script_ext;
100+
101+
auto create_script = [](const fs::u8path& p, const fs::u8path& marker_path)
102+
{
103+
std::ofstream out = open_ofstream(p);
104+
if (util::on_win)
105+
{
106+
out << "@echo off\n";
107+
out << "echo done > \"" << marker_path.string() << "\"\n";
108+
}
109+
else
110+
{
111+
out << "#!/bin/sh\n";
112+
out << "echo done > \"" << marker_path.string() << "\"\n";
113+
}
114+
};
115+
116+
fs::u8path pre_link_marker = tmp_dir.path() / "pre_link_done.txt";
117+
fs::u8path post_link_marker = tmp_dir.path() / "post_link_done.txt";
118+
fs::u8path pre_unlink_marker = tmp_dir.path() / "pre_unlink_done.txt";
119+
fs::u8path post_unlink_marker = tmp_dir.path() / "post_unlink_done.txt";
120+
121+
create_script(pkg_source / bin_dir_relative / pre_link_name, pre_link_marker);
122+
create_script(prefix / bin_dir_relative / post_link_name, post_link_marker);
123+
create_script(prefix / bin_dir_relative / pre_unlink_name, pre_unlink_marker);
124+
create_script(prefix / bin_dir_relative / post_unlink_name, post_unlink_marker);
125+
126+
// Prepare metadata for unlinking
127+
{
128+
auto out = open_ofstream(prefix / "conda-meta" / (pkg.str() + ".json"));
129+
out << R"({
130+
"name": "test_pkg",
131+
"version": "1.0",
132+
"build_string": "0",
133+
"paths_data": { "paths": [], "paths_version": 1 }
134+
})";
135+
}
136+
// Prepare paths.json for linking
137+
{
138+
fs::create_directories(pkg_source / "info");
139+
auto out = open_ofstream(pkg_source / "info" / "paths.json");
140+
out << R"({ "paths": [], "paths_version": 1 })";
141+
}
142+
// Prepare repodata_record.json for linking
143+
{
144+
auto out = open_ofstream(pkg_source / "info" / "repodata_record.json");
145+
out << R"({ "noarch": null })";
146+
}
147+
148+
SECTION("linking executes pre-link and post-link")
149+
{
150+
auto tx_context = make_tx_context();
151+
LinkPackage link_pkg(pkg, cache_dir, &tx_context);
152+
153+
REQUIRE(link_pkg.execute());
154+
155+
REQUIRE(fs::exists(pre_link_marker));
156+
REQUIRE(fs::exists(post_link_marker));
157+
}
158+
159+
SECTION("unlinking executes pre-unlink and post-unlink")
160+
{
161+
auto tx_context = make_tx_context();
162+
UnlinkPackage unlink_pkg(pkg, cache_dir, &tx_context);
163+
164+
REQUIRE(unlink_pkg.execute());
165+
166+
REQUIRE(fs::exists(pre_unlink_marker));
167+
// post-unlink script should not be executed as deprecated
168+
REQUIRE(!fs::exists(post_unlink_marker));
169+
}
170+
}
171+
}
172+
} // namespace mamba

micromamba/tests/test_create.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2426,10 +2426,13 @@ def test_create_from_mirror_with_prefix(tmp_home, tmp_root_prefix, tmp_path):
24262426
for package in res["actions"]["LINK"]
24272427
)
24282428

2429-
# Verify no warnings in output
2430-
combined_output = result.stdout + result.stderr
2431-
assert "warning" not in combined_output.lower(), (
2432-
f"Unexpected warning in output:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
2429+
# Verify warning in output
2430+
assert (
2431+
helpers.find_message_in_json_logs(
2432+
res,
2433+
"Security Warning: This transaction includes executing package scripts (pre/post-link/unlink) if present.",
2434+
)
2435+
is not None
24332436
)
24342437

24352438

0 commit comments

Comments
 (0)