use std::collections::{HashMap, HashSet};
pub const MAX_PAYLOAD_LINK_DEPTH: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntryKind {
File,
Link,
Directory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayloadEntry {
pub path: String,
pub kind: EntryKind,
pub link_target: Option<String>,
}
impl PayloadEntry {
#[must_use]
pub fn file(path: impl Into<String>) -> Self {
Self {
path: path.into(),
kind: EntryKind::File,
link_target: None,
}
}
#[must_use]
pub fn link(path: impl Into<String>, target: impl Into<String>) -> Self {
Self {
path: path.into(),
kind: EntryKind::Link,
link_target: Some(target.into()),
}
}
#[must_use]
pub fn directory(path: impl Into<String>) -> Self {
Self {
path: path.into(),
kind: EntryKind::Directory,
link_target: None,
}
}
}
#[must_use]
pub fn is_relative_link_target(target: &str) -> bool {
if target.is_empty() || target.contains('\0') || target.contains('\\') {
return false;
}
if target.starts_with('/') {
return false;
}
let mut characters = target.chars();
!matches!(
(characters.next(), characters.next()),
(Some(letter), Some(':')) if letter.is_ascii_alphabetic()
)
}
#[must_use]
pub fn resolve_payload_link_target(link_path: &str, target: &str) -> Option<String> {
if !is_relative_link_target(target) {
return None;
}
let segments: Vec<&str> = link_path.split('/').collect();
let mut stack: Vec<&str> = segments[..segments.len() - 1].to_vec();
if segments.last().is_none_or(|last| last.is_empty()) {
return None;
}
for part in target.split('/') {
if part.is_empty() || part == "." {
continue;
}
if part == ".." {
if stack.is_empty() {
return None;
}
stack.pop();
continue;
}
stack.push(part);
}
if stack.is_empty() {
return None;
}
let resolved = stack.join("/");
if resolved == link_path {
return None;
}
Some(resolved)
}
#[must_use]
pub fn find_entry_through_link(entries: &[PayloadEntry]) -> Option<&str> {
let links: HashSet<&str> = entries
.iter()
.filter(|entry| entry.kind == EntryKind::Link)
.map(|entry| entry.path.as_str())
.collect();
if links.is_empty() {
return None;
}
for entry in entries {
for (index, _) in entry.path.match_indices('/') {
if links.contains(&entry.path[..index]) {
return Some(&entry.path);
}
}
}
None
}
#[must_use]
pub fn find_unresolvable_link(entries: &[PayloadEntry]) -> Option<&str> {
let by_path: HashMap<&str, &PayloadEntry> = entries
.iter()
.map(|entry| (entry.path.as_str(), entry))
.collect();
let mut directories: HashSet<&str> = HashSet::new();
for entry in entries {
if entry.kind == EntryKind::Directory {
directories.insert(&entry.path);
}
for (index, _) in entry.path.match_indices('/') {
directories.insert(&entry.path[..index]);
}
}
for entry in entries {
if entry.kind != EntryKind::Link {
continue;
}
let mut seen: HashSet<&str> = HashSet::from([entry.path.as_str()]);
let mut current = entry;
let mut depth = 0usize;
loop {
if depth >= MAX_PAYLOAD_LINK_DEPTH {
return Some(&entry.path);
}
let target = current.link_target.as_deref().unwrap_or("");
let Some(resolved) = resolve_payload_link_target(¤t.path, target) else {
return Some(&entry.path);
};
if directories.contains(resolved.as_str()) {
return Some(&entry.path);
}
let Some(next) = by_path.get(resolved.as_str()) else {
return Some(&entry.path);
};
match next.kind {
EntryKind::File => break,
EntryKind::Link => {
if !seen.insert(next.path.as_str()) {
return Some(&entry.path);
}
current = next;
}
EntryKind::Directory => return Some(&entry.path),
}
depth += 1;
}
}
None
}
#[must_use]
pub fn target_carries_links(platform: &str) -> bool {
platform != "windows"
}
#[cfg(test)]
mod tests {
use super::{
find_entry_through_link, find_unresolvable_link, is_relative_link_target,
resolve_payload_link_target, target_carries_links, PayloadEntry, MAX_PAYLOAD_LINK_DEPTH,
};
#[test]
fn only_relative_targets_are_shaped_like_a_payload_link() {
assert!(is_relative_link_target("python3.11"));
assert!(is_relative_link_target("../lib/libfoo.so.1"));
for invalid in ["", "/usr/bin/python", "C:/windows/system32", "a\\b", "a\0b"] {
assert!(!is_relative_link_target(invalid), "{invalid} was accepted");
}
}
#[test]
fn a_target_resolves_against_the_links_own_directory() {
assert_eq!(
resolve_payload_link_target("venv/bin/python", "python3.11").as_deref(),
Some("venv/bin/python3.11")
);
assert_eq!(
resolve_payload_link_target("venv/lib/libfoo.so", "../lib64/libfoo.so.1").as_deref(),
Some("venv/lib64/libfoo.so.1")
);
}
#[test]
fn a_target_may_never_climb_past_the_payload_root() {
assert_eq!(resolve_payload_link_target("venv/bin/python", "../../../etc/passwd"), None);
assert_eq!(resolve_payload_link_target("python", "../escape"), None);
assert_eq!(resolve_payload_link_target("venv/bin/python", "python"), None);
}
#[test]
fn a_chain_that_ends_at_a_file_is_carryable() {
let entries = vec![
PayloadEntry::link("venv/bin/python", "python3"),
PayloadEntry::link("venv/bin/python3", "python3.11"),
PayloadEntry::file("venv/bin/python3.11"),
];
assert_eq!(find_unresolvable_link(&entries), None);
assert_eq!(find_entry_through_link(&entries), None);
}
#[test]
fn a_chain_that_ends_anywhere_else_is_refused() {
let dangling = vec![PayloadEntry::link("venv/bin/python", "python3.11")];
assert_eq!(find_unresolvable_link(&dangling), Some("venv/bin/python"));
let explicit_directory = vec![
PayloadEntry::link("venv/lib/python3.1", "python3.11"),
PayloadEntry::directory("venv/lib/python3.11"),
PayloadEntry::file("venv/lib/python3.11/os.py"),
];
assert_eq!(
find_unresolvable_link(&explicit_directory),
Some("venv/lib/python3.1")
);
let implicit_directory = vec![
PayloadEntry::link("venv/lib/python3.1", "python3.11"),
PayloadEntry::file("venv/lib/python3.11/os.py"),
];
assert_eq!(
find_unresolvable_link(&implicit_directory),
Some("venv/lib/python3.1")
);
let cycle = vec![
PayloadEntry::link("a", "b"),
PayloadEntry::link("b", "a"),
];
assert!(find_unresolvable_link(&cycle).is_some());
let mut long: Vec<PayloadEntry> = (0..=MAX_PAYLOAD_LINK_DEPTH)
.map(|index| PayloadEntry::link(format!("l{index}"), format!("l{}", index + 1)))
.collect();
long.push(PayloadEntry::file(format!("l{}", MAX_PAYLOAD_LINK_DEPTH + 1)));
assert_eq!(find_unresolvable_link(&long), Some("l0"));
}
#[test]
fn nothing_may_be_written_through_a_link() {
let entries = vec![
PayloadEntry::link("venv/lib/python3.1", "python3.11"),
PayloadEntry::file("venv/lib/python3.11/os.py"),
PayloadEntry::file("venv/lib/python3.1/evil.py"),
];
assert_eq!(
find_entry_through_link(&entries),
Some("venv/lib/python3.1/evil.py")
);
}
#[test]
fn windows_boxes_carry_no_links() {
assert!(target_carries_links("macos"));
assert!(target_carries_links("linux"));
assert!(!target_carries_links("windows"));
}
}