fix: retain structured Workdir refusal diagnostics across provider boundaries
This commit is contained in:
@@ -148,8 +148,9 @@ impl CheckoutSearchRequest {
|
||||
if !std::path::Path::new(self.operation.path().as_str())
|
||||
.starts_with(self.output_root.as_str())
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"checkout search must stay beneath its output root".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
|
||||
"checkout search must stay beneath its output root",
|
||||
));
|
||||
}
|
||||
if self.scope_layers.len() > 16
|
||||
|
||||
@@ -751,7 +751,15 @@ async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_e
|
||||
)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::OutOfScope(_)));
|
||||
let classification = match &error {
|
||||
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||
error => error,
|
||||
};
|
||||
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
|
||||
assert_ne!(
|
||||
error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||
);
|
||||
}
|
||||
for (relative, hidden) in [
|
||||
(".ignore", "root-ignore-visible.txt"),
|
||||
|
||||
@@ -497,18 +497,24 @@ impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirOperationError {
|
||||
code: crate::http::WorkdirTransportErrorCode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
denial_reason: Option<crate::WorkdirDenialReason>,
|
||||
}
|
||||
|
||||
impl ExternalWorkdirOperationError {
|
||||
/// Collapse provider failures to a closed error code. Provider-authored
|
||||
/// diagnostics never cross the External Workdir trust boundary.
|
||||
/// Keep only closed error codes and typed refusal reasons. Provider-authored
|
||||
/// text never crosses the External Workdir trust boundary.
|
||||
pub fn from_transport_error(error: WorkdirTransportError) -> Self {
|
||||
Self { code: error.code }
|
||||
Self {
|
||||
code: error.code,
|
||||
denial_reason: error.denial_reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_transport_error(self) -> WorkdirTransportError {
|
||||
WorkdirTransportError {
|
||||
code: self.code,
|
||||
denial_reason: self.denial_reason,
|
||||
message: format!("External Workdir provider reported {}", self.code.as_str()),
|
||||
}
|
||||
}
|
||||
@@ -719,10 +725,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_error_wire_forwards_allowlisted_reasons_without_provider_text() {
|
||||
use crate::WorkdirDenialReason as Reason;
|
||||
use crate::http::WorkdirTransportErrorCode as Code;
|
||||
for reason in [
|
||||
Some(Reason::ExternalScopeSymlink),
|
||||
Some(Reason::OsPermissionDenied),
|
||||
None,
|
||||
] {
|
||||
let external =
|
||||
ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
code: Code::Denied,
|
||||
message: "/home/operator/private provider-secret".repeat(4096),
|
||||
denial_reason: reason,
|
||||
});
|
||||
let wire = serde_json::to_string(&external).unwrap();
|
||||
assert!(wire.len() < 128);
|
||||
assert_no_provider_authority(&serde_json::from_str(&wire).unwrap());
|
||||
let decoded: ExternalWorkdirOperationError = serde_json::from_str(&wire).unwrap();
|
||||
let error = decoded.into_transport_error().into_workdir_error();
|
||||
let forwarded = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(forwarded.code, Code::Denied);
|
||||
assert_eq!(forwarded.denial_reason, reason);
|
||||
assert_eq!(forwarded.message, "Workdir operation was denied");
|
||||
}
|
||||
let old: ExternalWorkdirOperationError =
|
||||
serde_json::from_str(r#"{"code":"denied"}"#).unwrap();
|
||||
assert_eq!(old.into_transport_error().denial_reason, None);
|
||||
for reason in ["/home/operator/private", &"provider-secret".repeat(4096)] {
|
||||
let input = serde_json::json!({"code":"denied", "denial_reason":reason});
|
||||
assert!(serde_json::from_value::<ExternalWorkdirOperationError>(input).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
|
||||
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
code: crate::http::WorkdirTransportErrorCode::Internal,
|
||||
denial_reason: None,
|
||||
message: "/home/operator/private provider-secret\ncontrol".to_string(),
|
||||
});
|
||||
let frame = ExternalWorkdirProviderFrame::current(
|
||||
|
||||
@@ -357,9 +357,17 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let classification = match &error {
|
||||
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||
error => error,
|
||||
};
|
||||
assert_ne!(
|
||||
error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
classification,
|
||||
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
||||
),
|
||||
"unexpected error for {path}: {error:?}"
|
||||
@@ -375,6 +383,10 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::ExternalRootSymlink)
|
||||
);
|
||||
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||
}
|
||||
|
||||
@@ -408,9 +420,17 @@ async fn external_read_write_provider_rejects_symlink_mutation_escape() {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let classification = match &error {
|
||||
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||
error => error,
|
||||
};
|
||||
assert_ne!(
|
||||
error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
classification,
|
||||
WorkdirError::SymlinkOutOfScope { .. }
|
||||
| WorkdirError::OutOfScope(_)
|
||||
| WorkdirError::Denied(_)
|
||||
@@ -559,7 +579,15 @@ async fn external_grep_honors_gitignore_above_a_nested_search_root() {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(symlink_error, WorkdirError::OutOfScope(_)));
|
||||
let classification = match &symlink_error {
|
||||
WorkdirError::DenialContext { source, .. } => source.as_ref(),
|
||||
error => error,
|
||||
};
|
||||
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
|
||||
assert_ne!(
|
||||
symlink_error.denial_reason(),
|
||||
Some(crate::WorkdirDenialReason::OsPermissionDenied)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -269,6 +269,9 @@ impl WorkdirTransportErrorCode {
|
||||
pub struct WorkdirTransportError {
|
||||
pub code: WorkdirTransportErrorCode,
|
||||
pub message: String,
|
||||
/// Internal provider diagnostics. Public response boundaries may omit this.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub denial_reason: Option<crate::WorkdirDenialReason>,
|
||||
}
|
||||
|
||||
impl WorkdirTransportError {
|
||||
@@ -276,6 +279,11 @@ impl WorkdirTransportError {
|
||||
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
let (code, message) = match error {
|
||||
WorkdirError::DenialContext { reason, source } => {
|
||||
let mut transport = Self::from_workdir_error(source);
|
||||
transport.denial_reason = Some(*reason);
|
||||
return transport;
|
||||
}
|
||||
WorkdirError::OutcomeUnknown(_) => (
|
||||
Code::OutcomeUnknown,
|
||||
"Workdir operation may have effects; inspect before retrying",
|
||||
@@ -289,6 +297,7 @@ impl WorkdirTransportError {
|
||||
return Self {
|
||||
code: Code::Unsupported,
|
||||
message: format!("Workdir capability {capability:?} is not available"),
|
||||
denial_reason: None,
|
||||
};
|
||||
}
|
||||
WorkdirError::UnsupportedOperation(_) => {
|
||||
@@ -346,12 +355,13 @@ impl WorkdirTransportError {
|
||||
Self {
|
||||
code,
|
||||
message: message.to_string(),
|
||||
denial_reason: error.denial_reason(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_workdir_error(self) -> WorkdirError {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
match self.code {
|
||||
let error = match self.code {
|
||||
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
|
||||
Code::Conflict => WorkdirError::Conflict(self.message),
|
||||
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
|
||||
@@ -359,7 +369,11 @@ impl WorkdirTransportError {
|
||||
),
|
||||
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
|
||||
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
||||
Code::Denied => WorkdirError::Denied(self.message),
|
||||
Code::Denied => WorkdirError::Denied(crate::WorkdirDenial {
|
||||
reason: self.denial_reason,
|
||||
// Provider-authored text is not trusted diagnostic data.
|
||||
message: "Workdir operation was denied".into(),
|
||||
}),
|
||||
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
|
||||
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
|
||||
path: "<remote>".into(),
|
||||
@@ -390,7 +404,18 @@ impl WorkdirTransportError {
|
||||
},
|
||||
Code::Transport => WorkdirError::Transport(self.message),
|
||||
Code::Internal => WorkdirError::OperationFailed,
|
||||
};
|
||||
// Preserve any supplied reason independently of the public classification.
|
||||
// Without a reason, retain the existing classification's deliberate fallback.
|
||||
if let Some(reason) = self.denial_reason {
|
||||
if error.denial_reason() != Some(reason) {
|
||||
return WorkdirError::DenialContext {
|
||||
reason,
|
||||
source: Box::new(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,6 +974,7 @@ mod tests {
|
||||
] {
|
||||
let transport = WorkdirTransportError {
|
||||
code,
|
||||
denial_reason: None,
|
||||
message: if code == WorkdirTransportErrorCode::Conflict {
|
||||
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||
.to_string()
|
||||
|
||||
+143
-3
@@ -211,7 +211,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
}) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(
|
||||
Err(WorkdirError::denied(
|
||||
WorkdirDenialReason::ScopeResolutionUnavailable,
|
||||
"Workdir provider cannot establish resolved scope authority".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -221,7 +222,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
&self,
|
||||
_request: WorkdirScopeOverlapRequest,
|
||||
) -> Result<bool, WorkdirError> {
|
||||
Err(WorkdirError::Denied(
|
||||
Err(WorkdirError::denied(
|
||||
WorkdirDenialReason::ScopeComparisonUnavailable,
|
||||
"Workdir provider cannot compare resolved scope authority".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -292,10 +294,125 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
|
||||
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
|
||||
|
||||
/// Closed diagnostic vocabulary: no request text, paths or secrets.
|
||||
/// A reason describes a refusing branch, not an authorization grant.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkdirDenialReason {
|
||||
ScopeResolutionUnavailable,
|
||||
ScopeComparisonUnavailable,
|
||||
ScopedCapabilityDenied,
|
||||
LogicalScopeExceeded,
|
||||
InvalidScopedPath,
|
||||
ChildWriteLeaseConflict,
|
||||
EmptyScope,
|
||||
ParentCapabilityDenied,
|
||||
CommandRequiresWritableScope,
|
||||
ParentScopeExceeded,
|
||||
CwdOutsideReadableScope,
|
||||
ReadOnlySession,
|
||||
ExternalRootSymlink,
|
||||
ExternalRootChanged,
|
||||
ExternalRootMoved,
|
||||
ExternalScopeSymlink,
|
||||
AttachmentScopeExceeded,
|
||||
DelegatedScopeExceeded,
|
||||
CheckoutOutputRootExceeded,
|
||||
ProviderRootExceeded,
|
||||
ProviderSymlinkDenied,
|
||||
CheckoutTargetDenied,
|
||||
CheckoutCreateParentDenied,
|
||||
CheckoutSearchPathDenied,
|
||||
CheckoutEnumerationDenied,
|
||||
PathOutOfScope,
|
||||
SymlinkTargetOutOfScope,
|
||||
PathReadOnly,
|
||||
OsPermissionDenied,
|
||||
/// PermissionDenied without a typed provider origin or an OS error code.
|
||||
/// In particular, do not mislabel lower-layer synthetic refusals as OS errors.
|
||||
UnclassifiedPermissionDenied,
|
||||
}
|
||||
|
||||
impl WorkdirDenialReason {
|
||||
/// Stable allowlisted label for internal diagnostics, matching the wire value.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ScopeResolutionUnavailable => "scope_resolution_unavailable",
|
||||
Self::ScopeComparisonUnavailable => "scope_comparison_unavailable",
|
||||
Self::ScopedCapabilityDenied => "scoped_capability_denied",
|
||||
Self::LogicalScopeExceeded => "logical_scope_exceeded",
|
||||
Self::InvalidScopedPath => "invalid_scoped_path",
|
||||
Self::ChildWriteLeaseConflict => "child_write_lease_conflict",
|
||||
Self::EmptyScope => "empty_scope",
|
||||
Self::ParentCapabilityDenied => "parent_capability_denied",
|
||||
Self::CommandRequiresWritableScope => "command_requires_writable_scope",
|
||||
Self::ParentScopeExceeded => "parent_scope_exceeded",
|
||||
Self::CwdOutsideReadableScope => "cwd_outside_readable_scope",
|
||||
Self::ReadOnlySession => "read_only_session",
|
||||
Self::ExternalRootSymlink => "external_root_symlink",
|
||||
Self::ExternalRootChanged => "external_root_changed",
|
||||
Self::ExternalRootMoved => "external_root_moved",
|
||||
Self::ExternalScopeSymlink => "external_scope_symlink",
|
||||
Self::AttachmentScopeExceeded => "attachment_scope_exceeded",
|
||||
Self::DelegatedScopeExceeded => "delegated_scope_exceeded",
|
||||
Self::CheckoutOutputRootExceeded => "checkout_output_root_exceeded",
|
||||
Self::ProviderRootExceeded => "provider_root_exceeded",
|
||||
Self::ProviderSymlinkDenied => "provider_symlink_denied",
|
||||
Self::CheckoutTargetDenied => "checkout_target_denied",
|
||||
Self::CheckoutCreateParentDenied => "checkout_create_parent_denied",
|
||||
Self::CheckoutSearchPathDenied => "checkout_search_path_denied",
|
||||
Self::CheckoutEnumerationDenied => "checkout_enumeration_denied",
|
||||
Self::PathOutOfScope => "path_out_of_scope",
|
||||
Self::SymlinkTargetOutOfScope => "symlink_target_out_of_scope",
|
||||
Self::PathReadOnly => "path_read_only",
|
||||
Self::OsPermissionDenied => "os_permission_denied",
|
||||
Self::UnclassifiedPermissionDenied => "unclassified_permission_denied",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Original local messages remain available, but are not diagnostic wire data.
|
||||
/// Missing reasons from older providers remain explicitly unknown.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WorkdirDenial {
|
||||
pub reason: Option<WorkdirDenialReason>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorkdirDenial {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
impl From<String> for WorkdirDenial {
|
||||
fn from(message: String) -> Self {
|
||||
Self {
|
||||
reason: None,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<&str> for WorkdirDenial {
|
||||
fn from(message: &str) -> Self {
|
||||
message.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkdirError {
|
||||
#[error("Workdir operation denied: {0}")]
|
||||
Denied(String),
|
||||
Denied(WorkdirDenial),
|
||||
|
||||
/// Retain the source's existing classification and display while attaching
|
||||
/// a reason (including checkout's historical PermissionDenied mapping).
|
||||
#[error("{source}")]
|
||||
DenialContext {
|
||||
reason: WorkdirDenialReason,
|
||||
#[source]
|
||||
source: Box<WorkdirError>,
|
||||
},
|
||||
|
||||
#[error("Workdir session is closed")]
|
||||
SessionClosed,
|
||||
@@ -398,6 +515,29 @@ pub enum WorkdirError {
|
||||
}
|
||||
|
||||
impl WorkdirError {
|
||||
pub fn denied(reason: WorkdirDenialReason, message: impl Into<String>) -> Self {
|
||||
Self::Denied(WorkdirDenial {
|
||||
reason: Some(reason),
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Safe diagnostic data only; never infer a reason from arbitrary messages.
|
||||
pub fn denial_reason(&self) -> Option<WorkdirDenialReason> {
|
||||
use WorkdirDenialReason as Reason;
|
||||
match self {
|
||||
Self::Denied(denial) => denial.reason,
|
||||
Self::DenialContext { reason, .. } => Some(*reason),
|
||||
Self::OutOfScope(_) => Some(Reason::PathOutOfScope),
|
||||
Self::SymlinkOutOfScope { .. } => Some(Reason::SymlinkTargetOutOfScope),
|
||||
Self::ReadOnly(_) => Some(Reason::PathReadOnly),
|
||||
Self::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
Some(local::io_denial_reason(source))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.to_path_buf(),
|
||||
|
||||
+139
-24
@@ -213,6 +213,51 @@ struct ScopeAccess {
|
||||
operation_guard: Option<OperationGuard>,
|
||||
}
|
||||
|
||||
/// Policy refusals carried through FsAccessPolicy's I/O interface are not OS
|
||||
/// PermissionDenied. This marker contains only fixed text and a closed reason.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
struct PolicyPermissionDenied {
|
||||
reason: crate::WorkdirDenialReason,
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
fn policy_permission_denied(
|
||||
reason: crate::WorkdirDenialReason,
|
||||
message: &'static str,
|
||||
) -> std::io::Error {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
PolicyPermissionDenied { reason, message },
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn io_denial_reason(error: &std::io::Error) -> crate::WorkdirDenialReason {
|
||||
use crate::WorkdirDenialReason as Reason;
|
||||
if let Some(source) = error.get_ref() {
|
||||
if let Some(denial) = source.downcast_ref::<PolicyPermissionDenied>() {
|
||||
return denial.reason;
|
||||
}
|
||||
if let Some(denial) = source.downcast_ref::<fs_operation::FsDenialReason>() {
|
||||
return match denial {
|
||||
fs_operation::FsDenialReason::ProviderRootExceeded => Reason::ProviderRootExceeded,
|
||||
fs_operation::FsDenialReason::ProviderSymlinkDenied => {
|
||||
Reason::ProviderSymlinkDenied
|
||||
}
|
||||
fs_operation::FsDenialReason::CheckoutTargetDenied => Reason::CheckoutTargetDenied,
|
||||
fs_operation::FsDenialReason::CheckoutCreateParentDenied => {
|
||||
Reason::CheckoutCreateParentDenied
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
if error.raw_os_error().is_some() {
|
||||
Reason::OsPermissionDenied
|
||||
} else {
|
||||
Reason::UnclassifiedPermissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopeAccess {
|
||||
fn new(
|
||||
scope: Arc<Scope>,
|
||||
@@ -238,8 +283,8 @@ impl ScopeAccess {
|
||||
)
|
||||
})?;
|
||||
let relative = path.strip_prefix(&self.root).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
policy_permission_denied(
|
||||
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
)
|
||||
})?;
|
||||
@@ -288,8 +333,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
if logical.starts_with(&self.root) {
|
||||
Ok(logical.to_path_buf())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
Err(policy_permission_denied(
|
||||
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
))
|
||||
}
|
||||
@@ -394,8 +439,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
)
|
||||
})?;
|
||||
let relative = resolved.strip_prefix(&self.root).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
policy_permission_denied(
|
||||
crate::WorkdirDenialReason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
)
|
||||
})?;
|
||||
@@ -491,8 +536,8 @@ impl SearchAccess {
|
||||
if self.read_allowed(logical, resolved) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
Err(policy_permission_denied(
|
||||
crate::WorkdirDenialReason::CheckoutSearchPathDenied,
|
||||
"checkout search path denied",
|
||||
))
|
||||
}
|
||||
@@ -545,8 +590,8 @@ impl fs_operation::FsAccessPolicy for SearchAccess {
|
||||
}
|
||||
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||
if !self.enumeration_allowed(logical, resolved) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
return Err(policy_permission_denied(
|
||||
crate::WorkdirDenialReason::CheckoutEnumerationDenied,
|
||||
"checkout directory enumeration denied",
|
||||
));
|
||||
}
|
||||
@@ -648,7 +693,8 @@ impl ExternalWorkdirRoot {
|
||||
let selected_metadata = std::fs::symlink_metadata(directory)
|
||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||
if selected_metadata.file_type().is_symlink() {
|
||||
return Err(WorkdirError::Denied(
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ExternalRootSymlink,
|
||||
"External Workdir root must not be a symbolic link".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -678,7 +724,8 @@ impl ExternalWorkdirRoot {
|
||||
if (selected_metadata.dev(), selected_metadata.ino())
|
||||
!= (opened_metadata.dev(), opened_metadata.ino())
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ExternalRootChanged,
|
||||
"External Workdir root changed while it was being pinned".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -691,7 +738,8 @@ impl ExternalWorkdirRoot {
|
||||
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
|
||||
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
|
||||
if !path.is_absolute() || path.to_string_lossy().ends_with(" (deleted)") {
|
||||
return Err(WorkdirError::Denied(
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ExternalRootMoved,
|
||||
"External Workdir root moved while it was being pinned".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -1334,7 +1382,8 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let logical = self.inner.root.join(request.path.as_str());
|
||||
if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() {
|
||||
return Err(WorkdirError::Denied(
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ExternalScopeSymlink,
|
||||
"External Workdir scope cannot traverse symbolic links".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -1353,10 +1402,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
|
||||
};
|
||||
if !parent_allows {
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"Workdir path `{}` exceeds the provider attachment scope",
|
||||
request.path
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::AttachmentScopeExceeded,
|
||||
format!(
|
||||
"Workdir path `{}` exceeds the provider attachment scope",
|
||||
request.path
|
||||
),
|
||||
));
|
||||
}
|
||||
let allowed = request.rules.iter().any(|rule| {
|
||||
if request.permission == WorkdirToolScopePermission::Write
|
||||
@@ -1383,10 +1435,13 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(format!(
|
||||
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
||||
request.path
|
||||
)))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::DelegatedScopeExceeded,
|
||||
format!(
|
||||
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
||||
request.path
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1432,7 +1487,12 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
let logical = request.operation.path().clone();
|
||||
let relative = Path::new(logical.as_str())
|
||||
.strip_prefix(request.output_root.as_str())
|
||||
.map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?;
|
||||
.map_err(|_| {
|
||||
WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
|
||||
"checkout search escaped output root",
|
||||
)
|
||||
})?;
|
||||
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
|
||||
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
|
||||
})?)?;
|
||||
@@ -2075,7 +2135,10 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
||||
WorkdirError::Io { source, .. }
|
||||
if source.kind() == std::io::ErrorKind::PermissionDenied =>
|
||||
{
|
||||
WorkdirError::OutOfScope(path)
|
||||
WorkdirError::DenialContext {
|
||||
reason: io_denial_reason(&source),
|
||||
source: Box::new(WorkdirError::OutOfScope(path)),
|
||||
}
|
||||
}
|
||||
WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!(
|
||||
"I/O operation failed for {logical}: {}",
|
||||
@@ -2561,6 +2624,58 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn checkout_permission_mapping_distinguishes_provider_policy_from_os_without_reclassifying() {
|
||||
use crate::WorkdirDenialReason as Reason;
|
||||
use crate::http::{WorkdirTransportError, WorkdirTransportErrorCode as Code};
|
||||
use fs_operation::FsAccessPolicy;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let access = ScopeAccess::new(
|
||||
Arc::new(Scope::writable(root.path()).unwrap()),
|
||||
root.path(),
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
// Exercise the actual policy boundary, not a string-based stand-in.
|
||||
let policy = access
|
||||
.resolve_access_path(Path::new("/outside/private-secret"))
|
||||
.unwrap_err();
|
||||
let os = std::io::Error::from_raw_os_error(libc::EACCES);
|
||||
for (source, reason) in [
|
||||
(policy, Reason::ProviderRootExceeded),
|
||||
(os, Reason::OsPermissionDenied),
|
||||
] {
|
||||
let error = sanitize_error(
|
||||
WorkdirError::io(Path::new("/host/private-secret"), source),
|
||||
&WorkdirPath::new("file").unwrap(),
|
||||
);
|
||||
assert_eq!(error.denial_reason(), Some(reason));
|
||||
assert_eq!(error.to_string(), "path is outside allowed scope: file");
|
||||
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(transport.code, Code::OutOfScope);
|
||||
assert_eq!(transport.message, "Workdir path is out of scope");
|
||||
assert_eq!(transport.denial_reason, Some(reason));
|
||||
assert_eq!(
|
||||
WorkdirTransportError::from_workdir_error(&transport.clone().into_workdir_error()),
|
||||
transport
|
||||
);
|
||||
assert!(
|
||||
!serde_json::to_string(&transport)
|
||||
.unwrap()
|
||||
.contains("private-secret")
|
||||
);
|
||||
}
|
||||
// Ordinary OS I/O remains the existing generic denied/403 classification.
|
||||
let error = WorkdirError::io(
|
||||
Path::new("/host/private-secret"),
|
||||
std::io::Error::from_raw_os_error(libc::EACCES),
|
||||
);
|
||||
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(transport.code, Code::Denied);
|
||||
assert_eq!(transport.denial_reason, Some(Reason::OsPermissionDenied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
|
||||
let path = WorkdirPath::new("missing").unwrap();
|
||||
|
||||
+91
-44
@@ -595,9 +595,10 @@ impl ScopedWorkdirSession {
|
||||
if self.capabilities.supports(required) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkdirError::Denied(format!(
|
||||
"scoped Workdir tools do not permit {operation}"
|
||||
)))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ScopedCapabilityDenied,
|
||||
format!("scoped Workdir tools do not permit {operation}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,9 +613,12 @@ impl ScopedWorkdirSession {
|
||||
.iter()
|
||||
.any(|rule| rule_allows_path(rule, path, permission))
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::LogicalScopeExceeded,
|
||||
format!(
|
||||
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -626,9 +630,17 @@ impl ScopedWorkdirSession {
|
||||
}
|
||||
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
|
||||
let joined = joined.to_str().ok_or_else(|| {
|
||||
WorkdirError::Denied("logical Workdir path is not valid UTF-8".into())
|
||||
WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::InvalidScopedPath,
|
||||
"logical Workdir path is not valid UTF-8",
|
||||
)
|
||||
})?;
|
||||
FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string()))
|
||||
FsPath::new(joined).map_err(|error| {
|
||||
WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::InvalidScopedPath,
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_read(
|
||||
@@ -739,9 +751,10 @@ impl ScopedWorkdirSession {
|
||||
})
|
||||
.await?
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"path `{path}` is leased to child Workdir tools"
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
|
||||
format!("path `{path}` is leased to child Workdir tools"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -792,8 +805,9 @@ impl ScopedWorkdirSession {
|
||||
) -> Result<WorkdirSessionCapabilities, WorkdirError> {
|
||||
self.ensure_active()?;
|
||||
if rules.is_empty() {
|
||||
return Err(WorkdirError::Denied(
|
||||
"workdir tool scope requires at least one logical scope rule".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::EmptyScope,
|
||||
"workdir tool scope requires at least one logical scope rule",
|
||||
));
|
||||
}
|
||||
let writable = rules
|
||||
@@ -804,22 +818,25 @@ impl ScopedWorkdirSession {
|
||||
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"parent Workdir session cannot scope the requested capabilities".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ParentCapabilityDenied,
|
||||
"parent Workdir session cannot scope the requested capabilities",
|
||||
));
|
||||
}
|
||||
if command {
|
||||
if !writable {
|
||||
return Err(WorkdirError::Denied(
|
||||
"command execution requires a writable scoped path".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::CommandRequiresWritableScope,
|
||||
"command execution requires a writable scoped path",
|
||||
));
|
||||
}
|
||||
if !self
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command)
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"parent Workdir session does not support Command".into(),
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ParentCapabilityDenied,
|
||||
"parent Workdir session does not support Command",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -829,10 +846,13 @@ impl ScopedWorkdirSession {
|
||||
.iter()
|
||||
.any(|parent| rule_contains_rule(parent, requested))
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"logical workdir scope `{}` exceeds the parent tool scope",
|
||||
requested.target
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ParentScopeExceeded,
|
||||
format!(
|
||||
"logical workdir scope `{}` exceeds the parent tool scope",
|
||||
requested.target
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -866,10 +886,13 @@ impl ScopedWorkdirSession {
|
||||
.iter()
|
||||
.any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read))
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"scoped tool cwd `{}` is outside the readable scope",
|
||||
request.cwd
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::CwdOutsideReadableScope,
|
||||
format!(
|
||||
"scoped tool cwd `{}` is outside the readable scope",
|
||||
request.cwd
|
||||
),
|
||||
));
|
||||
}
|
||||
self.ensure_scope_targets_are_authorized(&request.rules)
|
||||
.await?;
|
||||
@@ -913,10 +936,13 @@ impl ScopedWorkdirSession {
|
||||
})
|
||||
.await?
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
||||
requested.target
|
||||
)));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
|
||||
format!(
|
||||
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
||||
requested.target
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1446,7 +1472,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
if request.permission == WorkdirToolScopePermission::Write {
|
||||
return Err(WorkdirError::Denied("read-only workdir session".into()));
|
||||
return Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
));
|
||||
}
|
||||
self.inner.authorize_scope_path(request).await
|
||||
}
|
||||
@@ -1509,11 +1538,17 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
}
|
||||
|
||||
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn edit(&self, _request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
@@ -1529,22 +1564,34 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
}
|
||||
|
||||
async fn start_command(&self, _request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn command_status(&self, _handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
&self,
|
||||
_request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
Err(WorkdirError::denied(
|
||||
crate::WorkdirDenialReason::ReadOnlySession,
|
||||
"read-only workdir session",
|
||||
))
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), WorkdirError> {
|
||||
@@ -2226,7 +2273,7 @@ mod tests {
|
||||
assert!(matches!(
|
||||
child.read(read("link")).await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("provider-resolved delegated scope")
|
||||
if message.message.contains("provider-resolved delegated scope")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2252,7 +2299,7 @@ mod tests {
|
||||
assert!(matches!(
|
||||
child.read(read("escape")).await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("provider-resolved delegated scope")
|
||||
if message.message.contains("provider-resolved delegated scope")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2353,7 +2400,7 @@ mod tests {
|
||||
.scope(request("alias-b", WorkdirToolScopePermission::Write))
|
||||
.await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("overlaps an active child scope after provider resolution")
|
||||
if message.message.contains("overlaps an active child scope after provider resolution")
|
||||
));
|
||||
assert!(matches!(
|
||||
parent
|
||||
@@ -2364,7 +2411,7 @@ mod tests {
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("leased to child Workdir tools")
|
||||
if message.message.contains("leased to child Workdir tools")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2383,7 +2430,7 @@ mod tests {
|
||||
assert!(matches!(
|
||||
child.scope(expanded).await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("exceeds the parent tool scope")
|
||||
if message.message.contains("exceeds the parent tool scope")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2452,7 +2499,7 @@ mod tests {
|
||||
.write(write("secret/parent", "must-be-blocked"))
|
||||
.await,
|
||||
Err(WorkdirError::Denied(message))
|
||||
if message.contains("leased to child Workdir tools")
|
||||
if message.message.contains("leased to child Workdir tools")
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Refusal reasons are selected by providers, not parsed from their messages.
|
||||
use std::sync::Arc;
|
||||
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
|
||||
use workdir::{
|
||||
LocalWorkdirSession, ReadOnlyWorkdirSession, StatRequest, Workdir,
|
||||
WorkdirDenialReason as Reason, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirToolBroker, WorkdirToolScope,
|
||||
WorkdirToolScopePermission as ToolPermission, WorkdirToolScopeRule, WriteRequest,
|
||||
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
|
||||
};
|
||||
|
||||
fn path(value: &str) -> WorkdirPath {
|
||||
WorkdirPath::new(value).unwrap()
|
||||
}
|
||||
fn rule(target: &str, permission: ToolPermission) -> WorkdirToolScopeRule {
|
||||
WorkdirToolScopeRule {
|
||||
target: path(target),
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: SymlinkPolicy::Resolved,
|
||||
}
|
||||
}
|
||||
fn scoped(target: &str, permission: ToolPermission) -> WorkdirToolScope {
|
||||
WorkdirToolScope {
|
||||
rules: vec![rule(target, permission)],
|
||||
cwd: path(target),
|
||||
command: false,
|
||||
}
|
||||
}
|
||||
fn assert_reason(error: workdir::WorkdirError, reason: Reason) {
|
||||
assert_eq!(error.denial_reason(), Some(reason), "{error}");
|
||||
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(transport.code.http_status(), 403);
|
||||
assert_eq!(transport.denial_reason, Some(reason));
|
||||
let wire = serde_json::to_string(&transport).unwrap();
|
||||
assert!(!wire.contains("private-input"));
|
||||
let decoded: WorkdirTransportError = serde_json::from_str(&wire).unwrap();
|
||||
assert_eq!(
|
||||
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
|
||||
transport
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_authorize_scope_refusals_distinguish_attachment_and_delegation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session = LocalWorkdirSession::materialized_bound(
|
||||
Workdir::new("denials"),
|
||||
root.path().to_owned(),
|
||||
root.path().to_owned(),
|
||||
SharedScope::new(
|
||||
Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: root.path().join("allowed"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: SymlinkPolicy::Resolved,
|
||||
}],
|
||||
deny: vec![],
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
);
|
||||
for (rules, requested, reason) in [
|
||||
(
|
||||
vec![rule("", ToolPermission::Read)],
|
||||
"private-input",
|
||||
Reason::AttachmentScopeExceeded,
|
||||
),
|
||||
(
|
||||
vec![rule("allowed/child", ToolPermission::Read)],
|
||||
"allowed/private-input",
|
||||
Reason::DelegatedScopeExceeded,
|
||||
),
|
||||
] {
|
||||
let error = session
|
||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||
rules,
|
||||
path: path(requested),
|
||||
permission: ToolPermission::Read,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_reason(error, reason);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broker_scope_refusal_matrix_preserves_distinct_reasons() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let local = Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_owned(),
|
||||
));
|
||||
let parent = WorkdirToolBroker::new(local);
|
||||
let mut empty = scoped("", ToolPermission::Read);
|
||||
empty.rules.clear();
|
||||
let mut command = scoped("", ToolPermission::Read);
|
||||
command.command = true;
|
||||
let mut cwd = scoped("allowed", ToolPermission::Read);
|
||||
cwd.cwd = path("private-input");
|
||||
for (request, reason) in [
|
||||
(empty, Reason::EmptyScope),
|
||||
(command, Reason::CommandRequiresWritableScope),
|
||||
(cwd, Reason::CwdOutsideReadableScope),
|
||||
] {
|
||||
assert_reason(parent.scope(request).await.unwrap_err(), reason);
|
||||
}
|
||||
let mut child_request = scoped("allowed", ToolPermission::Write);
|
||||
child_request.rules[0].recursive = false;
|
||||
let child = parent.scope(child_request).await.unwrap();
|
||||
assert_reason(
|
||||
child
|
||||
.scope(scoped("private-input", ToolPermission::Read))
|
||||
.await
|
||||
.unwrap_err(),
|
||||
Reason::ParentScopeExceeded,
|
||||
);
|
||||
assert_reason(
|
||||
parent
|
||||
.scope(scoped("allowed", ToolPermission::Write))
|
||||
.await
|
||||
.unwrap_err(),
|
||||
Reason::ChildWriteLeaseConflict,
|
||||
);
|
||||
assert_reason(
|
||||
parent
|
||||
.write(WriteRequest {
|
||||
path: path("allowed/private-input"),
|
||||
content: "unused".into(),
|
||||
expected_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err(),
|
||||
Reason::ChildWriteLeaseConflict,
|
||||
);
|
||||
assert_reason(
|
||||
child
|
||||
.stat(StatRequest {
|
||||
path: path("private-input/nested"),
|
||||
})
|
||||
.await
|
||||
.unwrap_err(),
|
||||
Reason::DelegatedScopeExceeded,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let local = Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_owned(),
|
||||
));
|
||||
let parent = WorkdirToolBroker::new(local.clone());
|
||||
let child = parent
|
||||
.scope(scoped("", ToolPermission::Read))
|
||||
.await
|
||||
.unwrap();
|
||||
let request = WriteRequest {
|
||||
path: path("private-input"),
|
||||
content: "unused".into(),
|
||||
expected_hash: None,
|
||||
};
|
||||
assert_reason(
|
||||
child
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "must-not-run".into(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err(),
|
||||
Reason::ScopedCapabilityDenied,
|
||||
);
|
||||
let readonly = ReadOnlyWorkdirSession::new(local);
|
||||
assert_reason(
|
||||
readonly.write(request).await.unwrap_err(),
|
||||
Reason::ReadOnlySession,
|
||||
);
|
||||
let error = parent
|
||||
.checkout_search(workdir::CheckoutSearchRequest {
|
||||
operation: workdir::CheckoutSearchOperation::List(workdir::ListRequest {
|
||||
path: path("private-input"),
|
||||
limit: 10,
|
||||
after: None,
|
||||
}),
|
||||
output_root: path("allowed"),
|
||||
scope_layers: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_reason(error, Reason::CheckoutOutputRootExceeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denial_wire_is_allowlisted_and_old_missing_reason_remains_unknown() {
|
||||
let old: WorkdirTransportError =
|
||||
serde_json::from_str(r#"{"code":"denied","message":"private-input secret"}"#).unwrap();
|
||||
let error = old.into_workdir_error();
|
||||
assert_eq!(error.denial_reason(), None);
|
||||
let forwarded = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(forwarded.code, Code::Denied);
|
||||
assert_eq!(forwarded.message, "Workdir operation was denied");
|
||||
assert_eq!(
|
||||
serde_json::to_value(forwarded).unwrap(),
|
||||
serde_json::json!({"code":"denied", "message":"Workdir operation was denied"})
|
||||
);
|
||||
for reason in ["/host/private-input", "secret", &"x".repeat(8192)] {
|
||||
let input =
|
||||
serde_json::json!({"code":"denied", "message":"ignored", "denial_reason":reason});
|
||||
assert!(serde_json::from_value::<WorkdirTransportError>(input).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// A provider with filesystem methods, but no provider-side scope resolver.
|
||||
#[derive(Debug)]
|
||||
struct UnresolvedProvider(LocalWorkdirSession);
|
||||
#[async_trait::async_trait]
|
||||
impl WorkdirSession for UnresolvedProvider {
|
||||
fn workdir(&self) -> &Workdir {
|
||||
self.0.workdir()
|
||||
}
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||
self.0.capabilities()
|
||||
}
|
||||
async fn stat(&self, r: StatRequest) -> Result<workdir::StatResult, workdir::WorkdirError> {
|
||||
self.0.stat(r).await
|
||||
}
|
||||
async fn read(
|
||||
&self,
|
||||
r: workdir::ReadRequest,
|
||||
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||
self.0.read(r).await
|
||||
}
|
||||
async fn write(&self, r: WriteRequest) -> Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||
self.0.write(r).await
|
||||
}
|
||||
async fn edit(
|
||||
&self,
|
||||
r: workdir::EditRequest,
|
||||
) -> Result<workdir::EditResult, workdir::WorkdirError> {
|
||||
self.0.edit(r).await
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
r: workdir::ListRequest,
|
||||
) -> Result<workdir::ListResult, workdir::WorkdirError> {
|
||||
self.0.list(r).await
|
||||
}
|
||||
async fn glob(
|
||||
&self,
|
||||
r: workdir::GlobRequest,
|
||||
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||
self.0.glob(r).await
|
||||
}
|
||||
async fn grep(
|
||||
&self,
|
||||
r: workdir::GrepRequest,
|
||||
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||
self.0.grep(r).await
|
||||
}
|
||||
async fn start_command(
|
||||
&self,
|
||||
r: workdir::CommandRequest,
|
||||
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
self.0.start_command(r).await
|
||||
}
|
||||
async fn command_status(
|
||||
&self,
|
||||
h: workdir::CommandHandle,
|
||||
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||
self.0.command_status(h).await
|
||||
}
|
||||
async fn command_output(
|
||||
&self,
|
||||
r: workdir::CommandOutputRequest,
|
||||
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
self.0.command_output(r).await
|
||||
}
|
||||
async fn cancel_command(&self, h: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> {
|
||||
self.0.cancel_command(h).await
|
||||
}
|
||||
async fn close(&self) -> Result<(), workdir::WorkdirError> {
|
||||
self.0.close().await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn providers_without_scope_resolution_fail_closed_with_specific_reasons() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let provider = UnresolvedProvider(LocalWorkdirSession::new(
|
||||
Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_owned(),
|
||||
));
|
||||
let rules = vec![rule("", ToolPermission::Read)];
|
||||
assert_reason(
|
||||
provider
|
||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||
rules: rules.clone(),
|
||||
path: path("private-input"),
|
||||
permission: ToolPermission::Read,
|
||||
})
|
||||
.await
|
||||
.unwrap_err(),
|
||||
Reason::ScopeResolutionUnavailable,
|
||||
);
|
||||
assert_reason(
|
||||
provider
|
||||
.scope_rules_overlap(workdir::WorkdirScopeOverlapRequest {
|
||||
left: rules[0].clone(),
|
||||
right: rules[0].clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err(),
|
||||
Reason::ScopeComparisonUnavailable,
|
||||
);
|
||||
let mut logical = rules;
|
||||
logical[0].symlink_policy = SymlinkPolicy::Logical;
|
||||
provider
|
||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||
rules: logical,
|
||||
path: path("private-input"),
|
||||
permission: ToolPermission::Read,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::path::Path;
|
||||
|
||||
use fs_operation::{AtomicWriteMode, CheckedTarget, FsAccessPolicy, FsDenialReason, FsError};
|
||||
use workdir::{
|
||||
WorkdirDenialReason as Reason, WorkdirError,
|
||||
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
|
||||
};
|
||||
|
||||
fn assert_origin(source: std::io::Error, reason: Reason, message: &str) {
|
||||
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(source.raw_os_error(), None);
|
||||
assert_eq!(source.to_string(), message);
|
||||
assert_transport_reason(source, reason);
|
||||
}
|
||||
|
||||
fn assert_transport_reason(source: std::io::Error, reason: Reason) {
|
||||
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
|
||||
assert!(matches!(&error, WorkdirError::Io { .. }));
|
||||
assert_eq!(error.denial_reason(), Some(reason));
|
||||
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(transport.code, Code::Denied);
|
||||
assert_eq!(transport.code.http_status(), 403);
|
||||
assert_eq!(transport.message, "Workdir operation was denied");
|
||||
let wire = serde_json::to_value(&transport).unwrap();
|
||||
assert_eq!(wire["denial_reason"], reason.as_str());
|
||||
assert!(!wire.to_string().contains("private-input"));
|
||||
assert!(!wire.to_string().contains("secret"));
|
||||
let decoded: WorkdirTransportError = serde_json::from_value(wire).unwrap();
|
||||
assert_eq!(
|
||||
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
|
||||
transport
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_fs_origins_survive_fs_workdir_and_transport_conversion() {
|
||||
for (origin, reason, message) in [
|
||||
(
|
||||
FsDenialReason::ProviderRootExceeded,
|
||||
Reason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
),
|
||||
(
|
||||
FsDenialReason::ProviderSymlinkDenied,
|
||||
Reason::ProviderSymlinkDenied,
|
||||
"symbolic links are not permitted by this provider",
|
||||
),
|
||||
(
|
||||
FsDenialReason::CheckoutTargetDenied,
|
||||
Reason::CheckoutTargetDenied,
|
||||
"checkout target denied",
|
||||
),
|
||||
(
|
||||
FsDenialReason::CheckoutCreateParentDenied,
|
||||
Reason::CheckoutCreateParentDenied,
|
||||
"Create parent denied",
|
||||
),
|
||||
] {
|
||||
assert_origin(origin.into_io_error(), reason, message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_permission_denied_is_not_guessed_from_kind_or_message() {
|
||||
for message in [
|
||||
"Permission denied (os error 13)",
|
||||
"checkout target denied",
|
||||
"/private-input/secret",
|
||||
] {
|
||||
assert_transport_reason(
|
||||
std::io::Error::new(std::io::ErrorKind::PermissionDenied, message),
|
||||
Reason::UnclassifiedPermissionDenied,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn raw_os_permission_denied_retains_errno_and_os_origin() {
|
||||
for errno in [libc::EACCES, libc::EPERM] {
|
||||
let source = std::io::Error::from_raw_os_error(errno);
|
||||
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
|
||||
let WorkdirError::Io { source, .. } = &error else {
|
||||
panic!("classification changed")
|
||||
};
|
||||
assert_eq!(source.raw_os_error(), Some(errno));
|
||||
assert_transport_reason(
|
||||
std::io::Error::from_raw_os_error(errno),
|
||||
Reason::OsPermissionDenied,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn actual_confined_open_and_write_refusals_keep_distinct_origins() {
|
||||
use fs_operation::{
|
||||
atomic_write_beneath_no_symlinks_at, open_beneath_no_symlinks, open_beneath_no_symlinks_at,
|
||||
open_root_no_symlinks,
|
||||
};
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let directory = open_root_no_symlinks(root.path()).unwrap();
|
||||
let outside = root.path().parent().unwrap().join("private-input");
|
||||
for source in [
|
||||
open_beneath_no_symlinks_at(&directory, &outside).unwrap_err(),
|
||||
open_beneath_no_symlinks(root.path(), &outside).unwrap_err(),
|
||||
atomic_write_beneath_no_symlinks_at(
|
||||
&directory,
|
||||
&outside,
|
||||
b"unused",
|
||||
AtomicWriteMode::CreateNew,
|
||||
|| Ok(()),
|
||||
)
|
||||
.unwrap_err(),
|
||||
atomic_write_beneath_no_symlinks_at(
|
||||
&directory,
|
||||
Path::new("../private-input"),
|
||||
b"unused",
|
||||
AtomicWriteMode::CreateNew,
|
||||
|| Ok(()),
|
||||
)
|
||||
.unwrap_err(),
|
||||
] {
|
||||
assert_origin(
|
||||
source,
|
||||
Reason::ProviderRootExceeded,
|
||||
"path is outside provider root",
|
||||
);
|
||||
}
|
||||
std::fs::write(root.path().join("file"), "secret").unwrap();
|
||||
std::os::unix::fs::symlink("file", root.path().join("link")).unwrap();
|
||||
assert_origin(
|
||||
open_beneath_no_symlinks_at(&directory, Path::new("link")).unwrap_err(),
|
||||
Reason::ProviderSymlinkDenied,
|
||||
"symbolic links are not permitted by this provider",
|
||||
);
|
||||
}
|
||||
|
||||
struct ExactWrite<'a>(&'a Path);
|
||||
impl FsAccessPolicy for ExactWrite<'_> {
|
||||
fn is_readable(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn is_writable(&self, path: &Path) -> bool {
|
||||
path == self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn actual_checked_target_and_create_parent_refusals_keep_distinct_origins() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let directory = fs_operation::open_root_no_symlinks(root.path()).unwrap();
|
||||
let destination = root.path().join("parent/file");
|
||||
let policy = ExactWrite(&destination);
|
||||
let source = CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, true)
|
||||
.err()
|
||||
.expect("target policy must reject");
|
||||
assert_origin(
|
||||
source,
|
||||
Reason::CheckoutTargetDenied,
|
||||
"checkout target denied",
|
||||
);
|
||||
let target =
|
||||
CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, false).unwrap();
|
||||
let create = target.create_access("parent/file".into(), destination.clone());
|
||||
let error = fs_operation::run_write(
|
||||
root.path(),
|
||||
fs_operation::WriteRequest {
|
||||
path: fs_operation::FsPath::new("parent/file").unwrap(),
|
||||
content: "unused".into(),
|
||||
expected_hash: None,
|
||||
},
|
||||
&create,
|
||||
)
|
||||
.unwrap_err();
|
||||
let FsError::Io { source, .. } = error else {
|
||||
panic!("expected parent policy denial")
|
||||
};
|
||||
assert_origin(
|
||||
source,
|
||||
Reason::CheckoutCreateParentDenied,
|
||||
"Create parent denied",
|
||||
);
|
||||
assert!(!root.path().join("parent").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_transport_code_forwards_supplied_reason_without_reclassification() {
|
||||
// JSON is the provider boundary. An optional reason is independent of code/status.
|
||||
for code in [
|
||||
"not_found",
|
||||
"conflict",
|
||||
"outcome_unknown",
|
||||
"unsupported",
|
||||
"invalid_request",
|
||||
"denied",
|
||||
"out_of_scope",
|
||||
"symlink_out_of_scope",
|
||||
"broken_symlink",
|
||||
"symlink_target_is_directory",
|
||||
"read_only",
|
||||
"is_directory",
|
||||
"symlink_directory_not_traversed",
|
||||
"unknown_command",
|
||||
"unavailable",
|
||||
"io",
|
||||
"transport",
|
||||
"internal",
|
||||
] {
|
||||
let decode = |reason| {
|
||||
serde_json::from_value::<WorkdirTransportError>(serde_json::json!({
|
||||
"code": code, "message": "private-input secret", "denial_reason": reason,
|
||||
}))
|
||||
.unwrap()
|
||||
.into_workdir_error()
|
||||
};
|
||||
let baseline_error = decode(None::<Reason>);
|
||||
let baseline = WorkdirTransportError::from_workdir_error(&baseline_error);
|
||||
let error = decode(Some(Reason::CheckoutTargetDenied));
|
||||
let forwarded = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(
|
||||
error.denial_reason(),
|
||||
Some(Reason::CheckoutTargetDenied),
|
||||
"{code}"
|
||||
);
|
||||
assert_eq!(forwarded.code, baseline.code, "{code}");
|
||||
assert_eq!(
|
||||
forwarded.code.http_status(),
|
||||
baseline.code.http_status(),
|
||||
"{code}"
|
||||
);
|
||||
assert_eq!(forwarded.message, baseline.message, "{code}");
|
||||
assert_eq!(error.to_string(), baseline_error.to_string(), "{code}");
|
||||
// Missing provider reasons retain the deliberate existing fallback: canonical
|
||||
// path classifications supply their known reason; all other codes stay unknown.
|
||||
assert_eq!(
|
||||
baseline_error.denial_reason(),
|
||||
match code {
|
||||
"out_of_scope" => Some(Reason::PathOutOfScope),
|
||||
"symlink_out_of_scope" => Some(Reason::SymlinkTargetOutOfScope),
|
||||
"read_only" => Some(Reason::PathReadOnly),
|
||||
_ => None,
|
||||
},
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_labels_match_allowlisted_wire_values() {
|
||||
for label in [
|
||||
"scope_resolution_unavailable",
|
||||
"scope_comparison_unavailable",
|
||||
"scoped_capability_denied",
|
||||
"logical_scope_exceeded",
|
||||
"invalid_scoped_path",
|
||||
"child_write_lease_conflict",
|
||||
"empty_scope",
|
||||
"parent_capability_denied",
|
||||
"command_requires_writable_scope",
|
||||
"parent_scope_exceeded",
|
||||
"cwd_outside_readable_scope",
|
||||
"read_only_session",
|
||||
"external_root_symlink",
|
||||
"external_root_changed",
|
||||
"external_root_moved",
|
||||
"external_scope_symlink",
|
||||
"attachment_scope_exceeded",
|
||||
"delegated_scope_exceeded",
|
||||
"checkout_output_root_exceeded",
|
||||
"provider_root_exceeded",
|
||||
"provider_symlink_denied",
|
||||
"checkout_target_denied",
|
||||
"checkout_create_parent_denied",
|
||||
"checkout_search_path_denied",
|
||||
"checkout_enumeration_denied",
|
||||
"path_out_of_scope",
|
||||
"symlink_target_out_of_scope",
|
||||
"path_read_only",
|
||||
"os_permission_denied",
|
||||
"unclassified_permission_denied",
|
||||
] {
|
||||
let reason: Reason = serde_json::from_value(serde_json::json!(label)).unwrap();
|
||||
assert_eq!(reason.as_str(), label);
|
||||
assert_eq!(serde_json::to_value(reason).unwrap(), label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#![cfg(feature = "http-client")]
|
||||
//! Loopback fake at the HTTP provider boundary; no product process or live service.
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use workdir::{
|
||||
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
|
||||
http::{
|
||||
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
|
||||
WorkdirTransportErrorCode as Code,
|
||||
},
|
||||
};
|
||||
|
||||
fn reply(listener: &TcpListener, status: &str, body: &str) {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
read_request(&mut stream);
|
||||
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
|
||||
}
|
||||
fn read_request(stream: &mut TcpStream) {
|
||||
let mut bytes = Vec::new();
|
||||
let mut byte = [0];
|
||||
while !bytes.ends_with(b"\r\n\r\n") {
|
||||
stream.read_exact(&mut byte).unwrap();
|
||||
bytes.push(byte[0]);
|
||||
assert!(bytes.len() < 16384);
|
||||
}
|
||||
let headers = String::from_utf8(bytes).unwrap();
|
||||
let length: usize = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
assert!(length < 16384);
|
||||
stream.read_exact(&mut vec![0; length]).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason() {
|
||||
for (code, reason) in [
|
||||
(Code::Denied, Some(Reason::ReadOnlySession)),
|
||||
(Code::Denied, Some(Reason::OsPermissionDenied)),
|
||||
(Code::OutOfScope, Some(Reason::OsPermissionDenied)),
|
||||
(Code::Denied, None),
|
||||
] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
// Host paths and secrets in provider-authored text must not become diagnostics.
|
||||
let mut error = serde_json::json!({"code":code, "message":"/secret/host/root bearer-secret command-secret"});
|
||||
if let Some(reason) = reason {
|
||||
error["denial_reason"] = serde_json::to_value(reason).unwrap();
|
||||
}
|
||||
let server = thread::spawn(move || {
|
||||
reply(
|
||||
&listener,
|
||||
"200 OK",
|
||||
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
|
||||
);
|
||||
reply(&listener, "403 Forbidden", &error.to_string());
|
||||
});
|
||||
let remote = RemoteWorkdirSession::open(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap(),
|
||||
format!("http://{address}").parse().unwrap(),
|
||||
"request-secret",
|
||||
Workdir::new("w").id().clone(),
|
||||
OpenWorkdirSessionRequest::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let denied = remote
|
||||
.stat(StatRequest {
|
||||
path: WorkdirPath::new("file").unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(denied.denial_reason(), reason);
|
||||
assert!(!denied.to_string().contains("secret"));
|
||||
let forwarded = WorkdirTransportError::from_workdir_error(&denied);
|
||||
assert_eq!(forwarded.denial_reason, reason);
|
||||
assert_eq!(forwarded.code, code);
|
||||
assert_eq!(forwarded.code.http_status(), 403);
|
||||
assert_eq!(
|
||||
forwarded.message,
|
||||
if code == Code::Denied {
|
||||
"Workdir operation was denied"
|
||||
} else {
|
||||
"Workdir path is out of scope"
|
||||
}
|
||||
);
|
||||
let wire = serde_json::to_string(&forwarded).unwrap();
|
||||
assert!(!wire.contains("secret"));
|
||||
assert!(wire.len() < 256);
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user