fix: audit validated external workdir operation outcomes
This commit is contained in:
@@ -1046,10 +1046,17 @@ impl ExternalProviderWorkdirSession {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn operate(
|
async fn operate<T, F, Fut>(
|
||||||
&self,
|
&self,
|
||||||
operation: WorkdirSessionOperation,
|
operation: WorkdirSessionOperation,
|
||||||
) -> std::result::Result<WorkdirSessionOperationResult, workdir::WorkdirError> {
|
validate: F,
|
||||||
|
) -> std::result::Result<T, workdir::WorkdirError>
|
||||||
|
where
|
||||||
|
T: Send,
|
||||||
|
F: FnOnce(std::result::Result<WorkdirSessionOperationResult, workdir::WorkdirError>) -> Fut
|
||||||
|
+ Send,
|
||||||
|
Fut: std::future::Future<Output = std::result::Result<T, workdir::WorkdirError>> + Send,
|
||||||
|
{
|
||||||
let operation_kind = external_workdir_operation_kind(&operation);
|
let operation_kind = external_workdir_operation_kind(&operation);
|
||||||
let operation_timeout = external_workdir_operation_timeout(&operation);
|
let operation_timeout = external_workdir_operation_timeout(&operation);
|
||||||
if self.closed.load(Ordering::Acquire) {
|
if self.closed.load(Ordering::Acquire) {
|
||||||
@@ -1169,6 +1176,12 @@ impl ExternalProviderWorkdirSession {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Provider dispatch is finished; validation may issue bounded command cleanup
|
||||||
|
// and must not retain a data-operation slot while awaiting another operation.
|
||||||
|
drop(_admission);
|
||||||
|
// A received response is not evidence of operation success until its requested
|
||||||
|
// type and semantics (including mutation uncertainty) have been checked.
|
||||||
|
let result = validate(result).await;
|
||||||
let outcome = match &result {
|
let outcome = match &result {
|
||||||
Ok(_) => Outcome::Succeeded,
|
Ok(_) => Outcome::Succeeded,
|
||||||
Err(error)
|
Err(error)
|
||||||
@@ -1275,9 +1288,15 @@ impl ExternalProviderWorkdirSession {
|
|||||||
// Do not acquire the output lock until cancellation has unblocked any reader.
|
// Do not acquire the output lock until cancellation has unblocked any reader.
|
||||||
// The reader records its last known cursor before cleanup acknowledges bytes.
|
// The reader records its last known cursor before cleanup acknowledges bytes.
|
||||||
let _ = self
|
let _ = self
|
||||||
.operate(WorkdirSessionOperation::CommandCancel(
|
.operate(
|
||||||
command.handle.clone(),
|
WorkdirSessionOperation::CommandCancel(command.handle.clone()),
|
||||||
))
|
|result| async move {
|
||||||
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::CommandCancel => Ok(()),
|
||||||
|
_ => Err(Self::mismatch("command_cancel")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
let mut cursor =
|
let mut cursor =
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(30), command.cursor.lock())
|
tokio::time::timeout(std::time::Duration::from_secs(30), command.cursor.lock())
|
||||||
@@ -1292,31 +1311,31 @@ impl ExternalProviderWorkdirSession {
|
|||||||
}
|
}
|
||||||
let drain = async {
|
let drain = async {
|
||||||
loop {
|
loop {
|
||||||
match self
|
let output = self
|
||||||
.operate(WorkdirSessionOperation::CommandOutput(
|
.operate(
|
||||||
workdir::CommandOutputRequest {
|
WorkdirSessionOperation::CommandOutput(workdir::CommandOutputRequest {
|
||||||
handle: command.handle.clone(),
|
handle: command.handle.clone(),
|
||||||
cursor: *cursor,
|
cursor: *cursor,
|
||||||
limit: workdir::MAX_COMMAND_READ_BYTES,
|
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||||
wait: true,
|
wait: true,
|
||||||
|
}),
|
||||||
|
|result| async move {
|
||||||
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::CommandOutput(output) => Ok(output),
|
||||||
|
_ => Err(Self::mismatch("command_output")),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
))
|
)
|
||||||
.await?
|
.await?;
|
||||||
{
|
*cursor = output.next_cursor;
|
||||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
if output.terminal.is_some() {
|
||||||
*cursor = output.next_cursor;
|
*command.terminal_at.lock().unwrap() = Some(std::time::Instant::now());
|
||||||
if output.terminal.is_some() {
|
return Ok(());
|
||||||
*command.terminal_at.lock().unwrap() = Some(std::time::Instant::now());
|
}
|
||||||
return Ok(());
|
if output.chunks.is_empty() {
|
||||||
}
|
return Err(workdir::WorkdirError::Unavailable(
|
||||||
if output.chunks.is_empty() {
|
"External Workdir provider did not confirm command termination".to_string(),
|
||||||
return Err(workdir::WorkdirError::Unavailable(
|
));
|
||||||
"External Workdir provider did not confirm command termination"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return Err(Self::mismatch("command_output")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1384,46 +1403,55 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
request: workdir::WorkdirScopeAuthorizationRequest,
|
request: workdir::WorkdirScopeAuthorizationRequest,
|
||||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::AuthorizeScope(request))
|
WorkdirSessionOperation::AuthorizeScope(request),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
|
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
|
||||||
_ => Err(Self::mismatch("authorize_scope")),
|
_ => Err(Self::mismatch("authorize_scope")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn scope_rules_overlap(
|
async fn scope_rules_overlap(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::WorkdirScopeOverlapRequest,
|
request: workdir::WorkdirScopeOverlapRequest,
|
||||||
) -> std::result::Result<bool, workdir::WorkdirError> {
|
) -> std::result::Result<bool, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
|
WorkdirSessionOperation::ScopeRulesOverlap(request),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
|
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
|
||||||
_ => Err(Self::mismatch("scope_rules_overlap")),
|
_ => Err(Self::mismatch("scope_rules_overlap")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn checkout_observe(
|
async fn checkout_observe(
|
||||||
&self,
|
&self,
|
||||||
path: workdir::WorkdirPath,
|
path: workdir::WorkdirPath,
|
||||||
) -> std::result::Result<workdir::CheckoutObservation, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::CheckoutObservation, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::CheckoutObserve(path.clone()))
|
WorkdirSessionOperation::CheckoutObserve(path.clone()),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::CheckoutObserve(mut value)
|
WorkdirSessionOperationResult::CheckoutObserve(mut value)
|
||||||
if value.path == path
|
if value.path == path
|
||||||
&& !value.validator.is_empty()
|
&& !value.validator.is_empty()
|
||||||
&& value.validator.len() <= 256 =>
|
&& value.validator.len() <= 256 =>
|
||||||
{
|
{
|
||||||
value.capabilities = value.capabilities.intersection(self.capabilities());
|
value.capabilities = value.capabilities.intersection(self.capabilities());
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
_ => Err(Self::mismatch("checkout_observe")),
|
_ => Err(Self::mismatch("checkout_observe")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn checkout_execute(
|
async fn checkout_execute(
|
||||||
&self,
|
&self,
|
||||||
@@ -1433,63 +1461,67 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
self.ensure_capability(cap)?;
|
self.ensure_capability(cap)?;
|
||||||
let target = request.target.clone();
|
let target = request.target.clone();
|
||||||
let mutation = cap != workdir::WorkdirSessionCapability::Read;
|
let mutation = cap != workdir::WorkdirSessionCapability::Read;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::CheckoutExecute(request))
|
WorkdirSessionOperation::CheckoutExecute(request),
|
||||||
.await
|
|result| async move {
|
||||||
{
|
match result {
|
||||||
Ok(WorkdirSessionOperationResult::CheckoutExecute(mut value))
|
Ok(WorkdirSessionOperationResult::CheckoutExecute(mut value))
|
||||||
if value.observation.path == target
|
if value.observation.path == target
|
||||||
&& !value.observation.validator.is_empty()
|
&& !value.observation.validator.is_empty()
|
||||||
&& value.observation.validator.len() <= 256
|
&& value.observation.validator.len() <= 256
|
||||||
&& matches!(
|
&& matches!(
|
||||||
(&value.output, cap),
|
(&value.output, cap),
|
||||||
(
|
(
|
||||||
workdir::CheckoutOutput::Read(_),
|
workdir::CheckoutOutput::Read(_),
|
||||||
workdir::WorkdirSessionCapability::Read
|
workdir::WorkdirSessionCapability::Read
|
||||||
) | (
|
) | (
|
||||||
workdir::CheckoutOutput::Write(_),
|
workdir::CheckoutOutput::Write(_),
|
||||||
workdir::WorkdirSessionCapability::Write
|
workdir::WorkdirSessionCapability::Write
|
||||||
) | (
|
) | (
|
||||||
workdir::CheckoutOutput::Edit(_),
|
workdir::CheckoutOutput::Edit(_),
|
||||||
workdir::WorkdirSessionCapability::Edit
|
workdir::WorkdirSessionCapability::Edit
|
||||||
)
|
)
|
||||||
) =>
|
) =>
|
||||||
{
|
{
|
||||||
value.observation.capabilities = value
|
value.observation.capabilities = value
|
||||||
.observation
|
.observation
|
||||||
.capabilities
|
.capabilities
|
||||||
.intersection(self.capabilities());
|
.intersection(self.capabilities());
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
Ok(_) if mutation => Err(workdir::WorkdirError::OutcomeUnknown(
|
Ok(_) if mutation => Err(workdir::WorkdirError::OutcomeUnknown(
|
||||||
"mismatched checkout mutation response".into(),
|
"mismatched checkout mutation response".into(),
|
||||||
)
|
)
|
||||||
.with_failure_reason(workdir::WorkdirFailureReason::OperationResultMismatch)),
|
.with_failure_reason(workdir::WorkdirFailureReason::OperationResultMismatch)),
|
||||||
Ok(_) => Err(Self::mismatch("checkout_execute")),
|
Ok(_) => Err(Self::mismatch("checkout_execute")),
|
||||||
Err(error)
|
Err(error)
|
||||||
if mutation
|
if mutation
|
||||||
&& matches!(
|
&& matches!(
|
||||||
error.classification_source(),
|
error.classification_source(),
|
||||||
workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Transport(_)
|
workdir::WorkdirError::Unavailable(_)
|
||||||
) =>
|
| workdir::WorkdirError::Transport(_)
|
||||||
{
|
) =>
|
||||||
let outcome = workdir::WorkdirError::OutcomeUnknown(
|
{
|
||||||
"checkout provider response lost; inspect effects before retry".into(),
|
let outcome = workdir::WorkdirError::OutcomeUnknown(
|
||||||
);
|
"checkout provider response lost; inspect effects before retry".into(),
|
||||||
let outcome = match error.failure_reason() {
|
);
|
||||||
Some(reason) => outcome.with_failure_reason(reason),
|
let outcome = match error.failure_reason() {
|
||||||
None => outcome,
|
Some(reason) => outcome.with_failure_reason(reason),
|
||||||
};
|
None => outcome,
|
||||||
Err(match error.denial_reason() {
|
};
|
||||||
Some(reason) => workdir::WorkdirError::DenialContext {
|
Err(match error.denial_reason() {
|
||||||
reason,
|
Some(reason) => workdir::WorkdirError::DenialContext {
|
||||||
source: Box::new(outcome),
|
reason,
|
||||||
},
|
source: Box::new(outcome),
|
||||||
None => outcome,
|
},
|
||||||
})
|
None => outcome,
|
||||||
}
|
})
|
||||||
Err(error) => Err(error),
|
}
|
||||||
}
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn checkout_search(
|
async fn checkout_search(
|
||||||
&self,
|
&self,
|
||||||
@@ -1505,108 +1537,153 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
Op::Grep(_) => Cap::Grep,
|
Op::Grep(_) => Cap::Grep,
|
||||||
};
|
};
|
||||||
self.ensure_capability(cap)?;
|
self.ensure_capability(cap)?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::CheckoutSearch(request))
|
WorkdirSessionOperation::CheckoutSearch(request),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::CheckoutSearch(value)
|
WorkdirSessionOperationResult::CheckoutSearch(value)
|
||||||
if matches!(
|
if matches!(
|
||||||
(&value, cap),
|
(&value, cap),
|
||||||
(Result::List(_), Cap::Read)
|
(Result::List(_), Cap::Read)
|
||||||
| (Result::Glob(_), Cap::Glob)
|
| (Result::Glob(_), Cap::Glob)
|
||||||
| (Result::Grep(_), Cap::Grep)
|
| (Result::Grep(_), Cap::Grep)
|
||||||
) =>
|
) =>
|
||||||
{
|
{
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
_ => Err(Self::mismatch("checkout_search")),
|
_ => Err(Self::mismatch("checkout_search")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn stat(
|
async fn stat(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::StatRequest,
|
request: workdir::StatRequest,
|
||||||
) -> std::result::Result<workdir::StatResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::StatResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
self.operate(
|
||||||
WorkdirSessionOperationResult::Stat(value) => Ok(value),
|
WorkdirSessionOperation::Stat(request),
|
||||||
_ => Err(Self::mismatch("stat")),
|
|result| async move {
|
||||||
}
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::Stat(value) => Ok(value),
|
||||||
|
_ => Err(Self::mismatch("stat")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn read(
|
async fn read(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::ReadRequest,
|
request: workdir::ReadRequest,
|
||||||
) -> std::result::Result<workdir::ReadResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||||
match self.operate(WorkdirSessionOperation::Read(request)).await? {
|
self.operate(
|
||||||
WorkdirSessionOperationResult::Read(value) => Ok(value),
|
WorkdirSessionOperation::Read(request),
|
||||||
_ => Err(Self::mismatch("read")),
|
|result| async move {
|
||||||
}
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::Read(value) => Ok(value),
|
||||||
|
_ => Err(Self::mismatch("read")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn read_bytes(
|
async fn read_bytes(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::ReadBytesRequest,
|
request: workdir::ReadBytesRequest,
|
||||||
) -> std::result::Result<workdir::ReadBytesResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::ReadBytesResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::ReadBytes(request))
|
WorkdirSessionOperation::ReadBytes(request),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::ReadBytes(value) => Ok(value),
|
WorkdirSessionOperationResult::ReadBytes(value) => Ok(value),
|
||||||
_ => Err(Self::mismatch("read_bytes")),
|
_ => Err(Self::mismatch("read_bytes")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn write(
|
async fn write(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::WriteRequest,
|
request: workdir::WriteRequest,
|
||||||
) -> std::result::Result<workdir::WriteResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Write)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Write)?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::Write(request))
|
WorkdirSessionOperation::Write(request),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::Write(value) => Ok(value),
|
WorkdirSessionOperationResult::Write(value) => Ok(value),
|
||||||
_ => Err(Self::mismatch("write")),
|
_ => Err(Self::mismatch("write")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn edit(
|
async fn edit(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::EditRequest,
|
request: workdir::EditRequest,
|
||||||
) -> std::result::Result<workdir::EditResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::EditResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Edit)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Edit)?;
|
||||||
match self.operate(WorkdirSessionOperation::Edit(request)).await? {
|
self.operate(
|
||||||
WorkdirSessionOperationResult::Edit(value) => Ok(value),
|
WorkdirSessionOperation::Edit(request),
|
||||||
_ => Err(Self::mismatch("edit")),
|
|result| async move {
|
||||||
}
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::Edit(value) => Ok(value),
|
||||||
|
_ => Err(Self::mismatch("edit")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn list(
|
async fn list(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::ListRequest,
|
request: workdir::ListRequest,
|
||||||
) -> std::result::Result<workdir::ListResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::ListResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||||
match self.operate(WorkdirSessionOperation::List(request)).await? {
|
self.operate(
|
||||||
WorkdirSessionOperationResult::List(value) => Ok(value),
|
WorkdirSessionOperation::List(request),
|
||||||
_ => Err(Self::mismatch("list")),
|
|result| async move {
|
||||||
}
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::List(value) => Ok(value),
|
||||||
|
_ => Err(Self::mismatch("list")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn glob(
|
async fn glob(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::GlobRequest,
|
request: workdir::GlobRequest,
|
||||||
) -> std::result::Result<workdir::GlobResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Glob)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Glob)?;
|
||||||
match self.operate(WorkdirSessionOperation::Glob(request)).await? {
|
self.operate(
|
||||||
WorkdirSessionOperationResult::Glob(value) => Ok(value),
|
WorkdirSessionOperation::Glob(request),
|
||||||
_ => Err(Self::mismatch("glob")),
|
|result| async move {
|
||||||
}
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::Glob(value) => Ok(value),
|
||||||
|
_ => Err(Self::mismatch("glob")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn grep(
|
async fn grep(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::GrepRequest,
|
request: workdir::GrepRequest,
|
||||||
) -> std::result::Result<workdir::GrepResult, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Grep)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Grep)?;
|
||||||
match self.operate(WorkdirSessionOperation::Grep(request)).await? {
|
self.operate(
|
||||||
WorkdirSessionOperationResult::Grep(value) => Ok(value),
|
WorkdirSessionOperation::Grep(request),
|
||||||
_ => Err(Self::mismatch("grep")),
|
|result| async move {
|
||||||
}
|
match result? {
|
||||||
|
WorkdirSessionOperationResult::Grep(value) => Ok(value),
|
||||||
|
_ => Err(Self::mismatch("grep")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn start_command(
|
async fn start_command(
|
||||||
&self,
|
&self,
|
||||||
@@ -1631,62 +1708,68 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
"External Workdir active command limit reached".to_string(),
|
"External Workdir active command limit reached".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::CommandStart(request))
|
WorkdirSessionOperation::CommandStart(request),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::CommandStart(provider_handle) => {
|
WorkdirSessionOperationResult::CommandStart(provider_handle) => {
|
||||||
let duplicate_handles = {
|
let duplicate_handles = {
|
||||||
let commands = self.active_commands.lock().await;
|
let commands = self.active_commands.lock().await;
|
||||||
commands
|
commands
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, active)| active.handle == provider_handle)
|
.filter(|(_, active)| active.handle == provider_handle)
|
||||||
.map(|(external, _)| external.clone())
|
.map(|(external, _)| external.clone())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
if !duplicate_handles.is_empty() {
|
if !duplicate_handles.is_empty() {
|
||||||
// A provider handle must identify exactly one command in one
|
// A provider handle must identify exactly one command in one
|
||||||
// generation. Remove every ambiguous route before attempting
|
// generation. Remove every ambiguous route before attempting
|
||||||
// cleanup so no caller can address the wrong process.
|
// cleanup so no caller can address the wrong process.
|
||||||
let mut commands = self.active_commands.lock().await;
|
let mut commands = self.active_commands.lock().await;
|
||||||
let mut quarantined = None;
|
let mut quarantined = None;
|
||||||
for external_handle in duplicate_handles {
|
for external_handle in duplicate_handles {
|
||||||
if let Some(command) = commands.remove(&external_handle) {
|
if let Some(command) = commands.remove(&external_handle) {
|
||||||
quarantined.get_or_insert(command);
|
quarantined.get_or_insert(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let quarantine_handle = workdir::CommandHandle(format!(
|
||||||
|
"quarantined-command-{}",
|
||||||
|
Uuid::now_v7()
|
||||||
|
));
|
||||||
|
let command = quarantined.expect("duplicate command route disappeared");
|
||||||
|
*command.terminal_at.lock().unwrap() = None;
|
||||||
|
commands.insert(quarantine_handle.0.clone(), command.clone());
|
||||||
|
drop(commands);
|
||||||
|
if self
|
||||||
|
.terminate_provider_command(command.clone(), true)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
self.remove_active_command_if_matches(&quarantine_handle, &command)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
// An earlier read of the reused handle cannot establish that
|
||||||
|
// the newly started, ambiguous process has been cleaned up.
|
||||||
|
*command.terminal_at.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
return Err(workdir::WorkdirError::Unavailable(
|
||||||
|
"External Workdir provider reused an active command handle"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
let external_handle =
|
||||||
|
workdir::CommandHandle(format!("external-command-{}", Uuid::now_v7()));
|
||||||
|
self.active_commands.lock().await.insert(
|
||||||
|
external_handle.0.clone(),
|
||||||
|
Arc::new(ExternalProviderCommandState::new(provider_handle)),
|
||||||
|
);
|
||||||
|
Ok(external_handle)
|
||||||
}
|
}
|
||||||
let quarantine_handle =
|
_ => Err(Self::mismatch("command_start")),
|
||||||
workdir::CommandHandle(format!("quarantined-command-{}", Uuid::now_v7()));
|
|
||||||
let command = quarantined.expect("duplicate command route disappeared");
|
|
||||||
*command.terminal_at.lock().unwrap() = None;
|
|
||||||
commands.insert(quarantine_handle.0.clone(), command.clone());
|
|
||||||
drop(commands);
|
|
||||||
if self
|
|
||||||
.terminate_provider_command(command.clone(), true)
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
self.remove_active_command_if_matches(&quarantine_handle, &command)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
// An earlier read of the reused handle cannot establish that
|
|
||||||
// the newly started, ambiguous process has been cleaned up.
|
|
||||||
*command.terminal_at.lock().unwrap() = None;
|
|
||||||
}
|
|
||||||
return Err(workdir::WorkdirError::Unavailable(
|
|
||||||
"External Workdir provider reused an active command handle".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let external_handle =
|
},
|
||||||
workdir::CommandHandle(format!("external-command-{}", Uuid::now_v7()));
|
)
|
||||||
self.active_commands.lock().await.insert(
|
.await
|
||||||
external_handle.0.clone(),
|
|
||||||
Arc::new(ExternalProviderCommandState::new(provider_handle)),
|
|
||||||
);
|
|
||||||
Ok(external_handle)
|
|
||||||
}
|
|
||||||
_ => Err(Self::mismatch("command_start")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async fn command_status(
|
async fn command_status(
|
||||||
&self,
|
&self,
|
||||||
@@ -1694,13 +1777,16 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
) -> std::result::Result<workdir::CommandStatus, workdir::WorkdirError> {
|
) -> std::result::Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||||
let provider_handle = self.provider_command_handle(&external_handle).await?;
|
let provider_handle = self.provider_command_handle(&external_handle).await?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::CommandStatus(provider_handle))
|
WorkdirSessionOperation::CommandStatus(provider_handle),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::CommandStatus(status) => Ok(status),
|
WorkdirSessionOperationResult::CommandStatus(status) => Ok(status),
|
||||||
_ => Err(Self::mismatch("command_status")),
|
_ => Err(Self::mismatch("command_status")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn command_output(
|
async fn command_output(
|
||||||
&self,
|
&self,
|
||||||
@@ -1709,29 +1795,33 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||||
let external_handle = request.handle.clone();
|
let external_handle = request.handle.clone();
|
||||||
let command = self.provider_command(&external_handle).await?;
|
let command = self.provider_command(&external_handle).await?;
|
||||||
|
let command = &command;
|
||||||
let mut cursor = command.cursor.lock().await;
|
let mut cursor = command.cursor.lock().await;
|
||||||
request.handle = command.handle.clone();
|
request.handle = command.handle.clone();
|
||||||
// If the response is lost, this request cursor still replays the provider page.
|
// If the response is lost, this request cursor still replays the provider page.
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::CommandOutput(request))
|
WorkdirSessionOperation::CommandOutput(request),
|
||||||
.await
|
|result| async move {
|
||||||
{
|
match result {
|
||||||
Ok(WorkdirSessionOperationResult::CommandOutput(output)) => {
|
Ok(WorkdirSessionOperationResult::CommandOutput(output)) => {
|
||||||
*cursor = output.next_cursor;
|
*cursor = output.next_cursor;
|
||||||
if output.terminal.is_some() {
|
if output.terminal.is_some() {
|
||||||
command
|
command
|
||||||
.terminal_at
|
.terminal_at
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_or_insert_with(std::time::Instant::now);
|
.get_or_insert_with(std::time::Instant::now);
|
||||||
|
}
|
||||||
|
drop(cursor);
|
||||||
|
self.prune_terminal_commands().await;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
Ok(_) => Err(Self::mismatch("command_output")),
|
||||||
|
Err(error) => Err(error),
|
||||||
}
|
}
|
||||||
drop(cursor);
|
},
|
||||||
self.prune_terminal_commands().await;
|
)
|
||||||
Ok(output)
|
.await
|
||||||
}
|
|
||||||
Ok(_) => Err(Self::mismatch("command_output")),
|
|
||||||
Err(error) => Err(error),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async fn cancel_command(
|
async fn cancel_command(
|
||||||
&self,
|
&self,
|
||||||
@@ -1739,13 +1829,16 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||||
let provider_handle = self.provider_command_handle(&external_handle).await?;
|
let provider_handle = self.provider_command_handle(&external_handle).await?;
|
||||||
match self
|
self.operate(
|
||||||
.operate(WorkdirSessionOperation::CommandCancel(provider_handle))
|
WorkdirSessionOperation::CommandCancel(provider_handle),
|
||||||
.await?
|
|result| async move {
|
||||||
{
|
match result? {
|
||||||
WorkdirSessionOperationResult::CommandCancel => Ok(()),
|
WorkdirSessionOperationResult::CommandCancel => Ok(()),
|
||||||
_ => Err(Self::mismatch("command_cancel")),
|
_ => Err(Self::mismatch("command_cancel")),
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
async fn close(&self) -> std::result::Result<(), workdir::WorkdirError> {
|
async fn close(&self) -> std::result::Result<(), workdir::WorkdirError> {
|
||||||
let _close_guard = self.close_lock.lock().await;
|
let _close_guard = self.close_lock.lock().await;
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ struct ProviderProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderProbe {
|
impl ProviderProbe {
|
||||||
async fn receive_write(&mut self, pending: &mut PendingHttpWrite) -> (String, ProviderReply) {
|
async fn receive_operation(
|
||||||
|
&mut self,
|
||||||
|
pending: &mut PendingHttpWrite,
|
||||||
|
) -> (String, WorkdirSessionOperation, ProviderReply) {
|
||||||
let command = tokio::time::timeout(BOUND, async {
|
let command = tokio::time::timeout(BOUND, async {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
command = self.receiver.recv() => command.expect("provider channel closed"),
|
command = self.receiver.recv() => command.expect("provider channel closed"),
|
||||||
@@ -66,6 +69,11 @@ impl ProviderProbe {
|
|||||||
panic!("expected write dispatch, received cancellation")
|
panic!("expected write dispatch, received cancellation")
|
||||||
};
|
};
|
||||||
self.dispatches += 1;
|
self.dispatches += 1;
|
||||||
|
(operation_id.as_str().to_owned(), operation, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive_write(&mut self, pending: &mut PendingHttpWrite) -> (String, ProviderReply) {
|
||||||
|
let (operation_id, operation, response) = self.receive_operation(pending).await;
|
||||||
let WorkdirSessionOperation::Write(request) = operation else {
|
let WorkdirSessionOperation::Write(request) = operation else {
|
||||||
panic!("expected mutation, got {operation:?}")
|
panic!("expected mutation, got {operation:?}")
|
||||||
};
|
};
|
||||||
@@ -75,7 +83,7 @@ impl ProviderProbe {
|
|||||||
);
|
);
|
||||||
assert_eq!(request.content, WRITE_SECRET);
|
assert_eq!(request.content, WRITE_SECRET);
|
||||||
assert_eq!(request.expected_hash, None);
|
assert_eq!(request.expected_hash, None);
|
||||||
(operation_id.as_str().to_owned(), response)
|
(operation_id, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_dispatches(&mut self, expected: usize) {
|
fn assert_dispatches(&mut self, expected: usize) {
|
||||||
@@ -200,14 +208,18 @@ impl AuditHttpFixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start_write(&self) -> PendingHttpWrite {
|
fn start_write(&self) -> PendingHttpWrite {
|
||||||
|
self.start_operation(WorkdirSessionOperation::Write(workdir::WriteRequest {
|
||||||
|
path: workdir::WorkdirPath::new(PATH_SECRET).unwrap(),
|
||||||
|
content: WRITE_SECRET.to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_operation(&self, operation: WorkdirSessionOperation) -> PendingHttpWrite {
|
||||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
|
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
|
||||||
let body = serde_json::to_vec(&WorkspaceWorkdirSessionOperationRequest {
|
let body = serde_json::to_vec(&WorkspaceWorkdirSessionOperationRequest {
|
||||||
target_workdir: "attachment".into(),
|
target_workdir: "attachment".into(),
|
||||||
operation: WorkdirSessionOperation::Write(workdir::WriteRequest {
|
operation,
|
||||||
path: workdir::WorkdirPath::new(PATH_SECRET).unwrap(),
|
|
||||||
content: WRITE_SECRET.to_vec(),
|
|
||||||
expected_hash: None,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let request = self.fixture.request(
|
let request = self.fixture.request(
|
||||||
@@ -259,6 +271,7 @@ impl AuditHttpFixture {
|
|||||||
encoded_content.as_str(),
|
encoded_content.as_str(),
|
||||||
PATH_SECRET,
|
PATH_SECRET,
|
||||||
PROVIDER_SECRET,
|
PROVIDER_SECRET,
|
||||||
|
PROVIDER_SECRET.trim_start_matches('/'),
|
||||||
pending.proof.as_str(),
|
pending.proof.as_str(),
|
||||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||||
self.fixture.identity.private_key.as_str(),
|
self.fixture.identity.private_key.as_str(),
|
||||||
@@ -276,6 +289,10 @@ impl AuditHttpFixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn assert_audits(&self, expected: &[(&str, &str)]) {
|
fn assert_audits(&self, expected: &[(&str, &str)]) {
|
||||||
|
self.assert_operation_audits(expected, "write");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_operation_audits(&self, expected: &[(&str, &str)], operation: &str) {
|
||||||
let connection =
|
let connection =
|
||||||
rusqlite::Connection::open(&self.fixture.api.config.database_path).unwrap();
|
rusqlite::Connection::open(&self.fixture.api.config.database_path).unwrap();
|
||||||
let mut statement = connection
|
let mut statement = connection
|
||||||
@@ -298,7 +315,10 @@ impl AuditHttpFixture {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(actual, expected, "persisted audits: {rows:?}");
|
assert_eq!(actual, expected, "persisted audits: {rows:?}");
|
||||||
for (_, _, summary) in rows {
|
for (_, _, summary) in rows {
|
||||||
assert!(summary.contains("operation_kind=write"), "{summary}");
|
assert!(
|
||||||
|
summary.contains(&format!("operation_kind={operation}")),
|
||||||
|
"{summary}"
|
||||||
|
);
|
||||||
for secret in [
|
for secret in [
|
||||||
AUDIT_SECRET,
|
AUDIT_SECRET,
|
||||||
PATH_SECRET,
|
PATH_SECRET,
|
||||||
@@ -697,3 +717,169 @@ async fn provider_reported_unknown_preserves_received_response_and_unknown_effec
|
|||||||
provider.assert_dispatches(1);
|
provider.assert_dispatches(1);
|
||||||
h.assert_audits(&[(&dispatched_id, "requested")]);
|
h.assert_audits(&[(&dispatched_id, "requested")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum CheckoutReplyFault {
|
||||||
|
WrongOperation,
|
||||||
|
WrongPath,
|
||||||
|
EmptyValidator,
|
||||||
|
WrongOutput,
|
||||||
|
ProviderUnavailable,
|
||||||
|
ProviderTransport,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_checkout_unknown_audit(fault: CheckoutReplyFault, fail_completion: bool) {
|
||||||
|
let (h, mut provider) = AuditHttpFixture::new().await;
|
||||||
|
if fail_completion {
|
||||||
|
h.fail_audit(false);
|
||||||
|
}
|
||||||
|
let target = workdir::WorkdirPath::new(PATH_SECRET).unwrap();
|
||||||
|
let mut pending = h.start_operation(WorkdirSessionOperation::CheckoutExecute(
|
||||||
|
workdir::CheckoutRequest {
|
||||||
|
target: target.clone(),
|
||||||
|
validator: vec![1],
|
||||||
|
operation: workdir::CheckoutOperation::Write {
|
||||||
|
content: WRITE_SECRET.to_vec(),
|
||||||
|
expected_hash: [0; 32],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let (operation_id, operation, reply) = provider.receive_operation(&mut pending).await;
|
||||||
|
let WorkdirSessionOperation::CheckoutExecute(request) = operation else {
|
||||||
|
panic!("expected checkout mutation, got {operation:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(request.target, target);
|
||||||
|
assert_eq!(request.validator, vec![1]);
|
||||||
|
assert!(
|
||||||
|
matches!(request.operation, workdir::CheckoutOperation::Write { ref content, .. } if content == WRITE_SECRET)
|
||||||
|
);
|
||||||
|
h.apply_provider_write();
|
||||||
|
let result = match fault {
|
||||||
|
CheckoutReplyFault::WrongOperation => Ok(write_result()),
|
||||||
|
CheckoutReplyFault::ProviderUnavailable => Err(WorkdirTransportError::from_workdir_error(
|
||||||
|
&workdir::WorkdirError::Unavailable(PROVIDER_SECRET.into()),
|
||||||
|
)),
|
||||||
|
CheckoutReplyFault::ProviderTransport => Err(WorkdirTransportError::from_workdir_error(
|
||||||
|
&workdir::WorkdirError::Transport(PROVIDER_SECRET.into()),
|
||||||
|
)),
|
||||||
|
_ => {
|
||||||
|
let mut result = workdir::CheckoutResult {
|
||||||
|
observation: workdir::CheckoutObservation {
|
||||||
|
path: target,
|
||||||
|
validator: vec![2],
|
||||||
|
kind: workdir::EntryKind::File,
|
||||||
|
size: WRITE_SECRET.len() as u64,
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||||
|
},
|
||||||
|
output: workdir::CheckoutOutput::Write(workdir::WriteResult {
|
||||||
|
bytes_written: WRITE_SECRET.len(),
|
||||||
|
created: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
match fault {
|
||||||
|
CheckoutReplyFault::WrongPath => {
|
||||||
|
result.observation.path =
|
||||||
|
workdir::WorkdirPath::new(PROVIDER_SECRET.trim_start_matches('/')).unwrap()
|
||||||
|
}
|
||||||
|
CheckoutReplyFault::EmptyValidator => result.observation.validator.clear(),
|
||||||
|
CheckoutReplyFault::WrongOutput => {
|
||||||
|
result.output = workdir::CheckoutOutput::Edit(workdir::EditResult {
|
||||||
|
replacements: 1,
|
||||||
|
content_hash: [0; 32],
|
||||||
|
bytes_written: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
Ok(WorkdirSessionOperationResult::CheckoutExecute(result))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reply.send(result).unwrap();
|
||||||
|
let (body, events) = h.finish(pending, StatusCode::INTERNAL_SERVER_ERROR).await;
|
||||||
|
assert_eq!(body["code"], "outcome_unknown", "{fault:?}: {body}");
|
||||||
|
assert_eq!(
|
||||||
|
body["message"],
|
||||||
|
"Workdir operation may have effects; inspect before retrying"
|
||||||
|
);
|
||||||
|
if !matches!(
|
||||||
|
fault,
|
||||||
|
CheckoutReplyFault::ProviderUnavailable | CheckoutReplyFault::ProviderTransport
|
||||||
|
) {
|
||||||
|
assert_failure_reason(&events, "operation_result_mismatch", "response_validation");
|
||||||
|
}
|
||||||
|
if fail_completion {
|
||||||
|
assert_eq!(
|
||||||
|
body["audit_failures"],
|
||||||
|
json!([{
|
||||||
|
"operation_id": operation_id,
|
||||||
|
"stage": "response_received",
|
||||||
|
"reason": "audit_store_unavailable",
|
||||||
|
"operation_outcome": {"kind":"unknown"},
|
||||||
|
}]),
|
||||||
|
"{fault:?}: {body}"
|
||||||
|
);
|
||||||
|
let audit_logs: Vec<_> = events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e["event"] == "external_workdir_audit_failed")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(audit_logs.len(), 1, "{events:?}");
|
||||||
|
let event = audit_logs[0];
|
||||||
|
assert_eq!(event["operation_id"], operation_id);
|
||||||
|
assert_eq!(event["operation"], "checkout_execute");
|
||||||
|
assert_eq!(event["audit_stage"], "ResponseReceived");
|
||||||
|
assert_eq!(event["operation_outcome"], "Unknown");
|
||||||
|
h.assert_operation_audits(&[(&operation_id, "requested")], "checkout_execute");
|
||||||
|
} else {
|
||||||
|
assert!(body.get("audit_failures").is_none(), "{body}");
|
||||||
|
assert!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.all(|e| e["event"] != "external_workdir_audit_failed")
|
||||||
|
);
|
||||||
|
h.assert_operation_audits(
|
||||||
|
&[
|
||||||
|
(&operation_id, "requested"),
|
||||||
|
(&operation_id, "outcome_unknown"),
|
||||||
|
],
|
||||||
|
"checkout_execute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read(h.effect_path()).unwrap(), WRITE_SECRET);
|
||||||
|
provider.assert_dispatches(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_invalid_reply_records_unknown_in_healthy_completion_audit() {
|
||||||
|
for fault in [
|
||||||
|
CheckoutReplyFault::WrongOperation,
|
||||||
|
CheckoutReplyFault::WrongPath,
|
||||||
|
CheckoutReplyFault::EmptyValidator,
|
||||||
|
CheckoutReplyFault::WrongOutput,
|
||||||
|
] {
|
||||||
|
assert_checkout_unknown_audit(fault, false).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_invalid_reply_records_unknown_in_failed_completion_receipt_and_log() {
|
||||||
|
for fault in [
|
||||||
|
CheckoutReplyFault::WrongOperation,
|
||||||
|
CheckoutReplyFault::WrongPath,
|
||||||
|
CheckoutReplyFault::EmptyValidator,
|
||||||
|
CheckoutReplyFault::WrongOutput,
|
||||||
|
] {
|
||||||
|
assert_checkout_unknown_audit(fault, true).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_uncertain_reply_normalization_matches_audit_outcome() {
|
||||||
|
for fault in [
|
||||||
|
CheckoutReplyFault::ProviderUnavailable,
|
||||||
|
CheckoutReplyFault::ProviderTransport,
|
||||||
|
] {
|
||||||
|
for fail_completion in [false, true] {
|
||||||
|
assert_checkout_unknown_audit(fault, fail_completion).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user