#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Anchor {
Free,
RelativePlain,
RelativeSensitive,
RelativeUnplaced,
ImplicitOutput,
ImplicitSource,
RunsFolderCode,
NamesItsWrites,
}
impl Anchor {
pub fn name(self) -> &'static str {
match self {
Anchor::Free => "anchor-free",
Anchor::RelativePlain => "relative-plain",
Anchor::RelativeSensitive => "relative-sensitive",
Anchor::RelativeUnplaced => "relative-unplaced",
Anchor::ImplicitOutput => "implicit-output",
Anchor::ImplicitSource => "implicit-source",
Anchor::RunsFolderCode => "runs-folder-code",
Anchor::NamesItsWrites => "names-its-writes",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum FolderLevel {
Reads,
Developer,
Workspace,
}
impl FolderLevel {
pub const DEFAULT: FolderLevel = FolderLevel::Developer;
pub const NAMES: [&'static str; 3] = ["reads", "developer", "workspace"];
pub fn parse(name: &str) -> Option<FolderLevel> {
match name {
"reads" => Some(FolderLevel::Reads),
"developer" => Some(FolderLevel::Developer),
"workspace" => Some(FolderLevel::Workspace),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
FolderLevel::Reads => "reads",
FolderLevel::Developer => "developer",
FolderLevel::Workspace => "workspace",
}
}
pub fn admits_implicit(self, anchor: Anchor) -> bool {
self >= FolderLevel::Developer
&& matches!(anchor, Anchor::Free | Anchor::ImplicitOutput | Anchor::ImplicitSource | Anchor::RunsFolderCode)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Use {
Read,
Write,
Rebind,
}
impl Use {
pub(crate) fn mutates(self) -> bool {
self != Use::Read
}
}
pub fn of_path(path: &str) -> Anchor {
if path.starts_with('/') || path.starts_with('~') {
return Anchor::Free;
}
match normalize(path) {
Some(segments) if !is_sensitive(&segments) => Anchor::RelativePlain,
Some(_) => Anchor::RelativeSensitive,
None => Anchor::RelativeUnplaced,
}
}
pub fn placement(path: &str, use_: Use, level: FolderLevel) -> Option<String> {
if path.starts_with('/') || path.starts_with('~') {
return None;
}
if use_ == Use::Read {
return read_placement(path);
}
if level == FolderLevel::Reads {
return None;
}
if let Some(segments) = normalize(path) {
if is_sensitive(&segments) {
return None;
}
let rebinds_the_folder = segments.is_empty();
let allowed = use_ == Use::Write || (level == FolderLevel::Workspace && !rebinds_the_folder);
return allowed.then(|| if segments.is_empty() { ".".to_string() } else { segments.join("/") });
}
if level < FolderLevel::Workspace {
return None;
}
let rest = sibling_hop(path)?;
if rest.len() < 2 && use_ == Use::Rebind {
return None;
}
Some(format!("../{}", rest.join("/")))
}
fn read_placement(path: &str) -> Option<String> {
let (dir, last) = path.rsplit_once('/').unwrap_or(("", path));
let globbed = last.contains(['*', '?', '[']);
let literal = if globbed { dir } else { path };
let mut segments = if literal.is_empty() { Vec::new() } else { normalize(literal)? };
if read_is_secret(&segments) {
return None;
}
if globbed {
let pattern = last.replace('[', "?");
if last.starts_with('.') || unreadable(&last.replace(['*', '?', '[', ']'], "")) || last.contains("..") {
return None;
}
if KEY_FILES.iter().copied().chain(pinned_names(true)).any(|name| glob_matches(&pattern, name)) {
return None;
}
segments.push(last);
}
Some(if segments.is_empty() { ".".to_string() } else { segments.join("/") })
}
fn glob_matches(pattern: &str, name: &str) -> bool {
let (p, n): (Vec<char>, Vec<char>) = (pattern.to_ascii_lowercase().chars().collect(), name.to_ascii_lowercase().chars().collect());
let (mut pi, mut ni, mut star, mut mark) = (0, 0, None, 0);
while ni < n.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
pi += 1;
ni += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
mark = ni;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
mark += 1;
ni = mark;
} else {
return false;
}
}
p[pi..].iter().all(|c| *c == '*')
}
fn normalize(path: &str) -> Option<Vec<&str>> {
if path.is_empty() || unreadable(path) {
return None;
}
let mut out = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
out.pop()?;
}
s => out.push(s),
}
}
Some(out)
}
fn sibling_hop(path: &str) -> Option<Vec<&str>> {
if unreadable(path) {
return None;
}
let rest = path.trim_start_matches("./").strip_prefix("../")?;
let segments = normalize(rest)?;
(!segments.is_empty() && !is_sensitive(&segments)).then_some(segments)
}
fn unreadable(path: &str) -> bool {
path.contains(['$', '*', '?', '[', '{', '}', '`', '\\']) || path.contains("__SAFE_CHAINS_") || path.contains("://")
}
fn is_sensitive(segments: &[&str]) -> bool {
if segments.is_empty() {
return false;
}
if segments.iter().any(|s| s.starts_with('.')) {
return true;
}
let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
if segments.windows(2).any(|w| same(w[0], "hooks") && GIT_HOOKS.iter().any(|h| same(h, w[1]))) {
return true;
}
let last = segments[segments.len() - 1];
if NAMED_FILES.iter().any(|n| same(n, last))
|| pinned_names(false).any(|n| same(n, last))
|| starts_with_the_end_of_a_home_node(segments, false)
{
return true;
}
let joined = segments.join("/");
use crate::engine::resolve::regions::names_a_region;
names_a_region(&joined) || names_a_region(&format!("~/{joined}"))
}
pub fn read_is_secret(segments: &[&str]) -> bool {
use crate::engine::resolve::regions::names_a_secret;
let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
let Some(last) = segments.last() else { return false };
let joined = segments.join("/");
KEY_FILES.iter().any(|n| same(n, last))
|| pinned_names(true).any(|n| same(n, last))
|| starts_with_the_end_of_a_home_node(segments, true)
|| names_a_secret(&joined)
|| names_a_secret(&format!("~/{joined}"))
}
const KEY_FILES: &[&str] = &["id_rsa", "id_dsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk"];
fn pinned_names(secret_only: bool) -> impl Iterator<Item = &'static str> {
crate::engine::resolve::regions::home_nodes()
.iter()
.filter(move |n| n.exact && (n.secret || !secret_only))
.filter_map(|n| n.segments.last().map(String::as_str))
.filter(|name| !name.starts_with('.'))
}
fn starts_with_the_end_of_a_home_node(segments: &[&str], secret_only: bool) -> bool {
let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
crate::engine::resolve::regions::home_nodes()
.iter()
.filter(|n| n.secret || !secret_only)
.any(|node| {
let node = &node.segments;
(1..node.len()).any(|k| node[k..].len() <= segments.len() && node[k..].iter().zip(segments).all(|(a, b)| same(a, b)))
})
}
pub const NAMED_FILES: &[&str] = &[
"authorized_keys", "authorized_keys2", "known_hosts", "id_rsa", "id_dsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk",
"rc", "environment", "gpg.conf", "gpg-agent.conf", "dirmngr.conf", "pubring.kbx", "trustdb.gpg", "config.fish", "hooks.json",
"settings.local.json", "default.rules",
];
pub const GIT_HOOKS: &[&str] = &[
"applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg", "commit-msg",
"post-commit", "pre-rebase", "post-checkout", "post-merge", "pre-push", "pre-receive", "update", "proc-receive", "post-receive",
"post-update", "reference-transaction", "push-to-checkout", "pre-auto-gc", "post-rewrite", "sendemail-validate", "fsmonitor-watchman",
"p4-changelist", "p4-prepare-changelist", "p4-post-changelist", "p4-pre-submit", "post-index-change",
];
#[cfg(test)]
#[path = "anchor_tests.rs"]
mod tests;