scrollcase_consumer/contract/
links.rs1use std::collections::{HashMap, HashSet};
22
23pub const MAX_PAYLOAD_LINK_DEPTH: usize = 8;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum EntryKind {
32 File,
34 Link,
36 Directory,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PayloadEntry {
43 pub path: String,
45 pub kind: EntryKind,
47 pub link_target: Option<String>,
49}
50
51impl PayloadEntry {
52 #[must_use]
54 pub fn file(path: impl Into<String>) -> Self {
55 Self {
56 path: path.into(),
57 kind: EntryKind::File,
58 link_target: None,
59 }
60 }
61
62 #[must_use]
64 pub fn link(path: impl Into<String>, target: impl Into<String>) -> Self {
65 Self {
66 path: path.into(),
67 kind: EntryKind::Link,
68 link_target: Some(target.into()),
69 }
70 }
71
72 #[must_use]
74 pub fn directory(path: impl Into<String>) -> Self {
75 Self {
76 path: path.into(),
77 kind: EntryKind::Directory,
78 link_target: None,
79 }
80 }
81}
82
83#[must_use]
85pub fn is_relative_link_target(target: &str) -> bool {
86 if target.is_empty() || target.contains('\0') || target.contains('\\') {
87 return false;
88 }
89 if target.starts_with('/') {
90 return false;
91 }
92 let mut characters = target.chars();
93 !matches!(
94 (characters.next(), characters.next()),
95 (Some(letter), Some(':')) if letter.is_ascii_alphabetic()
96 )
97}
98
99#[must_use]
104pub fn resolve_payload_link_target(link_path: &str, target: &str) -> Option<String> {
105 if !is_relative_link_target(target) {
106 return None;
107 }
108 let segments: Vec<&str> = link_path.split('/').collect();
109 let mut stack: Vec<&str> = segments[..segments.len() - 1].to_vec();
111 if segments.last().is_none_or(|last| last.is_empty()) {
112 return None;
113 }
114 for part in target.split('/') {
115 if part.is_empty() || part == "." {
116 continue;
117 }
118 if part == ".." {
119 if stack.is_empty() {
122 return None;
123 }
124 stack.pop();
125 continue;
126 }
127 stack.push(part);
128 }
129 if stack.is_empty() {
130 return None;
131 }
132 let resolved = stack.join("/");
133 if resolved == link_path {
134 return None;
135 }
136 Some(resolved)
137}
138
139#[must_use]
143pub fn find_entry_through_link(entries: &[PayloadEntry]) -> Option<&str> {
144 let links: HashSet<&str> = entries
145 .iter()
146 .filter(|entry| entry.kind == EntryKind::Link)
147 .map(|entry| entry.path.as_str())
148 .collect();
149 if links.is_empty() {
150 return None;
151 }
152 for entry in entries {
153 for (index, _) in entry.path.match_indices('/') {
154 if links.contains(&entry.path[..index]) {
155 return Some(&entry.path);
156 }
157 }
158 }
159 None
160}
161
162#[must_use]
168pub fn find_unresolvable_link(entries: &[PayloadEntry]) -> Option<&str> {
169 let by_path: HashMap<&str, &PayloadEntry> = entries
170 .iter()
171 .map(|entry| (entry.path.as_str(), entry))
172 .collect();
173 let mut directories: HashSet<&str> = HashSet::new();
174 for entry in entries {
175 if entry.kind == EntryKind::Directory {
176 directories.insert(&entry.path);
177 }
178 for (index, _) in entry.path.match_indices('/') {
179 directories.insert(&entry.path[..index]);
180 }
181 }
182
183 for entry in entries {
184 if entry.kind != EntryKind::Link {
185 continue;
186 }
187 let mut seen: HashSet<&str> = HashSet::from([entry.path.as_str()]);
188 let mut current = entry;
189 let mut depth = 0usize;
190 loop {
191 if depth >= MAX_PAYLOAD_LINK_DEPTH {
192 return Some(&entry.path);
193 }
194 let target = current.link_target.as_deref().unwrap_or("");
195 let Some(resolved) = resolve_payload_link_target(¤t.path, target) else {
196 return Some(&entry.path);
197 };
198 if directories.contains(resolved.as_str()) {
201 return Some(&entry.path);
202 }
203 let Some(next) = by_path.get(resolved.as_str()) else {
204 return Some(&entry.path);
205 };
206 match next.kind {
207 EntryKind::File => break,
208 EntryKind::Link => {
209 if !seen.insert(next.path.as_str()) {
210 return Some(&entry.path);
211 }
212 current = next;
213 }
214 EntryKind::Directory => return Some(&entry.path),
215 }
216 depth += 1;
217 }
218 }
219 None
220}
221
222#[must_use]
228pub fn target_carries_links(platform: &str) -> bool {
229 platform != "windows"
230}
231
232#[cfg(test)]
233mod tests {
234 use super::{
235 find_entry_through_link, find_unresolvable_link, is_relative_link_target,
236 resolve_payload_link_target, target_carries_links, PayloadEntry, MAX_PAYLOAD_LINK_DEPTH,
237 };
238
239 #[test]
240 fn only_relative_targets_are_shaped_like_a_payload_link() {
241 assert!(is_relative_link_target("python3.11"));
242 assert!(is_relative_link_target("../lib/libfoo.so.1"));
243 for invalid in ["", "/usr/bin/python", "C:/windows/system32", "a\\b", "a\0b"] {
244 assert!(!is_relative_link_target(invalid), "{invalid} was accepted");
245 }
246 }
247
248 #[test]
249 fn a_target_resolves_against_the_links_own_directory() {
250 assert_eq!(
251 resolve_payload_link_target("venv/bin/python", "python3.11").as_deref(),
252 Some("venv/bin/python3.11")
253 );
254 assert_eq!(
255 resolve_payload_link_target("venv/lib/libfoo.so", "../lib64/libfoo.so.1").as_deref(),
256 Some("venv/lib64/libfoo.so.1")
257 );
258 }
259
260 #[test]
261 fn a_target_may_never_climb_past_the_payload_root() {
262 assert_eq!(resolve_payload_link_target("venv/bin/python", "../../../etc/passwd"), None);
263 assert_eq!(resolve_payload_link_target("python", "../escape"), None);
264 assert_eq!(resolve_payload_link_target("venv/bin/python", "python"), None);
266 }
267
268 #[test]
269 fn a_chain_that_ends_at_a_file_is_carryable() {
270 let entries = vec![
271 PayloadEntry::link("venv/bin/python", "python3"),
272 PayloadEntry::link("venv/bin/python3", "python3.11"),
273 PayloadEntry::file("venv/bin/python3.11"),
274 ];
275 assert_eq!(find_unresolvable_link(&entries), None);
276 assert_eq!(find_entry_through_link(&entries), None);
277 }
278
279 #[test]
280 fn a_chain_that_ends_anywhere_else_is_refused() {
281 let dangling = vec![PayloadEntry::link("venv/bin/python", "python3.11")];
283 assert_eq!(find_unresolvable_link(&dangling), Some("venv/bin/python"));
284
285 let explicit_directory = vec![
290 PayloadEntry::link("venv/lib/python3.1", "python3.11"),
291 PayloadEntry::directory("venv/lib/python3.11"),
292 PayloadEntry::file("venv/lib/python3.11/os.py"),
293 ];
294 assert_eq!(
295 find_unresolvable_link(&explicit_directory),
296 Some("venv/lib/python3.1")
297 );
298
299 let implicit_directory = vec![
300 PayloadEntry::link("venv/lib/python3.1", "python3.11"),
301 PayloadEntry::file("venv/lib/python3.11/os.py"),
302 ];
303 assert_eq!(
304 find_unresolvable_link(&implicit_directory),
305 Some("venv/lib/python3.1")
306 );
307
308 let cycle = vec![
310 PayloadEntry::link("a", "b"),
311 PayloadEntry::link("b", "a"),
312 ];
313 assert!(find_unresolvable_link(&cycle).is_some());
314
315 let mut long: Vec<PayloadEntry> = (0..=MAX_PAYLOAD_LINK_DEPTH)
317 .map(|index| PayloadEntry::link(format!("l{index}"), format!("l{}", index + 1)))
318 .collect();
319 long.push(PayloadEntry::file(format!("l{}", MAX_PAYLOAD_LINK_DEPTH + 1)));
320 assert_eq!(find_unresolvable_link(&long), Some("l0"));
321 }
322
323 #[test]
324 fn nothing_may_be_written_through_a_link() {
325 let entries = vec![
326 PayloadEntry::link("venv/lib/python3.1", "python3.11"),
327 PayloadEntry::file("venv/lib/python3.11/os.py"),
328 PayloadEntry::file("venv/lib/python3.1/evil.py"),
329 ];
330 assert_eq!(
331 find_entry_through_link(&entries),
332 Some("venv/lib/python3.1/evil.py")
333 );
334 }
335
336 #[test]
337 fn windows_boxes_carry_no_links() {
338 assert!(target_carries_links("macos"));
339 assert!(target_carries_links("linux"));
340 assert!(!target_carries_links("windows"));
341 }
342}