1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum Anchor {
10 Free,
13 RelativePlain,
15 RelativeSensitive,
18 RelativeUnplaced,
21 ImplicitOutput,
24 ImplicitSource,
27 RunsFolderCode,
29 NamesItsWrites,
33}
34
35impl Anchor {
36 pub fn name(self) -> &'static str {
37 match self {
38 Anchor::Free => "anchor-free",
39 Anchor::RelativePlain => "relative-plain",
40 Anchor::RelativeSensitive => "relative-sensitive",
41 Anchor::RelativeUnplaced => "relative-unplaced",
42 Anchor::ImplicitOutput => "implicit-output",
43 Anchor::ImplicitSource => "implicit-source",
44 Anchor::RunsFolderCode => "runs-folder-code",
45 Anchor::NamesItsWrites => "names-its-writes",
46 }
47 }
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
53pub enum FolderLevel {
54 Reads,
56 Developer,
58 Workspace,
60}
61
62impl FolderLevel {
63 pub const DEFAULT: FolderLevel = FolderLevel::Developer;
64 pub const NAMES: [&'static str; 3] = ["reads", "developer", "workspace"];
65
66 pub fn parse(name: &str) -> Option<FolderLevel> {
67 match name {
68 "reads" => Some(FolderLevel::Reads),
69 "developer" => Some(FolderLevel::Developer),
70 "workspace" => Some(FolderLevel::Workspace),
71 _ => None,
72 }
73 }
74
75 pub fn name(self) -> &'static str {
76 match self {
77 FolderLevel::Reads => "reads",
78 FolderLevel::Developer => "developer",
79 FolderLevel::Workspace => "workspace",
80 }
81 }
82
83 pub fn admits_implicit(self, anchor: Anchor) -> bool {
85 self >= FolderLevel::Developer
86 && matches!(anchor, Anchor::Free | Anchor::ImplicitOutput | Anchor::ImplicitSource | Anchor::RunsFolderCode)
87 }
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Use {
93 Read,
94 Write,
95 Rebind,
100}
101
102impl Use {
103 pub(crate) fn mutates(self) -> bool {
106 self != Use::Read
107 }
108}
109
110pub fn of_path(path: &str) -> Anchor {
112 if path.starts_with('/') || path.starts_with('~') {
113 return Anchor::Free;
114 }
115 match normalize(path) {
116 Some(segments) if !is_sensitive(&segments) => Anchor::RelativePlain,
117 Some(_) => Anchor::RelativeSensitive,
118 None => Anchor::RelativeUnplaced,
119 }
120}
121
122pub fn placement(path: &str, use_: Use, level: FolderLevel) -> Option<String> {
132 if path.starts_with('/') || path.starts_with('~') {
133 return None;
134 }
135 if use_ == Use::Read {
136 return read_placement(path);
137 }
138 if level == FolderLevel::Reads {
139 return None;
140 }
141 if let Some(segments) = normalize(path) {
142 if is_sensitive(&segments) {
143 return None;
144 }
145 let rebinds_the_folder = segments.is_empty();
146 let allowed = use_ == Use::Write || (level == FolderLevel::Workspace && !rebinds_the_folder);
147 return allowed.then(|| if segments.is_empty() { ".".to_string() } else { segments.join("/") });
148 }
149 if level < FolderLevel::Workspace {
150 return None;
151 }
152 let rest = sibling_hop(path)?;
153 if rest.len() < 2 && use_ == Use::Rebind {
154 return None;
155 }
156 Some(format!("../{}", rest.join("/")))
157}
158
159fn read_placement(path: &str) -> Option<String> {
163 let (dir, last) = path.rsplit_once('/').unwrap_or(("", path));
164 let globbed = last.contains(['*', '?', '[']);
165 let literal = if globbed { dir } else { path };
166 let mut segments = if literal.is_empty() { Vec::new() } else { normalize(literal)? };
167 if read_is_secret(&segments) {
168 return None;
169 }
170 if globbed {
171 let pattern = last.replace('[', "?");
172 if last.starts_with('.') || unreadable(&last.replace(['*', '?', '[', ']'], "")) || last.contains("..") {
173 return None;
174 }
175 if KEY_FILES.iter().copied().chain(pinned_names(true)).any(|name| glob_matches(&pattern, name)) {
176 return None;
177 }
178 segments.push(last);
179 }
180 Some(if segments.is_empty() { ".".to_string() } else { segments.join("/") })
181}
182
183fn glob_matches(pattern: &str, name: &str) -> bool {
185 let (p, n): (Vec<char>, Vec<char>) = (pattern.to_ascii_lowercase().chars().collect(), name.to_ascii_lowercase().chars().collect());
186 let (mut pi, mut ni, mut star, mut mark) = (0, 0, None, 0);
187 while ni < n.len() {
188 if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
189 pi += 1;
190 ni += 1;
191 } else if pi < p.len() && p[pi] == '*' {
192 star = Some(pi);
193 mark = ni;
194 pi += 1;
195 } else if let Some(s) = star {
196 pi = s + 1;
197 mark += 1;
198 ni = mark;
199 } else {
200 return false;
201 }
202 }
203 p[pi..].iter().all(|c| *c == '*')
204}
205
206fn normalize(path: &str) -> Option<Vec<&str>> {
209 if path.is_empty() || unreadable(path) {
210 return None;
211 }
212 let mut out = Vec::new();
213 for segment in path.split('/') {
214 match segment {
215 "" | "." => {}
216 ".." => {
217 out.pop()?;
218 }
219 s => out.push(s),
220 }
221 }
222 Some(out)
223}
224
225fn sibling_hop(path: &str) -> Option<Vec<&str>> {
229 if unreadable(path) {
230 return None;
231 }
232 let rest = path.trim_start_matches("./").strip_prefix("../")?;
233 let segments = normalize(rest)?;
234 (!segments.is_empty() && !is_sensitive(&segments)).then_some(segments)
235}
236
237fn unreadable(path: &str) -> bool {
238 path.contains(['$', '*', '?', '[', '{', '}', '`', '\\']) || path.contains("__SAFE_CHAINS_") || path.contains("://")
239}
240
241fn is_sensitive(segments: &[&str]) -> bool {
244 if segments.is_empty() {
245 return false;
246 }
247 if segments.iter().any(|s| s.starts_with('.')) {
248 return true;
249 }
250 let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
251 if segments.windows(2).any(|w| same(w[0], "hooks") && GIT_HOOKS.iter().any(|h| same(h, w[1]))) {
252 return true;
253 }
254 let last = segments[segments.len() - 1];
255 if NAMED_FILES.iter().any(|n| same(n, last))
256 || pinned_names(false).any(|n| same(n, last))
257 || starts_with_the_end_of_a_home_node(segments, false)
258 {
259 return true;
260 }
261 let joined = segments.join("/");
262 use crate::engine::resolve::regions::names_a_region;
263 names_a_region(&joined) || names_a_region(&format!("~/{joined}"))
264}
265
266pub fn read_is_secret(segments: &[&str]) -> bool {
271 use crate::engine::resolve::regions::names_a_secret;
272 let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
273 let Some(last) = segments.last() else { return false };
274 let joined = segments.join("/");
275 KEY_FILES.iter().any(|n| same(n, last))
276 || pinned_names(true).any(|n| same(n, last))
277 || starts_with_the_end_of_a_home_node(segments, true)
278 || names_a_secret(&joined)
279 || names_a_secret(&format!("~/{joined}"))
280}
281
282const KEY_FILES: &[&str] = &["id_rsa", "id_dsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk"];
284
285fn pinned_names(secret_only: bool) -> impl Iterator<Item = &'static str> {
288 crate::engine::resolve::regions::home_nodes()
289 .iter()
290 .filter(move |n| n.exact && (n.secret || !secret_only))
291 .filter_map(|n| n.segments.last().map(String::as_str))
292 .filter(|name| !name.starts_with('.'))
293}
294
295fn starts_with_the_end_of_a_home_node(segments: &[&str], secret_only: bool) -> bool {
298 let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
299 crate::engine::resolve::regions::home_nodes()
300 .iter()
301 .filter(|n| n.secret || !secret_only)
302 .any(|node| {
303 let node = &node.segments;
304 (1..node.len()).any(|k| node[k..].len() <= segments.len() && node[k..].iter().zip(segments).all(|(a, b)| same(a, b)))
305 })
306}
307
308pub const NAMED_FILES: &[&str] = &[
316 "authorized_keys", "authorized_keys2", "known_hosts", "id_rsa", "id_dsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk",
317 "rc", "environment", "gpg.conf", "gpg-agent.conf", "dirmngr.conf", "pubring.kbx", "trustdb.gpg", "config.fish", "hooks.json",
318 "settings.local.json", "default.rules",
319];
320
321pub const GIT_HOOKS: &[&str] = &[
324 "applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg", "commit-msg",
325 "post-commit", "pre-rebase", "post-checkout", "post-merge", "pre-push", "pre-receive", "update", "proc-receive", "post-receive",
326 "post-update", "reference-transaction", "push-to-checkout", "pre-auto-gc", "post-rewrite", "sendemail-validate", "fsmonitor-watchman",
327 "p4-changelist", "p4-prepare-changelist", "p4-post-changelist", "p4-pre-submit", "post-index-change",
328];
329
330#[cfg(test)]
331#[path = "anchor_tests.rs"]
332mod tests;