Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Release Notes

## 8.2.0 - Jul 06 2026

- Fix: HTML hexadecimal character references (`A`) are now decoded correctly; previously digit-only hex refs were parsed as decimal and refs containing `a`–`f` were left as literal text.
- Fix: HTML table parsing clamps `colspan`/`rowspan` to the HTML spec limits (1000/65534) and no longer materializes unbounded ranges — malicious/malformed span attributes previously caused `OverflowException`, out-of-memory, or hangs.
- Fix: `HtmlNode.ToString()` uses an explicit work stack, so deeply nested documents (which already parsed fine) no longer crash serialization with an uncatchable `StackOverflowException`.
- Fix: `Http` multipart uploads no longer truncate when a source stream returns a partial read (e.g. network/gzip streams), and multipart parts of 2GB or more no longer overflow. User-supplied body streams are now disposed even when the upload faults.
- Fix: `Http` `Range` headers in the RFC 7233 open-ended (`bytes=N-`) and suffix (`bytes=-N`) forms no longer throw `FormatException`.
- Fix: `CsvFile.Rows` with `skipRows` re-applies the skip on repeated enumerations; previously the second enumeration returned garbage rows or threw.
- Fix: `CsvFile.Save` now quotes headers, fields containing a lone `\r`, and fields containing any of the document's separators, so saved CSV round-trips correctly. `Save(TextWriter)` no longer disposes the caller-owned writer (it flushes instead).
- Fix: JsonProvider-generated constructors no longer throw `OverflowException` for `NaN`, infinite, or very large `float` values; such values serialize through `JsonValue.Float`.
- Fix: the JSON parser no longer silently swallows a stray `/` as whitespace (e.g. `[1, /2]` previously parsed as `[1, 2]`).
- Fix: the in-memory cache no longer overflows `Int32` for expirations over ~24.8 days (which could crash the host process via an unhandled thread-pool exception), and a concurrently re-set entry can no longer be evicted by a stale expiration check. The file cache writes via temp-file-and-rename so concurrent readers never observe half-written entries.
- Fix: file-watcher subscriptions are synchronized; concurrent subscribe/change events could corrupt the subscription dictionary and crash the IDE host at design time. Provider instance ids are now allocated with `Interlocked.Increment` and the design-time dispose-action set is locked.
- Fix: the design-time web cache key now includes the `Encoding` (and row-limit) parameters, so two providers reading the same URL with different encodings no longer share the first decode.
- Fix: WorldBank no longer caches HTTP-200 error bodies (which poisoned the cache for 30 days); invalid responses now go through the retry logic. Topics whose indicators are all filtered out by `Sources` return an empty collection instead of throwing `KeyNotFoundException`. Country/indicator codes are escaped with `Uri.EscapeDataString`, preventing URL path/query injection.
- Fix: `XmlProvider` record creation no longer corrupts the structure when an intermediate path segment has the same name as the leaf (e.g. nested elements named `item`).

## 8.1.14 - May 09 2026

- Eng: Remove manual `AssemblyInfo*.fs` files; use SDK-generated assembly attributes instead.
Expand Down
59 changes: 39 additions & 20 deletions src/FSharp.Data.Csv.Core/CsvRuntime.fs
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,29 @@ module private CsvHelpers =
LineIterator: IEnumerator<string[] * int>
ColumnCount: int
HasHeaders: bool
SkipRows: int
Separators: string
Quote: char }

/// An enumerable that will return elements from the 'firstSeq' first time it
/// is accessed and then will call 'nextSeq' each time for all future GetEnumerator calls
type private ReentrantEnumerable<'T>(firstSeq: seq<'T>, nextSeq: unit -> seq<'T>) =
let mutable first = true
let sync = obj ()

interface seq<'T> with
member x.GetEnumerator() =
if first then
first <- false
// synchronized so concurrent enumerations cannot both claim 'firstSeq'
// and end up sharing one live reader
let isFirst =
lock sync (fun () ->
if first then
first <- false
true
else
false)

if isFirst then
firstSeq.GetEnumerator()
else
nextSeq().GetEnumerator()
Expand Down Expand Up @@ -165,6 +176,7 @@ module private CsvHelpers =
LineIterator = linesIterator
ColumnCount = numberOfColumns
HasHeaders = hasHeaders
SkipRows = skipRows
Separators = separators
Quote = quote }

Expand All @@ -181,6 +193,7 @@ module private CsvHelpers =
LineIterator = linesIterator
ColumnCount = numberOfColumns
HasHeaders = hasHeaders
SkipRows = skipRows
Separators = separators
Quote = quote }
=
Expand All @@ -205,6 +218,8 @@ module private CsvHelpers =
let nextSeq () =
let reader: TextReader = newReader ()
let csv = CsvReader.readCsvFile reader separators quote
// re-apply skipRows exactly like the first read did, then the header row
let csv = if skipRows > 0 then Seq.skip skipRows csv else csv
if hasHeaders then Seq.skip 1 csv else csv

let untypedRows = ReentrantEnumerable<_>(firstSeq, nextSeq)
Expand Down Expand Up @@ -459,8 +474,6 @@ type CsvFile<'RowType>
let quote = (defaultArg quote x.Quote).ToString()
let doubleQuote = quote + quote

use writer = writer

// RFC 4180 (https://www.rfc-editor.org/rfc/rfc4180)
// 2. Definition of the CSV Format
// Each record is located on a separated line, delimited by a line break CRLF
Expand All @@ -471,6 +484,22 @@ type CsvFile<'RowType>
| null -> String.Empty
| _ -> str

// quote fields containing any of the document's separators (not just the one
// used for saving), the quote char, or a line break (a bare CR also terminates
// a row when re-parsing, so it needs quoting like LF)
let charsNeedingQuoting =
(x.Separators + separator + quote + "\r\n").ToCharArray() |> Array.distinct

let writeEscaped (item: string) =
let item = item |> nullSafeguard

if item.IndexOfAny(charsNeedingQuoting) >= 0 then
writer.Write quote
writer.Write(item.Replace(quote, doubleQuote))
writer.Write quote
else
writer.Write item

let writeLine writeItem (items: string[]) =
for i = 0 to items.Length - 2 do
writeItem items.[i]
Expand All @@ -480,25 +509,15 @@ type CsvFile<'RowType>
writer.WriteLine()

match x.Headers with
| Some headers -> headers |> writeLine writer.Write
| Some headers -> headers |> writeLine writeEscaped
| None -> ()

for row in x.Rows do
row
|> rowToStringArray.Invoke
|> writeLine (fun item ->
let item = item |> nullSafeguard

if
item.IndexOf(separator, StringComparison.Ordinal) >= 0
|| item.IndexOf(quote, StringComparison.Ordinal) >= 0
|| item.IndexOf('\n') >= 0
then
writer.Write quote
writer.Write(item.Replace(quote, doubleQuote))
writer.Write quote
else
writer.Write item)
row |> rowToStringArray.Invoke |> writeLine writeEscaped

// the writer is owned by the caller and must not be disposed here,
// but buffered output should be visible when Save returns
writer.Flush()

/// Saves CSV to the specified stream
member x.Save(stream: Stream, [<Optional>] ?separator, [<Optional>] ?quote) =
Expand Down
26 changes: 20 additions & 6 deletions src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,12 @@ type DisposableTypeProviderForNamespaces(config, ?assemblyReplacementMap) as x =

static let mutable idCount = 0

let id = idCount
// IDE hosts can construct provider instances concurrently; an unsynchronized
// read-then-increment could hand two instances the same id, breaking the
// (fullTypeName, id) keys used for dispose actions and file watchers
let id = System.Threading.Interlocked.Increment(&idCount)
let filesToWatch = Dictionary()

do idCount <- idCount + 1

let dispose typeNameOpt =
lock lockObj (fun () ->
for i = disposeActions.Count - 1 downto 0 do
Expand Down Expand Up @@ -317,12 +318,23 @@ module internal ProviderHelpers =

let sample =
if isWeb uri then
// the cached value is the decoded (and possibly row-limited) text,
// so the key must include everything that affects it, not just the URL
let cacheKey =
sprintf
"%s|encoding=%s|maxRows=%s"
uri.OriginalString
encodingStr
(match maxNumberOfRows with
| Some n -> string n
| None -> "")

let text =
match webUrisCache.TryRetrieve(uri.OriginalString) with
match webUrisCache.TryRetrieve(cacheKey) with
| Some text -> text
| None ->
let text = readText ()
webUrisCache.Set(uri.OriginalString, text)
webUrisCache.Set(cacheKey, text)
text

text
Expand Down Expand Up @@ -375,7 +387,9 @@ module internal ProviderHelpers =

let setupDisposeAction providedType fileToWatch =

if activeDisposeActions.Add(fullTypeName, tp.Id) then
// synchronized: getOrCreateProvidedType can run concurrently on IDE threads
// and HashSet mutation is not thread-safe
if lock activeDisposeActions (fun () -> activeDisposeActions.Add(fullTypeName, tp.Id)) then

log "Setting up dispose action"

Expand Down
9 changes: 5 additions & 4 deletions src/FSharp.Data.Html.Core/HtmlCharRefs.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2260,15 +2260,16 @@ module internal HtmlCharRefs =

match delimeters with
| ("&#", _) ->
let num =
let styles, num =
if discriminator <> 'x' then
s.Substring(2, s.Length - 2)
NumberStyles.Integer, s.Substring(2, s.Length - 2)
else
s.Substring(3, s.Length - 3)
NumberStyles.AllowHexSpecifier, s.Substring(3, s.Length - 3)

match UInt32.TryParse(num, NumberStyles.Integer, CultureInfo.InvariantCulture) with
match UInt32.TryParse(num, styles, CultureInfo.InvariantCulture) with
| true, i -> Number(i)
| false, _ -> Lookup(orig)
// legacy hex form without the '#', e.g. &x41;
| ("&x", _) ->
let num = s.Substring(2, s.Length - 2)

Expand Down
54 changes: 34 additions & 20 deletions src/FSharp.Data.Html.Core/HtmlNode.fs
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,24 @@ type HtmlNode =
static member NewCData content = HtmlCData(content)

override x.ToString() =
let rec serialize (sb: StringBuilder) indentation canAddNewLine insidePre html =
let append (str: string) = sb.Append str |> ignore
let sb = StringBuilder()
let append (str: string) = sb.Append str |> ignore

let appendEndTag name =
append "</"
append name
append ">"
let appendEndTag name =
append "</"
append name
append ">"

let newLine plus =
sb.AppendLine() |> ignore
sb.Append(' ', indentation + plus) |> ignore
let newLine indentation plus =
sb.AppendLine() |> ignore
sb.Append(' ', indentation + plus) |> ignore

// serialization uses an explicit work stack instead of call-stack recursion:
// the parser accepts arbitrarily deep documents (it uses its own stack), so
// ToString must not StackOverflow on them either
let work = System.Collections.Generic.Stack<unit -> unit>()

let rec serialize indentation canAddNewLine insidePre html =
match html with
| HtmlElement(name, attributes, elements) ->
let onlyText =
Expand All @@ -143,7 +149,7 @@ type HtmlNode =
let nowInsidePre = insidePre || isPreTag

if canAddNewLine && not insidePre && not (onlyText || isPreTag) then
newLine 0
newLine indentation 0

append "<"
append name
Expand All @@ -164,18 +170,23 @@ type HtmlNode =
append ">"

if not insidePre && not (onlyText || isPreTag) then
newLine 2
newLine indentation 2

let mutable canAddNewLine = false
// pushed first so it runs after all children are processed
work.Push(fun () ->
if not insidePre && not (onlyText || isPreTag) then
newLine indentation 0

for element in elements do
serialize sb (indentation + 2) canAddNewLine nowInsidePre element
canAddNewLine <- true
appendEndTag name)

if not insidePre && not (onlyText || isPreTag) then
newLine 0
// push children in reverse so they pop in document order;
// only the first child is serialized with canAddNewLine = false
let elements = List.toArray elements

appendEndTag name
for i in elements.Length - 1 .. -1 .. 0 do
let element = elements.[i]
let canAddNewLine = i > 0
work.Push(fun () -> serialize (indentation + 2) canAddNewLine nowInsidePre element)
| HtmlText str -> append str
| HtmlComment str ->
append "<!--"
Expand All @@ -186,8 +197,11 @@ type HtmlNode =
append str
append "]]>"

let sb = StringBuilder()
serialize sb 0 false false x |> ignore
serialize 0 false false x

while work.Count > 0 do
work.Pop () ()

sb.ToString()

/// <exclude />
Expand Down
13 changes: 7 additions & 6 deletions src/FSharp.Data.Html.Core/HtmlRuntime.fs
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,13 @@ module HtmlRuntime =
index
(table: HtmlNode, parents: HtmlNode list)
=
// the HTML spec caps rowspan at 65534 and colspan at 1000; without the cap a
// malicious/malformed attribute overflows the column-count sum or allocates huge arrays
let rowSpan cell =
max 1 (defaultArg (TextConversions.AsInteger CultureInfo.InvariantCulture cell?rowspan) 0)
min 65534 (max 1 (defaultArg (TextConversions.AsInteger CultureInfo.InvariantCulture cell?rowspan) 0))

let colSpan cell =
max 1 (defaultArg (TextConversions.AsInteger CultureInfo.InvariantCulture cell?colspan) 0)
min 1000 (max 1 (defaultArg (TextConversions.AsInteger CultureInfo.InvariantCulture cell?colspan) 0))

let rows =
let header =
Expand Down Expand Up @@ -347,10 +349,9 @@ module HtmlRuntime =
while col_i < res.[rowindex].Length && res.[rowindex].[col_i] <> Empty do
col_i <- col_i + 1

for j in [ col_i .. (col_i + colSpan cell - 1) ] do
for i in [ rowindex .. (rowindex + rowSpan cell - 1) ] do
if i < rows.Length && j < numberOfColumns then
res.[i].[j] <- data
for j in col_i .. min (col_i + colSpan cell - 1) (numberOfColumns - 1) do
for i in rowindex .. min (rowindex + rowSpan cell - 1) (rows.Length - 1) do
res.[i].[j] <- data

let numberOfHeaderRows =
res |> Array.countWhile (Array.forall (fun cell -> cell.IsHeader))
Expand Down
32 changes: 18 additions & 14 deletions src/FSharp.Data.Http/Http.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1471,19 +1471,15 @@ module internal HttpHelpers =
match streams |> Seq.tryHead with
| None -> 0
| Some stream ->
let qty =
if stream.CanSeek then
min count (int stream.Length)
else
count

let read = stream.Read(buffer, offset, qty)
let read = stream.Read(buffer, offset, count)

if read < count then
if read = 0 then
// only a zero-byte read signals the end of the current stream: Read may
// legally return fewer bytes than requested while data is still pending
// (network/gzip/pipe streams), so a partial read must not skip the stream
stream.Dispose()
streams <- streams |> Seq.skip 1
let readFromRest = readFromStream buffer (offset + read) (count - read)
read + readFromRest
readFromStream buffer offset count
else
read

Expand Down Expand Up @@ -1615,9 +1611,12 @@ module internal HttpHelpers =

let asyncCopy (source: Stream) (dest: Stream) =
async {
do! source.CopyToAsync(dest) |> Async.AwaitIAsyncResult |> Async.Ignore

source.Dispose()
try
do! source.CopyToAsync(dest) |> Async.AwaitIAsyncResult |> Async.Ignore
finally
// dispose user-supplied body streams even when the copy faults
// (e.g. connection reset mid-upload)
source.Dispose()
}

let writeBody (req: HttpWebRequest) (data: Stream) =
Expand Down Expand Up @@ -1747,7 +1746,12 @@ module internal HttpHelpers =
if bytes.Length <> 2 then
failwithf "Invalid value for the Range header (%s)" value

req.AddRange(int64 bytes.[0], int64 bytes.[1])
// RFC 7233 also allows open-ended (bytes=N-) and suffix (bytes=-N) ranges
match bytes.[0], bytes.[1] with
| "", "" -> failwithf "Invalid value for the Range header (%s)" value
| rangeStart, "" -> req.AddRange(int64 rangeStart)
| "", suffixLength -> req.AddRange(-(int64 suffixLength))
| rangeStart, rangeEnd -> req.AddRange(int64 rangeStart, int64 rangeEnd)
| "proxy-authorization" -> req.Headers.[HeaderEnum.ProxyAuthorization] <- value
| "referer" -> req.Referer <- value
| "te" -> req.Headers.[HeaderEnum.Te] <- value
Expand Down
Loading
Loading