1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fmt,
4 rc::Rc,
5};
6
7#[derive(Debug)]
9pub struct SourceLinkIssue {
10 pub path: String,
11 pub target: String,
12 pub reason: &'static str,
13}
14
15impl fmt::Display for SourceLinkIssue {
16 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17 write!(formatter, "{:?} -> {:?}: {}", self.path, self.target, self.reason)
18 }
19}
20
21pub fn validate_source_links<'a>(entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>) -> Result<(), String> {
28 let issues = source_link_issues(entries)?;
29 if issues.is_empty() {
30 Ok(())
31 } else {
32 Err(issues.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n"))
33 }
34}
35
36pub fn source_link_issues<'a>(
41 entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
42) -> Result<Vec<SourceLinkIssue>, String> {
43 let entries = entries.into_iter().collect::<BTreeMap<_, _>>();
44 let mut links = BTreeMap::new();
45 let mut cache = BTreeMap::new();
46 for (&path, &target) in &entries {
47 let mut parent = path;
48 while let Some((ancestor, _)) = parent.rsplit_once('/') {
49 if entries.contains_key(ancestor) {
50 return Err(format!("source entry `{ancestor}` is also a parent of `{path}`"));
51 }
52 parent = ancestor;
53 }
54 if let Some(target) = target {
55 if let Some(reason) = invalid_target(target) {
56 cache.insert(
57 path.to_owned(),
58 Err(Rc::new(LinkFailure {
59 path: path.to_owned(),
60 reason,
61 })),
62 );
63 }
64 links.insert(path, target);
65 }
66 }
67 let mut issues = BTreeMap::new();
68 for &path in links.keys() {
69 if let Err(failure) = resolve(path, &links, &mut cache, None) {
70 issues.entry(failure.path.clone()).or_insert_with(|| SourceLinkIssue {
71 path: failure.path.clone(),
72 target: links[failure.path.as_str()].to_owned(),
73 reason: failure.reason,
74 });
75 }
76 }
77 Ok(issues.into_values().collect())
78}
79
80pub fn source_link_warnings<'a>(entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>) -> Vec<SourceLinkIssue> {
83 let entries = entries.into_iter().collect::<Vec<_>>();
84 let links = entries
85 .iter()
86 .filter_map(|&(path, target)| target.map(|target| (path, target)))
87 .collect::<BTreeMap<_, _>>();
88 if links.is_empty() {
89 return Vec::new();
90 }
91 let paths = SourcePaths::new(entries.iter().map(|&(path, _)| path));
92 let mut cache = BTreeMap::new();
93 links
94 .iter()
95 .filter_map(|(&path, &target)| {
96 let reason = match resolve(path, &links, &mut cache, Some(&paths)) {
97 Ok(Some(_)) => return None,
98 Ok(None) => "target contains a symlink cycle",
99 Err(failure) => failure.reason,
100 };
101 Some(SourceLinkIssue {
102 path: path.to_owned(),
103 target: target.to_owned(),
104 reason,
105 })
106 })
107 .collect()
108}
109
110struct SourcePaths<'a> {
111 files: BTreeSet<&'a str>,
112 directories: BTreeSet<&'a str>,
113}
114
115impl<'a> SourcePaths<'a> {
116 fn new(files: impl Iterator<Item = &'a str>) -> Self {
117 let files = files.collect::<BTreeSet<_>>();
118 let mut directories = BTreeSet::from([""]);
119 for &file in &files {
120 let mut path = file;
121 while let Some((parent, _)) = path.rsplit_once('/') {
122 directories.insert(parent);
123 path = parent;
124 }
125 }
126 Self { files, directories }
127 }
128
129 fn issue(&self, path: &[&str], traversing: bool) -> Option<&'static str> {
130 let path = path.join("/");
131 if self.directories.contains(path.as_str()) {
132 None
133 } else if self.files.contains(path.as_str()) {
134 traversing.then_some("target traverses a file as a directory")
135 } else {
136 Some("target is missing from the submitted source")
137 }
138 }
139}
140
141fn invalid_target(target: &str) -> Option<&'static str> {
142 if target.is_empty() {
143 Some("target is empty")
144 } else if target.len() > 4096 {
145 Some("target exceeds 4096 bytes")
146 } else if target.starts_with('/') || target.as_bytes().get(1) == Some(&b':') {
147 Some("absolute or drive-qualified targets are not supported")
148 } else if target.contains(['\\', '\0']) {
149 Some("target contains a backslash or NUL byte")
150 } else if target.split('/').any(|part| part.eq_ignore_ascii_case(".git")) {
151 Some("target enters reserved .git or .hydra metadata")
152 } else {
153 None
154 }
155}
156
157pub fn resolve_source_path<'a>(
162 entries: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
163 path: &'a str,
164) -> Result<Option<String>, String> {
165 let entries = entries.into_iter().collect::<BTreeMap<_, _>>();
166 let links = entries
167 .iter()
168 .filter_map(|(&path, &target)| target.map(|target| (path, target)))
169 .collect::<BTreeMap<_, _>>();
170 let follows_link = std::iter::successors(Some(path), |path| path.rsplit_once('/').map(|(parent, _)| parent))
173 .any(|prefix| links.contains_key(prefix));
174 let paths = follows_link.then(|| SourcePaths::new(entries.keys().copied()));
175 resolve(path, &links, &mut BTreeMap::new(), paths.as_ref())
176 .map_err(|error| format!("symlink {:?}: {}", error.path, error.reason))
177 .map(|resolved| {
178 resolved.map(|parts| {
179 if parts.is_empty() {
180 ".".to_owned()
181 } else {
182 parts.join("/")
183 }
184 })
185 })
186}
187
188#[derive(Debug)]
189struct LinkFailure {
190 path: String,
191 reason: &'static str,
192}
193
194type ResolvedPath<'a> = Result<Option<Vec<&'a str>>, Rc<LinkFailure>>;
195
196fn cache_failure<'a>(
197 stack: &[Resolution<'a>],
198 cache: &mut BTreeMap<String, ResolvedPath<'a>>,
199 failure: Rc<LinkFailure>,
200) -> Rc<LinkFailure> {
201 for frame in stack {
202 if let Some(link) = &frame.link {
203 cache.insert(link.clone(), Err(Rc::clone(&failure)));
204 }
205 }
206 failure
207}
208
209struct Resolution<'a> {
210 path: Vec<&'a str>,
211 remaining: std::str::Split<'a, char>,
212 link: Option<String>,
213}
214
215impl Resolution<'_> {
216 fn failure(&self, path: &str, reason: &'static str) -> Rc<LinkFailure> {
217 Rc::new(LinkFailure {
218 path: self.link.clone().unwrap_or_else(|| path.to_owned()),
219 reason,
220 })
221 }
222}
223
224fn resolve<'a>(
225 remaining: &'a str,
226 links: &BTreeMap<&str, &'a str>,
227 cache: &mut BTreeMap<String, ResolvedPath<'a>>,
228 paths: Option<&SourcePaths<'_>>,
229) -> ResolvedPath<'a> {
230 let mut stack = vec![Resolution {
232 path: Vec::new(),
233 remaining: remaining.split('/'),
234 link: None,
235 }];
236 let mut active = BTreeSet::new();
237 while let Some(frame) = stack.last_mut() {
238 let Some(component) = frame.remaining.next() else {
239 let resolved = stack.pop().expect("resolution frame exists");
240 if let Some(link) = resolved.link {
241 active.remove(&link);
242 cache.insert(link, Ok(Some(resolved.path.clone())));
243 }
244 if let Some(parent) = stack.last_mut() {
245 parent.path = resolved.path;
246 continue;
247 }
248 return Ok(Some(resolved.path));
249 };
250 if let Some(reason) = paths.and_then(|paths| paths.issue(&frame.path, true)) {
251 let failure = frame.failure(remaining, reason);
252 return Err(cache_failure(&stack, cache, failure));
253 }
254 match component {
255 "" | "." => {}
256 ".." => {
257 if frame.path.pop().is_none() {
258 let failure = frame.failure(remaining, "target escapes the repository");
259 return Err(cache_failure(&stack, cache, failure));
260 }
261 }
262 component => {
263 if component == ".hydra" && frame.path.is_empty() {
266 let failure = frame.failure(remaining, "target enters reserved .git or .hydra metadata");
267 return Err(cache_failure(&stack, cache, failure));
268 }
269 frame.path.push(component);
270 if frame.path.iter().map(|part| part.len() + 1).sum::<usize>() > 4096 {
271 let failure = frame.failure(remaining, "resolved target exceeds 4096 bytes");
272 return Err(cache_failure(&stack, cache, failure));
273 }
274 if let Some(reason) = paths.and_then(|paths| paths.issue(&frame.path, false)) {
275 let failure = frame.failure(remaining, reason);
276 return Err(cache_failure(&stack, cache, failure));
277 }
278 let name = frame.path.join("/");
279 if let Some(&target) = links.get(name.as_str()) {
280 if let Some(cached) = cache.get(&name) {
281 match cached {
282 Ok(Some(resolved)) => {
283 frame.path.clone_from(resolved);
284 continue;
285 }
286 Err(failure) => {
287 return Err(cache_failure(&stack, cache, Rc::clone(failure)));
288 }
289 Ok(None) => {}
290 }
291 }
292 if cache.contains_key(&name) || !active.insert(name.clone()) {
293 for link in active {
295 cache.insert(link, Ok(None));
296 }
297 return Ok(None);
298 }
299 frame.path.pop();
300 let path = std::mem::take(&mut frame.path);
301 stack.push(Resolution {
302 path,
303 remaining: target.split('/'),
304 link: Some(name),
305 });
306 }
307 }
308 }
309 }
310 Ok(Some(Vec::new()))
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn long_chains_are_preserved_and_checked_without_dereferencing() {
319 let mut entries = (0..1000)
320 .map(|index| (format!("link{index:04}"), format!("link{:04}", index + 1)))
321 .collect::<Vec<_>>();
322 validate_source_links(
323 entries
324 .iter()
325 .map(|(path, target)| (path.as_str(), Some(target.as_str()))),
326 )
327 .unwrap();
328 assert_eq!(
329 source_link_warnings(
330 entries
331 .iter()
332 .map(|(path, target)| (path.as_str(), Some(target.as_str())))
333 )
334 .len(),
335 1000
336 );
337 entries.push(("link1000".to_owned(), "../outside".to_owned()));
338 let issues = source_link_issues(
339 entries
340 .iter()
341 .map(|(path, target)| (path.as_str(), Some(target.as_str()))),
342 )
343 .unwrap();
344 assert_eq!(issues.len(), 1);
345 assert_eq!(issues[0].path, "link1000");
346 assert_eq!(issues[0].target, "../outside");
347 assert!(!issues[0].to_string().contains("related link"));
348 }
349
350 #[test]
351 fn git_links_preserve_missing_targets_cycles_and_parent_components() {
352 validate_source_links([
353 ("src/file", None),
354 ("src/link", Some("../src/file")),
355 ("missing", Some("generated/output")),
356 ("cycle-a", Some("cycle-b")),
357 ("cycle-b", Some("cycle-a")),
358 ("dir", Some("src")),
359 ("twice", Some("dir/../dir/file")),
360 ])
361 .unwrap();
362 }
363
364 #[test]
365 fn warnings_distinguish_missing_targets_cycles_and_non_directory_paths() {
366 let entries = [
367 ("src/file", None),
368 ("src/nested/file", None),
369 ("file", Some("src/file")),
370 ("directory", Some("src")),
371 ("directory-file", Some("directory/nested/file")),
372 ("root", Some(".")),
373 ("root-file", Some("root/src/file")),
374 ("missing", Some("generated/output")),
375 ("missing-chain", Some("missing")),
376 ("missing-parent", Some("absent/../src/file")),
377 ("not-directory", Some("file/../file")),
378 ("trailing-slash", Some("file/")),
379 ("cycle-a", Some("cycle-b")),
380 ("cycle-b", Some("cycle-a")),
381 ];
382 validate_source_links(entries).unwrap();
383 let warnings = source_link_warnings(entries);
384 let details = warnings
385 .iter()
386 .map(|warning| (warning.path.as_str(), warning.reason))
387 .collect::<Vec<_>>();
388 assert_eq!(
389 details,
390 [
391 ("cycle-a", "target contains a symlink cycle"),
392 ("cycle-b", "target contains a symlink cycle"),
393 ("missing", "target is missing from the submitted source"),
394 ("missing-chain", "target is missing from the submitted source"),
395 ("missing-parent", "target is missing from the submitted source"),
396 ("not-directory", "target traverses a file as a directory"),
397 ("trailing-slash", "target traverses a file as a directory"),
398 ]
399 );
400 assert_eq!(warnings[3].target, "missing");
401 assert!(source_link_warnings([("src/file", None), ("alias", Some("src"))]).is_empty());
402 }
403
404 #[test]
405 fn rejects_escapes_including_directory_links_and_missing_components() {
406 for target in [
407 "../outside",
408 "/outside",
409 "C:/outside",
410 "C:outside",
411 "\\\\host\\share",
412 "missing/../../outside",
413 ".git/config",
414 ] {
415 assert!(validate_source_links([("link", Some(target))]).is_err(), "{target}");
416 }
417 assert!(validate_source_links([("deep/dir", Some("..")), ("link", Some("deep/dir/../outside")),]).is_err());
418 assert!(validate_source_links([("link", Some("dir")), ("link/file", None)]).is_err());
419 }
420
421 #[test]
422 fn hydra_metadata_is_reserved_only_at_the_resolved_repository_root() {
423 let entries = [
424 ("nested/.hydra/state", None),
425 ("nested/link", Some(".hydra/state")),
426 ("dir", Some("nested")),
427 ("alias", Some("dir/.hydra/state")),
428 ("missing", Some("other/.hydra/state")),
429 ];
430 validate_source_links(entries).unwrap();
431 for path in ["nested/link", "alias"] {
432 assert_eq!(
433 resolve_source_path(entries, path).unwrap().as_deref(),
434 Some("nested/.hydra/state")
435 );
436 }
437
438 for (path, target) in [
439 ("link", ".hydra/state"),
440 ("link", "./.hydra/state"),
441 ("link", ".hydra/../source"),
442 ("nested/link", "../.hydra/state"),
443 ("link", "nested/../.hydra/state"),
444 ("link", "dir/../.hydra/state"),
445 ("link", "nested/up/.hydra/state"),
446 ("link", "root/.hydra/state"),
447 ("link", ".git/config"),
448 ("link", "nested/.git/config"),
449 ] {
450 let entries = [
451 ("dir", Some("nested")),
452 ("nested/up", Some("..")),
453 ("root", Some(".")),
454 (path, Some(target)),
455 ];
456 let issues = source_link_issues(entries).unwrap();
457 assert_eq!(issues.len(), 1, "{path} -> {target}");
458 assert_eq!(issues[0].path, path);
459 assert_eq!(issues[0].target, target);
460 assert_eq!(issues[0].reason, "target enters reserved .git or .hydra metadata");
461 }
462 }
463
464 #[test]
465 fn reports_all_causes_and_retains_aliases_after_the_causes_are_excluded() {
466 let entries = [
467 ("alias", Some("outside")),
468 ("another-alias", Some("alias")),
469 ("absolute", Some("/external")),
470 ("deep/dir", Some("..")),
471 ("indirect", Some("deep/dir/../secret")),
472 ("metadata", Some(".git/config")),
473 ("outside", Some("../external")),
474 ("cycle-a", Some("cycle-b")),
475 ("cycle-b", Some("cycle-a")),
476 ("missing", Some("generated/output")),
477 ];
478 let issues = source_link_issues(entries).unwrap();
479 assert_eq!(
480 issues.iter().map(|issue| issue.path.as_str()).collect::<Vec<_>>(),
481 ["absolute", "indirect", "metadata", "outside"]
482 );
483 assert_eq!(
484 issues[3].to_string(),
485 "\"outside\" -> \"../external\": target escapes the repository"
486 );
487 let remaining = entries
488 .into_iter()
489 .filter(|(path, _)| !issues.iter().any(|issue| issue.path == *path))
490 .collect::<Vec<_>>();
491 validate_source_links(remaining.iter().copied()).unwrap();
492 assert!(
493 resolve_source_path(remaining, "another-alias")
494 .unwrap_err()
495 .contains("missing")
496 );
497 }
498
499 #[test]
500 fn invalid_targets_and_diagnostics_are_safe_and_deterministic() {
501 let entries = [
502 ("tab\tlink", Some("../outside\nname")),
503 ("windows", Some("C:/outside")),
504 ("reserved", Some(".hydra/state")),
505 ];
506 let forward = validate_source_links(entries).unwrap_err();
507 let reversed = validate_source_links(entries.into_iter().rev()).unwrap_err();
508 assert_eq!(forward, reversed);
509 assert!(forward.contains("\"tab\\tlink\" -> \"../outside\\nname\""));
510 assert!(forward.contains("\"windows\" -> \"C:/outside\""));
511 assert!(forward.contains("\"reserved\" -> \".hydra/state\""));
512 assert!(!forward.contains('\t'));
513 }
514}