use super::{
DecodedBufferEdit, DecodedCapabilityRef, DecodedCount, DecodedGuestString, DecodedPayload,
DecodedTextRange, DecodedWorkspacePath, GuestDecodeError, GuestDecodeField, GuestDecodeLimits,
};
use crate::{
plugin::{CapabilityAtom, PluginCapabilityRef, PluginCapabilityShape, WorkspacePathError},
text_stream::TextRange,
};
use std::num::NonZeroU64;
#[test]
fn guest_decode_fields_render_stable_text() {
let cases = [
(GuestDecodeField::StatusText, "status.text"),
(GuestDecodeField::Capability, "capability"),
(GuestDecodeField::BufferObservePath, "buffer.observe.path"),
(
GuestDecodeField::BufferEditByteIndex,
"buffer.edit.byte_index",
),
(GuestDecodeField::BufferEditRange, "buffer.edit.range"),
(GuestDecodeField::BufferEditText, "buffer.edit.text"),
(
GuestDecodeField::BufferProposeEditPath,
"buffer.propose_edit.path",
),
(
GuestDecodeField::WorkspaceObservePath,
"workspace.observe.path",
),
(
GuestDecodeField::WorkspaceArtifactWritePath,
"workspace.artifact_write.path",
),
(
GuestDecodeField::WorkspaceArtifactWriteBytes,
"workspace.artifact_write.bytes",
),
(GuestDecodeField::StatusPublishPath, "status.publish.path"),
];
for (field, expected) in cases {
assert_eq!(field.as_str(), expected);
assert_eq!(field.to_string(), expected);
assert_eq!(format!("{field:?}"), expected);
}
}
#[test]
fn guest_decode_limits_retain_non_zero_caps() {
let limits = limits(64);
assert_eq!(limits.max_string_bytes(), 64);
assert_eq!(limits.max_payload_bytes(), 64);
assert_eq!(limits.max_count(), 64);
assert_eq!(
format!("{limits:?}"),
"GuestDecodeLimits { string_bytes: 64, payload_bytes: 64, count: 64 }"
);
}
#[test]
fn guest_strings_reject_malformed_utf8_without_echoing_bytes() {
let error =
DecodedGuestString::from_bytes(GuestDecodeField::StatusText, b"\xffsecret", limits(64))
.expect_err("invalid UTF-8 should fail");
assert_eq!(
error,
GuestDecodeError::MalformedUtf8 {
field: GuestDecodeField::StatusText,
size: 7,
}
);
assert!(!error.to_string().contains("secret"));
}
#[test]
fn payload_decode_rejects_oversized_bytes_and_redacts_debug() {
let error = DecodedPayload::from_bytes(
GuestDecodeField::WorkspaceArtifactWriteBytes,
b"abcdef",
limits(4),
)
.expect_err("payload should exceed cap");
let payload = DecodedPayload::from_bytes(
GuestDecodeField::WorkspaceArtifactWriteBytes,
b"hide",
limits(4),
)
.expect("payload should fit");
assert_eq!(
error,
GuestDecodeError::PayloadTooLarge {
field: GuestDecodeField::WorkspaceArtifactWriteBytes,
size: 6,
max: 4,
}
);
assert_eq!(payload.as_bytes().len(), 4);
assert!(!format!("{payload:?}").contains("hide"));
}
#[test]
fn workspace_paths_reject_traversal_without_echoing_path() {
let error = DecodedWorkspacePath::from_bytes(
GuestDecodeField::WorkspaceObservePath,
b"docs/../secret",
limits(64),
)
.expect_err("traversal should fail");
assert_eq!(
error,
GuestDecodeError::InvalidWorkspacePath {
field: GuestDecodeField::WorkspaceObservePath,
source: WorkspacePathError::DotComponent,
}
);
assert!(!error.to_string().contains("secret"));
}
#[test]
fn capabilities_reject_missing_workspace_paths_and_scalar_paths() {
assert_eq!(
DecodedCapabilityRef::from_guest(b"workspace.observe", None, limits(64)),
Err(GuestDecodeError::MissingWorkspacePath {
capability: PluginCapabilityShape::WorkspaceObserve,
})
);
assert_eq!(
DecodedCapabilityRef::from_guest(b"status.publish", Some(b"docs/out.txt"), limits(64)),
Err(GuestDecodeError::UnexpectedWorkspacePath {
capability: PluginCapabilityShape::StatusPublish,
})
);
}
#[test]
fn capabilities_reject_unknown_names_without_echoing_name() {
let error = DecodedCapabilityRef::from_guest(b"workspace.delete.secret", None, limits(64))
.expect_err("unknown capability should fail");
assert_eq!(error, GuestDecodeError::UnknownCapability { byte_len: 23 });
assert!(!error.to_string().contains("secret"));
}
#[test]
fn decoded_workspace_capability_borrows_as_authorization_ref() {
let capability = DecodedCapabilityRef::from_guest(
b"workspace.artifact_write",
Some(b"docs/out.txt"),
limits(64),
)
.expect("capability should decode");
assert_eq!(capability.atom(), CapabilityAtom::WorkspaceArtifactWrite);
assert!(matches!(
capability.as_ref(),
PluginCapabilityRef::WorkspaceArtifactWrite(path) if path.as_str() == "docs/out.txt"
));
}
#[test]
fn counts_and_ranges_are_bounded_before_host_use() {
assert_eq!(
DecodedCount::new(GuestDecodeField::BufferEditByteIndex, 4, limits(4))
.expect("count")
.get(),
4
);
assert_eq!(
DecodedCount::new(GuestDecodeField::BufferEditByteIndex, 5, limits(4)),
Err(GuestDecodeError::CountTooLarge {
field: GuestDecodeField::BufferEditByteIndex,
value: 5,
max: 4,
})
);
assert_eq!(
DecodedTextRange::new(GuestDecodeField::BufferEditRange, 1, 3, limits(8))
.expect("range")
.get(),
TextRange::new(1, 3)
);
assert_eq!(
DecodedTextRange::new(GuestDecodeField::BufferEditRange, 3, 1, limits(8)),
Err(GuestDecodeError::ReversedRange {
field: GuestDecodeField::BufferEditRange,
start: 3,
end: 1,
})
);
}
#[test]
fn buffer_edit_decode_bounds_text_and_ranges_before_owner_use() {
let edit = DecodedBufferEdit::insert(2, "ok", limits(4))
.expect("insert should decode")
.into_buffer_edit();
let shape = crate::buffer::BufferEditShape::from_edit(&edit);
assert_eq!(shape.byte_index(), Some(2));
assert_eq!(shape.replacement_byte_len(), Some(2));
assert_eq!(
DecodedBufferEdit::insert(0, "secret", limits(4)),
Err(GuestDecodeError::PayloadTooLarge {
field: GuestDecodeField::BufferEditText,
size: 6,
max: 4,
})
);
assert_eq!(
DecodedBufferEdit::delete(5, 1, limits(8)),
Err(GuestDecodeError::ReversedRange {
field: GuestDecodeField::BufferEditRange,
start: 5,
end: 1,
})
);
}
fn limits(max: u64) -> GuestDecodeLimits {
let max = NonZeroU64::new(max).expect("test decode limit should be non-zero");
GuestDecodeLimits::new(max, max, max)
}