Skip to content

Commit 1934b98

Browse files
committed
refactor(uv-trampoline): use PE resources to store trampoline type + path to python binary
`.rsrc` is the idiomatic way of storing metadata and non-code resources in PE binaries. This should make the resulting binaries more robust as they are no longer dependent on the exact location of a certain magic number. Addresses: #15022
1 parent 60ddadd commit 1934b98

File tree

3 files changed

+102
-157
lines changed

3 files changed

+102
-157
lines changed

crates/uv-trampoline-builder/src/lib.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,50 @@ if __name__ == "__main__":
578578
assert!(launcher.kind == LauncherKind::Python);
579579
assert!(launcher.python_path == python_executable_path);
580580

581+
// Now sign the launcher using Set-AuthenticodeSignature...
582+
#[cfg(windows)]
583+
{
584+
Command::new("powershell")
585+
.args([
586+
"-NoProfile",
587+
"-NonInteractive",
588+
"-Command",
589+
&format!(
590+
r#"
591+
$ErrorActionPreference = 'Stop'
592+
$store = 'Cert:\CurrentUser\My'
593+
$cert = New-SelfSignedCertificate `
594+
-Subject 'CN=UvTrampolineTest' `
595+
-Type CodeSigning `
596+
-KeyUsage DigitalSignature `
597+
-KeyAlgorithm RSA `
598+
-KeyLength 2048 `
599+
-CertStoreLocation $store;
600+
try {{
601+
Set-AuthenticodeSignature -FilePath '{}' -Certificate $cert;
602+
}} finally {{
603+
Remove-Item -Path "$store\$($cert.Thumbprint)" -Force;
604+
}}"#,
605+
console_bin_path
606+
.path()
607+
.display()
608+
.to_string()
609+
.replace("'", "''")
610+
),
611+
])
612+
.assert()
613+
.success();
614+
615+
// ...and verify that it still runs as it did.
616+
let stdout_predicate = "Hello from uv-trampoline-console.exe\r\n";
617+
let stderr_predicate = "Hello from uv-trampoline-console.exe\r\n";
618+
Command::new(console_bin_path.path())
619+
.assert()
620+
.success()
621+
.stdout(stdout_predicate)
622+
.stderr(stderr_predicate);
623+
}
624+
581625
Ok(())
582626
}
583627

crates/uv-trampoline/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,15 @@ windows = { version = "0.61.0", features = [
4242
"Win32_System_Console",
4343
"Win32_System_Environment",
4444
"Win32_System_JobObjects",
45+
"Win32_System_LibraryLoader",
4546
"Win32_System_Threading",
4647
"Win32_UI_WindowsAndMessaging",
4748
] }
4849
ufmt-write = "0.1.0"
4950
ufmt = { version = "0.2.0", features = ["std"] }
5051
dunce = { version = "1.0.5" }
52+
# TODO: not sure if we will need this for uv-trampoline-builder after all.
53+
# object = { version = "0.37.2" }
5154

5255
[build-dependencies]
5356
embed-manifest = "1.4.0"

crates/uv-trampoline/src/bounce.rs

Lines changed: 55 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
#![allow(clippy::disallowed_types)]
22
use std::ffi::{CString, c_void};
3-
use std::fs::File;
4-
use std::io::{Read, Seek, SeekFrom};
5-
use std::mem::size_of;
63
use std::path::{Path, PathBuf};
74
use std::vec::Vec;
85

@@ -14,6 +11,7 @@ use windows::Win32::{
1411
System::Console::{
1512
GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCtrlHandler, SetStdHandle,
1613
},
14+
System::LibraryLoader::{FindResourceA, LoadResource, LockResource, SizeofResource},
1715
System::Environment::GetCommandLineA,
1816
System::JobObjects::{
1917
AssignProcessToJobObject, CreateJobObjectA, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
@@ -32,10 +30,14 @@ use windows::Win32::{
3230
};
3331
use windows::core::{BOOL, PSTR, s};
3432

33+
// TODO: see if we need object::pe after all. Maybe we can keep the nice const for readability.
34+
// use object::pe::RT_RCDATA;
35+
3536
use crate::{error, format, warn};
3637

37-
const PATH_LEN_SIZE: usize = size_of::<u32>();
38-
const MAX_PATH_LEN: u32 = 32 * 1024;
38+
/// Resource IDs for the trampoline metadata
39+
const RESOURCE_TRAMPOLINE_TYPE: &str = "TRAMPOLINE_TYPE\0";
40+
const RESOURCE_PYTHON_PATH: &str = "PYTHON_PATH\0";
3941

4042
/// The kind of trampoline.
4143
enum TrampolineKind {
@@ -46,21 +48,33 @@ enum TrampolineKind {
4648
}
4749

4850
impl TrampolineKind {
49-
const fn magic_number(&self) -> &'static [u8; 4] {
50-
match self {
51-
Self::Script => b"UVSC",
52-
Self::Python => b"UVPY",
51+
fn from_resource(data: &[u8]) -> Option<Self> {
52+
match data.get(0) {
53+
Some(1) => Some(Self::Script),
54+
Some(2) => Some(Self::Python),
55+
_ => {None},
5356
}
5457
}
58+
}
5559

56-
fn from_buffer(buffer: &[u8]) -> Option<Self> {
57-
if buffer.ends_with(Self::Script.magic_number()) {
58-
Some(Self::Script)
59-
} else if buffer.ends_with(Self::Python.magic_number()) {
60-
Some(Self::Python)
61-
} else {
62-
None
63-
}
60+
/// Safely loads a resource from the current module
61+
fn load_resource(resource_id: &str) -> Option<Vec<u8>> {
62+
unsafe {
63+
// Find the resource
64+
let resource = FindResourceA(
65+
None,
66+
windows::core::PCSTR(resource_id.as_ptr() as _),
67+
windows::core::PCSTR(10 as *const _), // RT_RCDATA = 10
68+
).ok()?;
69+
70+
// Get resource size and data
71+
let size = SizeofResource(None, resource);
72+
let data = LoadResource(None, resource).ok();
73+
74+
let ptr = LockResource(data?) as *const u8;
75+
76+
// Copy the resource data into a Vec
77+
Some(std::slice::from_raw_parts(ptr, size as usize).to_vec())
6478
}
6579
}
6680

@@ -70,14 +84,34 @@ fn make_child_cmdline() -> CString {
7084
let executable_name = std::env::current_exe().unwrap_or_else(|_| {
7185
error_and_exit("Failed to get executable name");
7286
});
73-
let (kind, python_exe) = read_trampoline_metadata(executable_name.as_ref());
87+
88+
// Load trampoline type
89+
let trampoline_type = load_resource(RESOURCE_TRAMPOLINE_TYPE)
90+
.and_then(|data| TrampolineKind::from_resource(&data))
91+
.unwrap_or_else(|| error_and_exit("Failed to load trampoline type from resources"));
92+
93+
// Load Python path
94+
let python_path = load_resource(RESOURCE_PYTHON_PATH)
95+
.and_then(|data| String::from_utf8(data).ok())
96+
.map(PathBuf::from)
97+
.unwrap_or_else(|| error_and_exit("Failed to load Python path from resources"));
98+
99+
// Convert relative paths to absolute
100+
let python_exe = if python_path.is_absolute() {
101+
python_path
102+
} else {
103+
executable_name
104+
.parent()
105+
.unwrap_or_else(|| error_and_exit("Executable path has no parent directory"))
106+
.join(python_path)
107+
};
108+
74109
let mut child_cmdline = Vec::<u8>::new();
75-
76110
push_quoted_path(python_exe.as_ref(), &mut child_cmdline);
77111
child_cmdline.push(b' ');
78112

79113
// Only execute the trampoline again if it's a script, otherwise, just invoke Python.
80-
match kind {
114+
match trampoline_type {
81115
TrampolineKind::Python => {
82116
// SAFETY: `std::env::set_var` is safe to call on Windows, and
83117
// this code only ever runs on Windows.
@@ -145,6 +179,7 @@ fn push_quoted_path(path: &Path, command: &mut Vec<u8>) {
145179
command.extend(br#"""#);
146180
}
147181

182+
148183
/// Checks if the given executable is part of a virtual environment
149184
///
150185
/// Checks if a `pyvenv.cfg` file exists in grandparent directory of the given executable.
@@ -159,143 +194,6 @@ fn is_virtualenv(executable: &Path) -> bool {
159194
.unwrap_or(false)
160195
}
161196

162-
/// Reads the executable binary from the back to find:
163-
///
164-
/// * The path to the Python executable
165-
/// * The kind of trampoline we are executing
166-
///
167-
/// The executable is expected to have the following format:
168-
///
169-
/// * The file must end with the magic number 'UVPY' or 'UVSC' (identifying the trampoline kind)
170-
/// * The last 4 bytes (little endian) are the length of the path to the Python executable.
171-
/// * The path encoded as UTF-8 comes right before the length
172-
///
173-
/// # Panics
174-
///
175-
/// If there's any IO error, or the file does not conform to the specified format.
176-
fn read_trampoline_metadata(executable_name: &Path) -> (TrampolineKind, PathBuf) {
177-
let mut file_handle = File::open(executable_name).unwrap_or_else(|_| {
178-
print_last_error_and_exit(&format!(
179-
"Failed to open executable '{}'",
180-
&*executable_name.to_string_lossy(),
181-
));
182-
});
183-
184-
let metadata = executable_name.metadata().unwrap_or_else(|_| {
185-
print_last_error_and_exit(&format!(
186-
"Failed to get the size of the executable '{}'",
187-
&*executable_name.to_string_lossy(),
188-
));
189-
});
190-
let file_size = metadata.len();
191-
192-
// Start with a size of 1024 bytes which should be enough for most paths but avoids reading the
193-
// entire file.
194-
let mut buffer: Vec<u8> = Vec::new();
195-
let mut bytes_to_read = 1024.min(u32::try_from(file_size).unwrap_or(u32::MAX));
196-
197-
let mut kind;
198-
let path: String = loop {
199-
// SAFETY: Casting to usize is safe because we only support 64bit systems where usize is guaranteed to be larger than u32.
200-
buffer.resize(bytes_to_read as usize, 0);
201-
202-
file_handle
203-
.seek(SeekFrom::Start(file_size - u64::from(bytes_to_read)))
204-
.unwrap_or_else(|_| {
205-
print_last_error_and_exit("Failed to set the file pointer to the end of the file");
206-
});
207-
208-
// Pulls in core::fmt::{write, Write, getcount}
209-
let read_bytes = file_handle.read(&mut buffer).unwrap_or_else(|_| {
210-
print_last_error_and_exit("Failed to read the executable file");
211-
});
212-
213-
// Truncate the buffer to the actual number of bytes read.
214-
buffer.truncate(read_bytes);
215-
216-
let Some(inner_kind) = TrampolineKind::from_buffer(&buffer) else {
217-
error_and_exit(
218-
"Magic number 'UVSC' or 'UVPY' not found at the end of the file. Did you append the magic number, the length and the path to the python executable at the end of the file?",
219-
);
220-
};
221-
kind = inner_kind;
222-
223-
// Remove the magic number
224-
buffer.truncate(buffer.len() - kind.magic_number().len());
225-
226-
let path_len = match buffer.get(buffer.len() - PATH_LEN_SIZE..) {
227-
Some(path_len) => {
228-
let path_len = u32::from_le_bytes(path_len.try_into().unwrap_or_else(|_| {
229-
error_and_exit("Slice length is not equal to 4 bytes");
230-
}));
231-
232-
if path_len > MAX_PATH_LEN {
233-
error_and_exit(&format!(
234-
"Only paths with a length up to 32KBs are supported but the python path has a length of {}",
235-
path_len
236-
));
237-
}
238-
239-
// SAFETY: path len is guaranteed to be less than 32KBs
240-
path_len as usize
241-
}
242-
None => {
243-
error_and_exit(
244-
"Python executable length missing. Did you write the length of the path to the Python executable before the Magic number?",
245-
);
246-
}
247-
};
248-
249-
// Remove the path length
250-
buffer.truncate(buffer.len() - PATH_LEN_SIZE);
251-
252-
if let Some(path_offset) = buffer.len().checked_sub(path_len) {
253-
buffer.drain(..path_offset);
254-
255-
break String::from_utf8(buffer).unwrap_or_else(|_| {
256-
error_and_exit("Python executable path is not a valid UTF-8 encoded path");
257-
});
258-
} else {
259-
// SAFETY: Casting to u32 is safe because `path_len` is guaranteed to be less than 32KBs,
260-
// MAGIC_NUMBER is 4 bytes and PATH_LEN_SIZE is 4 bytes.
261-
bytes_to_read = (path_len + kind.magic_number().len() + PATH_LEN_SIZE) as u32;
262-
263-
if u64::from(bytes_to_read) > file_size {
264-
error_and_exit(
265-
"The length of the python executable path exceeds the file size. Verify that the path length is appended to the end of the launcher script as a u32 in little endian",
266-
);
267-
}
268-
}
269-
};
270-
271-
let path = PathBuf::from(path);
272-
let path = if path.is_absolute() {
273-
path
274-
} else {
275-
let parent_dir = match executable_name.parent() {
276-
Some(parent) => parent,
277-
None => {
278-
error_and_exit("Executable path has no parent directory");
279-
}
280-
};
281-
parent_dir.join(path)
282-
};
283-
284-
let path = if !path.is_absolute() || matches!(kind, TrampolineKind::Script) {
285-
// NOTICE: dunce adds 5kb~
286-
// TODO(john): In order to avoid resolving junctions and symlinks for relative paths and
287-
// scripts, we can consider reverting https://github.com/astral-sh/uv/pull/5750/files#diff-969979506be03e89476feade2edebb4689a9c261f325988d3c7efc5e51de26d1L273-L277.
288-
dunce::canonicalize(path.as_path()).unwrap_or_else(|_| {
289-
error_and_exit("Failed to canonicalize script path");
290-
})
291-
} else {
292-
// For Python trampolines with absolute paths, we skip `dunce::canonicalize` to
293-
// avoid resolving junctions.
294-
path
295-
};
296-
297-
(kind, path)
298-
}
299197

300198
fn push_arguments(output: &mut Vec<u8>) {
301199
// SAFETY: We rely on `GetCommandLineA` to return a valid pointer to a null terminated string.

0 commit comments

Comments
 (0)