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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 51 additions & 26 deletions src/Microsoft.ComponentDetection.Common/FileWritingService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <inheritdoc />
public sealed class FileWritingService : IFileWritingService
{
/// <summary>
/// The format string used to generate the timestamp for the manifest file.
/// </summary>
public const string TimestampFormatString = "yyyyMMddHHmmssfff";

private readonly object lockObject = new object();
private readonly string timestamp = DateTime.Now.ToString(TimestampFormatString);
private readonly ConcurrentDictionary<string, StreamWriter> bufferedStreams = new();

private readonly object lockObject = new();
private readonly string timestamp = DateTime.Now.ToString(TimestampFormatString, CultureInfo.InvariantCulture);

/// <summary>
/// The base path to write files to.
/// If null or empty, the temp path will be used.
/// </summary>
public string BasePath { get; private set; }

/// <inheritdoc />
public void Init(string basePath)
{
if (!string.IsNullOrEmpty(basePath) && !Directory.Exists(basePath))
Expand All @@ -25,19 +37,25 @@ public void Init(string basePath)
this.BasePath = string.IsNullOrEmpty(basePath) ? Path.GetTempPath() : basePath;
}

public void AppendToFile(string relativeFilePath, string text)
/// <inheritdoc />
public void AppendToFile<T>(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);
}

/// <inheritdoc />
public void WriteFile(string relativeFilePath, string text)
{
relativeFilePath = this.ResolveFilePath(relativeFilePath);
Expand All @@ -48,30 +66,52 @@ public void WriteFile(string relativeFilePath, string text)
}
}

/// <inheritdoc />
public async Task WriteFileAsync(string relativeFilePath, string text)
{
relativeFilePath = this.ResolveFilePath(relativeFilePath);

await File.WriteAllTextAsync(relativeFilePath, text);
}

public void WriteFile(FileInfo relativeFilePath, string text)
/// <inheritdoc />
public void WriteFile<T>(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);
}

/// <inheritdoc />
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;
}

/// <inheritdoc />
public void Dispose() => this.Dispose(true);

/// <inheritdoc />
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))
Expand All @@ -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 _);
}
}
}
41 changes: 38 additions & 3 deletions src/Microsoft.ComponentDetection.Common/IFileWritingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// <summary>
/// Provides methods for writing files.
/// </summary>
public interface IFileWritingService : IDisposable, IAsyncDisposable
{
/// <summary>
/// Initializes the file writing service with the given base path.
/// </summary>
/// <param name="basePath">The base path to use for all file operations.</param>
void Init(string basePath);

void AppendToFile(string relativeFilePath, string text);
/// <summary>
/// Appends the object to the file as JSON.
/// </summary>
/// <param name="relativeFilePath">The relative path to the file.</param>
/// <param name="obj">The object to append.</param>
/// <typeparam name="T">The type of the object to append.</typeparam>
void AppendToFile<T>(string relativeFilePath, T obj);

/// <summary>
/// Writes the text to the file.
/// </summary>
/// <param name="relativeFilePath">The relative path to the file.</param>
/// <param name="text">The text to write.</param>
void WriteFile(string relativeFilePath, string text);

/// <summary>
/// Writes the text to the file.
/// </summary>
/// <param name="relativeFilePath">The relative path to the file.</param>
/// <param name="text">The text to write.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task WriteFileAsync(string relativeFilePath, string text);

void WriteFile(FileInfo relativeFilePath, string text);
/// <summary>
/// Writes the object to the file as JSON.
/// </summary>
/// <param name="relativeFilePath">The relative path to the file.</param>
/// <param name="obj">The object to write.</param>
/// <typeparam name="T">The type of the object to write.</typeparam>
void WriteFile<T>(FileInfo relativeFilePath, T obj);

/// <summary>
/// Resolves the complete file path from the given relative file path.
/// Replaces occurrences of {timestamp} with the shared file timestamp.
/// </summary>
/// <param name="relativeFilePath">The relative path to the file.</param>
/// <returns>The complete file path.</returns>
string ResolveFilePath(string relativeFilePath);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace Microsoft.ComponentDetection.Common.Tests;

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using FluentAssertions;
Expand All @@ -11,6 +13,17 @@
[TestCategory("Governance/ComponentDetection")]
public class FileWritingServiceTests
{
private const string SampleObjectJson = @"{
""key1"": ""value1"",
""key2"": ""value2""
}";

private static readonly IDictionary<string, string> SampleObject = new Dictionary<string, string>
{
{ "key1", "value1" },
{ "key2", "value2" },
};

private FileWritingService serviceUnderTest;
private string tempFolder;

Expand All @@ -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]
Expand All @@ -53,30 +64,43 @@ 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");
}

[TestMethod]
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");
}
Expand All @@ -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<InvalidUserInputException>();
}
Expand Down