11import { writeFile } from "node:fs/promises" ;
2+ import type { DocumentLoader } from "@fedify/vocab-runtime" ;
23import process from "node:process" ;
34import { getContextLoader , getDocumentLoader } from "../docloader.ts" ;
45import { buildFleet } from "./actor/fleet.ts" ;
56import type { BenchCommand } from "./command.ts" ;
7+ import { discoverInbox , selectInbox } from "./discovery/discover.ts" ;
68import {
79 buildReport ,
810 buildScenarioResult ,
@@ -18,20 +20,25 @@ import {
1820 type ResolvedScenario ,
1921 type ResolvedSuite ,
2022} from "./scenario/normalize.ts" ;
21- import type { Suite } from "./scenario/types.ts" ;
23+ import type { LoadConfig , Suite } from "./scenario/types.ts" ;
2224import { validateSuite } from "./scenario/validate.ts" ;
2325import {
2426 assertInboxDestinationAllowed ,
2527 assertTargetAllowed ,
28+ assertUnsafeOverrideAllowed ,
2629 UnsafeTargetError ,
2730} from "./safety/gate.ts" ;
28- import { classifyTarget } from "./safety/tiers.ts" ;
31+ import {
32+ classifyResolvedTarget ,
33+ type ResolveTargetAddresses ,
34+ } from "./safety/tiers.ts" ;
2935import { runnerFor } from "./scenarios/registry.ts" ;
3036import {
3137 resolveAdvertiseHost ,
3238 spawnSyntheticServer ,
3339 type SyntheticServer ,
3440} from "./server/synthetic.ts" ;
41+ import { convertUrlIfHandle } from "../webfinger/lib.ts" ;
3542
3643/** Injectable dependencies for {@link runBench}, overridable in tests. */
3744export interface RunBenchDeps {
@@ -46,6 +53,8 @@ export interface RunBenchDeps {
4653 readonly log ?: ( message : string ) => void ;
4754 /** Fetch implementation. */
4855 readonly fetch ?: typeof fetch ;
56+ /** Hostname resolver used for target risk classification. */
57+ readonly resolveTargetAddresses ?: ResolveTargetAddresses ;
4958}
5059
5160/** The scenario types that need the synthetic actor/key server. */
@@ -109,19 +118,26 @@ export default async function runBench(
109118 return void exit ( 2 ) ;
110119 }
111120
112- if ( command . dryRun ) {
113- await writeOutput ( renderPlan ( suite ) , command . output ) ;
114- return void exit ( 0 ) ;
115- }
116-
117- const tier = classifyTarget ( suite . target ) ;
121+ const tier = await classifyResolvedTarget (
122+ suite . target ,
123+ deps . resolveTargetAddresses ,
124+ ) ;
118125 const probe = await probeBenchmarkMode ( suite . target , fetchImpl ) ;
119126 try {
127+ if ( ! command . dryRun ) {
128+ assertUnsafeOverrideAllowed ( {
129+ tier,
130+ benchmarkMode : probe . benchmarkMode ,
131+ allowUnsafe : command . allowUnsafeTarget ,
132+ explicitCliTarget : command . target != null ,
133+ scenarios : unsafeOverrideScenarios ( validated ) ,
134+ } ) ;
135+ }
120136 assertTargetAllowed ( {
121137 tier,
122138 benchmarkMode : probe . benchmarkMode ,
123139 allowUnsafe : command . allowUnsafeTarget ,
124- dryRun : false ,
140+ dryRun : command . dryRun ,
125141 } ) ;
126142 } catch ( error ) {
127143 if ( error instanceof UnsafeTargetError ) {
@@ -136,20 +152,6 @@ export default async function runBench(
136152 // same-machine (loopback) target; a non-loopback target needs an advertised,
137153 // reachable host (--advertise-host). Without one, refuse signed scenarios
138154 // rather than let every signed delivery fail key lookup.
139- if (
140- tier !== "loopback" && command . advertiseHost == null &&
141- suite . scenarios . some ( ( s ) => SIGNED_TYPES . has ( s . type ) )
142- ) {
143- log (
144- "Signed scenarios (inbox) need the benchmark's synthetic actor server to " +
145- "be reachable from the target. A loopback target reaches it " +
146- "automatically; for a non-loopback target, pass --advertise-host with " +
147- "an address the target can reach (the synthetic server then binds all " +
148- "interfaces), or use a read scenario such as webfinger." ,
149- ) ;
150- return void exit ( 2 ) ;
151- }
152-
153155 const allowPrivateAddress = tier !== "public" ;
154156 const documentLoader = await getDocumentLoader ( {
155157 allowPrivateAddress,
@@ -170,6 +172,43 @@ export default async function runBench(
170172 advertised : command . advertiseHost != null ,
171173 } ) ;
172174
175+ if ( command . dryRun ) {
176+ try {
177+ await writeOutput (
178+ await renderPlan ( suite , {
179+ documentLoader,
180+ contextLoader,
181+ allowPrivateAddress,
182+ assertDestinationAllowed,
183+ } ) ,
184+ command . output ,
185+ ) ;
186+ return void exit ( 0 ) ;
187+ } catch ( error ) {
188+ log ( error instanceof Error ? error . message : String ( error ) ) ;
189+ return void exit ( 2 ) ;
190+ }
191+ }
192+
193+ // The target dereferences the synthetic actor server while verifying
194+ // signatures. By default that server is loopback-only, reachable just by a
195+ // same-machine (loopback) target; a non-loopback target needs an advertised,
196+ // reachable host (--advertise-host). Without one, refuse signed scenarios
197+ // rather than let every signed delivery fail key lookup.
198+ if (
199+ tier !== "loopback" && command . advertiseHost == null &&
200+ suite . scenarios . some ( ( s ) => SIGNED_TYPES . has ( s . type ) )
201+ ) {
202+ log (
203+ "Signed scenarios (inbox) need the benchmark's synthetic actor server to " +
204+ "be reachable from the target. A loopback target reaches it " +
205+ "automatically; for a non-loopback target, pass --advertise-host with " +
206+ "an address the target can reach (the synthetic server then binds all " +
207+ "interfaces), or use a read scenario such as webfinger." ,
208+ ) ;
209+ return void exit ( 2 ) ;
210+ }
211+
173212 let fleet : SyntheticServer | undefined ;
174213 const startedAt = new Date ( ) . toISOString ( ) ;
175214 try {
@@ -278,7 +317,17 @@ async function defaultWriteOutput(
278317 await writeFile ( outputPath , content , { encoding : "utf-8" } ) ;
279318}
280319
281- function renderPlan ( suite : ResolvedSuite ) : string {
320+ interface DryRunPlanContext {
321+ readonly documentLoader : DocumentLoader ;
322+ readonly contextLoader : DocumentLoader ;
323+ readonly allowPrivateAddress : boolean ;
324+ readonly assertDestinationAllowed : ( url : URL ) => void ;
325+ }
326+
327+ async function renderPlan (
328+ suite : ResolvedSuite ,
329+ context : DryRunPlanContext ,
330+ ) : Promise < string > {
282331 const lines = [
283332 "Fedify benchmark plan (dry run)" ,
284333 "" ,
@@ -289,8 +338,13 @@ function renderPlan(suite: ResolvedSuite): string {
289338 lines . push (
290339 `- ${ scenario . name } (${ scenario . type } ): ${ describePlan ( scenario ) } ` ,
291340 ) ;
341+ lines . push ( ...await describeDiscoveryPlan ( scenario , suite , context ) ) ;
292342 }
293- lines . push ( "" , "No requests were sent." ) ;
343+ lines . push (
344+ "" ,
345+ "No benchmark load was sent. Discovery and stats probe requests may " +
346+ "have been sent." ,
347+ ) ;
294348 return `${ lines . join ( "\n" ) } \n` ;
295349}
296350
@@ -300,3 +354,89 @@ function describePlan(scenario: ResolvedScenario): string {
300354 : `closed-loop concurrency ${ scenario . load . concurrency } ` ;
301355 return `${ load } , duration ${ scenario . durationMs } ms, signing ${ scenario . signing } ` ;
302356}
357+
358+ async function describeDiscoveryPlan (
359+ scenario : ResolvedScenario ,
360+ suite : ResolvedSuite ,
361+ context : DryRunPlanContext ,
362+ ) : Promise < string [ ] > {
363+ switch ( scenario . type ) {
364+ case "inbox" :
365+ return await describeInboxDiscoveryPlan ( scenario , context ) ;
366+ case "webfinger" :
367+ return describeWebFingerPlan ( scenario , suite . target ) ;
368+ default :
369+ return [ " discovery: not available for this scenario type" ] ;
370+ }
371+ }
372+
373+ async function describeInboxDiscoveryPlan (
374+ scenario : ResolvedScenario ,
375+ context : DryRunPlanContext ,
376+ ) : Promise < string [ ] > {
377+ const lines : string [ ] = [ ] ;
378+ for ( const recipient of scenario . recipients ) {
379+ const discovered = await discoverInbox ( recipient , {
380+ documentLoader : context . documentLoader ,
381+ contextLoader : context . contextLoader ,
382+ allowPrivateAddress : context . allowPrivateAddress ,
383+ } ) ;
384+ const inbox = selectInbox ( discovered , scenario . inbox ) ;
385+ lines . push (
386+ ` recipient ${ recipient } : actor ${ discovered . actorUri . href } , ` +
387+ `inbox ${ inbox . href } ` ,
388+ ) ;
389+ lines . push (
390+ ` destination safety: ${ describeDestinationSafety ( inbox , context ) } ` ,
391+ ) ;
392+ }
393+ return lines ;
394+ }
395+
396+ function describeWebFingerPlan (
397+ scenario : ResolvedScenario ,
398+ target : URL ,
399+ ) : string [ ] {
400+ const recipients = scenario . recipients . length > 0
401+ ? scenario . recipients
402+ : [ target . href ] ;
403+ return recipients . map ( ( recipient ) => {
404+ const resource = convertUrlIfHandle ( recipient ) . href ;
405+ const url = new URL ( "/.well-known/webfinger" , target ) ;
406+ url . searchParams . set ( "resource" , resource ) ;
407+ return ` webfinger ${ resource } : GET ${ url . href } ` ;
408+ } ) ;
409+ }
410+
411+ function describeDestinationSafety (
412+ inbox : URL ,
413+ context : DryRunPlanContext ,
414+ ) : string {
415+ try {
416+ context . assertDestinationAllowed ( inbox ) ;
417+ return "allowed" ;
418+ } catch ( error ) {
419+ if ( error instanceof UnsafeTargetError ) {
420+ return `would be refused: ${ error . message } ` ;
421+ }
422+ throw error ;
423+ }
424+ }
425+
426+ function unsafeOverrideScenarios (
427+ suite : Suite ,
428+ ) : Parameters < typeof assertUnsafeOverrideAllowed > [ 0 ] [ "scenarios" ] {
429+ const defaultDuration = suite . defaults ?. duration != null ;
430+ const defaultLoad = hasExplicitLoad ( suite . defaults ?. load ) ;
431+ return suite . scenarios . map ( ( scenario ) => ( {
432+ name : scenario . name ,
433+ explicitDuration : scenario . duration != null || defaultDuration ,
434+ explicitLoad : hasExplicitLoad ( scenario . load ) || defaultLoad ,
435+ } ) ) ;
436+ }
437+
438+ function hasExplicitLoad ( load : LoadConfig | undefined ) : boolean {
439+ return load != null &&
440+ ( ( "rate" in load && load . rate != null ) ||
441+ ( "concurrency" in load && load . concurrency != null ) ) ;
442+ }
0 commit comments