Skip to content

Commit 8628185

Browse files
Smaug123claude
andcommitted
Reject recursive argument schemas instead of overflowing the stack
toParseSpec and unionToParseSpec descend into ambient records and unions by name with no memory of what they were already expanding, so a schema which refers to itself (even indirectly, e.g. a union whose case payload record holds a field of that union) recursed until the Myriad subprocess died with a stack overflow. Thread the chain of types currently being lowered through the recursion and fail with the cycle path on re-entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 96f7204 commit 8628185

2 files changed

Lines changed: 116 additions & 5 deletions

File tree

WoofWare.Myriad.Plugins/ArgParserGenerator.fs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -811,14 +811,31 @@ module internal ArgParserGenerator =
811811

812812
parser, Accumulation.Required, ty
813813

814+
/// An argument schema must be a finite tree: a record or union which refers to itself, even
815+
/// indirectly, would expand forever. `ancestors` is the chain of type names currently being
816+
/// lowered, innermost first; re-entry into any of them is a cycle, which we reject rather
817+
/// than dying with a stack overflow. (Names are the bare idText, matching the by-name lookup
818+
/// which resolves ambient type references.)
819+
let private pushSchemaType (ancestors : string list) (name : Ident) : string list =
820+
if ancestors |> List.contains name.idText then
821+
let path = name.idText :: ancestors |> List.rev |> String.concat " -> "
822+
823+
failwith
824+
$"The [<ArgParser>] schema is recursive: %s{path}. Argument records and unions may not contain themselves, even indirectly."
825+
826+
name.idText :: ancestors
827+
814828
let rec private toParseSpec
829+
(ancestors : string list)
815830
(counter : int)
816831
(flagDus : FlagDu list)
817832
(ambientUnions : UnionType list)
818833
(ambientRecords : RecordType list)
819834
(finalRecord : RecordType)
820835
: ParseTreeCrate * int
821836
=
837+
let ancestors = pushSchemaType ancestors finalRecord.Name
838+
822839
finalRecord.Fields
823840
|> List.iter (fun (SynField.SynField (isStatic = isStatic)) ->
824841
if isStatic then
@@ -911,7 +928,8 @@ module internal ArgParserGenerator =
911928
match ambientRecordMatch with
912929
| Some ambient ->
913930
// This field has a type we need to obtain from parsing another record.
914-
let spec, counter = toParseSpec counter flagDus ambientUnions ambientRecords ambient
931+
let spec, counter =
932+
toParseSpec ancestors counter flagDus ambientUnions ambientRecords ambient
915933

916934
counter, (ident, spec) :: acc
917935
| None ->
@@ -921,7 +939,7 @@ module internal ArgParserGenerator =
921939
// A discriminated union of alternative argument sets: exactly one case's
922940
// arguments must be supplied.
923941
let spec, counter =
924-
unionToParseSpec counter flagDus ambientUnions ambientRecords union
942+
unionToParseSpec ancestors counter flagDus ambientUnions ambientRecords union
925943

926944
counter, (ident, spec) :: acc
927945
| None ->
@@ -1051,13 +1069,16 @@ module internal ArgParserGenerator =
10511069
/// is a record defined alongside it, into a Sum parse-tree node: exactly one case's
10521070
/// arguments must be supplied at runtime.
10531071
and private unionToParseSpec
1072+
(ancestors : string list)
10541073
(counter : int)
10551074
(flagDus : FlagDu list)
10561075
(ambientUnions : UnionType list)
10571076
(ambientRecords : RecordType list)
10581077
(union : UnionType)
10591078
: ParseTreeCrate * int
10601079
=
1080+
let ancestors = pushSchemaType ancestors union.Name
1081+
10611082
let sumId = counter
10621083
let counter = counter + 1
10631084

@@ -1084,7 +1105,7 @@ module internal ArgParserGenerator =
10841105
$"Case %s{case.Name.idText} of [<ArgParser>] union %s{union.Name.idText} must have exactly one field: a record holding that case's arguments."
10851106

10861107
let spec, counter =
1087-
toParseSpec counter flagDus ambientUnions ambientRecords payloadRecord
1108+
toParseSpec ancestors counter flagDus ambientUnions ambientRecords payloadRecord
10881109

10891110
let tree =
10901111
ParseTree.assertNoPositional
@@ -1997,7 +2018,7 @@ module internal ArgParserGenerator =
19972018
_) ->
19982019
let record = RecordType.OfRecord sci smd access fields
19992020

2000-
let spec, _ = toParseSpec 0 flagDus structuralUnions allRecordTypes record
2021+
let spec, _ = toParseSpec [] 0 flagDus structuralUnions allRecordTypes record
20012022

20022023
record.Name, typeHelp attrs, spec
20032024
| SynTypeDefn.SynTypeDefn (SynComponentInfo.SynComponentInfo (attributes = attrs) as sci,
@@ -2008,7 +2029,7 @@ module internal ArgParserGenerator =
20082029
_) ->
20092030
let union = UnionType.OfUnion sci smd access cases
20102031

2011-
let spec, _ = unionToParseSpec 0 flagDus structuralUnions allRecordTypes union
2032+
let spec, _ = unionToParseSpec [] 0 flagDus structuralUnions allRecordTypes union
20122033

20132034
union.Name, typeHelp attrs, spec
20142035
| _ ->

WoofWare.Myriad.Plugins/Test/TestArgParserRejection.fs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,96 @@ type ParentRecord =
620620

621621
List.length modules |> shouldEqual 2
622622

623+
// ------------------------------------------------------------------------------------------
624+
// Recursive schemas. An argument schema must be a finite tree: a record or union which
625+
// refers to itself, even indirectly, would expand forever. Without an explicit check the
626+
// generator recurses until the process dies with a stack overflow instead of producing a
627+
// comprehensible error.
628+
629+
[<Test>]
630+
let ``A record which contains itself is rejected`` () =
631+
"""namespace TestMe
632+
633+
open WoofWare.Myriad.Plugins
634+
635+
[<ArgParser>]
636+
type SelfRef =
637+
{
638+
Value : int
639+
Nested : SelfRef
640+
}
641+
"""
642+
|> shouldRejectWith
643+
"The [<ArgParser>] schema is recursive: SelfRef -> SelfRef. Argument records and unions may not contain themselves, even indirectly."
644+
645+
[<Test>]
646+
let ``Mutually recursive records are rejected`` () =
647+
"""namespace TestMe
648+
649+
open WoofWare.Myriad.Plugins
650+
651+
type Inner =
652+
{
653+
A : int
654+
Outer : OuterRef
655+
}
656+
657+
[<ArgParser>]
658+
type OuterRef =
659+
{
660+
B : int
661+
Inner : Inner
662+
}
663+
"""
664+
|> shouldRejectWith
665+
"The [<ArgParser>] schema is recursive: OuterRef -> Inner -> OuterRef. Argument records and unions may not contain themselves, even indirectly."
666+
667+
[<Test>]
668+
let ``A union whose case payload contains the union itself is rejected`` () =
669+
// The review counterexample: descending into the payload record re-enters the union,
670+
// and the Myriad subprocess used to die with a stack overflow (exit 134).
671+
"""namespace TestMe
672+
673+
open WoofWare.Myriad.Plugins
674+
675+
type LoopArgs =
676+
{
677+
Foo : int
678+
Again : LoopDu
679+
}
680+
681+
[<ArgParser>]
682+
type LoopDu =
683+
| Loop of LoopArgs
684+
"""
685+
|> shouldRejectWith
686+
"The [<ArgParser>] schema is recursive: LoopDu -> LoopArgs -> LoopDu. Argument records and unions may not contain themselves, even indirectly."
687+
688+
[<Test>]
689+
let ``A cycle which does not pass through the tagged type is rejected`` () =
690+
"""namespace TestMe
691+
692+
open WoofWare.Myriad.Plugins
693+
694+
type ModePayload =
695+
{
696+
Level : int
697+
Fallback : Mode
698+
}
699+
700+
type Mode =
701+
| Auto of ModePayload
702+
703+
[<ArgParser>]
704+
type TopArgs =
705+
{
706+
Verbose : bool
707+
Mode : Mode
708+
}
709+
"""
710+
|> shouldRejectWith
711+
"The [<ArgParser>] schema is recursive: TopArgs -> Mode -> ModePayload -> Mode. Argument records and unions may not contain themselves, even indirectly."
712+
623713
[<Test>]
624714
let ``The motivating union of alternative argument sets generates successfully`` () =
625715
let modules =

0 commit comments

Comments
 (0)