diff --git a/src/Microsoft.ComponentDetection.Common/FileWritingService.cs b/src/Microsoft.ComponentDetection.Common/FileWritingService.cs index 9539bb9e6..bd46e2f18 100644 --- a/src/Microsoft.ComponentDetection.Common/FileWritingService.cs +++ b/src/Microsoft.ComponentDetection.Common/FileWritingService.cs @@ -1,20 +1,32 @@ namespace Microsoft.ComponentDetection.Common; + using System; using System.Collections.Concurrent; +using System.Globalization; using System.IO; using System.Threading.Tasks; using Microsoft.ComponentDetection.Common.Exceptions; +using Newtonsoft.Json; +/// public sealed class FileWritingService : IFileWritingService { + /// + /// The format string used to generate the timestamp for the manifest file. + /// public const string TimestampFormatString = "yyyyMMddHHmmssfff"; - - private readonly object lockObject = new object(); - private readonly string timestamp = DateTime.Now.ToString(TimestampFormatString); private readonly ConcurrentDictionary bufferedStreams = new(); + private readonly object lockObject = new(); + private readonly string timestamp = DateTime.Now.ToString(TimestampFormatString, CultureInfo.InvariantCulture); + + /// + /// The base path to write files to. + /// If null or empty, the temp path will be used. + /// public string BasePath { get; private set; } + /// public void Init(string basePath) { if (!string.IsNullOrEmpty(basePath) && !Directory.Exists(basePath)) @@ -25,19 +37,25 @@ public void Init(string basePath) this.BasePath = string.IsNullOrEmpty(basePath) ? Path.GetTempPath() : basePath; } - public void AppendToFile(string relativeFilePath, string text) + /// + public void AppendToFile(string relativeFilePath, T obj) { relativeFilePath = this.ResolveFilePath(relativeFilePath); if (!this.bufferedStreams.TryGetValue(relativeFilePath, out var streamWriter)) { streamWriter = new StreamWriter(relativeFilePath, true); - this.bufferedStreams.TryAdd(relativeFilePath, streamWriter); + _ = this.bufferedStreams.TryAdd(relativeFilePath, streamWriter); } - streamWriter.Write(text); + var serializer = new JsonSerializer + { + Formatting = Formatting.Indented, + }; + serializer.Serialize(streamWriter, obj); } + /// public void WriteFile(string relativeFilePath, string text) { relativeFilePath = this.ResolveFilePath(relativeFilePath); @@ -48,6 +66,7 @@ public void WriteFile(string relativeFilePath, string text) } } + /// public async Task WriteFileAsync(string relativeFilePath, string text) { relativeFilePath = this.ResolveFilePath(relativeFilePath); @@ -55,23 +74,44 @@ public async Task WriteFileAsync(string relativeFilePath, string text) await File.WriteAllTextAsync(relativeFilePath, text); } - public void WriteFile(FileInfo relativeFilePath, string text) + /// + public void WriteFile(FileInfo relativeFilePath, T obj) { - File.WriteAllText(relativeFilePath.FullName, text); + using var streamWriter = new StreamWriter(relativeFilePath.FullName); + using var jsonWriter = new JsonTextWriter(streamWriter); + var serializer = new JsonSerializer + { + Formatting = Formatting.Indented, + }; + serializer.Serialize(jsonWriter, obj); } + /// public string ResolveFilePath(string relativeFilePath) { this.EnsureInit(); - if (relativeFilePath.Contains("{timestamp}")) + if (relativeFilePath.Contains("{timestamp}", StringComparison.Ordinal)) { - relativeFilePath = relativeFilePath.Replace("{timestamp}", this.timestamp); + relativeFilePath = relativeFilePath.Replace("{timestamp}", this.timestamp, StringComparison.Ordinal); } relativeFilePath = Path.Combine(this.BasePath, relativeFilePath); return relativeFilePath; } + /// + public void Dispose() => this.Dispose(true); + + /// + public async ValueTask DisposeAsync() + { + foreach (var (filename, streamWriter) in this.bufferedStreams) + { + await streamWriter.DisposeAsync(); + _ = this.bufferedStreams.TryRemove(filename, out _); + } + } + private void EnsureInit() { if (string.IsNullOrEmpty(this.BasePath)) @@ -90,22 +130,7 @@ private void Dispose(bool disposing) foreach (var (filename, streamWriter) in this.bufferedStreams) { streamWriter.Dispose(); - this.bufferedStreams.TryRemove(filename, out _); - } - } - - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - public async ValueTask DisposeAsync() - { - foreach (var (filename, streamWriter) in this.bufferedStreams) - { - await streamWriter.DisposeAsync(); - this.bufferedStreams.TryRemove(filename, out _); + _ = this.bufferedStreams.TryRemove(filename, out _); } } } diff --git a/src/Microsoft.ComponentDetection.Common/IFileWritingService.cs b/src/Microsoft.ComponentDetection.Common/IFileWritingService.cs index 8ef32b030..ea0c7c9aa 100644 --- a/src/Microsoft.ComponentDetection.Common/IFileWritingService.cs +++ b/src/Microsoft.ComponentDetection.Common/IFileWritingService.cs @@ -4,18 +4,53 @@ using System.IO; using System.Threading.Tasks; -// All file paths are relative and will replace occurrences of {timestamp} with the shared file timestamp. +/// +/// Provides methods for writing files. +/// public interface IFileWritingService : IDisposable, IAsyncDisposable { + /// + /// Initializes the file writing service with the given base path. + /// + /// The base path to use for all file operations. void Init(string basePath); - void AppendToFile(string relativeFilePath, string text); + /// + /// Appends the object to the file as JSON. + /// + /// The relative path to the file. + /// The object to append. + /// The type of the object to append. + void AppendToFile(string relativeFilePath, T obj); + /// + /// Writes the text to the file. + /// + /// The relative path to the file. + /// The text to write. void WriteFile(string relativeFilePath, string text); + /// + /// Writes the text to the file. + /// + /// The relative path to the file. + /// The text to write. + /// A task that represents the asynchronous operation. Task WriteFileAsync(string relativeFilePath, string text); - void WriteFile(FileInfo relativeFilePath, string text); + /// + /// Writes the object to the file as JSON. + /// + /// The relative path to the file. + /// The object to write. + /// The type of the object to write. + void WriteFile(FileInfo relativeFilePath, T obj); + /// + /// Resolves the complete file path from the given relative file path. + /// Replaces occurrences of {timestamp} with the shared file timestamp. + /// + /// The relative path to the file. + /// The complete file path. string ResolveFilePath(string relativeFilePath); } diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Services/BcdeScanCommandService.cs b/src/Microsoft.ComponentDetection.Orchestrator/Services/BcdeScanCommandService.cs index 6b10e3fe5..d2b754b96 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Services/BcdeScanCommandService.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Services/BcdeScanCommandService.cs @@ -54,20 +54,23 @@ private void WriteComponentManifest(IDetectionArguments detectionArguments, Scan this.logger.LogInformation("Scan Manifest file: {ManifestFile}", this.fileWritingService.ResolveFilePath(ManifestRelativePath)); } - var manifestJson = JsonConvert.SerializeObject(scanResult, Formatting.Indented); - if (userRequestedManifestPath == null) { - this.fileWritingService.AppendToFile(ManifestRelativePath, manifestJson); + this.fileWritingService.AppendToFile(ManifestRelativePath, scanResult); } else { - this.fileWritingService.WriteFile(userRequestedManifestPath, manifestJson); + this.fileWritingService.WriteFile(userRequestedManifestPath, scanResult); } if (detectionArguments.PrintManifest) { - Console.WriteLine(manifestJson); + using var jsonWriter = new JsonTextWriter(Console.Out); + var serializer = new JsonSerializer + { + Formatting = Formatting.Indented, + }; + serializer.Serialize(jsonWriter, scanResult); } } } diff --git a/test/Microsoft.ComponentDetection.Common.Tests/FileWritingServiceTests.cs b/test/Microsoft.ComponentDetection.Common.Tests/FileWritingServiceTests.cs index db9f085f9..dcc1014ad 100644 --- a/test/Microsoft.ComponentDetection.Common.Tests/FileWritingServiceTests.cs +++ b/test/Microsoft.ComponentDetection.Common.Tests/FileWritingServiceTests.cs @@ -1,5 +1,7 @@ namespace Microsoft.ComponentDetection.Common.Tests; + using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using FluentAssertions; @@ -11,6 +13,17 @@ [TestCategory("Governance/ComponentDetection")] public class FileWritingServiceTests { + private const string SampleObjectJson = @"{ + ""key1"": ""value1"", + ""key2"": ""value2"" +}"; + + private static readonly IDictionary SampleObject = new Dictionary + { + { "key1", "value1" }, + { "key2", "value2" }, + }; + private FileWritingService serviceUnderTest; private string tempFolder; @@ -29,22 +42,20 @@ public void TestInitialize() } [TestCleanup] - public void TestCleanup() - { - Directory.Delete(this.tempFolder, true); - } + public void TestCleanup() => Directory.Delete(this.tempFolder, true); [TestMethod] public void AppendToFile_AppendsToFiles() { - var relativeDir = "someOtherFileName.txt"; + var relativeDir = "someOtherFileName.json"; var fileLocation = Path.Combine(this.tempFolder, relativeDir); File.Create(fileLocation).Dispose(); - this.serviceUnderTest.AppendToFile(relativeDir, "someSampleText"); + + this.serviceUnderTest.AppendToFile(relativeDir, SampleObject); this.serviceUnderTest.Dispose(); + var text = File.ReadAllText(Path.Combine(this.tempFolder, relativeDir)); - text - .Should().Be("someSampleText"); + text.Should().Be(SampleObjectJson); } [TestMethod] @@ -53,22 +64,33 @@ public void WriteFile_CreatesAFile() var relativeDir = "someFileName.txt"; this.serviceUnderTest.WriteFile(relativeDir, "sampleText"); var text = File.ReadAllText(Path.Combine(this.tempFolder, relativeDir)); - text - .Should().Be("sampleText"); + text.Should().Be("sampleText"); + } + + [TestMethod] + public void WriteFile_WritesJson() + { + var relativeDir = "someFileName.txt"; + var fileInfo = new FileInfo(Path.Combine(this.tempFolder, relativeDir)); + + this.serviceUnderTest.WriteFile(fileInfo, SampleObject); + + var text = File.ReadAllText(Path.Combine(this.tempFolder, relativeDir)); + text.Should().Be(SampleObjectJson); } [TestMethod] public void WriteFile_AppendToFile_WorkWithTemplatizedPaths() { var relativeDir = "somefile_{timestamp}.txt"; + this.serviceUnderTest.WriteFile(relativeDir, "sampleText"); - this.serviceUnderTest.AppendToFile(relativeDir, "sampleText2"); + this.serviceUnderTest.AppendToFile(relativeDir, SampleObject); this.serviceUnderTest.Dispose(); + var files = Directory.GetFiles(this.tempFolder); - files - .Should().NotBeEmpty(); - File.ReadAllText(files[0]) - .Should().Contain($"sampleTextsampleText2"); + files.Should().NotBeEmpty(); + File.ReadAllText(files[0]).Should().Contain($"sampleText{SampleObjectJson}"); this.VerifyTimestamp(files[0], "somefile_", ".txt"); } @@ -76,7 +98,9 @@ public void WriteFile_AppendToFile_WorkWithTemplatizedPaths() public void ResolveFilePath_ResolvedTemplatizedPaths() { var relativeDir = "someOtherFile_{timestamp}.txt"; + this.serviceUnderTest.WriteFile(relativeDir, string.Empty); + var fullPath = this.serviceUnderTest.ResolveFilePath(relativeDir); this.VerifyTimestamp(fullPath, "someOtherFile_", ".txt"); } @@ -86,7 +110,8 @@ public void InitLogger_FailsOnDirectoryThatDoesNotExist() { var relativeDir = Guid.NewGuid(); var actualServiceUnderTest = new FileWritingService(); - Action action = () => actualServiceUnderTest.Init(Path.Combine(this.serviceUnderTest.BasePath, relativeDir.ToString())); + + var action = () => actualServiceUnderTest.Init(Path.Combine(this.serviceUnderTest.BasePath, relativeDir.ToString())); action.Should().Throw(); }