@@ -23,7 +23,10 @@ export {
2323 scanResolvedSecretString ,
2424} from '@/executor/utils/resolved-secret-matcher'
2525
26- const MAX_CONTENT_NODES = 100_000
26+ /** Values one model-content projection will walk before refusing the whole payload. */
27+ export const MAX_CONTENT_NODES = 100_000
28+ /** Encoded bytes a model-content projection accepts by default before refusing the payload. */
29+ export const MAX_MODEL_CONTENT_BYTES = MAX_INLINE_MATERIALIZATION_BYTES
2730const MAX_CONTENT_DEPTH = 100
2831const INTERNAL_DIAGNOSTIC_IDENTIFIER_PATTERN =
2932 / _ _ v a r _ [ A - Z a - z 0 - 9 _ ] + | _ _ s i m _ c o d e _ \d + _ (?: b i n d i n g | i n p u t | r u n t i m e ) _ \d + [ A - Z a - z 0 - 9 _ ] * | _ _ s i m _ p l a c e h o l d e r _ [ a - f 0 - 9 ] { 64 } _ _ | _ _ s i m _ r u n t i m e _ [ A - Z a - z 0 - 9 _ ] + _ \d + [ A - Z a - z 0 - 9 _ ] * | _ _ S I M _ R U N T I M E _ P A Y L O A D _ P A T H / g
@@ -351,12 +354,116 @@ function projectContent(
351354export function projectResolvedSecretContent (
352355 value : unknown ,
353356 matcher : ResolvedSecretMatcher ,
354- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES ,
357+ maxBytes = MAX_MODEL_CONTENT_BYTES ,
355358 options : ResolvedSecretContentProjectionOptions = { }
356359) : ResolvedSecretContentProjection {
357360 return projectContent ( value , matcher , maxBytes , options )
358361}
359362
363+ /** Size of a value as the model-content projection sees it, for budgeting and diagnostics. */
364+ export interface ModelContentMeasure {
365+ /** Values walked: the root and every array item and object property value JSON encodes. */
366+ values : number
367+ /** Bytes of the value's JSON encoding. */
368+ bytes : number
369+ /**
370+ * True once the walk passed the projection's value, byte, or depth limit and stopped; `values`
371+ * and `bytes` are then only what was counted before stopping.
372+ */
373+ exceeded : boolean
374+ }
375+
376+ class ModelContentMeasureExceeded extends Error { }
377+ class ModelContentUnencodable extends Error { }
378+
379+ /**
380+ * Measures a value in the units the projection caps, following JSON's encoding rules, without
381+ * serializing it: strings are measured one at a time and only while they fit the remaining byte
382+ * budget, and the walk stops at the first limit it passes. Returns undefined for a value JSON
383+ * cannot encode (a BigInt, a cycle), which the projection refuses too.
384+ */
385+ export function measureModelContent ( value : unknown ) : ModelContentMeasure | undefined {
386+ let values = 0
387+ let bytes = 0
388+ const ancestors = new Set < object > ( )
389+
390+ const addBytes = ( count : number ) : void => {
391+ bytes += count
392+ if ( bytes > MAX_MODEL_CONTENT_BYTES ) throw new ModelContentMeasureExceeded ( )
393+ }
394+ const addString = ( text : string ) : void => {
395+ // A JSON string is never shorter than its UTF-16 length plus its quotes.
396+ if ( text . length + 2 > MAX_MODEL_CONTENT_BYTES - bytes ) throw new ModelContentMeasureExceeded ( )
397+ addBytes ( Buffer . byteLength ( JSON . stringify ( text ) , 'utf8' ) )
398+ }
399+ const walk = ( raw : unknown , key : string , depth : number ) : void => {
400+ const item =
401+ raw !== null &&
402+ typeof raw === 'object' &&
403+ typeof ( raw as { toJSON ?: unknown } ) . toJSON === 'function'
404+ ? ( raw as { toJSON : ( key : string ) => unknown } ) . toJSON ( key )
405+ : raw
406+ values += 1
407+ if ( values > MAX_CONTENT_NODES || depth > MAX_CONTENT_DEPTH ) {
408+ throw new ModelContentMeasureExceeded ( )
409+ }
410+ if ( typeof item === 'string' ) {
411+ addString ( item )
412+ return
413+ }
414+ if ( typeof item === 'number' ) {
415+ addBytes ( Number . isFinite ( item ) ? String ( item ) . length : 4 )
416+ return
417+ }
418+ if ( typeof item === 'boolean' ) {
419+ addBytes ( item ? 4 : 5 )
420+ return
421+ }
422+ if ( typeof item === 'bigint' ) throw new ModelContentUnencodable ( )
423+ if ( item === null || typeof item !== 'object' ) {
424+ addBytes ( 4 )
425+ return
426+ }
427+ if ( ancestors . has ( item ) ) throw new ModelContentUnencodable ( )
428+ ancestors . add ( item )
429+ if ( Array . isArray ( item ) ) {
430+ addBytes ( 2 + Math . max ( 0 , item . length - 1 ) )
431+ for ( const [ index , child ] of item . entries ( ) ) {
432+ if ( child === undefined || typeof child === 'function' || typeof child === 'symbol' ) {
433+ values += 1
434+ addBytes ( 4 )
435+ } else {
436+ walk ( child , String ( index ) , depth + 1 )
437+ }
438+ }
439+ } else {
440+ addBytes ( 2 )
441+ let first = true
442+ for ( const [ childKey , child ] of Object . entries ( item ) ) {
443+ if ( child === undefined || typeof child === 'function' || typeof child === 'symbol' )
444+ continue
445+ if ( ! first ) addBytes ( 1 )
446+ first = false
447+ addString ( childKey )
448+ addBytes ( 1 )
449+ walk ( child , childKey , depth + 1 )
450+ }
451+ }
452+ ancestors . delete ( item )
453+ }
454+
455+ if ( value === undefined || typeof value === 'function' || typeof value === 'symbol' ) {
456+ return { values : 0 , bytes : 0 , exceeded : false }
457+ }
458+ try {
459+ walk ( value , '' , 0 )
460+ return { values, bytes, exceeded : false }
461+ } catch ( error ) {
462+ if ( error instanceof ModelContentMeasureExceeded ) return { values, bytes, exceeded : true }
463+ return undefined
464+ }
465+ }
466+
360467/** Returns the registry-revision-cached matcher used for all model-visible projection. */
361468export function getResolvedSecretModelMatcher (
362469 registry : ResolvedSecretTraceRegistry | undefined
@@ -396,7 +503,7 @@ export function getResolvedSecretModelMatcher(
396503export function projectResolvedSecretModelContent (
397504 value : unknown ,
398505 registry : ResolvedSecretTraceRegistry | undefined ,
399- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES ,
506+ maxBytes = MAX_MODEL_CONTENT_BYTES ,
400507 options : ResolvedSecretContentProjectionOptions = { }
401508) : ResolvedSecretContentProjection {
402509 const snapshot = getResolvedSecretModelMatcher ( registry )
@@ -419,7 +526,7 @@ export function projectResolvedSecretModelContent(
419526export function projectResolvedSecretModelJsonContent (
420527 value : unknown ,
421528 registry : ResolvedSecretTraceRegistry | undefined ,
422- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES ,
529+ maxBytes = MAX_MODEL_CONTENT_BYTES ,
423530 options : ResolvedSecretContentProjectionOptions = { }
424531) : ResolvedSecretContentProjection {
425532 const snapshot = getResolvedSecretModelMatcher ( registry )
@@ -455,7 +562,7 @@ export function projectResolvedSecretModelJsonContent(
455562export function projectResolvedSecretDiagnosticContent (
456563 value : unknown ,
457564 registry : ResolvedSecretTraceRegistry | undefined ,
458- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES
565+ maxBytes = MAX_MODEL_CONTENT_BYTES
459566) : ResolvedSecretContentProjection {
460567 return projectResolvedSecretModelContent ( value , registry , maxBytes , {
461568 sanitizeInternalIdentifiers : true ,
@@ -509,7 +616,7 @@ export function isResolvedSecretModelContentUnchanged(
509616 if ( ! snapshot . complete ) return false
510617 if ( ! snapshot . matcher ) return true
511618
512- const projection = projectContent ( value , snapshot . matcher , MAX_INLINE_MATERIALIZATION_BYTES , {
619+ const projection = projectContent ( value , snapshot . matcher , MAX_MODEL_CONTENT_BYTES , {
513620 projectPrimitiveLiterals : true ,
514621 rejectResolvedSecretLiterals : true ,
515622 } )
@@ -523,7 +630,7 @@ export function isResolvedSecretModelContentUnchanged(
523630export function projectResolvedSecretModelJsonStrings (
524631 values : readonly ( string | undefined ) [ ] ,
525632 registry : ResolvedSecretTraceRegistry | undefined ,
526- maxBytes = MAX_INLINE_MATERIALIZATION_BYTES
633+ maxBytes = MAX_MODEL_CONTENT_BYTES
527634) : ResolvedSecretContentProjection {
528635 const snapshot = getResolvedSecretModelMatcher ( registry )
529636 if ( ! snapshot . complete ) return { safe : false }
0 commit comments