shepherd-core 6.6.0

The harness-agnostic shepherd engine: domain types, configuration schema, and run state. Knows nothing about any CLI, harness, or process.
//! Pure, segment-aware dispatch write-scope validation and matching.

#[cfg(feature = "alloc")]
use alloc::{string::String, vec::Vec};

use super::{DispatchError, DispatchResult};

/// Return whether one already repository-relative path is inside any declared
/// dispatch scope. Invalid paths and unsupported glob syntax are errors rather
/// than non-matches so callers can fail closed.
pub fn path_in_write_scope(path: &str, scopes: &[String]) -> DispatchResult<bool> {
    let path_parts = validate_write_path(path)?;
    for scope in scopes {
        let scope_parts = validate_write_scope_pattern(scope)?;
        if matches_scope(&path_parts, &scope_parts) {
            return Ok(true);
        }
    }
    Ok(false)
}

/// The pattern grammar is hand-written on purpose, not for lack of a crate.
///
/// `glob::Pattern::matches` would do this, and `glob` IS a workspace
/// dependency -- but it is std-only, and this crate is `#![no_std]` without its
/// `std` feature. `glob` is also the wrong shape twice over: it walks the
/// filesystem, while every function here is pure and must stay that way so a
/// scope can be judged for a file that does not exist yet, and its grammar is
/// broader than a write scope should be. The narrow grammar below is the
/// security boundary, so widening it is a deliberate act rather than an
/// upstream release note.
///
/// Validate one repository-relative glob pattern without touching the filesystem.
///
/// Registry and adapter boundaries use this same grammar when persisting a
/// scope fingerprint, so a loaded claim cannot be reinterpreted under a
/// second path language. Repository names preserve case (for example,
/// `Cargo.toml`); matching never folds case or adopts an identifier grammar.
pub fn validate_write_scope_pattern(scope: &str) -> DispatchResult<Vec<&str>> {
    if scope.is_empty()
        || scope.len() > 512
        || scope.starts_with('/')
        || scope.contains(['\\', '\0', ':'])
        || scope.chars().any(char::is_control)
        || !scope.is_ascii()
    {
        return Err(DispatchError::InvalidWriteScope(scope.into()));
    }
    let parts: Vec<&str> = scope.split('/').collect();
    let valid = parts.iter().all(|part| {
        !part.is_empty()
            && *part != "."
            && *part != ".."
            && !part.ends_with('.')
            && !part.ends_with(' ')
            && !part.contains('~')
            && (*part == "*"
                // `**` was trailing-only, which made the one scope shape the
                // roles actually need -- "any depth below this run, but only
                // Markdown" -- inexpressible. It is a real glob-star now and
                // may appear anywhere.
                || *part == "**"
                || (part.starts_with('*') && part.len() > 1 && part[1..].find('*').is_none())
                || !part.contains('*'))
    });
    if valid {
        Ok(parts)
    } else {
        Err(DispatchError::InvalidWriteScope(scope.into()))
    }
}

fn validate_write_path(path: &str) -> DispatchResult<Vec<&str>> {
    if path.is_empty()
        || path.len() > 4_096
        || path.starts_with('/')
        || path.contains(['\\', '\0', '*', ':'])
        || path.chars().any(char::is_control)
        || !path.is_ascii()
        || path
            .split('/')
            .any(|part| part.ends_with('.') || part.ends_with(' ') || part.contains('~'))
    {
        return Err(DispatchError::InvalidWriteScope(path.into()));
    }
    let parts: Vec<&str> = path.split('/').collect();
    if parts
        .iter()
        .any(|part| part.is_empty() || *part == "." || *part == "..")
    {
        return Err(DispatchError::InvalidWriteScope(path.into()));
    }
    Ok(parts)
}

/// Match one already-validated path against one already-validated pattern.
///
/// `**` matches zero or more whole segments at any position, so
/// `.shepherd/runs/v660/**/*.md` admits `.shepherd/runs/v660/report.md` and
/// `.shepherd/runs/v660/lanes/l1/findings.md` while refusing
/// `.shepherd/runs/v660/notes.txt`. A trailing `**` still matches the entire
/// remainder, including an empty one, which is the behavior every existing
/// scope relies on.
fn matches_scope(path: &[&str], scope: &[&str]) -> bool {
    match scope.split_first() {
        None => path.is_empty(),
        Some((&"**", rest)) => {
            rest.is_empty() || (0..=path.len()).any(|skip| matches_scope(&path[skip..], rest))
        }
        Some((expected, rest)) => {
            let Some((actual, tail)) = path.split_first() else {
                return false;
            };
            segment_matches(expected, actual) && matches_scope(tail, rest)
        }
    }
}

fn segment_matches(expected: &str, actual: &str) -> bool {
    expected == "*"
        || expected == actual
        || expected
            .strip_prefix('*')
            .is_some_and(|suffix| !suffix.is_empty() && actual.ends_with(suffix))
}