use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeStruct};
use std::{
borrow::Cow,
fmt::{Debug, Formatter},
};
#[derive(Clone, Copy, Eq, PartialEq)]
pub struct WorkspacePathRef<'path>(&'path str);
impl<'path> WorkspacePathRef<'path> {
#[must_use]
pub const fn as_str(self) -> &'path str {
self.0
}
}
impl<'path> TryFrom<&'path str> for WorkspacePathRef<'path> {
type Error = WorkspacePathError;
fn try_from(path: &'path str) -> Result<Self, Self::Error> {
validate_workspace_relative_path(path)?;
Ok(Self(path))
}
}
impl Debug for WorkspacePathRef<'_> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("WorkspacePathRef")
.field("path_byte_len", &self.0.len())
.finish()
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct WorkspacePath(String);
impl WorkspacePath {
pub fn try_new(path: impl Into<String>) -> Result<Self, WorkspacePathError> {
let path = path.into();
validate_workspace_relative_path(&path)?;
Ok(Self(path))
}
#[must_use]
pub fn from_ref(path: WorkspacePathRef<'_>) -> Self {
Self(path.as_str().to_owned())
}
#[must_use]
pub const fn as_ref(&self) -> WorkspacePathRef<'_> {
WorkspacePathRef(self.0.as_str())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl Debug for WorkspacePath {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("WorkspacePath")
.field("path_byte_len", &self.0.len())
.finish()
}
}
#[derive(Clone, Eq, PartialEq)]
struct WorkspacePathPrefix(String);
impl WorkspacePathPrefix {
fn try_new(prefix: impl Into<String>) -> Result<Self, WorkspacePathError> {
let prefix = prefix.into();
validate_workspace_grant_prefix(&prefix)?;
Ok(Self(prefix))
}
fn as_str(&self) -> &str {
&self.0
}
}
impl Debug for WorkspacePathPrefix {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("WorkspacePathPrefix")
.field("prefix_byte_len", &self.0.len())
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum WorkspacePathError {
EmptyGrantPrefix,
Absolute,
DotComponent,
EmptyComponent,
PlatformSeparator,
WindowsDrivePrefix,
}
#[derive(Clone, Eq, PartialEq)]
pub struct WorkspacePathGrant {
scope: WorkspacePathGrantScope,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum WorkspacePathGrantScope {
AllWorkspace,
Prefix(WorkspacePathPrefix),
}
impl WorkspacePathGrantScope {
fn as_str(&self) -> &str {
match self {
Self::AllWorkspace => "",
Self::Prefix(prefix) => prefix.as_str(),
}
}
const fn is_all_workspace(&self) -> bool {
matches!(self, Self::AllWorkspace)
}
fn covers(&self, prefix: &str) -> bool {
match self {
Self::AllWorkspace => true,
Self::Prefix(grant_prefix) => {
let grant_prefix = grant_prefix.as_str();
prefix == grant_prefix
|| prefix
.strip_prefix(grant_prefix)
.is_some_and(|suffix| suffix.starts_with('/'))
}
}
}
}
impl WorkspacePathGrant {
#[must_use]
pub fn new(prefix: impl Into<String>) -> Self {
Self::try_new(prefix).expect("workspace path grant literals should be valid")
}
#[must_use]
pub const fn all_workspace() -> Self {
Self {
scope: WorkspacePathGrantScope::AllWorkspace,
}
}
pub fn try_new(prefix: impl Into<String>) -> Result<Self, WorkspacePathError> {
let prefix = prefix.into();
if prefix.is_empty() {
return Err(WorkspacePathError::EmptyGrantPrefix);
}
let prefix = WorkspacePathPrefix::try_new(prefix)?;
Ok(Self {
scope: WorkspacePathGrantScope::Prefix(prefix),
})
}
#[must_use]
pub fn allows(&self, workspace_relative_path: WorkspacePathRef<'_>) -> bool {
self.covers(workspace_relative_path.as_str())
}
#[must_use]
pub fn prefix(&self) -> &str {
self.scope.as_str()
}
#[must_use]
pub const fn is_all_workspace(&self) -> bool {
self.scope.is_all_workspace()
}
#[must_use]
pub(crate) fn covers(&self, prefix: &str) -> bool {
self.scope.covers(prefix)
}
}
impl Debug for WorkspacePathGrant {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("WorkspacePathGrant")
.field("all_workspace", &self.is_all_workspace())
.field("prefix_byte_len", &self.prefix().len())
.finish()
}
}
impl<'de> Deserialize<'de> for WorkspacePathGrant {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGrant {
prefix: String,
all_workspace: Option<bool>,
}
let raw = RawGrant::deserialize(deserializer)?;
if raw.prefix.is_empty() {
if raw.all_workspace == Some(true) {
return Ok(Self::all_workspace());
}
return Err(de::Error::custom(WorkspacePathError::EmptyGrantPrefix));
}
if raw.all_workspace.unwrap_or(false) {
return Err(de::Error::custom(
"all_workspace may only be true for an empty prefix",
));
}
Self::try_new(raw.prefix).map_err(de::Error::custom)
}
}
impl Serialize for WorkspacePathGrant {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if self.is_all_workspace() {
let mut state = serializer.serialize_struct("WorkspacePathGrant", 2)?;
state.serialize_field("prefix", &self.prefix())?;
state.serialize_field("all_workspace", &true)?;
state.end()
} else {
let mut state = serializer.serialize_struct("WorkspacePathGrant", 1)?;
state.serialize_field("prefix", &self.prefix())?;
state.end()
}
}
}
pub(super) fn intersect_path_grants(
configured: &[WorkspacePathGrant],
requested: &[WorkspacePathGrant],
) -> Vec<WorkspacePathGrant> {
let mut grants = Vec::new();
for configured in configured {
for requested in requested {
let intersection = if configured.covers(requested.prefix()) {
Some(requested.clone())
} else if requested.covers(configured.prefix()) {
Some(configured.clone())
} else {
None
};
if let Some(intersection) = intersection {
push_unique_grant(&mut grants, intersection);
}
}
}
grants
}
fn push_unique_grant(grants: &mut Vec<WorkspacePathGrant>, grant: WorkspacePathGrant) {
if !grants.contains(&grant) {
grants.push(grant);
}
}
fn validate_workspace_grant_prefix(prefix: &str) -> Result<(), WorkspacePathError> {
validate_workspace_relative_path(prefix)
}
fn validate_workspace_relative_path(path: &str) -> Result<(), WorkspacePathError> {
if path.starts_with('/') {
return Err(WorkspacePathError::Absolute);
}
if path.as_bytes().get(1) == Some(&b':') {
return Err(WorkspacePathError::WindowsDrivePrefix);
}
if path.contains('\\') {
return Err(WorkspacePathError::PlatformSeparator);
}
for component in path.split('/') {
if component.is_empty() {
return Err(WorkspacePathError::EmptyComponent);
}
if matches!(component, "." | "..") {
return Err(WorkspacePathError::DotComponent);
}
}
Ok(())
}
impl std::fmt::Display for WorkspacePathError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyGrantPrefix => formatter
.write_str("whole-workspace plugin grants must use explicit all-workspace grant"),
Self::Absolute => formatter.write_str("workspace plugin paths must be relative"),
Self::DotComponent => {
formatter.write_str("workspace plugin paths must not contain . or .. components")
}
Self::EmptyComponent => {
formatter.write_str("workspace plugin paths must not contain empty components")
}
Self::PlatformSeparator => {
formatter.write_str("workspace plugin paths must use / separators")
}
Self::WindowsDrivePrefix => {
formatter.write_str("workspace plugin paths must not use Windows drive prefixes")
}
}
}
}
impl JsonSchema for WorkspacePathGrant {
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("WorkspacePathGrant")
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
#[derive(JsonSchema)]
#[schemars(deny_unknown_fields)]
#[allow(dead_code)]
struct WorkspacePathGrantSchema {
prefix: String,
#[schemars(default)]
all_workspace: Option<bool>,
}
WorkspacePathGrantSchema::json_schema(generator)
}
}
#[cfg(test)]
pub mod tests {
use super::{WorkspacePath, WorkspacePathError, WorkspacePathGrant, WorkspacePathRef};
use crate::plugin::PluginCapabilityRef;
use proptest::prelude::*;
use serde_json::json;
pub fn path_grant_strategy() -> impl Strategy<Value = WorkspacePathGrant> {
prop::option::of("[a-z]{1,8}(/[a-z]{1,8}){0,3}").prop_map(|prefix| {
prefix.map_or_else(WorkspacePathGrant::all_workspace, WorkspacePathGrant::new)
})
}
pub fn invalid_workspace_path_strategy() -> impl Strategy<Value = String> {
prop_oneof![
"[a-z]{1,8}/\\.\\.?(/[a-z]{1,8}){0,2}",
"/[a-z]{1,8}(/[a-z]{1,8}){0,2}",
"[a-z]{1,8}//[a-z]{1,8}",
"[a-z]{1,8}\\\\[a-z]{1,8}",
"[A-Z]:/[a-z]{1,8}",
Just(String::new()),
]
}
#[must_use]
pub fn grant_witness_paths(grant: &WorkspacePathGrant) -> [String; 2] {
if grant.is_all_workspace() {
[String::from("a"), String::from("a/b")]
} else {
[grant.prefix().to_owned(), format!("{}/a", grant.prefix())]
}
}
#[test]
fn workspace_path_grants_reject_traversal_and_non_normal_paths() {
let invalid_paths = [
"/docs/arch.md",
"docs/../secrets",
"docs/./arch.md",
"docs//arch.md",
"docs\\arch.md",
"C:/docs/arch.md",
"",
];
for path in invalid_paths {
assert!(
PluginCapabilityRef::workspace_observe(path).is_err(),
"{path:?} should not form an authorization request"
);
}
for prefix in [
"",
"/docs",
"docs/..",
"docs/.",
"docs//generated",
"docs\\generated",
"C:/docs",
] {
assert!(
WorkspacePathGrant::try_new(prefix).is_err(),
"{prefix:?} should not form a grant"
);
}
}
#[test]
fn whole_workspace_grants_are_explicit() {
let grant = WorkspacePathGrant::all_workspace();
let read = PluginCapabilityRef::workspace_observe("docs/arch.md")
.expect("workspace path should be valid");
assert_eq!(grant.prefix(), "");
assert!(grant.is_all_workspace());
assert!(grant.allows(match read {
PluginCapabilityRef::WorkspaceObserve(path) => path,
_ => unreachable!("workspace_observe should create read capability"),
}));
assert_eq!(
WorkspacePathGrant::try_new(""),
Err(WorkspacePathError::EmptyGrantPrefix)
);
}
#[test]
fn workspace_path_debug_redacts_normalized_paths() {
let path = "docs/secret-workspace-path.txt";
let borrowed = WorkspacePathRef::try_from(path).expect("path should validate");
let owned = WorkspacePath::from_ref(borrowed);
let grant = WorkspacePathGrant::new(path);
for debug in [
format!("{borrowed:?}"),
format!("{owned:?}"),
format!("{grant:?}"),
] {
assert!(debug.contains("byte_len"));
assert!(!debug.contains(path));
assert!(!debug.contains("secret-workspace-path"));
}
}
#[test]
fn whole_workspace_grant_json_requires_explicit_marker() {
let value = serde_json::to_value(WorkspacePathGrant::all_workspace())
.expect("grant should serialize");
assert_eq!(value, json!({"prefix": "", "all_workspace": true}));
assert_eq!(
serde_json::from_value::<WorkspacePathGrant>(value)
.expect("explicit whole-workspace grant should deserialize"),
WorkspacePathGrant::all_workspace()
);
}
proptest! {
#[test]
fn owned_workspace_paths_round_trip_valid_path_vocabulary(path in "[a-z]{1,8}(/[a-z]{1,8}){0,3}") {
let owned = WorkspacePath::try_new(path.clone()).expect("path should validate");
prop_assert_eq!(owned.as_str(), path.as_str());
prop_assert_eq!(owned.as_ref().as_str(), path.as_str());
prop_assert_eq!(WorkspacePath::from_ref(owned.as_ref()), owned);
}
#[test]
fn workspace_grant_construction_is_idempotent(prefix in prop::option::of("[a-z]{1,8}(/[a-z]{1,8}){0,3}")) {
let first = prefix.map_or_else(WorkspacePathGrant::all_workspace, WorkspacePathGrant::new);
let second = if first.is_all_workspace() {
WorkspacePathGrant::all_workspace()
} else {
WorkspacePathGrant::new(first.prefix())
};
prop_assert_eq!(first, second);
}
#[test]
fn workspace_grants_cover_only_their_normalized_subtree(
prefix in "[a-z]{1,8}(/[a-z]{1,8}){0,3}",
child in "[a-z]{1,8}",
) {
let grant = WorkspacePathGrant::new(prefix.clone());
let exact = WorkspacePath::try_new(prefix.clone()).expect("prefix should validate");
let descendant = WorkspacePath::try_new(format!("{prefix}/{child}"))
.expect("descendant should validate");
let sibling = WorkspacePath::try_new(format!("{prefix}x"))
.expect("sibling should validate");
prop_assert!(grant.allows(exact.as_ref()));
prop_assert!(grant.allows(descendant.as_ref()));
prop_assert!(!grant.allows(sibling.as_ref()));
}
#[test]
fn invalid_workspace_paths_cannot_form_authorization_refs(path in invalid_workspace_path_strategy()) {
prop_assert!(WorkspacePath::try_new(path.clone()).is_err());
prop_assert!(PluginCapabilityRef::workspace_observe(&path).is_err());
prop_assert!(PluginCapabilityRef::workspace_artifact_write(&path).is_err());
}
}
}