[pitboss] sweep after phase 23: 4 deferred items resolved

This commit is contained in:
pitboss 2026-05-15 14:59:13 -05:00
parent 655ec45b21
commit a6d88def1a
13 changed files with 328 additions and 67 deletions

View file

@ -439,7 +439,7 @@ pub fn handle(
let preview_tier_seen = Arc::new(AtomicBool::new(false));
let mut diags: Vec<Diag> = if index_mode == IndexMode::Off {
scan_filesystem_with_observer(
let (diags, _surface_map) = scan_filesystem_with_observer(
&scan_path,
config,
show_progress,
@ -447,7 +447,8 @@ pub fn handle(
None,
None,
Some(&preview_tier_seen),
)?
)?;
diags
} else {
if index_mode == IndexMode::Rebuild || !db_path.exists() {
tracing::debug!("Scanning filesystem index filesystem");
@ -1756,6 +1757,20 @@ pub(crate) fn scan_filesystem(
cfg: &Config,
show_progress: bool,
) -> NyxResult<Vec<Diag>> {
scan_filesystem_with_observer(root, cfg, show_progress, None, None, None, None)
.map(|(diags, _surface_map)| diags)
}
/// Same as [`scan_filesystem`] but additionally returns the `SurfaceMap`
/// built from the post-pass-2 view. The non-indexed path used to drop
/// the surface map on the floor; this entry-point lets `nyx surface` (and
/// other consumers that need the attack-surface model alongside the
/// findings) avoid running the analysis twice.
pub(crate) fn scan_filesystem_with_surface_map(
root: &Path,
cfg: &Config,
show_progress: bool,
) -> NyxResult<(Vec<Diag>, crate::surface::SurfaceMap)> {
scan_filesystem_with_observer(root, cfg, show_progress, None, None, None, None)
}
@ -1774,7 +1789,7 @@ pub(crate) fn scan_filesystem_with_observer(
metrics: Option<&Arc<ScanMetrics>>,
logs: Option<&Arc<ScanLogCollector>>,
preview_tier_seen: Option<&Arc<AtomicBool>>,
) -> NyxResult<Vec<Diag>> {
) -> NyxResult<(Vec<Diag>, crate::surface::SurfaceMap)> {
// Ensure framework context is available (handle sets it, but direct
// callers like scan_no_index may not).
let owned_cfg = ensure_framework_ctx(root, cfg);
@ -1905,7 +1920,8 @@ pub(crate) fn scan_filesystem_with_observer(
p.set_stage(ScanStage::Complete);
}
post_process_diags(&mut diags, cfg);
return Ok(diags);
// AST-only mode does not produce a SurfaceMap (no CFG / summaries).
return Ok((diags, crate::surface::SurfaceMap::new()));
}
// ── Taint mode: two-pass with fused pass 1 ──────────────────────────
@ -2180,9 +2196,10 @@ pub(crate) fn scan_filesystem_with_observer(
// Phase 21: build the SurfaceMap from the post-pass-2 view.
// No persistence here; the index-backed path persists into the
// `surface_map` SQLite table. Errors here are swallowed: the
// surface map is an additive Phase F deliverable, not a gate.
let _surface_map = crate::surface::build::build_surface_map(
// `surface_map` SQLite table. The map is returned alongside the
// diagnostics so consumers (e.g. `nyx surface`) can avoid scanning
// twice.
let surface_map = crate::surface::build::build_surface_map(
&crate::surface::build::SurfaceBuildInputs {
files: &all_paths,
scan_root: Some(root),
@ -2225,7 +2242,7 @@ pub(crate) fn scan_filesystem_with_observer(
);
}
Ok(diags)
Ok((diags, surface_map))
}
// --------------------------------------------------------------------------------------------

View file

@ -141,12 +141,20 @@ pub fn render_text(map: &SurfaceMap, scan_root: Option<&Path>) -> String {
} else {
out.push_str("Surface map\n");
}
let entry_count = count_kind(map, |n| matches!(n, SurfaceNode::EntryPoint(_)));
let ds_count = count_kind(map, |n| matches!(n, SurfaceNode::DataStore(_)));
let es_count = count_kind(map, |n| matches!(n, SurfaceNode::ExternalService(_)));
let dl_count = count_kind(map, |n| matches!(n, SurfaceNode::DangerousLocal(_)));
out.push_str(&format!(
" {} entry-points, {} data stores, {} external services, {} dangerous locals\n\n",
count_kind(map, |n| matches!(n, SurfaceNode::EntryPoint(_))),
count_kind(map, |n| matches!(n, SurfaceNode::DataStore(_))),
count_kind(map, |n| matches!(n, SurfaceNode::ExternalService(_))),
count_kind(map, |n| matches!(n, SurfaceNode::DangerousLocal(_))),
" {} {}, {} {}, {} {}, {} {}\n\n",
entry_count,
plural(entry_count, "entry-point", "entry-points"),
ds_count,
plural(ds_count, "data store", "data stores"),
es_count,
plural(es_count, "external service", "external services"),
dl_count,
plural(dl_count, "dangerous local", "dangerous locals"),
));
if map.nodes.is_empty() {
@ -305,6 +313,10 @@ fn count_kind<F: Fn(&SurfaceNode) -> bool>(map: &SurfaceMap, f: F) -> usize {
map.nodes.iter().filter(|n| f(n)).count()
}
fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
if count == 1 { singular } else { plural }
}
fn method_str(m: crate::entry_points::HttpMethod) -> &'static str {
use crate::entry_points::HttpMethod::*;
match m {

View file

@ -148,3 +148,22 @@ use utils::config::Config;
pub fn scan_no_index(root: &Path, cfg: &Config) -> NyxResult<Vec<commands::scan::Diag>> {
commands::scan::scan_filesystem(root, cfg, false)
}
/// Same as [`scan_no_index`] but additionally returns the [`SurfaceMap`]
/// built from the post-pass-2 view.
///
/// The non-indexed scan path used to drop the surface map on the floor,
/// which forced `nyx surface` (and any other consumer that wanted both
/// findings and the attack-surface model) to either run the analysis
/// twice or fall back to an entry-point-only build with no DataStore /
/// ExternalService / DangerousLocal nodes and no `Reaches` edges.
///
/// Use this entry point when you need both halves of the analysis.
///
/// [`SurfaceMap`]: surface::SurfaceMap
pub fn scan_no_index_with_surface_map(
root: &Path,
cfg: &Config,
) -> NyxResult<(Vec<commands::scan::Diag>, surface::SurfaceMap)> {
commands::scan::scan_filesystem_with_surface_map(root, cfg, false)
}

View file

@ -90,6 +90,119 @@ pub fn child_or_named<'tree>(parent: Node<'tree>, kind: &str) -> Option<Node<'tr
parent.children(&mut cursor).find(|c| c.kind() == kind)
}
/// Return `true` when `bytes` contains a top-level Python `import` /
/// `from … import …` statement whose leading package segment starts
/// with one of `modules` (case-insensitive prefix match). This means
/// `["flask"]` matches `flask`, `flask_login`, and `flask_jwt_extended`
/// — the canonical Flask framework family — but does not match
/// `os.flask_helper` or a comment that mentions flask.
pub fn python_imports_any(bytes: &[u8], modules: &[&str]) -> bool {
let text = match std::str::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return false,
};
for line in text.lines() {
let line = line.trim_start();
let pkg = if let Some(rest) = line.strip_prefix("from ") {
rest.split_whitespace().next().unwrap_or("")
} else if let Some(rest) = line.strip_prefix("import ") {
rest.split([',', ' ', ';'])
.next()
.unwrap_or("")
.trim()
} else {
continue;
};
if pkg.is_empty() {
continue;
}
let head = pkg.split('.').next().unwrap_or(pkg);
if matches_prefix_ci(head, modules) {
return true;
}
}
false
}
fn matches_prefix_ci(head: &str, prefixes: &[&str]) -> bool {
let head_lc = head.to_ascii_lowercase();
prefixes
.iter()
.any(|p| head_lc.starts_with(&p.to_ascii_lowercase()))
}
/// Return `true` when `bytes` contains a top-level Rust `use` (or
/// `extern crate`) statement whose leading path segment matches one of
/// `crates` (case-insensitive). Optional `pub` / `pub(crate)` /
/// `pub(super)` visibility prefixes are stripped before the `use`
/// keyword check.
pub fn rust_uses_any(bytes: &[u8], crates: &[&str]) -> bool {
let text = match std::str::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return false,
};
for line in text.lines() {
let mut line = line.trim_start();
if let Some(rest) = line.strip_prefix("pub") {
let rest = rest.trim_start();
line = if let Some(r) = rest.strip_prefix("(crate)") {
r.trim_start()
} else if let Some(r) = rest.strip_prefix("(super)") {
r.trim_start()
} else if let Some(r) = rest.strip_prefix("(self)") {
r.trim_start()
} else {
rest
};
}
let rest = if let Some(r) = line.strip_prefix("use ") {
r
} else if let Some(r) = line.strip_prefix("extern crate ") {
r
} else {
continue;
};
let head = rest
.split(['{', ';', ' ', ':', '/'])
.next()
.unwrap_or("")
.trim();
if head.is_empty() {
continue;
}
if matches_prefix_ci(head, crates) {
return true;
}
}
false
}
/// Return `true` when `bytes` contains a top-level Java `import`
/// statement (including `import static`) whose package path begins
/// with one of `prefixes`. Comment-only mentions do *not* match.
pub fn java_imports_any(bytes: &[u8], prefixes: &[&str]) -> bool {
let text = match std::str::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return false,
};
for line in text.lines() {
let line = line.trim_start();
let Some(rest) = line.strip_prefix("import ") else {
continue;
};
let path = rest
.strip_prefix("static ")
.unwrap_or(rest)
.trim()
.trim_end_matches(';')
.trim();
if prefixes.iter().any(|p| path.starts_with(p)) {
return true;
}
}
false
}
/// Walk every descendant of `root`, invoking `visit` once per node.
/// Useful when a probe needs to look at multiple node kinds in a single
/// pass (e.g. annotations + method declarations on the same walk).
@ -128,4 +241,63 @@ mod tests {
assert!(leaf_matches("Auth::JwtRequired", &["JwtRequired"]));
assert!(!leaf_matches("OtherDecorator", &["login_required"]));
}
#[test]
fn python_imports_any_matches_actual_imports() {
assert!(python_imports_any(b"from flask import Flask\n", &["flask"]));
assert!(python_imports_any(b"import flask\n", &["flask"]));
assert!(python_imports_any(b"from flask.app import Flask\n", &["flask"]));
assert!(python_imports_any(b"import django.urls\n", &["django"]));
// Comment-only mention must not match.
assert!(!python_imports_any(b"# flask is great\n", &["flask"]));
// String-only mention must not match.
assert!(!python_imports_any(b"x = 'flask'\n", &["flask"]));
// Wrong module.
assert!(!python_imports_any(b"import os\n", &["flask"]));
}
#[test]
fn rust_uses_any_matches_use_statements() {
assert!(rust_uses_any(b"use actix_web::web;\n", &["actix_web"]));
assert!(rust_uses_any(b"use actix_web;\n", &["actix_web"]));
assert!(rust_uses_any(
b"pub use axum::Router;\n",
&["axum"]
));
assert!(rust_uses_any(
b"pub(crate) use axum::extract::Path;\n",
&["axum"]
));
assert!(rust_uses_any(b"extern crate axum;\n", &["axum"]));
// Comment-only mention must not match.
assert!(!rust_uses_any(b"// use actix_web::web;\n", &["actix_web"]));
// Wrong crate.
assert!(!rust_uses_any(b"use serde::Deserialize;\n", &["actix_web"]));
}
#[test]
fn java_imports_any_matches_package_prefix() {
assert!(java_imports_any(
b"import io.quarkus.runtime.Quarkus;\n",
&["io.quarkus"]
));
assert!(java_imports_any(
b"import jakarta.ws.rs.GET;\n",
&["jakarta.ws.rs"]
));
assert!(java_imports_any(
b"import static io.quarkus.runtime.Quarkus.run;\n",
&["io.quarkus"]
));
// Comment-only mention must not match.
assert!(!java_imports_any(
b"// import io.quarkus.runtime.Quarkus;\n",
&["io.quarkus"]
));
// Wrong prefix.
assert!(!java_imports_any(
b"import org.springframework.web.bind.annotation.GetMapping;\n",
&["io.quarkus"]
));
}
}

View file

@ -16,7 +16,7 @@
//! `@DenyAll` (Quarkus Security).
use crate::entry_points::HttpMethod;
use crate::surface::lang::common::{loc_for, rel_file};
use crate::surface::lang::common::{java_imports_any, loc_for, rel_file};
use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
use std::path::Path;
use tree_sitter::{Node, Tree};
@ -53,7 +53,10 @@ pub fn detect_quarkus_routes(
scan_root: Option<&Path>,
) -> Vec<SurfaceNode> {
let file_rel = rel_file(path, scan_root);
if !file_uses_quarkus(tree.root_node(), bytes) {
// Phase 23 follow-up: tighten witness to top-level `import`
// statements with the strict package prefix, replacing the
// previous AST `import_declaration.contains(...)` substring scan.
if !java_imports_any(bytes, &["io.quarkus", "jakarta.ws.rs"]) {
return Vec::new();
}
let mut out = Vec::new();
@ -94,19 +97,6 @@ pub fn detect_quarkus_routes(
out
}
fn file_uses_quarkus(root: Node, bytes: &[u8]) -> bool {
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "import_declaration"
&& let Ok(text) = child.utf8_text(bytes)
&& (text.contains("io.quarkus") || text.contains("jakarta.ws.rs"))
{
return true;
}
}
false
}
fn class_is_quarkus_resource(class: Node, bytes: &[u8]) -> bool {
let modifiers = match crate::surface::lang::common::child_or_named(class, "modifiers") {
Some(m) => m,

View file

@ -19,7 +19,7 @@
use crate::entry_points::HttpMethod;
use crate::surface::lang::common::{
leaf_matches, loc_for, rel_file, string_node_value,
leaf_matches, loc_for, python_imports_any, rel_file, string_node_value,
};
use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
use std::collections::HashMap;
@ -59,12 +59,10 @@ pub fn detect_django_routes(
scan_root: Option<&Path>,
) -> Vec<SurfaceNode> {
// File-level gate: only fire when the file actually imports
// django (or extends the Django CBV bases via name witness).
let file_text = std::str::from_utf8(bytes).unwrap_or("");
let has_django_witness = file_text.contains("django")
|| file_text.contains("rest_framework")
|| CBV_BASES.iter().any(|b| file_text.contains(b));
if !has_django_witness {
// django or DRF. Phase 23 follow-up tightens the witness to
// top-level `import` / `from` statements so a comment or string
// mention of "django" / "rest_framework" cannot trigger detection.
if !python_imports_any(bytes, &["django", "rest_framework"]) {
return Vec::new();
}
let file_rel = rel_file(path, scan_root);
@ -356,7 +354,7 @@ mod tests {
#[test]
fn detects_class_based_view() {
let src = "class UserList(APIView):\n def get(self, request): pass\n def post(self, request): pass\n";
let src = "from rest_framework.views import APIView\n\nclass UserList(APIView):\n def get(self, request): pass\n def post(self, request): pass\n";
let (tree, bytes) = parse(src);
let nodes = detect_django_routes(&tree, &bytes, &PathBuf::from("views.py"), None);
assert_eq!(nodes.len(), 2);

View file

@ -12,7 +12,9 @@
//! decorator-stack guards drawn from [`AUTH_DECORATORS`].
use crate::entry_points::HttpMethod;
use crate::surface::lang::common::{leaf_matches, loc_for, rel_file, string_node_value};
use crate::surface::lang::common::{
leaf_matches, loc_for, python_imports_any, rel_file, string_node_value,
};
use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
use std::path::Path;
use tree_sitter::{Node, Tree};
@ -51,13 +53,10 @@ pub fn detect_fastapi_routes(
scan_root: Option<&Path>,
) -> Vec<SurfaceNode> {
// File-level gate: avoid double-detection on Flask files that
// also use `app.get(...)` shape. FastAPI / Starlette / APIRouter
// require an explicit import of the relevant package.
let file_text = std::str::from_utf8(bytes).unwrap_or("");
let has_fastapi_witness = file_text.contains("fastapi")
|| file_text.contains("starlette")
|| file_text.contains("APIRouter");
if !has_fastapi_witness {
// also use `app.get(...)` shape. Phase 23 follow-up tightens the
// witness to actual top-level `import` / `from` statements so a
// comment or string mention of "fastapi" cannot trigger detection.
if !python_imports_any(bytes, &["fastapi", "starlette"]) {
return Vec::new();
}
let file_rel = rel_file(path, scan_root);
@ -314,7 +313,7 @@ mod tests {
#[test]
fn detects_router_post() {
let src = "router = APIRouter()\n@router.post('/items')\ndef create(): pass\n";
let src = "from fastapi import APIRouter\nrouter = APIRouter()\n@router.post('/items')\ndef create(): pass\n";
let (tree, bytes) = parse(src);
let nodes = detect_fastapi_routes(&tree, &bytes, &PathBuf::from("api.py"), None);
let SurfaceNode::EntryPoint(ep) = &nodes[0] else {

View file

@ -16,6 +16,7 @@
//! and -JWT-Extended).
use crate::entry_points::HttpMethod;
use crate::surface::lang::common::python_imports_any;
use crate::surface::{
EntryPoint, Framework, SourceLocation, SurfaceNode, relative_path_string,
};
@ -52,13 +53,11 @@ pub fn detect_flask_routes(
) -> Vec<SurfaceNode> {
// File-level gate: avoid double-detection on FastAPI files where
// `app.get(...)` shape overlaps. Phase 21 was lenient because no
// sibling probe existed; Phase 22 splits per-framework, so each
// probe only fires when its framework witness is present.
let file_text = std::str::from_utf8(bytes).unwrap_or("");
let has_flask_witness = file_text.contains("flask")
|| file_text.contains("Flask")
|| file_text.contains("Blueprint");
if !has_flask_witness {
// sibling probe existed; Phase 22 split per-framework via free
// text witness; Phase 23 follow-up tightens the witness to actual
// top-level `import` / `from` statements so a comment or vendored
// license header that names "flask" cannot trigger detection.
if !python_imports_any(bytes, &["flask"]) {
return Vec::new();
}
let file_rel = relative_path_string(path, scan_root);

View file

@ -11,7 +11,7 @@
//! `BearerAuth`, `JwtClaims`, etc.).
use crate::entry_points::HttpMethod;
use crate::surface::lang::common::{loc_for, rel_file};
use crate::surface::lang::common::{loc_for, rel_file, rust_uses_any};
use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
use std::path::Path;
use tree_sitter::{Node, Tree};
@ -42,11 +42,11 @@ pub fn detect_actix_routes(
path: &Path,
scan_root: Option<&Path>,
) -> Vec<SurfaceNode> {
let file_text = std::str::from_utf8(bytes).unwrap_or("");
if !file_text.contains("actix_web::") && !file_text.contains("use actix_web") {
// Best-effort gate so the actix probe does not over-fire on
// Rocket / generic Rust files that also define a `#[get]`
// macro from a user crate.
// Phase 23 follow-up: gate on a real top-level `use actix_web…` /
// `extern crate actix_web` so a comment or string literal
// mentioning actix_web cannot trigger detection on a Rocket /
// generic Rust file that also defines a `#[get]` user macro.
if !rust_uses_any(bytes, &["actix_web"]) {
return Vec::new();
}
let file_rel = rel_file(path, scan_root);

View file

@ -9,7 +9,7 @@
//! `Router::route(...)` registration in the same file references it).
use crate::entry_points::HttpMethod;
use crate::surface::lang::common::{loc_for, rel_file, string_node_value};
use crate::surface::lang::common::{loc_for, rel_file, rust_uses_any, string_node_value};
use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
use std::collections::HashMap;
use std::path::Path;
@ -39,8 +39,10 @@ pub fn detect_axum_routes(
path: &Path,
scan_root: Option<&Path>,
) -> Vec<SurfaceNode> {
let file_text = std::str::from_utf8(bytes).unwrap_or("");
if !file_text.contains("axum::") && !file_text.contains("use axum") {
// Phase 23 follow-up: gate on a real top-level `use axum…` /
// `extern crate axum` so a comment / string literal mentioning
// axum cannot trigger detection.
if !rust_uses_any(bytes, &["axum"]) {
return Vec::new();
}
let file_rel = rel_file(path, scan_root);

View file

@ -60,16 +60,25 @@ pub fn populate_reaches_edges(
// call graph cannot resolve the seed FuncKey.
reachable_files.insert(ep.handler_location.file.clone());
// Locate seed FuncKeys whose `namespace` matches the entry's
// file and whose `name` matches the handler. More than one
// seed is possible (overloaded methods, duplicate definitions).
// Locate seed FuncKeys whose `namespace` (project-relative
// POSIX path, optionally prefixed with `@pkg/name::`) matches
// the entry's file and whose `name` matches the handler. More
// than one seed is possible (overloaded methods, duplicate
// definitions).
//
// Phase 23 follow-up: this used to be an `ends_with` substring
// check on both sides, which silently aliased same-basename
// files in sibling directories — `subdir/app.py` and
// `other/app.py` would both seed when the entry-point pointed
// at `app.py`. We now compare the file part exactly so a
// handler in `subdir/app.py` only seeds the FuncKey whose
// namespace strips to `subdir/app.py`.
let seeds = call_graph
.index
.iter()
.filter(|(k, _)| k.name == ep.handler_name)
.filter(|(k, _)| {
k.namespace.ends_with(&ep.handler_location.file)
|| ep.handler_location.file.ends_with(&k.namespace)
file_part_of_namespace(&k.namespace) == ep.handler_location.file
})
.map(|(_, idx)| *idx)
.collect::<Vec<_>>();
@ -108,6 +117,15 @@ pub fn populate_reaches_edges(
map.edges.extend(new_edges);
}
/// Strip the optional `@pkg/name::` package prefix from a `FuncKey`
/// namespace, returning the project-relative POSIX file path part.
/// `namespace_with_package` produces `"@scope/name::src/file.ts"` for
/// JS/TS files inside resolved packages; the file part is what
/// matches an entry-point's `handler_location.file`.
fn file_part_of_namespace(ns: &str) -> &str {
ns.rsplit_once("::").map(|(_, rest)| rest).unwrap_or(ns)
}
/// Build a lookup from destination node index → destination file.
/// Restricted to the three reachable-from-entry-point variants.
fn build_destination_index(map: &SurfaceMap) -> Vec<(usize, String)> {
@ -189,4 +207,19 @@ mod tests {
assert_eq!(map.edges[0].from, 0);
assert_eq!(map.edges[0].to, 1);
}
#[test]
fn file_part_of_namespace_strips_package_prefix() {
assert_eq!(file_part_of_namespace("app.py"), "app.py");
assert_eq!(file_part_of_namespace("src/main.rs"), "src/main.rs");
assert_eq!(
file_part_of_namespace("@scope/name::src/file.ts"),
"src/file.ts"
);
// Last `::` wins, matching `namespace_with_package`'s shape.
assert_eq!(
file_part_of_namespace("@a/b::@c/d::lib/x.ts"),
"lib/x.ts"
);
}
}

View file

@ -1,5 +1,5 @@
Surface map
1 entry-points, 0 data stores, 0 external services, 0 dangerous locals
1 entry-point, 0 data stores, 0 external services, 0 dangerous locals
app.py
GET /users (Flask)

View file

@ -118,3 +118,23 @@ fn load_or_build_falls_back_to_filesystem_when_no_db() {
"expected at least one entry-point in fallback path"
);
}
/// Phase 21 follow-up: the non-indexed scan path now returns the
/// SurfaceMap built during pass 2 alongside the diagnostics, so
/// consumers can avoid re-running the analysis to render the surface.
#[test]
fn scan_no_index_with_surface_map_returns_entry_points() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("app.py"),
"from flask import Flask\napp = Flask(__name__)\n@app.get('/x')\ndef x(): pass\n",
)
.unwrap();
let cfg = Config::default();
let (_diags, map) = nyx_scanner::scan_no_index_with_surface_map(tmp.path(), &cfg)
.expect("scan_no_index_with_surface_map should succeed");
assert!(
map.entry_points().next().is_some(),
"expected at least one entry-point in returned SurfaceMap"
);
}