shuck-linter 0.0.41

Lint rule engine and checker for shell scripts
Documentation
use shuck_semantic::{SourceRef, SourceRefKind};

pub(crate) fn source_ref_has_unanchored_path(
    source_ref: &SourceRef,
    source: &str,
    allowed_anchors: &[String],
) -> bool {
    let raw_path = source_ref.path_span.slice(source).trim();
    let path = strip_outer_quotes(raw_path).trim();
    let outer_quote = outer_quote(raw_path);
    if path.is_empty()
        || (outer_quote != Some('\'') && starts_with_allowed_anchor(path, allowed_anchors))
        || starts_with_absolute_anchor(path)
        || starts_with_unquoted_home_anchor(raw_path, path)
        || (outer_quote != Some('\'') && is_entire_command_substitution(path))
    {
        return false;
    }

    match &source_ref.kind {
        SourceRefKind::Literal(path) => !path.is_empty(),
        SourceRefKind::Directive(_) => {
            if outer_quote == Some('\'') {
                !path.is_empty()
            } else {
                directive_runtime_path_can_be_unanchored(path)
            }
        }
        SourceRefKind::Dynamic | SourceRefKind::SingleVariableStaticTail { .. } => {
            path_has_static_path_component(path)
        }
        SourceRefKind::DirectiveDevNull => {
            if outer_quote == Some('\'') {
                !path.is_empty()
            } else {
                directive_runtime_path_can_be_unanchored(path)
            }
        }
    }
}

fn starts_with_allowed_anchor(path: &str, allowed_anchors: &[String]) -> bool {
    allowed_anchors
        .iter()
        .filter(|anchor| !anchor.is_empty())
        .any(|anchor| {
            starts_with_anchor_boundary(path, anchor)
                || starts_with_split_quoted_anchor(path, anchor)
        })
}

fn starts_with_anchor_boundary(path: &str, anchor: &str) -> bool {
    path.strip_prefix(anchor)
        .is_some_and(anchor_suffix_is_boundary)
}

fn starts_with_split_quoted_anchor(path: &str, anchor: &str) -> bool {
    let Some(rest) = path.strip_prefix('"') else {
        return false;
    };
    let Some(after_anchor) = rest.strip_prefix(anchor) else {
        return false;
    };

    let Some(after_quote) = after_anchor.strip_prefix('"') else {
        return false;
    };

    anchor_suffix_is_boundary(after_quote)
}

fn anchor_suffix_is_boundary(suffix: &str) -> bool {
    suffix.is_empty()
        || suffix.starts_with('/')
        || suffix
            .strip_prefix(['"', '\''])
            .is_some_and(|rest| rest.starts_with('/'))
}

fn starts_with_absolute_anchor(path: &str) -> bool {
    path.starts_with('/')
}

fn starts_with_unquoted_home_anchor(raw_path: &str, path: &str) -> bool {
    raw_path == path && unquoted_home_anchor_len(path).is_some()
}

fn unquoted_home_anchor_len(path: &str) -> Option<usize> {
    let suffix = path.strip_prefix('~')?;
    if suffix.is_empty() || suffix.starts_with('/') {
        return Some(1);
    }

    let user_end = suffix.find('/').unwrap_or(suffix.len());
    let user = &suffix[..user_end];
    if user.is_empty()
        || user.starts_with(['+', '-'])
        || user.as_bytes().first().is_some_and(u8::is_ascii_digit)
        || !user
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
    {
        return None;
    }

    Some(1 + user_end)
}

fn path_has_static_path_component(path: &str) -> bool {
    path.starts_with("./") || path.starts_with("../") || path.contains('/')
}

fn directive_runtime_path_can_be_unanchored(path: &str) -> bool {
    !path_has_dynamic_expansion(path) || path_has_static_path_component(path)
}

fn path_has_dynamic_expansion(path: &str) -> bool {
    path.contains('$') || path.contains('`')
}

fn strip_outer_quotes(text: &str) -> &str {
    if outer_quote(text).is_some() {
        &text[1..text.len() - 1]
    } else {
        text
    }
}

fn outer_quote(text: &str) -> Option<char> {
    let bytes = text.as_bytes();
    if bytes.len() >= 2
        && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
    {
        Some(bytes[0] as char)
    } else {
        None
    }
}

fn is_entire_command_substitution(path: &str) -> bool {
    is_entire_backtick_substitution(path) || is_entire_dollar_paren_substitution(path)
}

fn is_entire_backtick_substitution(path: &str) -> bool {
    path.len() >= 2
        && path.starts_with('`')
        && path.ends_with('`')
        && !path[1..path.len() - 1].contains('`')
}

fn is_entire_dollar_paren_substitution(path: &str) -> bool {
    if !path.starts_with("$(") {
        return false;
    }

    let mut depth = 0usize;
    let mut in_single_quote = false;
    let mut in_double_quote = false;
    let mut escaped = false;
    let mut chars = path.char_indices().peekable();
    while let Some((index, ch)) = chars.next() {
        if escaped {
            escaped = false;
            continue;
        }

        if in_single_quote {
            if ch == '\'' {
                in_single_quote = false;
            }
            continue;
        }

        if in_double_quote {
            match ch {
                '\\' => escaped = true,
                '"' => in_double_quote = false,
                _ => {}
            }
            continue;
        }

        match ch {
            '\'' if depth > 0 => in_single_quote = true,
            '"' if depth > 0 => in_double_quote = true,
            '\\' if depth > 0 => escaped = true,
            '$' if chars.peek().is_some_and(|(_, next)| *next == '(') => {
                let _ = chars.next();
                depth += 1;
            }
            '(' if depth > 0 => depth += 1,
            ')' if depth > 0 => {
                depth -= 1;
                if depth == 0 {
                    return index + ch.len_utf8() == path.len();
                }
            }
            _ => {}
        }
    }

    false
}