diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d34cd511b..b4f8565fb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/src/FSharp.Data.Csv.Core/CsvRuntime.fs b/src/FSharp.Data.Csv.Core/CsvRuntime.fs index 4d148a92f..a15d4a1be 100644 --- a/src/FSharp.Data.Csv.Core/CsvRuntime.fs +++ b/src/FSharp.Data.Csv.Core/CsvRuntime.fs @@ -104,6 +104,7 @@ module private CsvHelpers = LineIterator: IEnumerator ColumnCount: int HasHeaders: bool + SkipRows: int Separators: string Quote: char } @@ -111,11 +112,21 @@ module private CsvHelpers = /// 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() @@ -165,6 +176,7 @@ module private CsvHelpers = LineIterator = linesIterator ColumnCount = numberOfColumns HasHeaders = hasHeaders + SkipRows = skipRows Separators = separators Quote = quote } @@ -181,6 +193,7 @@ module private CsvHelpers = LineIterator = linesIterator ColumnCount = numberOfColumns HasHeaders = hasHeaders + SkipRows = skipRows Separators = separators Quote = quote } = @@ -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) @@ -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 @@ -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] @@ -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, [] ?separator, [] ?quote) = diff --git a/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs b/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs index d69484bac..b4d192b73 100644 --- a/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs +++ b/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs @@ -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 @@ -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 @@ -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" diff --git a/src/FSharp.Data.Html.Core/HtmlCharRefs.fs b/src/FSharp.Data.Html.Core/HtmlCharRefs.fs index c0d85a26c..896c4d736 100644 --- a/src/FSharp.Data.Html.Core/HtmlCharRefs.fs +++ b/src/FSharp.Data.Html.Core/HtmlCharRefs.fs @@ -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) diff --git a/src/FSharp.Data.Html.Core/HtmlNode.fs b/src/FSharp.Data.Html.Core/HtmlNode.fs index 5db6e8cef..217a1c8ee 100644 --- a/src/FSharp.Data.Html.Core/HtmlNode.fs +++ b/src/FSharp.Data.Html.Core/HtmlNode.fs @@ -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 "" + let appendEndTag 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>() + + let rec serialize indentation canAddNewLine insidePre html = match html with | HtmlElement(name, attributes, elements) -> let onlyText = @@ -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 @@ -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 "