Compare commits

...

5 Commits

Author SHA1 Message Date
Yuki Okushi f2b353c39f
Merge branch 'main' into introspection 2026-02-11 13:58:15 +09:00
Yuki Okushi 9f679990ed
test(http): wrap timeout test with rt::time::timeout (#3915) 2026-02-11 13:57:29 +09:00
Yuki Okushi 7e192b6079 fmt 2026-02-11 12:41:27 +09:00
Yuki Okushi a1a26e9b53 tweak 2026-02-11 11:38:05 +09:00
Yuki Okushi 1facfec04b put all the items behind feature gate 2026-02-11 09:07:13 +09:00
8 changed files with 283 additions and 104 deletions

View File

@ -149,10 +149,16 @@ async fn h2_content_length() {
{
let req = srv.request(Method::HEAD, srv.surl("/0")).send();
req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
let req = srv.request(Method::GET, srv.surl("/0")).send();
req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
let req = srv.request(Method::GET, srv.surl("/1")).send();
let response = req.await.unwrap();

View File

@ -219,13 +219,19 @@ async fn h2_content_length() {
let req = srv
.request(Method::HEAD, srv.surl(&format!("/{}", i)))
.send();
let _response = req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
// assert_eq!(response.headers().get(&header), None);
let req = srv
.request(Method::GET, srv.surl(&format!("/{}", i)))
.send();
let _response = req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
// assert_eq!(response.headers().get(&header), None);
}

View File

@ -126,7 +126,7 @@ compat = ["compat-routing-macros-force-pub"]
compat-routing-macros-force-pub = ["actix-web-codegen?/compat-routing-macros-force-pub"]
# Enabling the retrieval of metadata for initialized resources, including path and HTTP method.
experimental-introspection = []
experimental-introspection = ["serde/derive"]
[dependencies]
actix-codec = "0.5"

View File

@ -165,10 +165,7 @@ impl AppService {
}
})
.collect::<Vec<_>>();
let guard_names = guard_list
.iter()
.map(|g| g.name().to_string())
.collect::<Vec<_>>();
let guard_names = guard_list.iter().map(|g| g.name()).collect::<Vec<_>>();
let guard_details = crate::introspection::guard_reports_from_iter(guard_list.iter());
let is_resource = nested.is_none();

View File

@ -1,4 +1,4 @@
use super::{Guard, GuardContext, GuardDetail};
use super::{Guard, GuardContext};
use crate::http::header::Accept;
/// A guard that verifies that an `Accept` header is present and it contains a compatible MIME type.
@ -64,35 +64,25 @@ impl Guard for Acceptable {
false
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
#[cfg(feature = "experimental-introspection")]
{
if self.match_star_star {
format!("Acceptable({}, match_star_star=true)", self.mime)
} else {
format!("Acceptable({})", self.mime)
}
}
#[cfg(not(feature = "experimental-introspection"))]
{
std::any::type_name::<Self>().to_string()
if self.match_star_star {
format!("Acceptable({}, match_star_star=true)", self.mime)
} else {
format!("Acceptable({})", self.mime)
}
}
fn details(&self) -> Option<Vec<GuardDetail>> {
#[cfg(feature = "experimental-introspection")]
{
let mut details = Vec::new();
details.push(GuardDetail::Generic(format!("mime={}", self.mime)));
if self.match_star_star {
details.push(GuardDetail::Generic("match_star_star=true".to_string()));
}
Some(details)
}
#[cfg(not(feature = "experimental-introspection"))]
{
None
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<super::GuardDetail>> {
let mut details = Vec::new();
details.push(super::GuardDetail::Generic(format!("mime={}", self.mime)));
if self.match_star_star {
details.push(super::GuardDetail::Generic(
"match_star_star=true".to_string(),
));
}
Some(details)
}
}
@ -135,12 +125,12 @@ mod tests {
let details = guard.details().expect("missing guard details");
assert!(details.iter().any(|detail| match detail {
GuardDetail::Generic(value) => value == "match_star_star=true",
crate::guard::GuardDetail::Generic(value) => value == "match_star_star=true",
_ => false,
}));
let expected = format!("mime={}", mime::APPLICATION_JSON);
assert!(details.iter().any(|detail| match detail {
GuardDetail::Generic(value) => value == &expected,
crate::guard::GuardDetail::Generic(value) => value == &expected,
_ => false,
}));
assert_eq!(

View File

@ -1,6 +1,6 @@
use actix_http::{header, uri::Uri, RequestHead, Version};
use super::{Guard, GuardContext, GuardDetail};
use super::{Guard, GuardContext};
/// Creates a guard that matches requests targeting a specific host.
///
@ -118,39 +118,27 @@ impl Guard for HostGuard {
true
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
#[cfg(feature = "experimental-introspection")]
{
if let Some(ref scheme) = self.scheme {
format!("Host({}, scheme={})", self.host, scheme)
} else {
format!("Host({})", self.host)
}
}
#[cfg(not(feature = "experimental-introspection"))]
{
std::any::type_name::<Self>().to_string()
if let Some(ref scheme) = self.scheme {
format!("Host({}, scheme={})", self.host, scheme)
} else {
format!("Host({})", self.host)
}
}
fn details(&self) -> Option<Vec<GuardDetail>> {
#[cfg(feature = "experimental-introspection")]
{
let mut details = vec![GuardDetail::Headers(vec![(
"host".to_string(),
self.host.clone(),
)])];
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<super::GuardDetail>> {
let mut details = vec![super::GuardDetail::Headers(vec![(
"host".to_string(),
self.host.clone(),
)])];
if let Some(ref scheme) = self.scheme {
details.push(GuardDetail::Generic(format!("scheme={scheme}")));
}
if let Some(ref scheme) = self.scheme {
details.push(super::GuardDetail::Generic(format!("scheme={scheme}")));
}
Some(details)
}
#[cfg(not(feature = "experimental-introspection"))]
{
None
}
Some(details)
}
}
@ -282,13 +270,13 @@ mod tests {
let details = host.details().expect("missing guard details");
assert!(details.iter().any(|detail| match detail {
GuardDetail::Headers(headers) => headers
crate::guard::GuardDetail::Headers(headers) => headers
.iter()
.any(|(name, value)| name == "host" && value == "example.com"),
_ => false,
}));
assert!(details.iter().any(|detail| match detail {
GuardDetail::Generic(value) => value == "scheme=https",
crate::guard::GuardDetail::Generic(value) => value == "scheme=https",
_ => false,
}));
assert_eq!(host.name(), "Host(example.com, scheme=https)");

View File

@ -67,6 +67,8 @@ pub use self::{
};
/// Enum to encapsulate various introspection details of a guard.
#[cfg(feature = "experimental-introspection")]
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum GuardDetail {
/// Detail associated with explicit HTTP method guards.
@ -137,6 +139,7 @@ pub trait Guard {
fn check(&self, ctx: &GuardContext<'_>) -> bool;
/// Returns a nominal representation of the guard.
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
std::any::type_name::<Self>().to_string()
}
@ -144,6 +147,7 @@ pub trait Guard {
/// Returns detailed introspection information, when available.
///
/// This is best-effort and may omit complex guard logic.
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<GuardDetail>> {
None
}
@ -153,9 +157,13 @@ impl Guard for Rc<dyn Guard> {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
(**self).check(ctx)
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
(**self).name()
}
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<GuardDetail>> {
(**self).details()
}
@ -248,6 +256,8 @@ impl Guard for AnyGuard {
false
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
format!(
"AnyGuard({})",
@ -258,6 +268,8 @@ impl Guard for AnyGuard {
.join(", ")
)
}
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<GuardDetail>> {
Some(
self.guards
@ -318,6 +330,8 @@ impl Guard for AllGuard {
true
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
format!(
"AllGuard({})",
@ -328,6 +342,8 @@ impl Guard for AllGuard {
.join(", ")
)
}
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<GuardDetail>> {
Some(
self.guards
@ -356,18 +372,15 @@ impl<G: Guard> Guard for Not<G> {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
!self.0.check(ctx)
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
format!("Not({})", self.0.name())
}
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<GuardDetail>> {
#[cfg(feature = "experimental-introspection")]
{
Some(vec![GuardDetail::Generic(self.name())])
}
#[cfg(not(feature = "experimental-introspection"))]
{
None
}
Some(vec![GuardDetail::Generic(self.name())])
}
}
@ -398,9 +411,13 @@ impl Guard for MethodGuard {
ctx.head().method == self.0
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
self.0.to_string()
}
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<GuardDetail>> {
Some(vec![GuardDetail::HttpMethods(vec![self.0.to_string()])])
}
@ -466,9 +483,13 @@ impl Guard for HeaderGuard {
false
}
#[cfg(feature = "experimental-introspection")]
fn name(&self) -> String {
format!("Header({}, {})", self.0, self.1.to_str().unwrap_or(""))
}
#[cfg(feature = "experimental-introspection")]
fn details(&self) -> Option<Vec<GuardDetail>> {
Some(vec![GuardDetail::Headers(vec![(
self.0.to_string(),

View File

@ -12,7 +12,9 @@
//!
//! Notes:
//! - Method lists are best-effort and derived only from explicit method guards; an empty list means
//! the route matches any method.
//! no explicit method guards were observed for the node.
//! - Guard and method lists are aggregated per `full_path` and do not preserve per-route
//! correlations when multiple routes/services share the same path.
//! - Reachability hints are best-effort and should be treated as diagnostics, not a hard guarantee.
//!
//! This feature is intended for local/non-production use. Avoid exposing introspection endpoints
@ -32,7 +34,7 @@ use crate::{
};
#[derive(Clone)]
pub struct RouteDetail {
struct RouteDetail {
methods: Vec<Method>,
guards: Vec<String>,
guard_details: Vec<GuardReport>,
@ -43,7 +45,7 @@ pub struct RouteDetail {
/// Input data for registering routes with the introspector.
#[derive(Clone)]
pub struct RouteInfo {
pub(crate) struct RouteInfo {
full_path: String,
methods: Vec<Method>,
guards: Vec<String>,
@ -53,7 +55,7 @@ pub struct RouteInfo {
}
impl RouteInfo {
pub fn new(
pub(crate) fn new(
full_path: String,
methods: Vec<Method>,
guards: Vec<String>,
@ -72,6 +74,7 @@ impl RouteInfo {
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GuardReport {
pub name: String,
@ -79,6 +82,7 @@ pub struct GuardReport {
pub details: Vec<GuardDetailReport>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GuardDetailReport {
@ -87,6 +91,7 @@ pub enum GuardDetailReport {
Generic { value: String },
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HeaderReport {
pub name: String,
@ -97,6 +102,7 @@ pub struct HeaderReport {
///
/// `origin_scope` is the scope path where the external resource was registered. It is informational
/// only and does not affect URL generation or routing; external resources are always global.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExternalResourceReportItem {
#[serde(skip_serializing_if = "Option::is_none")]
@ -130,6 +136,7 @@ struct ShadowingContext {
}
/// Node type within an introspection tree.
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum ResourceType {
/// The application root.
@ -149,6 +156,7 @@ fn resource_type_label(kind: ResourceType) -> &'static str {
}
/// A node in the introspection tree.
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct IntrospectionNode {
/// The node's classification.
@ -176,15 +184,19 @@ pub struct IntrospectionNode {
}
/// A flattened report item for a route.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize)]
pub struct IntrospectionReportItem {
/// Full path for the route.
pub full_path: String,
/// Methods derived from explicit method guards.
///
/// An empty list indicates the route matches any method.
/// An empty list indicates no explicit method guards were observed for the node.
pub methods: Vec<String>,
/// Guard names attached to the route.
///
/// This is aggregated per `full_path` and does not necessarily represent a single matching
/// condition when multiple routes/services share the same path.
pub guards: Vec<String>,
/// Structured guard details, when available.
///
@ -201,7 +213,10 @@ pub struct IntrospectionReportItem {
pub patterns: Vec<String>,
/// The type of node represented by the report item.
pub resource_type: String,
/// Depth within the scope tree (root = 0).
/// Depth within this report tree (root = 0).
///
/// This currently corresponds to the number of path segments (for example, `/foo` has depth 1
/// and `/foo/bar` has depth 2).
pub scope_depth: usize,
/// True if the route might be unreachable at runtime.
#[serde(skip_serializing_if = "is_false")]
@ -276,7 +291,7 @@ impl From<&IntrospectionNode> for Vec<IntrospectionReportItem> {
/// Collects route details during app configuration.
#[derive(Clone, Default)]
pub struct IntrospectionCollector {
pub(crate) struct IntrospectionCollector {
details: BTreeMap<String, RouteDetail>,
registrations: Vec<Registration>,
externals: Vec<ExternalResourceReportItem>,
@ -286,7 +301,7 @@ pub struct IntrospectionCollector {
impl IntrospectionCollector {
/// Creates a new, empty collector.
pub fn new() -> Self {
pub(crate) fn new() -> Self {
Self {
details: BTreeMap::new(),
registrations: Vec::new(),
@ -296,13 +311,13 @@ impl IntrospectionCollector {
}
}
pub fn next_scope_id(&mut self) -> usize {
pub(crate) fn next_scope_id(&mut self) -> usize {
let scope_id = self.next_scope_id;
self.next_scope_id += 1;
scope_id
}
pub fn register_service(
pub(crate) fn register_service(
&mut self,
info: RouteInfo,
is_resource: bool,
@ -327,7 +342,7 @@ impl IntrospectionCollector {
self.next_registration_order += 1;
}
pub fn register_route(&mut self, info: RouteInfo, scope_id: Option<usize>) {
pub(crate) fn register_route(&mut self, info: RouteInfo, scope_id: Option<usize>) {
let full_path = normalize_path(&info.full_path);
self.register_pattern_detail(&full_path, &info, true);
@ -345,7 +360,7 @@ impl IntrospectionCollector {
self.next_registration_order += 1;
}
pub fn register_external(&mut self, rdef: &ResourceDef, origin_scope: &str) {
pub(crate) fn register_external(&mut self, rdef: &ResourceDef, origin_scope: &str) {
let report = external_report_from_rdef(rdef, origin_scope);
if let Some(name) = report.name.as_deref() {
@ -365,12 +380,7 @@ impl IntrospectionCollector {
}
/// Registers details for a route pattern.
pub fn register_pattern_detail(
&mut self,
full_path: &str,
info: &RouteInfo,
is_resource: bool,
) {
fn register_pattern_detail(&mut self, full_path: &str, info: &RouteInfo, is_resource: bool) {
let full_path = normalize_path(full_path);
self.details
@ -398,7 +408,7 @@ impl IntrospectionCollector {
}
/// Produces the finalized introspection tree.
pub fn finalize(&mut self) -> IntrospectionTree {
pub(crate) fn finalize(&mut self) -> IntrospectionTree {
let detail_registry = std::mem::take(&mut self.details);
let registrations = std::mem::take(&mut self.registrations);
let externals = std::mem::take(&mut self.externals);
@ -457,6 +467,7 @@ impl IntrospectionCollector {
}
/// The finalized introspection tree.
#[non_exhaustive]
#[derive(Clone)]
pub struct IntrospectionTree {
/// Root node of the tree.
@ -790,19 +801,122 @@ fn guards_only_methods(guards: &[String], methods: &[Method]) -> bool {
}
fn has_conflicting_methods(methods: &[Method], guards: &[String]) -> bool {
let method_names = method_set(methods);
if method_names.len() <= 1 {
// This check is best-effort: it tries to determine if the conjunction of method guards can
// match any single HTTP method. It relies on guard names since introspection details flatten
// guard structure.
if method_set(methods).len() <= 1 {
return false;
}
let has_any = guards.iter().any(|name| name.starts_with("AnyGuard("));
let has_all = guards.iter().any(|name| name.starts_with("AllGuard("));
fn split_top_level_args(mut args: &str) -> Vec<&str> {
args = args.trim();
if args.is_empty() {
return Vec::new();
}
if has_all {
return true;
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
for (idx, ch) in args.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
parts.push(args[start..idx].trim());
start = idx + 1;
}
_ => {}
}
}
parts.push(args[start..].trim());
parts.into_iter().filter(|s| !s.is_empty()).collect()
}
!has_any
fn parse_method(name: &str) -> Option<BTreeSet<String>> {
name.trim().parse::<Method>().ok().map(|method| {
let mut set = BTreeSet::new();
set.insert(method.to_string());
set
})
}
fn union_methods(
left: Option<BTreeSet<String>>,
right: Option<BTreeSet<String>>,
) -> Option<BTreeSet<String>> {
match (left, right) {
// If any branch doesn't constrain methods, the disjunction doesn't either.
(None, _) | (_, None) => None,
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
}
}
fn intersect_methods(
left: Option<BTreeSet<String>>,
right: Option<BTreeSet<String>>,
) -> Option<BTreeSet<String>> {
match (left, right) {
(None, x) | (x, None) => x,
(Some(a), Some(b)) => Some(a.intersection(&b).cloned().collect()),
}
}
fn guard_possible_methods(name: &str) -> Option<BTreeSet<String>> {
let name = name.trim();
if name.is_empty() {
return None;
}
if let Some(set) = parse_method(name) {
return Some(set);
}
if let Some(inner) = name
.strip_prefix("AnyGuard(")
.and_then(|s| s.strip_suffix(')'))
{
let mut acc = Some(BTreeSet::new());
for arg in split_top_level_args(inner) {
acc = union_methods(acc, guard_possible_methods(arg));
if acc.is_none() {
break;
}
}
return acc;
}
if let Some(inner) = name
.strip_prefix("AllGuard(")
.and_then(|s| s.strip_suffix(')'))
{
let mut acc = None;
for arg in split_top_level_args(inner) {
acc = intersect_methods(acc, guard_possible_methods(arg));
if matches!(acc, Some(ref set) if set.is_empty()) {
break;
}
}
return acc;
}
// `Not(...)` (and unknown/custom guard names) are treated as not restricting methods.
None
}
let mut possible = None;
for guard in guards {
possible = intersect_methods(possible, guard_possible_methods(guard));
if matches!(possible, Some(ref set) if set.is_empty()) {
return true;
}
}
false
}
fn method_set(methods: &[Method]) -> BTreeSet<String> {
@ -887,10 +1001,12 @@ fn format_reachability(item: &IntrospectionReportItem) -> String {
if item.reachability_notes.is_empty() {
" | PotentiallyUnreachable".to_string()
} else {
format!(
" | PotentiallyUnreachable | Notes: {:?}",
item.reachability_notes
)
let notes = item
.reachability_notes
.iter()
.map(|note| sanitize_text(note))
.collect::<Vec<_>>();
format!(" | PotentiallyUnreachable | Notes: {:?}", notes)
}
}
@ -1057,6 +1173,61 @@ mod tests {
.contains(&"conflicting_method_guards".to_string()));
}
#[test]
fn allguard_anyguard_does_not_mark_conflict_when_methods_are_feasible() {
let mut collector = IntrospectionCollector::new();
let info = route_info(
"/feasible",
vec![Method::GET, Method::POST],
vec![
"AllGuard(AnyGuard(GET, POST), Header(x, y))".to_string(),
"Header(x, y)".to_string(),
],
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/feasible")
.expect("missing route");
assert!(!item.potentially_unreachable);
assert!(!item
.reachability_notes
.contains(&"conflicting_method_guards".to_string()));
}
#[test]
fn allguard_anyguard_marks_conflict_when_methods_are_impossible() {
let mut collector = IntrospectionCollector::new();
let info = route_info(
"/impossible",
vec![Method::GET, Method::POST],
vec!["AllGuard(GET, AnyGuard(POST))".to_string()],
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/impossible")
.expect("missing route");
assert!(item.potentially_unreachable);
assert!(item
.reachability_notes
.contains(&"conflicting_method_guards".to_string()));
}
#[test]
fn shadowed_scopes_mark_routes() {
let mut collector = IntrospectionCollector::new();