diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 9e83541b0d8..c1d19162ea1 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -74,4 +74,13 @@ + + + + + + + + + diff --git a/src/LegacySupport/FilePolyfills/FilePolyfills.cs b/src/LegacySupport/FilePolyfills/FilePolyfills.cs new file mode 100644 index 00000000000..f00fcf2a8a3 --- /dev/null +++ b/src/LegacySupport/FilePolyfills/FilePolyfills.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER + +#pragma warning disable CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.IO; + +/// +/// Provides polyfill extension members for for older frameworks. +/// +[ExcludeFromCodeCoverage] +internal static class FilePolyfills +{ + extension(File) + { +#if !NET + /// + /// Asynchronously reads all bytes from a file. + /// + /// The file to read from. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous read operation, which wraps the byte array containing the contents of the file. + public static async Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); + byte[] data = new byte[stream.Length]; + int totalRead = 0; + while (totalRead < data.Length) + { + int read = await stream.ReadAsync(data, totalRead, data.Length - totalRead, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + totalRead += read; + } + + return data; + } +#endif + + /// + /// Asynchronously writes all bytes to a file. + /// + /// The file to write to. + /// The bytes to write to the file. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous write operation. + public static async Task WriteAllBytesAsync(string path, ReadOnlyMemory bytes, CancellationToken cancellationToken = default) + { + // Try to avoid ToArray() if the data is backed by a byte[] with offset 0 and matching length + byte[] byteArray; + if (MemoryMarshal.TryGetArray(bytes, out ArraySegment segment) && + segment.Offset == 0 && + segment.Count == segment.Array!.Length) + { + byteArray = segment.Array; + } + else + { + byteArray = bytes.ToArray(); + } + +#if NET + await File.WriteAllBytesAsync(path, byteArray, cancellationToken).ConfigureAwait(false); +#else + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true); + await stream.WriteAsync(byteArray, 0, byteArray.Length, cancellationToken).ConfigureAwait(false); +#endif + } + } +} + +#endif diff --git a/src/LegacySupport/FilePolyfills/README.md b/src/LegacySupport/FilePolyfills/README.md new file mode 100644 index 00000000000..bb0834cef27 --- /dev/null +++ b/src/LegacySupport/FilePolyfills/README.md @@ -0,0 +1,9 @@ +# About FilePolyfills + +This folder contains C# 14 extension member polyfills for `System.IO.File` methods +that are not available on older frameworks. + +- `File.ReadAllBytesAsync` - Added in .NET Core 2.0, not available in .NET Framework 4.6.2 or .NET Standard 2.0 + +The polyfill uses C# 14 extension members so the call site can use `File.ReadAllBytesAsync` naturally +and it will use the real one on supported platforms and the polyfill elsewhere. diff --git a/src/LegacySupport/MediaTypeMap/MediaTypeMap.cs b/src/LegacySupport/MediaTypeMap/MediaTypeMap.cs new file mode 100644 index 00000000000..ac28edb5bcb --- /dev/null +++ b/src/LegacySupport/MediaTypeMap/MediaTypeMap.cs @@ -0,0 +1,777 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1201 +#pragma warning disable SA1124 +#pragma warning disable CA1859 +#pragma warning disable IDE1006 + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace System.Net.Mime; + +/// +/// Provides methods for mapping between file extensions and media types (MIME types). +/// +[ExcludeFromCodeCoverage] +internal static class MediaTypeMap +{ + private static readonly Dictionary s_extensionToMediaType = new(StringComparer.OrdinalIgnoreCase) + { + [".3g2"] = "video/3gpp2", + [".3gp"] = "video/3gpp", + [".3gp2"] = "video/3gpp2", + [".3gpp"] = "video/3gpp", + [".7z"] = "application/x-7z-compressed", + [".aac"] = "audio/aac", + [".abw"] = "application/x-abiword", + [".accdb"] = "application/msaccess", + [".accde"] = "application/msaccess", + [".accdt"] = "application/msaccess", + [".adt"] = "audio/vnd.dlna.adts", + [".adts"] = "audio/vnd.dlna.adts", + [".ai"] = "application/postscript", + [".aif"] = "audio/x-aiff", + [".aifc"] = "audio/aifc", + [".aiff"] = "audio/aiff", + [".apk"] = "application/vnd.android.package-archive", + [".apng"] = "image/apng", + [".appcache"] = "text/cache-manifest", + [".application"] = "application/x-ms-application", + [".arc"] = "application/x-freearc", + [".asf"] = "video/x-ms-asf", + [".asm"] = "text/plain", + [".asr"] = "video/x-ms-asf", + [".asx"] = "video/x-ms-asf", + [".atom"] = "application/atom+xml", + [".au"] = "audio/basic", + [".avi"] = "video/x-msvideo", + [".avif"] = "image/avif", + [".azw"] = "application/vnd.amazon.ebook", + [".bas"] = "text/plain", + [".bcpio"] = "application/x-bcpio", + [".bmp"] = "image/bmp", + [".br"] = "application/brotli", + [".bz"] = "application/x-bzip", + [".bz2"] = "application/x-bzip2", + [".c"] = "text/plain", + [".cab"] = "application/vnd.ms-cab-compressed", + [".calx"] = "application/vnd.ms-office.calx", + [".cat"] = "application/vnd.ms-pki.seccat", + [".cbor"] = "application/cbor", + [".cdf"] = "application/x-cdf", + [".cer"] = "application/x-x509-ca-cert", + [".cjs"] = "text/javascript", + [".class"] = "application/java-vm", + [".clp"] = "application/x-msclip", + [".cmx"] = "image/x-cmx", + [".cnf"] = "text/plain", + [".config"] = "text/plain", + [".cpio"] = "application/x-cpio", + [".cpp"] = "text/plain", + [".crd"] = "application/x-mscardfile", + [".crl"] = "application/pkix-crl", + [".crt"] = "application/x-x509-ca-cert", + [".cs"] = "text/x-csharp", + [".csproj"] = "text/xml", + [".csh"] = "application/x-csh", + [".css"] = "text/css", + [".csv"] = "text/csv", + [".cue"] = "application/x-cue", + [".cts"] = "text/typescript", + [".dart"] = "text/x-dart", + [".db"] = "application/x-sqlite3", + [".dbf"] = "application/x-dbf", + [".deb"] = "application/vnd.debian.binary-package", + [".dcr"] = "application/x-director", + [".der"] = "application/x-x509-ca-cert", + [".dib"] = "image/bmp", + [".dir"] = "application/x-director", + [".disco"] = "text/xml", + [".dmg"] = "application/x-apple-diskimage", + [".doc"] = "application/msword", + [".docm"] = "application/vnd.ms-word.document.macroEnabled.12", + [".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + [".dot"] = "application/msword", + [".dotm"] = "application/vnd.ms-word.template.macroEnabled.12", + [".dotx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + [".dtd"] = "application/xml-dtd", + [".dvi"] = "application/x-dvi", + [".dvr-ms"] = "video/x-ms-dvr", + [".dwf"] = "drawing/x-dwf", + [".dxr"] = "application/x-director", + [".eml"] = "message/rfc822", + [".eot"] = "application/vnd.ms-fontobject", + [".eps"] = "application/postscript", + [".epub"] = "application/epub+zip", + [".etx"] = "text/x-setext", + [".fdf"] = "application/vnd.fdf", + [".flac"] = "audio/flac", + [".flv"] = "video/x-flv", + [".geojson"] = "application/geo+json", + [".gif"] = "image/gif", + [".glb"] = "model/gltf-binary", + [".gltf"] = "model/gltf+json", + [".go"] = "text/x-go", + [".gpx"] = "application/gpx+xml", + [".graphql"] = "application/graphql", + [".gtar"] = "application/x-gtar", + [".gz"] = "application/gzip", + [".h"] = "text/plain", + [".hdf"] = "application/x-hdf", + [".heic"] = "image/heic", + [".heif"] = "image/heif", + [".hhc"] = "application/x-oleobject", + [".hlp"] = "application/winhlp", + [".hqx"] = "application/mac-binhex40", + [".hta"] = "application/hta", + [".htc"] = "text/x-component", + [".htm"] = "text/html", + [".html"] = "text/html", + [".hwp"] = "application/x-hwp", + [".ical"] = "text/calendar", + [".icalendar"] = "text/calendar", + [".ico"] = "image/vnd.microsoft.icon", + [".ics"] = "text/calendar", + [".ief"] = "image/ief", + [".ifb"] = "text/calendar", + [".ini"] = "text/plain", + [".ins"] = "application/x-internet-signup", + [".iso"] = "application/x-iso9660-image", + [".isp"] = "application/x-internet-signup", + [".ivf"] = "video/x-ivf", + [".jar"] = "application/java-archive", + [".java"] = "text/x-java-source", + [".jfif"] = "image/jpeg", + [".jpe"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".jpg"] = "image/jpeg", + [".js"] = "text/javascript", + [".json"] = "application/json", + [".jsonld"] = "application/ld+json", + [".jsonl"] = "application/x-ndjson", + [".jsx"] = "text/jsx", + [".jxl"] = "image/jxl", + [".kt"] = "text/x-kotlin", + [".kts"] = "text/x-kotlin", + [".key"] = "application/vnd.apple.keynote", + [".kml"] = "application/vnd.google-earth.kml+xml", + [".kmz"] = "application/vnd.google-earth.kmz", + [".latex"] = "application/x-latex", + [".less"] = "text/x-less", + [".lit"] = "application/x-ms-reader", + [".log"] = "text/plain", + [".lsf"] = "video/x-la-asf", + [".lwp"] = "application/vnd.lotus-wordpro", + [".lsx"] = "video/x-la-asf", + [".m13"] = "application/x-msmediaview", + [".m14"] = "application/x-msmediaview", + [".m1v"] = "video/mpeg", + [".m2t"] = "video/vnd.dlna.mpeg-tts", + [".m2ts"] = "video/vnd.dlna.mpeg-tts", + [".m3u"] = "audio/x-mpegurl", + [".m3u8"] = "application/vnd.apple.mpegurl", + [".m4a"] = "audio/mp4", + [".m4v"] = "video/mp4", + [".man"] = "application/x-troff-man", + [".manifest"] = "application/x-ms-manifest", + [".markdown"] = "text/markdown", + [".md"] = "text/markdown", + [".mdb"] = "application/x-msaccess", + [".mdx"] = "text/mdx", + [".me"] = "application/x-troff-me", + [".mht"] = "message/rfc822", + [".mhtml"] = "message/rfc822", + [".mid"] = "audio/midi", + [".midi"] = "audio/midi", + [".mjs"] = "text/javascript", + [".mka"] = "audio/x-matroska", + [".mkv"] = "video/x-matroska", + [".mmf"] = "application/x-smaf", + [".mno"] = "text/xml", + [".mny"] = "application/x-msmoney", + [".mov"] = "video/quicktime", + [".movie"] = "video/x-sgi-movie", + [".mp2"] = "video/mpeg", + [".mp3"] = "audio/mpeg", + [".mp4"] = "video/mp4", + [".mp4v"] = "video/mp4", + [".mpa"] = "video/mpeg", + [".mpd"] = "application/dash+xml", + [".mpe"] = "video/mpeg", + [".mpeg"] = "video/mpeg", + [".mpg"] = "video/mpeg", + [".mpkg"] = "application/vnd.apple.installer+xml", + [".mpp"] = "application/vnd.ms-project", + [".mpv2"] = "video/mpeg", + [".mts"] = "video/mp2t", + [".mvb"] = "application/x-msmediaview", + [".mvc"] = "application/x-miva-compiled", + [".nc"] = "application/x-netcdf", + [".nsc"] = "video/x-ms-asf", + [".ndjson"] = "application/x-ndjson", + [".numbers"] = "application/vnd.apple.numbers", + [".nupkg"] = "application/zip", + [".nws"] = "message/rfc822", + [".oda"] = "application/oda", + [".odc"] = "text/x-ms-odc", + [".odf"] = "application/vnd.oasis.opendocument.formula", + [".odg"] = "application/vnd.oasis.opendocument.graphics", + [".odp"] = "application/vnd.oasis.opendocument.presentation", + [".ods"] = "application/vnd.oasis.opendocument.spreadsheet", + [".odt"] = "application/vnd.oasis.opendocument.text", + [".oga"] = "audio/ogg", + [".ogg"] = "audio/ogg", + [".ogv"] = "video/ogg", + [".ogx"] = "application/ogg", + [".one"] = "application/onenote", + [".onea"] = "application/onenote", + [".onepkg"] = "application/onenote", + [".onetmp"] = "application/onenote", + [".onetoc"] = "application/onenote", + [".onetoc2"] = "application/onenote", + [".opus"] = "audio/opus", + [".osdx"] = "application/opensearchdescription+xml", + [".otf"] = "font/otf", + [".pages"] = "application/vnd.apple.pages", + [".parquet"] = "application/vnd.apache.parquet", + [".p10"] = "application/pkcs10", + [".p12"] = "application/x-pkcs12", + [".p7b"] = "application/x-pkcs7-certificates", + [".p7c"] = "application/pkcs7-mime", + [".p7m"] = "application/pkcs7-mime", + [".p7r"] = "application/x-pkcs7-certreqresp", + [".p7s"] = "application/pkcs7-signature", + [".pbm"] = "image/x-portable-bitmap", + [".pdf"] = "application/pdf", + [".pem"] = "application/x-pem-file", + [".pfx"] = "application/x-pkcs12", + [".pgm"] = "image/x-portable-graymap", + [".php"] = "application/x-httpd-php", + [".pko"] = "application/vnd.ms-pki.pko", + [".pl"] = "text/x-perl", + [".pm"] = "text/x-perl", + [".pma"] = "application/x-perfmon", + [".pmc"] = "application/x-perfmon", + [".pml"] = "application/x-perfmon", + [".pmr"] = "application/x-perfmon", + [".pmw"] = "application/x-perfmon", + [".png"] = "image/png", + [".pnm"] = "image/x-portable-anymap", + [".pnz"] = "image/png", + [".pot"] = "application/vnd.ms-powerpoint", + [".potm"] = "application/vnd.ms-powerpoint.template.macroEnabled.12", + [".potx"] = "application/vnd.openxmlformats-officedocument.presentationml.template", + [".ppam"] = "application/vnd.ms-powerpoint.addin.macroEnabled.12", + [".ppm"] = "image/x-portable-pixmap", + [".pps"] = "application/vnd.ms-powerpoint", + [".ppsm"] = "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + [".ppsx"] = "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + [".ppt"] = "application/vnd.ms-powerpoint", + [".pptm"] = "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + [".pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", + [".proto"] = "text/x-protobuf", + [".ps"] = "application/postscript", + [".ps1"] = "application/powershell", + [".pub"] = "application/x-mspublisher", + [".py"] = "text/x-python", + [".pyi"] = "text/x-python", + [".pyw"] = "text/x-python", + [".qt"] = "video/quicktime", + [".qtl"] = "application/x-quicktimeplayer", + [".rar"] = "application/vnd.rar", + [".ras"] = "image/x-cmu-raster", + [".rgb"] = "image/x-rgb", + [".rmi"] = "audio/mid", + [".roff"] = "application/x-troff", + [".rpm"] = "application/x-rpm", + [".rs"] = "text/x-rust", + [".rss"] = "application/rss+xml", + [".rtf"] = "application/rtf", + [".rtx"] = "text/richtext", + [".sass"] = "text/x-sass", + [".scd"] = "application/x-msschedule", + [".scss"] = "text/x-scss", + [".sct"] = "text/scriptlet", + [".sda"] = "application/vnd.stardivision.draw", + [".sdd"] = "application/vnd.stardivision.impress", + [".sdw"] = "application/vnd.stardivision.writer", + [".setpay"] = "application/set-payment-initiation", + [".setreg"] = "application/set-registration-initiation", + [".sgml"] = "text/sgml", + [".sh"] = "application/x-sh", + [".shar"] = "application/x-shar", + [".sit"] = "application/x-stuffit", + [".slk"] = "text/vnd.sylk", + [".sln"] = "text/plain", + [".sldm"] = "application/vnd.ms-powerpoint.slide.macroEnabled.12", + [".sldx"] = "application/vnd.openxmlformats-officedocument.presentationml.slide", + [".snd"] = "audio/basic", + [".snupkg"] = "application/zip", + [".spc"] = "application/x-pkcs7-certificates", + [".spx"] = "audio/ogg", + [".sql"] = "application/sql", + [".sqlite"] = "application/x-sqlite3", + [".sqlite3"] = "application/x-sqlite3", + [".src"] = "application/x-wais-source", + [".sst"] = "application/vnd.ms-pki.certstore", + [".sti"] = "application/vnd.sun.xml.impress.template", + [".stl"] = "model/stl", + [".stw"] = "application/vnd.sun.xml.writer.template", + [".sv4cpio"] = "application/x-sv4cpio", + [".sv4crc"] = "application/x-sv4crc", + [".svelte"] = "text/svelte", + [".svg"] = "image/svg+xml", + [".svgz"] = "image/svg+xml", + [".sxg"] = "application/vnd.sun.xml.writer.global", + [".sxi"] = "application/vnd.sun.xml.impress", + [".sxw"] = "application/vnd.sun.xml.writer", + [".swf"] = "application/x-shockwave-flash", + [".sylk"] = "text/vnd.sylk", + [".swift"] = "text/x-swift", + [".tar"] = "application/x-tar", + [".tcl"] = "application/x-tcl", + [".tex"] = "application/x-tex", + [".texi"] = "application/x-texinfo", + [".texinfo"] = "application/x-texinfo", + [".tgz"] = "application/x-compressed", + [".thmx"] = "application/vnd.ms-officetheme", + [".tif"] = "image/tiff", + [".tiff"] = "image/tiff", + [".toml"] = "application/toml", + [".tr"] = "application/x-troff", + [".trm"] = "application/x-msterminal", + [".ts"] = "text/typescript", + [".tsv"] = "text/tab-separated-values", + [".tsx"] = "text/tsx", + [".ttc"] = "font/collection", + [".ttf"] = "font/ttf", + [".tts"] = "video/vnd.dlna.mpeg-tts", + [".txt"] = "text/plain", + [".ustar"] = "application/x-ustar", + [".vbs"] = "text/vbscript", + [".vcf"] = "text/vcard", + [".vcs"] = "text/x-vcalendar", + [".vdx"] = "application/vnd.ms-visio.viewer", + [".vml"] = "text/xml", + [".vsd"] = "application/vnd.visio", + [".vss"] = "application/vnd.visio", + [".vst"] = "application/vnd.visio", + [".vsto"] = "application/x-ms-vsto", + [".vsw"] = "application/vnd.visio", + [".vsx"] = "application/vnd.visio", + [".vtt"] = "text/vtt", + [".vtx"] = "application/vnd.visio", + [".vue"] = "text/vue", + [".wasm"] = "application/wasm", + [".wav"] = "audio/wav", + [".wax"] = "audio/x-ms-wax", + [".wbmp"] = "image/vnd.wap.wbmp", + [".wcm"] = "application/vnd.ms-works", + [".wdb"] = "application/vnd.ms-works", + [".weba"] = "audio/webm", + [".webm"] = "video/webm", + [".webmanifest"] = "application/manifest+json", + [".webp"] = "image/webp", + [".wks"] = "application/vnd.ms-works", + [".wm"] = "video/x-ms-wm", + [".wma"] = "audio/x-ms-wma", + [".wmd"] = "application/x-ms-wmd", + [".wmf"] = "application/x-msmetafile", + [".wmp"] = "video/x-ms-wmp", + [".wmv"] = "video/x-ms-wmv", + [".wmx"] = "video/x-ms-wmx", + [".wmz"] = "application/x-ms-wmz", + [".woff"] = "font/woff", + [".woff2"] = "font/woff2", + [".wpd"] = "application/vnd.wordperfect", + [".wps"] = "application/vnd.ms-works", + [".wri"] = "application/x-mswrite", + [".wsdl"] = "text/xml", + [".wtv"] = "video/x-ms-wtv", + [".wvx"] = "video/x-ms-wvx", + [".xaml"] = "application/xaml+xml", + [".xbm"] = "image/x-xbitmap", + [".xdr"] = "text/plain", + [".xht"] = "application/xhtml+xml", + [".xhtml"] = "application/xhtml+xml", + [".xla"] = "application/vnd.ms-excel", + [".xlam"] = "application/vnd.ms-excel.addin.macroEnabled.12", + [".xlc"] = "application/vnd.ms-excel", + [".xlm"] = "application/vnd.ms-excel", + [".xls"] = "application/vnd.ms-excel", + [".xlsb"] = "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + [".xlsm"] = "application/vnd.ms-excel.sheet.macroEnabled.12", + [".xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + [".xlt"] = "application/vnd.ms-excel", + [".xltm"] = "application/vnd.ms-excel.template.macroEnabled.12", + [".xltx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + [".xlw"] = "application/vnd.ms-excel", + [".xml"] = "application/xml", + [".xpm"] = "image/x-xpixmap", + [".xps"] = "application/vnd.ms-xpsdocument", + [".xsd"] = "text/xml", + [".xsf"] = "text/xml", + [".xsl"] = "text/xml", + [".xslt"] = "text/xml", + [".xul"] = "application/vnd.mozilla.xul+xml", + [".xwd"] = "image/x-xwindowdump", + [".yaml"] = "application/yaml", + [".yml"] = "application/yaml", + [".z"] = "application/x-compress", + [".zip"] = "application/zip", + [".zst"] = "application/zstd", + }; + + private static readonly Dictionary s_mediaTypeToExtension = new(StringComparer.OrdinalIgnoreCase) + { + ["application/atom+xml"] = ".atom", + ["application/brotli"] = ".br", + ["application/cbor"] = ".cbor", + ["application/dash+xml"] = ".mpd", + ["application/epub+zip"] = ".epub", + ["application/geo+json"] = ".geojson", + ["application/graphql"] = ".graphql", + ["application/gpx+xml"] = ".gpx", + ["application/gzip"] = ".gz", + ["application/hta"] = ".hta", + ["application/java-archive"] = ".jar", + ["application/java-vm"] = ".class", + ["application/json"] = ".json", + ["application/ld+json"] = ".jsonld", + ["application/mac-binhex40"] = ".hqx", + ["application/manifest+json"] = ".webmanifest", + ["application/msaccess"] = ".mdb", + ["application/msword"] = ".doc", + ["application/oda"] = ".oda", + ["application/ogg"] = ".ogx", + ["application/onenote"] = ".one", + ["application/opensearchdescription+xml"] = ".osdx", + ["application/pdf"] = ".pdf", + ["application/pkcs10"] = ".p10", + ["application/pkcs7-mime"] = ".p7c", + ["application/pkcs7-signature"] = ".p7s", + ["application/pkix-crl"] = ".crl", + ["application/powershell"] = ".ps1", + ["application/postscript"] = ".ps", + ["application/rtf"] = ".rtf", + ["application/rss+xml"] = ".rss", + ["application/set-payment-initiation"] = ".setpay", + ["application/set-registration-initiation"] = ".setreg", + ["application/sql"] = ".sql", + ["application/toml"] = ".toml", + ["application/vnd.amazon.ebook"] = ".azw", + ["application/vnd.android.package-archive"] = ".apk", + ["application/vnd.apache.parquet"] = ".parquet", + ["application/vnd.apple.installer+xml"] = ".mpkg", + ["application/vnd.apple.keynote"] = ".key", + ["application/vnd.apple.mpegurl"] = ".m3u8", + ["application/vnd.apple.numbers"] = ".numbers", + ["application/vnd.apple.pages"] = ".pages", + ["application/vnd.debian.binary-package"] = ".deb", + ["application/vnd.fdf"] = ".fdf", + ["application/vnd.google-earth.kml+xml"] = ".kml", + ["application/vnd.google-earth.kmz"] = ".kmz", + ["application/vnd.lotus-wordpro"] = ".lwp", + ["application/vnd.mozilla.xul+xml"] = ".xul", + ["application/vnd.ms-cab-compressed"] = ".cab", + ["application/vnd.ms-excel"] = ".xls", + ["application/vnd.ms-excel.addin.macroEnabled.12"] = ".xlam", + ["application/vnd.ms-excel.sheet.binary.macroEnabled.12"] = ".xlsb", + ["application/vnd.ms-excel.sheet.macroEnabled.12"] = ".xlsm", + ["application/vnd.ms-excel.template.macroEnabled.12"] = ".xltm", + ["application/vnd.ms-fontobject"] = ".eot", + ["application/vnd.ms-office.calx"] = ".calx", + ["application/vnd.ms-officetheme"] = ".thmx", + ["application/vnd.ms-pki.certstore"] = ".sst", + ["application/vnd.ms-pki.pko"] = ".pko", + ["application/vnd.ms-pki.seccat"] = ".cat", + ["application/vnd.ms-powerpoint"] = ".ppt", + ["application/vnd.ms-powerpoint.addin.macroEnabled.12"] = ".ppam", + ["application/vnd.ms-powerpoint.presentation.macroEnabled.12"] = ".pptm", + ["application/vnd.ms-powerpoint.slide.macroEnabled.12"] = ".sldm", + ["application/vnd.ms-powerpoint.slideshow.macroEnabled.12"] = ".ppsm", + ["application/vnd.ms-powerpoint.template.macroEnabled.12"] = ".potm", + ["application/vnd.ms-project"] = ".mpp", + ["application/vnd.ms-visio.viewer"] = ".vdx", + ["application/vnd.ms-word.document.macroEnabled.12"] = ".docm", + ["application/vnd.ms-word.template.macroEnabled.12"] = ".dotm", + ["application/vnd.ms-works"] = ".wcm", + ["application/vnd.ms-xpsdocument"] = ".xps", + ["application/vnd.oasis.opendocument.formula"] = ".odf", + ["application/vnd.oasis.opendocument.graphics"] = ".odg", + ["application/vnd.oasis.opendocument.presentation"] = ".odp", + ["application/vnd.oasis.opendocument.spreadsheet"] = ".ods", + ["application/vnd.oasis.opendocument.text"] = ".odt", + ["application/vnd.openxmlformats-officedocument.presentationml.presentation"] = ".pptx", + ["application/vnd.openxmlformats-officedocument.presentationml.slide"] = ".sldx", + ["application/vnd.openxmlformats-officedocument.presentationml.slideshow"] = ".ppsx", + ["application/vnd.openxmlformats-officedocument.presentationml.template"] = ".potx", + ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"] = ".xlsx", + ["application/vnd.openxmlformats-officedocument.spreadsheetml.template"] = ".xltx", + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = ".docx", + ["application/vnd.openxmlformats-officedocument.wordprocessingml.template"] = ".dotx", + ["application/vnd.rar"] = ".rar", + ["application/vnd.stardivision.draw"] = ".sda", + ["application/vnd.stardivision.impress"] = ".sdd", + ["application/vnd.stardivision.writer"] = ".sdw", + ["application/vnd.sun.xml.impress"] = ".sxi", + ["application/vnd.sun.xml.impress.template"] = ".sti", + ["application/vnd.sun.xml.writer"] = ".sxw", + ["application/vnd.sun.xml.writer.global"] = ".sxg", + ["application/vnd.sun.xml.writer.template"] = ".stw", + ["application/vnd.visio"] = ".vsd", + ["application/vnd.wordperfect"] = ".wpd", + ["application/wasm"] = ".wasm", + ["application/winhlp"] = ".hlp", + ["application/x-7z-compressed"] = ".7z", + ["application/x-abiword"] = ".abw", + ["application/x-apple-diskimage"] = ".dmg", + ["application/xaml+xml"] = ".xaml", + ["application/x-bcpio"] = ".bcpio", + ["application/x-dbf"] = ".dbf", + ["application/x-brotli"] = ".br", + ["application/x-bzip"] = ".bz", + ["application/x-bzip2"] = ".bz2", + ["application/x-cdf"] = ".cdf", + ["application/x-compress"] = ".z", + ["application/x-compressed"] = ".tgz", + ["application/x-cpio"] = ".cpio", + ["application/x-cue"] = ".cue", + ["application/x-csh"] = ".csh", + ["application/x-director"] = ".dcr", + ["application/x-dvi"] = ".dvi", + ["application/x-freearc"] = ".arc", + ["application/x-gtar"] = ".gtar", + ["application/x-hdf"] = ".hdf", + ["application/x-hwp"] = ".hwp", + ["application/x-iso9660-image"] = ".iso", + ["application/xhtml+xml"] = ".xhtml", + ["application/x-httpd-php"] = ".php", + ["application/x-internet-signup"] = ".ins", + ["application/x-latex"] = ".latex", + ["application/x-miva-compiled"] = ".mvc", + ["application/xml"] = ".xml", + ["application/xml-dtd"] = ".dtd", + ["application/x-msaccess"] = ".mdb", + ["application/x-ms-application"] = ".application", + ["application/x-mscardfile"] = ".crd", + ["application/x-msclip"] = ".clp", + ["application/x-ms-manifest"] = ".manifest", + ["application/x-msmediaview"] = ".m13", + ["application/x-msmetafile"] = ".wmf", + ["application/x-msmoney"] = ".mny", + ["application/x-mspublisher"] = ".pub", + ["application/x-ms-reader"] = ".lit", + ["application/x-msschedule"] = ".scd", + ["application/x-msterminal"] = ".trm", + ["application/x-ms-vsto"] = ".vsto", + ["application/x-ms-wmd"] = ".wmd", + ["application/x-ms-wmz"] = ".wmz", + ["application/x-mswrite"] = ".wri", + ["application/x-netcdf"] = ".nc", + ["application/x-ndjson"] = ".jsonl", + ["application/x-oleobject"] = ".hhc", + ["application/x-perfmon"] = ".pma", + ["application/x-pem-file"] = ".pem", + ["application/x-pkcs12"] = ".p12", + ["application/x-pkcs7-certificates"] = ".p7b", + ["application/x-pkcs7-certreqresp"] = ".p7r", + ["application/x-quicktimeplayer"] = ".qtl", + ["application/x-rpm"] = ".rpm", + ["application/x-sh"] = ".sh", + ["application/x-shar"] = ".shar", + ["application/x-shockwave-flash"] = ".swf", + ["application/x-smaf"] = ".mmf", + ["application/x-sqlite3"] = ".sqlite", + ["application/x-stuffit"] = ".sit", + ["application/x-sv4cpio"] = ".sv4cpio", + ["application/x-sv4crc"] = ".sv4crc", + ["application/x-tar"] = ".tar", + ["application/x-tcl"] = ".tcl", + ["application/x-tex"] = ".tex", + ["application/x-texinfo"] = ".texi", + ["application/x-troff"] = ".roff", + ["application/x-troff-man"] = ".man", + ["application/x-troff-me"] = ".me", + ["application/x-ustar"] = ".ustar", + ["application/x-wais-source"] = ".src", + ["application/x-x509-ca-cert"] = ".crt", + ["application/yaml"] = ".yaml", + ["application/zip"] = ".zip", + ["application/zstd"] = ".zst", + ["audio/aac"] = ".aac", + ["audio/aifc"] = ".aifc", + ["audio/aiff"] = ".aiff", + ["audio/basic"] = ".au", + ["audio/flac"] = ".flac", + ["audio/mid"] = ".mid", + ["audio/midi"] = ".mid", + ["audio/mp4"] = ".m4a", + ["audio/mpeg"] = ".mp3", + ["audio/ogg"] = ".oga", + ["audio/opus"] = ".opus", + ["audio/vnd.dlna.adts"] = ".adt", + ["audio/wav"] = ".wav", + ["audio/webm"] = ".weba", + ["audio/x-aiff"] = ".aif", + ["audio/x-mpegurl"] = ".m3u", + ["audio/x-matroska"] = ".mka", + ["audio/x-ms-wax"] = ".wax", + ["audio/x-ms-wma"] = ".wma", + ["drawing/x-dwf"] = ".dwf", + ["font/collection"] = ".ttc", + ["font/otf"] = ".otf", + ["font/ttf"] = ".ttf", + ["font/woff"] = ".woff", + ["font/woff2"] = ".woff2", + ["image/apng"] = ".apng", + ["image/avif"] = ".avif", + ["image/bmp"] = ".bmp", + ["image/gif"] = ".gif", + ["image/heic"] = ".heic", + ["image/heif"] = ".heif", + ["image/ief"] = ".ief", + ["image/jpeg"] = ".jpg", + ["image/jxl"] = ".jxl", + ["image/png"] = ".png", + ["image/svg+xml"] = ".svg", + ["image/tiff"] = ".tif", + ["image/vnd.microsoft.icon"] = ".ico", + ["image/vnd.wap.wbmp"] = ".wbmp", + ["image/webp"] = ".webp", + ["image/x-cmu-raster"] = ".ras", + ["image/x-cmx"] = ".cmx", + ["image/x-icon"] = ".ico", + ["image/x-portable-anymap"] = ".pnm", + ["image/x-portable-bitmap"] = ".pbm", + ["image/x-portable-graymap"] = ".pgm", + ["image/x-portable-pixmap"] = ".ppm", + ["image/x-rgb"] = ".rgb", + ["image/x-xbitmap"] = ".xbm", + ["image/x-xpixmap"] = ".xpm", + ["image/x-xwindowdump"] = ".xwd", + ["message/rfc822"] = ".eml", + ["model/gltf+json"] = ".gltf", + ["model/gltf-binary"] = ".glb", + ["model/stl"] = ".stl", + ["text/cache-manifest"] = ".appcache", + ["text/calendar"] = ".ics", + ["text/css"] = ".css", + ["text/csv"] = ".csv", + ["text/html"] = ".html", + ["text/javascript"] = ".js", + ["text/jsx"] = ".jsx", + ["text/markdown"] = ".md", + ["text/mdx"] = ".mdx", + ["text/plain"] = ".txt", + ["text/richtext"] = ".rtx", + ["text/scriptlet"] = ".sct", + ["text/sgml"] = ".sgml", + ["text/svelte"] = ".svelte", + ["text/tab-separated-values"] = ".tsv", + ["text/tsx"] = ".tsx", + ["text/typescript"] = ".ts", + ["text/vbscript"] = ".vbs", + ["text/vcard"] = ".vcf", + ["text/vnd.sylk"] = ".sylk", + ["text/vtt"] = ".vtt", + ["text/x-vcalendar"] = ".vcs", + ["text/vue"] = ".vue", + ["text/x-component"] = ".htc", + ["text/x-csharp"] = ".cs", + ["text/x-dart"] = ".dart", + ["text/x-go"] = ".go", + ["text/x-java-source"] = ".java", + ["text/x-kotlin"] = ".kt", + ["text/x-less"] = ".less", + ["text/xml"] = ".xml", + ["text/x-ms-odc"] = ".odc", + ["text/yaml"] = ".yaml", + ["text/x-perl"] = ".pl", + ["text/x-protobuf"] = ".proto", + ["text/x-python"] = ".py", + ["text/x-rust"] = ".rs", + ["text/x-sass"] = ".sass", + ["text/x-scss"] = ".scss", + ["text/x-setext"] = ".etx", + ["text/x-swift"] = ".swift", + ["video/3gpp"] = ".3gp", + ["video/3gpp2"] = ".3g2", + ["video/mp2t"] = ".mts", + ["video/mp4"] = ".mp4", + ["video/mpeg"] = ".mpeg", + ["video/ogg"] = ".ogv", + ["video/quicktime"] = ".mov", + ["video/vnd.dlna.mpeg-tts"] = ".tts", + ["video/webm"] = ".webm", + ["video/x-flv"] = ".flv", + ["video/x-ivf"] = ".ivf", + ["video/x-la-asf"] = ".lsf", + ["video/x-matroska"] = ".mkv", + ["video/x-ms-asf"] = ".asf", + ["video/x-ms-dvr"] = ".dvr-ms", + ["video/x-msvideo"] = ".avi", + ["video/x-ms-wm"] = ".wm", + ["video/x-ms-wmp"] = ".wmp", + ["video/x-ms-wmv"] = ".wmv", + ["video/x-ms-wmx"] = ".wmx", + ["video/x-ms-wtv"] = ".wtv", + ["video/x-ms-wvx"] = ".wvx", + ["video/x-sgi-movie"] = ".movie", + }; + + /// + /// Gets the media type (MIME type) for the specified file path or extension. + /// + /// A file path or extension (with or without leading period). + /// The media type associated with the extension, or if no mapping exists. + public static string? GetMediaType(string? pathOrExtension) + { + if (string.IsNullOrEmpty(pathOrExtension)) + { + return null; + } + + string extension = IO.Path.GetExtension(pathOrExtension); + + if (string.IsNullOrEmpty(extension)) + { + // The input might be an extension itself (e.g., ".pdf" or "pdf") + extension = pathOrExtension!; + if (extension[0] != '.') + { + extension = "." + extension; + } + } + + _ = s_extensionToMediaType.TryGetValue(extension, out string? result); + return result; + } + + /// + /// Gets the file extension for the specified media type (MIME type). + /// + /// The media type (e.g. "application/pdf"). + /// The file extension (with leading period) associated with the media type, or if no mapping exists. + public static string? GetExtension(string? mediaType) + { + if (string.IsNullOrEmpty(mediaType)) + { + return null; + } + + // Remove any parameters from the media type (e.g., "text/html; charset=utf-8") +#pragma warning disable CA1307 // Specify StringComparison for clarity + int semicolonIndex = mediaType!.IndexOf(';'); +#pragma warning restore CA1307 + if (semicolonIndex >= 0) + { + mediaType = mediaType.Substring(0, semicolonIndex).Trim(); + } + + _ = s_mediaTypeToExtension.TryGetValue(mediaType, out string? value); + return value; + } +} diff --git a/src/LegacySupport/MediaTypeMap/README.md b/src/LegacySupport/MediaTypeMap/README.md new file mode 100644 index 00000000000..f4290678f54 --- /dev/null +++ b/src/LegacySupport/MediaTypeMap/README.md @@ -0,0 +1,9 @@ +# About MediaTypeMap + +This folder contains a polyfill for `System.Net.Mime.MediaTypeMap` which was added in .NET 10. +It provides methods for mapping between file extensions and media types (MIME types). + +The implementation is a simplified version of the original that works on older frameworks +that don't have the `AlternateLookup` dictionary feature. + +See: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Mail/src/System/Net/Mime/MediaTypeMap.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index fbc05e14405..45475fb930a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -9,11 +9,15 @@ #endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Text; +using System.IO; +using System.Net.Mime; #if !NET using System.Runtime.InteropServices; #endif +using System.Text; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; #pragma warning disable IDE0032 // Use auto property @@ -124,6 +128,127 @@ public DataContent(ReadOnlyMemory data, string mediaType) _data = data; } + /// + /// Loads a from a file path asynchronously. + /// + /// + /// The absolute or relative file path to load the data from. Relative file paths are relative to the current working directory. + /// + /// + /// The media type (also known as MIME type) represented by the content. If not provided, + /// it will be inferred from the file extension. If it cannot be inferred, "application/octet-stream" is used. + /// + /// The to monitor for cancellation requests. + /// A containing the file data with the inferred or specified media type and name. + /// is . + /// is empty. + public static async ValueTask LoadFromAsync(string path, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNullOrEmpty(path); + + using FileStream fileStream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, useAsync: true); + return await LoadFromAsync(fileStream, mediaType, cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads a from a stream asynchronously. + /// + /// The stream to load the data from. + /// + /// The media type (also known as MIME type) represented by the content. If not provided and + /// the stream is a , it will be inferred from the file extension. + /// If it cannot be inferred, "application/octet-stream" is used. + /// + /// The to monitor for cancellation requests. + /// A containing the stream data with the inferred or specified media type and name. + /// is . + public static async ValueTask LoadFromAsync(Stream stream, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(stream); + + string? name = null; + + // If the stream is a FileStream, try to infer media type and name from its path. + if (stream is FileStream fileStream) + { + string? filePath = fileStream.Name; + string? fileName = Path.GetFileName(filePath); + if (!string.IsNullOrEmpty(fileName)) + { + name = fileName; + } + + mediaType ??= MediaTypeMap.GetMediaType(filePath); + } + + // Fall back to default media type if still not set. + mediaType ??= DefaultMediaType; + + // Read the stream contents + MemoryStream memoryStream = stream.CanSeek ? new((int)Math.Min(stream.Length, int.MaxValue)) : new(); + await stream.CopyToAsync(memoryStream, +#if !NET + 80 * 1024, // same as the default buffer size +#endif + cancellationToken).ConfigureAwait(false); + + return new DataContent(new ReadOnlyMemory(memoryStream.GetBuffer(), 0, (int)memoryStream.Length), mediaType) + { + Name = name + }; + } + + /// + /// Saves the data content to a file asynchronously. + /// + /// + /// The absolute or relative file path to save the data to. If the path is to an existing directory, the file name will be inferred + /// from the property, or a random name will be used with an extension based on the , if possible. + /// + /// The to monitor for cancellation requests. + /// The actual path where the data was saved, which may include an inferred file name and/or extension. + /// is . + public async ValueTask SaveToAsync(string path, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(path); + + // If path is a directory, infer the file name from the Name property if available, + // or use a random name along with an extension inferred from the media type. + // If the path is empty, treat it as the current directory. + if (path.Length == 0 || Directory.Exists(path)) + { + string? name = null; + + if (Name is not null) + { + name = Path.GetFileName(Name); + } + + if (string.IsNullOrEmpty(name)) + { + name = $"{Guid.NewGuid():N}{MediaTypeMap.GetExtension(MediaType)}"; + } + + path = path.Length == 0 ? name! : Path.Combine(path, name); + } + + // Write the data to the file. + using FileStream fileStream = new(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 1, useAsync: true); + +#if NET + await fileStream.WriteAsync(Data, cancellationToken).ConfigureAwait(false); +#else + if (!MemoryMarshal.TryGetArray(Data, out ArraySegment array)) + { + array = new(Data.ToArray()); + } + + await fileStream.WriteAsync(array.Array, array.Offset, array.Count, cancellationToken).ConfigureAwait(false); +#endif + + return fileStream.Name; + } + /// /// Determines whether the 's top-level type matches the specified . /// @@ -189,8 +314,14 @@ public string Uri /// Gets or sets an optional name associated with the data. /// + /// /// A service might use this name as part of citations or to help infer the type of data /// being represented based on a file extension. + /// + /// + /// When using , if the path provided is a directory, + /// may be used as part of the output file's name. + /// /// public string? Name { get; set; } @@ -256,4 +387,7 @@ private string DebuggerDisplay $"Data = {uri.Substring(0, MaxLength)}..."; } } + + /// The default media type for unknown file extensions. + private const string DefaultMediaType = "application/octet-stream"; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs index fe976231635..6bf2086d133 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -15,18 +15,6 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public static class ImageGeneratorExtensions { - private static readonly Dictionary _extensionToMimeType = new(StringComparer.OrdinalIgnoreCase) - { - [".png"] = "image/png", - [".jpg"] = "image/jpeg", - [".jpeg"] = "image/jpeg", - [".webp"] = "image/webp", - [".gif"] = "image/gif", - [".bmp"] = "image/bmp", - [".tiff"] = "image/tiff", - [".tif"] = "image/tiff", - }; - /// Asks the for an object of type . /// The type of the object to be retrieved. /// The generator. @@ -203,13 +191,6 @@ public static Task EditImageAsync( /// The inferred media type. private static string GetMediaTypeFromFileName(string fileName) { - string extension = Path.GetExtension(fileName); - - if (_extensionToMimeType.TryGetValue(extension, out string? mediaType)) - { - return mediaType; - } - - return "image/png"; // Default to PNG if unknown extension + return MediaTypeMap.GetMediaType(fileName) ?? "image/png"; // Default to PNG if unknown extension } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 72c354ccb97..d2c3ccc3449 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -22,6 +22,8 @@ true + true + true true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index e401502d82b..6362fcaa00e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1464,6 +1464,18 @@ { "Member": "bool Microsoft.Extensions.AI.DataContent.HasTopLevelMediaType(string topLevelType);", "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.DataContent.LoadFromAsync(string path, string? mediaType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.DataContent.LoadFromAsync(System.IO.Stream stream, string? mediaType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.DataContent.SaveToAsync(string path, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" } ], "Properties": [ diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index bf0841290e8..fe25368e83b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -26,6 +26,7 @@ true true + true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 36d8677e70a..f558a148e80 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -5,6 +5,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Net.Mime; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -218,15 +219,7 @@ internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string /// Gets a media type for an image based on the file extension in the provided URI. internal static string ImageUriToMediaType(Uri uri) { - string absoluteUri = uri.AbsoluteUri; - return - absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : - absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : - absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : - absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" : - absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" : - absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" : - "image/*"; + return MediaTypeMap.GetMediaType(uri.AbsoluteUri) ?? "image/*"; } /// Sets $.model in to if not already set. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs index a51454d532c..631b710b640 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs @@ -6,6 +6,7 @@ using System.Drawing; using System.IO; using System.Linq; +using System.Net.Mime; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; @@ -23,16 +24,6 @@ namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . internal sealed class OpenAIImageGenerator : IImageGenerator { - private static readonly Dictionary _mimeTypeToExtension = new(StringComparer.OrdinalIgnoreCase) - { - ["image/png"] = ".png", - ["image/jpeg"] = ".jpg", - ["image/webp"] = ".webp", - ["image/gif"] = ".gif", - ["image/bmp"] = ".bmp", - ["image/tiff"] = ".tiff", - }; - /// Metadata about the client. private readonly ImageGeneratorMetadata _metadata; @@ -72,20 +63,9 @@ public async Task GenerateAsync(ImageGenerationRequest imageStream = MemoryMarshal.TryGetArray(dataContent.Data, out var array) ? new MemoryStream(array.Array!, array.Offset, array.Count) : new MemoryStream(dataContent.Data.ToArray()); - fileName = dataContent.Name; - - if (fileName is null) - { - // If no file name is provided, use the default based on the content type. - if (dataContent.MediaType is not null && _mimeTypeToExtension.TryGetValue(dataContent.MediaType, out var extension)) - { - fileName = $"image{extension}"; - } - else - { - fileName = "image.png"; // Default to PNG if no content type is available. - } - } + fileName = + dataContent.Name ?? + $"{Guid.NewGuid():N}{MediaTypeMap.GetExtension(dataContent.MediaType) ?? ".png"}"; // Default to PNG if no content type is available. } GeneratedImageCollection editResult = await _imageClient.GenerateImageEditsAsync( diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs index 8bdb651321a..cffd56f3c1c 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -55,88 +56,6 @@ public virtual async Task ReadAsync(FileInfo source, string i /// A task representing the asynchronous read operation. public abstract Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default); - private static string GetMediaType(FileInfo source) - => source.Extension switch - { - ".123" => "application/vnd.lotus-1-2-3", - ".602" => "application/x-t602", - ".abw" => "application/x-abiword", - ".bmp" => "image/bmp", - ".cgm" => "image/cgm", - ".csv" => "text/csv", - ".cwk" => "application/x-cwk", - ".dbf" => "application/vnd.dbf", - ".dif" => "application/x-dif", - ".doc" => "application/msword", - ".docm" => "application/vnd.ms-word.document.macroEnabled.12", - ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".dot" => "application/msword", - ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", - ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - ".epub" => "application/epub+zip", - ".et" => "application/vnd.ms-excel", - ".eth" => "application/ethos", - ".fods" => "application/vnd.oasis.opendocument.spreadsheet", - ".gif" => "image/gif", - ".htm" => "text/html", - ".html" => "text/html", - ".hwp" => "application/x-hwp", - ".jpeg" => "image/jpeg", - ".jpg" => "image/jpeg", - ".key" => "application/x-iwork-keynote-sffkey", - ".lwp" => "application/vnd.lotus-wordpro", - ".mcw" => "application/macwriteii", - ".mw" => "application/macwriteii", - ".numbers" => "application/x-iwork-numbers-sffnumbers", - ".ods" => "application/vnd.oasis.opendocument.spreadsheet", - ".pages" => "application/x-iwork-pages-sffpages", - ".pbd" => "application/x-pagemaker", - ".pdf" => "application/pdf", - ".png" => "image/png", - ".pot" => "application/vnd.ms-powerpoint", - ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", - ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", - ".ppt" => "application/vnd.ms-powerpoint", - ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", - ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".prn" => "application/x-prn", - ".qpw" => "application/x-quattro-pro", - ".rtf" => "application/rtf", - ".sda" => "application/vnd.stardivision.draw", - ".sdd" => "application/vnd.stardivision.impress", - ".sdp" => "application/sdp", - ".sdw" => "application/vnd.stardivision.writer", - ".sgl" => "application/vnd.stardivision.writer", - ".slk" => "text/vnd.sylk", - ".sti" => "application/vnd.sun.xml.impress.template", - ".stw" => "application/vnd.sun.xml.writer.template", - ".svg" => "image/svg+xml", - ".sxg" => "application/vnd.sun.xml.writer.global", - ".sxi" => "application/vnd.sun.xml.impress", - ".sxw" => "application/vnd.sun.xml.writer", - ".sylk" => "text/vnd.sylk", - ".tiff" => "image/tiff", - ".tsv" => "text/tab-separated-values", - ".txt" => "text/plain", - ".uof" => "application/vnd.uoml+xml", - ".uop" => "application/vnd.openofficeorg.presentation", - ".uos1" => "application/vnd.uoml+xml", - ".uos2" => "application/vnd.uoml+xml", - ".uot" => "application/x-uo", - ".vor" => "application/vnd.stardivision.writer", - ".webp" => "image/webp", - ".wpd" => "application/wordperfect", - ".wps" => "application/vnd.ms-works", - ".wq1" => "application/x-lotus", - ".wq2" => "application/x-lotus", - ".xls" => "application/vnd.ms-excel", - ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", - ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", - ".xlr" => "application/vnd.ms-works", - ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".xlw" => "application/vnd.ms-excel", - ".xml" => "application/xml", - ".zabw" => "application/x-abiword", - _ => "application/octet-stream" - }; + private static string GetMediaType(FileInfo source) => + MediaTypeMap.GetMediaType(source.Extension) ?? "application/octet-stream"; } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj index e22de778e06..459bd2cc850 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj @@ -15,6 +15,10 @@ $(NoWarn);S1694;S2368 + + true + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs index 753f0053b90..cbec488cb2e 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs @@ -43,21 +43,8 @@ public override async Task ReadAsync(FileInfo source, string throw new FileNotFoundException("The specified file does not exist.", source.FullName); } - // Read file content and create DataContent -#if NET - ReadOnlyMemory fileBytes = await File.ReadAllBytesAsync(source.FullName, cancellationToken).ConfigureAwait(false); -#else - ReadOnlyMemory fileBytes; - using (FileStream fs = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1, FileOptions.Asynchronous)) - { - using MemoryStream ms = new((int)Math.Min(int.MaxValue, fs.Length)); - await fs.CopyToAsync(ms).ConfigureAwait(false); - fileBytes = ms.GetBuffer().AsMemory(0, (int)ms.Length); - } -#endif - DataContent dataContent = new( - fileBytes, - string.IsNullOrEmpty(mediaType) ? "application/octet-stream" : mediaType!); + // Read file content and create DataContent (media type is inferred from extension if not provided) + DataContent dataContent = await DataContent.LoadFromAsync(source.FullName, mediaType, cancellationToken).ConfigureAwait(false); string markdown = await ConvertToMarkdownAsync(dataContent, cancellationToken).ConfigureAwait(false); @@ -70,16 +57,8 @@ public override async Task ReadAsync(Stream source, string id _ = Throw.IfNull(source); _ = Throw.IfNullOrEmpty(identifier); - // Read stream content and create DataContent - using MemoryStream ms = source.CanSeek ? new((int)Math.Min(int.MaxValue, source.Length)) : new(); -#if NET - await source.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); -#else - await source.CopyToAsync(ms).ConfigureAwait(false); -#endif - DataContent dataContent = new( - ms.GetBuffer().AsMemory(0, (int)ms.Length), - string.IsNullOrEmpty(mediaType) ? "application/octet-stream" : mediaType); + // Read stream content and create DataContent (media type is inferred from FileStream path if applicable) + DataContent dataContent = await DataContent.LoadFromAsync(source, mediaType, cancellationToken).ConfigureAwait(false); string markdown = await ConvertToMarkdownAsync(dataContent, cancellationToken).ConfigureAwait(false); diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj index 013097ea6c7..381e524df97 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj @@ -18,6 +18,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs index 47bc6ccfd62..2730ee596cb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.IO; using System.Text; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Microsoft.Extensions.AI; @@ -276,4 +279,746 @@ public void FileName_Roundtrips() content.Name = "test.bin"; Assert.Equal("test.bin", content.Name); } + + [Fact] + public async Task LoadFromAsync_Path_InfersMediaTypeAndName() + { + // Create a temporary file with known content + string tempPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.json"); + try + { + byte[] testData = Encoding.UTF8.GetBytes("{\"key\": \"value\"}"); + await File.WriteAllBytesAsync(tempPath, testData); + + // Load from path + DataContent content = await DataContent.LoadFromAsync(tempPath); + + // Verify the content + Assert.Equal("application/json", content.MediaType); + Assert.Equal(Path.GetFileName(tempPath), content.Name); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task LoadFromAsync_Path_UsesProvidedMediaType() + { + // Create a temporary file with known content + string tempPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.bin"); + try + { + byte[] testData = new byte[] { 1, 2, 3, 4, 5 }; + await File.WriteAllBytesAsync(tempPath, testData); + + // Load from path with specified media type + DataContent content = await DataContent.LoadFromAsync(tempPath, "custom/type"); + + // Verify the content uses the provided media type, not inferred + Assert.Equal("custom/type", content.MediaType); + Assert.Equal(Path.GetFileName(tempPath), content.Name); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task LoadFromAsync_Path_FallsBackToOctetStream() + { + // Create a temporary file with unknown extension + string tempPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.unknownextension"); + try + { + byte[] testData = new byte[] { 1, 2, 3 }; + await File.WriteAllBytesAsync(tempPath, testData); + + // Load from path + DataContent content = await DataContent.LoadFromAsync(tempPath); + + // Verify the content falls back to octet-stream + Assert.Equal("application/octet-stream", content.MediaType); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task LoadFromAsync_Stream_InfersFromFileStream() + { + // Create a temporary file + string tempPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.png"); + try + { + byte[] testData = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; // PNG signature + await File.WriteAllBytesAsync(tempPath, testData); + + // Load from FileStream + using FileStream fs = new(tempPath, FileMode.Open, FileAccess.Read); + DataContent content = await DataContent.LoadFromAsync(fs); + + // Verify inference from FileStream path + Assert.Equal("image/png", content.MediaType); + Assert.Equal(Path.GetFileName(tempPath), content.Name); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task LoadFromAsync_Stream_UsesProvidedMediaType() + { + // Create a MemoryStream with test data + byte[] testData = [1, 2, 3, 4]; + using MemoryStream ms = new(testData); + + // Load from stream with explicit media type + DataContent content = await DataContent.LoadFromAsync(ms, "video/mp4"); + + // Verify the explicit media type is used + Assert.Equal("video/mp4", content.MediaType); + Assert.Null(content.Name); // Name can be assigned after the call if needed + Assert.Equal(testData, content.Data.ToArray()); + } + + [Fact] + public async Task LoadFromAsync_Stream_FallsBackToOctetStream() + { + // Create a MemoryStream with test data (non-FileStream, no inference possible) + byte[] testData = new byte[] { 1, 2, 3 }; + using MemoryStream ms = new(testData); + + // Load from stream without media type or name + DataContent content = await DataContent.LoadFromAsync(ms); + + // Verify fallback to octet-stream + Assert.Equal("application/octet-stream", content.MediaType); + Assert.Null(content.Name); + Assert.Equal(testData, content.Data.ToArray()); + } + + [Fact] + public async Task SaveToAsync_WritesDataToFile() + { + // Create DataContent with known data + byte[] testData = new byte[] { 1, 2, 3, 4, 5 }; + DataContent content = new(testData, "application/octet-stream"); + + string tempPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.bin"); + try + { + // Save to path + string actualPath = await content.SaveToAsync(tempPath); + + // Verify data was written + Assert.Equal(tempPath, actualPath); + Assert.True(File.Exists(actualPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(actualPath)); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task SaveToAsync_InfersExtension_WhenPathHasNoExtension() + { + // Create DataContent with JSON media type + byte[] testData = Encoding.UTF8.GetBytes("{}"); + DataContent content = new(testData, "application/json"); + + string tempPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}"); + try + { + // Save to path without extension + string actualPath = await content.SaveToAsync(tempPath); + + // Verify extension was inferred + Assert.Equal(tempPath, actualPath); + Assert.True(File.Exists(actualPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(actualPath)); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task SaveToAsync_UsesDirectoryAndInfersNameFromNameProperty() + { + // Create DataContent with JSON media type and a name + byte[] testData = Encoding.UTF8.GetBytes("{}"); + DataContent content = new(testData, "application/json") + { + Name = "myfile.json" + }; + + string tempDir = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + string expectedPath = Path.Combine(tempDir, "myfile.json"); + try + { + // Save to directory path + string actualPath = await content.SaveToAsync(tempDir); + + // Verify the name was used + Assert.Equal(expectedPath, actualPath); + Assert.True(File.Exists(actualPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(actualPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task SaveToAsync_UsesRandomNameWhenDirectoryProvidedAndNoName() + { + // Create DataContent with JSON media type but no name + byte[] testData = Encoding.UTF8.GetBytes("{}"); + DataContent content = new(testData, "application/json"); + + string tempDir = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + // Save to directory path + string actualPath = await content.SaveToAsync(tempDir); + + // Verify a file was created with .json extension + Assert.StartsWith(tempDir, actualPath); + Assert.EndsWith(".json", actualPath); + Assert.True(File.Exists(actualPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(actualPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task SaveToAsync_DoesNotInferExtension_WhenPathAlreadyHasExtension() + { + // Create DataContent with JSON media type + byte[] testData = Encoding.UTF8.GetBytes("{}"); + DataContent content = new(testData, "application/json"); + + string tempPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.txt"); + try + { + // Save to path that already has an extension + string actualPath = await content.SaveToAsync(tempPath); + + // Verify the original extension was preserved, not replaced + Assert.Equal(tempPath, actualPath); + Assert.True(File.Exists(actualPath)); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task SaveToAsync_WithEmptyPath_UsesCurrentDirectory() + { + // Empty string path is valid - it uses the current directory with inferred filename. + // We use a unique name to avoid conflicts with concurrent tests. + string tempDir = Path.Combine(Path.GetTempPath(), $"test_empty_path_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + string uniqueName = $"test_{Guid.NewGuid()}.json"; + + try + { + // Save with empty path from within the temp directory context + // Since we can't change current directory, we test the directory path behavior instead + DataContent content = new(new byte[] { 1, 2, 3 }, "application/json") { Name = uniqueName }; + string savedPath = await content.SaveToAsync(tempDir); + + // When given a directory, it should use Name as the filename + Assert.Equal(Path.Combine(tempDir, uniqueName), savedPath); + Assert.True(File.Exists(savedPath)); + Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(savedPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task LoadFromAsync_Path_ThrowsOnNull() + { + await Assert.ThrowsAsync("path", async () => await DataContent.LoadFromAsync((string)null!)); + } + + [Fact] + public async Task LoadFromAsync_Path_ThrowsOnEmpty() + { + await Assert.ThrowsAsync("path", async () => await DataContent.LoadFromAsync(string.Empty)); + } + + [Fact] + public async Task LoadFromAsync_Stream_ThrowsOnNull() + { + await Assert.ThrowsAsync("stream", async () => await DataContent.LoadFromAsync((Stream)null!)); + } + + [Fact] + public async Task SaveToAsync_ThrowsOnNull() + { + DataContent content = new(new byte[] { 1 }, "application/octet-stream"); + await Assert.ThrowsAsync("path", async () => await content.SaveToAsync(null!)); + } + + [Fact] + public async Task LoadFromAsync_AbsolutePath_LoadsCorrectly() + { + // Create a temp file with an absolute path + string tempDir = Path.GetTempPath(); + string absolutePath = Path.Combine(tempDir, $"test_absolute_{Guid.NewGuid()}.txt"); + + try + { + byte[] testData = Encoding.UTF8.GetBytes("absolute path test"); + await File.WriteAllBytesAsync(absolutePath, testData); + + DataContent content = await DataContent.LoadFromAsync(absolutePath); + + Assert.Equal("text/plain", content.MediaType); + Assert.Equal(Path.GetFileName(absolutePath), content.Name); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (File.Exists(absolutePath)) + { + File.Delete(absolutePath); + } + } + } + + [Fact] + public async Task LoadFromAsync_RelativePath_LoadsCorrectly() + { + // Test that LoadFromAsync works with paths that have different components. + // We use absolute paths but verify the Name extraction works correctly. + string tempDir = Path.Combine(Path.GetTempPath(), $"test_relative_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + + try + { + string filePath = Path.Combine(tempDir, "relativefile.xml"); + byte[] testData = Encoding.UTF8.GetBytes(""); + await File.WriteAllBytesAsync(filePath, testData); + + DataContent content = await DataContent.LoadFromAsync(filePath); + + Assert.Equal("application/xml", content.MediaType); + Assert.Equal("relativefile.xml", content.Name); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task LoadFromAsync_RelativePathWithSubdirectory_LoadsCorrectly() + { + // Test loading from a nested directory structure + string tempDir = Path.Combine(Path.GetTempPath(), $"test_subdir_{Guid.NewGuid()}"); + string subDir = Path.Combine(tempDir, "subdir"); + Directory.CreateDirectory(subDir); + + try + { + string filePath = Path.Combine(subDir, "nested.html"); + byte[] testData = Encoding.UTF8.GetBytes(""); + await File.WriteAllBytesAsync(filePath, testData); + + DataContent content = await DataContent.LoadFromAsync(filePath); + + Assert.Equal("text/html", content.MediaType); + Assert.Equal("nested.html", content.Name); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task SaveToAsync_AbsolutePath_SavesCorrectly() + { + byte[] testData = [1, 2, 3, 4, 5]; + DataContent content = new(testData, "application/octet-stream"); + + string tempDir = Path.GetTempPath(); + string absolutePath = Path.Combine(tempDir, $"test_absolute_{Guid.NewGuid()}.bin"); + + try + { + string savedPath = await content.SaveToAsync(absolutePath); + + Assert.Equal(absolutePath, savedPath); + Assert.True(File.Exists(absolutePath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(absolutePath)); + } + finally + { + if (File.Exists(absolutePath)) + { + File.Delete(absolutePath); + } + } + } + + [Fact] + public async Task SaveToAsync_ToSubdirectory_SavesCorrectly() + { + byte[] testData = [10, 20, 30]; + DataContent content = new(testData, "image/png"); + + string tempDir = Path.Combine(Path.GetTempPath(), $"test_save_subdir_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + + try + { + string filePath = Path.Combine(tempDir, "output.png"); + string savedPath = await content.SaveToAsync(filePath); + + Assert.Equal(filePath, savedPath); + Assert.True(File.Exists(savedPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(savedPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task SaveToAsync_ToNestedSubdirectory_SavesCorrectly() + { + byte[] testData = [100, 200]; + DataContent content = new(testData, "audio/wav"); + + string tempDir = Path.Combine(Path.GetTempPath(), $"test_save_nested_{Guid.NewGuid()}"); + string subDir = Path.Combine(tempDir, "audio"); + Directory.CreateDirectory(subDir); + + try + { + string filePath = Path.Combine(subDir, "sound.wav"); + string savedPath = await content.SaveToAsync(filePath); + + Assert.Equal(filePath, savedPath); + Assert.True(File.Exists(savedPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(savedPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task SaveToAsync_EmptyPath_WithoutName_GeneratesRandomName() + { + byte[] testData = [1, 2, 3]; + DataContent content = new(testData, "image/png"); + + // Note: Name is NOT set - should generate a random GUID-based name. + // Test using a directory path instead of empty string to avoid current directory issues. + string tempDir = Path.Combine(Path.GetTempPath(), $"test_empty_noname_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + + try + { + string savedPath = await content.SaveToAsync(tempDir); + + // Should generate a random GUID-based name with .png extension + Assert.StartsWith(tempDir, savedPath); + Assert.EndsWith(".png", savedPath); + Assert.True(File.Exists(savedPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(savedPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task SaveToAsync_DirectoryPath_WithoutName_GeneratesRandomName() + { + byte[] testData = [5, 6, 7]; + DataContent content = new(testData, "text/css"); + + // Note: Name is NOT set + + string tempDir = Path.Combine(Path.GetTempPath(), $"test_dir_noname_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + + try + { + string savedPath = await content.SaveToAsync(tempDir); + + // Should generate a random GUID-based name with .css extension + Assert.StartsWith(tempDir, savedPath); + Assert.EndsWith(".css", savedPath); + Assert.True(File.Exists(savedPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(savedPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task LoadFromAsync_WithCancellationToken_PropagatesToken() + { + // This test verifies that the cancellation token is properly propagated. + // With already-cancelled tokens, TaskCanceledException (a subclass of OperationCanceledException) may be thrown. + string tempPath = Path.Combine(Path.GetTempPath(), $"test_cancel_{Guid.NewGuid()}.txt"); + + try + { + await File.WriteAllBytesAsync(tempPath, new byte[] { 1, 2, 3 }); + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await DataContent.LoadFromAsync(tempPath, cancellationToken: cts.Token)); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task SaveToAsync_WithCancellationToken_PropagatesToken() + { + // This test verifies that the cancellation token is properly propagated. + // With already-cancelled tokens, TaskCanceledException (a subclass of OperationCanceledException) may be thrown. + DataContent content = new(new byte[] { 1, 2, 3 }, "application/octet-stream"); + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + string tempPath = Path.Combine(Path.GetTempPath(), $"test_save_cancel_{Guid.NewGuid()}.bin"); + + try + { + await Assert.ThrowsAnyAsync(async () => + await content.SaveToAsync(tempPath, cts.Token)); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task LoadFromAsync_Stream_WithCancellationToken_PropagatesToken() + { + // This test verifies that the cancellation token is properly propagated. + // With already-cancelled tokens, TaskCanceledException (a subclass of OperationCanceledException) may be thrown. + byte[] testData = [1, 2, 3]; + using MemoryStream ms = new(testData); + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await DataContent.LoadFromAsync(ms, cancellationToken: cts.Token)); + } + + [Fact] + public async Task SaveToAsync_ExistingDirectory_WithName_UsesNameAsFilename() + { + byte[] testData = [1, 2, 3]; + DataContent content = new(testData, "application/json") + { + Name = "specific-output.json" + }; + + string tempDir = Path.Combine(Path.GetTempPath(), $"test_dir_name_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + string expectedPath = Path.Combine(tempDir, "specific-output.json"); + + try + { + string savedPath = await content.SaveToAsync(tempDir); + + Assert.Equal(expectedPath, savedPath); + Assert.True(File.Exists(expectedPath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(expectedPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task SaveToAsync_NonExistentPath_TreatedAsFilePath() + { + // When the path doesn't exist, it's treated as a file path, not a directory + byte[] testData = [1, 2, 3]; + DataContent content = new(testData, "application/json"); + + string tempDir = Path.Combine(Path.GetTempPath(), $"test_nonexist_{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + string filePath = Path.Combine(tempDir, "newfile"); + + try + { + string savedPath = await content.SaveToAsync(filePath); + + // Since the path doesn't exist as a directory, it's used as the file path directly + Assert.Equal(filePath, savedPath); + Assert.True(File.Exists(filePath)); + Assert.Equal(testData, await File.ReadAllBytesAsync(filePath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task LoadFromAsync_LargeFile_LoadsCorrectly() + { + // Test with a larger file to ensure streaming works properly + byte[] testData = new byte[100_000]; + new Random(42).NextBytes(testData); + + string tempPath = Path.Combine(Path.GetTempPath(), $"test_large_{Guid.NewGuid()}.bin"); + + try + { + await File.WriteAllBytesAsync(tempPath, testData); + + DataContent content = await DataContent.LoadFromAsync(tempPath); + + Assert.Equal("application/octet-stream", content.MediaType); + Assert.Equal(testData, content.Data.ToArray()); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + [Fact] + public async Task SaveToAsync_LargeContent_SavesCorrectly() + { + // Test with larger content to ensure streaming works properly + byte[] testData = new byte[100_000]; + new Random(42).NextBytes(testData); + + DataContent content = new(testData, "application/octet-stream"); + + string tempPath = Path.Combine(Path.GetTempPath(), $"test_save_large_{Guid.NewGuid()}.bin"); + + try + { + string savedPath = await content.SaveToAsync(tempPath); + + Assert.Equal(tempPath, savedPath); + Assert.Equal(testData, await File.ReadAllBytesAsync(savedPath)); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index 4c275e54993..93bc059c213 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -20,6 +20,7 @@ true true true + true