From a4dd8c477d5b8e1361f5a22dc1596fcc8e04cb38 Mon Sep 17 00:00:00 2001 From: Daniel Klecha Date: Fri, 8 Mar 2024 19:26:54 +0100 Subject: [PATCH 1/3] update structure and use dependency injection --- README.md | 17 +- .../Commands/ScanCommand.cs | 139 ++++++++++++ .../Directory.Packages.props | 8 +- .../DotnetThirdPartyNotices.csproj | 15 +- .../Extensions/AssemblyExtensions.cs | 14 -- .../Extensions/ProjectExtensions.cs | 110 --------- .../Extensions/ResolvedFileInfoExtensions.cs | 211 ------------------ .../Extensions/UriExtensions.cs | 69 ------ src/DotnetThirdPartyNotices/GithubService.cs | 53 ----- .../LicenseResolvers/GithubLicenseResolver.cs | 32 --- .../Interfaces/ILicenseResolver.cs | 5 - .../Interfaces/ILicenseUriLicenseResolver.cs | 10 - .../Interfaces/IProjectUriLicenseResolver.cs | 10 - .../IRepositoryUriLicenseResolver.cs | 13 -- .../LocalPackageLicenseResolver.cs | 33 --- .../OpenSourceOrgLicenseResolver.cs | 21 -- src/DotnetThirdPartyNotices/Models/NuSpec.cs | 11 + .../Models/ResolvedFileInfo.cs | 1 + .../TargetFrameworkIdentifiers.cs | 2 +- src/DotnetThirdPartyNotices/NuSpec.cs | 69 ------ src/DotnetThirdPartyNotices/Program.cs | 176 ++++----------- .../Services/GithubRawLicenseResolver.cs | 24 ++ .../GithubRepositoryLicenseResolver.cs | 38 ++++ .../IFileVersionInfoLicenseResolver.cs | 2 +- .../Services/ILicenseResolver.cs | 5 + .../Services/ILicenseService.cs | 10 + .../Services/ILicenseUriLicenseResolver.cs | 6 + .../Services/ILocalPackageService.cs | 16 ++ .../Services/IProjectService.cs | 11 + .../Services/IProjectUriLicenseResolver.cs | 6 + .../Services/IRepositoryUriLicenseResolver.cs | 6 + .../Services/IUriLicenseResolver.cs | 10 + .../Services/LicenseService.cs | 206 +++++++++++++++++ .../Services/LocalPackageService.cs | 99 ++++++++ .../NetFrameworkLicenseResolver.cs | 3 +- .../Services/OpenSourceOrgLicenseResolver.cs | 28 +++ .../Services/ProjectService.cs | 117 ++++++++++ src/DotnetThirdPartyNotices/Utils.cs | 47 ---- 38 files changed, 804 insertions(+), 849 deletions(-) create mode 100644 src/DotnetThirdPartyNotices/Commands/ScanCommand.cs delete mode 100644 src/DotnetThirdPartyNotices/Extensions/AssemblyExtensions.cs delete mode 100644 src/DotnetThirdPartyNotices/Extensions/ProjectExtensions.cs delete mode 100644 src/DotnetThirdPartyNotices/Extensions/ResolvedFileInfoExtensions.cs delete mode 100644 src/DotnetThirdPartyNotices/Extensions/UriExtensions.cs delete mode 100644 src/DotnetThirdPartyNotices/GithubService.cs delete mode 100644 src/DotnetThirdPartyNotices/LicenseResolvers/GithubLicenseResolver.cs delete mode 100644 src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseResolver.cs delete mode 100644 src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseUriLicenseResolver.cs delete mode 100644 src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IProjectUriLicenseResolver.cs delete mode 100644 src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IRepositoryUriLicenseResolver.cs delete mode 100644 src/DotnetThirdPartyNotices/LicenseResolvers/LocalPackageLicenseResolver.cs delete mode 100644 src/DotnetThirdPartyNotices/LicenseResolvers/OpenSourceOrgLicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Models/NuSpec.cs rename src/DotnetThirdPartyNotices/{ => Models}/TargetFrameworkIdentifiers.cs (82%) delete mode 100644 src/DotnetThirdPartyNotices/NuSpec.cs create mode 100644 src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs rename src/DotnetThirdPartyNotices/{LicenseResolvers/Interfaces => Services}/IFileVersionInfoLicenseResolver.cs (79%) create mode 100644 src/DotnetThirdPartyNotices/Services/ILicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Services/ILicenseService.cs create mode 100644 src/DotnetThirdPartyNotices/Services/ILicenseUriLicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs create mode 100644 src/DotnetThirdPartyNotices/Services/IProjectService.cs create mode 100644 src/DotnetThirdPartyNotices/Services/IProjectUriLicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Services/IRepositoryUriLicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Services/LicenseService.cs create mode 100644 src/DotnetThirdPartyNotices/Services/LocalPackageService.cs rename src/DotnetThirdPartyNotices/{LicenseResolvers => Services}/NetFrameworkLicenseResolver.cs (91%) create mode 100644 src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs create mode 100644 src/DotnetThirdPartyNotices/Services/ProjectService.cs delete mode 100644 src/DotnetThirdPartyNotices/Utils.cs diff --git a/README.md b/README.md index 45048c2..a9de450 100644 --- a/README.md +++ b/README.md @@ -12,25 +12,26 @@ dotnet tool install -g DotnetThirdPartyNotices ## Get started -Go inside the project directory and run: +Go inside the project or solution directory and run: ``` dotnet-thirdpartynotices ``` -To change the name of the file that will be generated: - -``` -dotnet-thirdpartynotices --output-filename "third party notices.txt" -``` - If your project is in a different directory: ``` dotnet-thirdpartynotices -dotnet-thirdpartynotices --output-filename "third party notices.txt" ``` +Options: + +- `--output-filename` allow to change output filename +- `--copy-to-outdir` allow to copy output file to output directory in Release configuration +- `--filter` allow to use regex to filter project files + +Note that if you use the solution folder and don't use `--copy-to-outdir` then licenses from all projects will be merged to single file. + ## How it works ### 1. Resolve assemblies diff --git a/src/DotnetThirdPartyNotices/Commands/ScanCommand.cs b/src/DotnetThirdPartyNotices/Commands/ScanCommand.cs new file mode 100644 index 0000000..f7f6a4a --- /dev/null +++ b/src/DotnetThirdPartyNotices/Commands/ScanCommand.cs @@ -0,0 +1,139 @@ +using DotnetThirdPartyNotices.Models; +using DotnetThirdPartyNotices.Services; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Locator; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace DotnetThirdPartyNotices.Commands; + +internal class ScanCommand : Command +{ + public ScanCommand() : base("scan", "A tool to generate file with third party legal notices for .NET projects") + { + AddArgument(new Argument("scan-dir", "Path of the directory to look for projects (optional)") { Arity = ArgumentArity.ZeroOrOne }); + AddOption(new Option("--output-filename", () => "third-party-notices.txt", "Output filename")); + AddOption(new Option("--copy-to-outdir", () => false, "Copy output file to output directory in Release configuration")); + AddOption(new Option("--filter", () => string.Empty, "Filter project files")); + } + + internal new class Handler(ILogger logger, IProjectService projectService, ILicenseService licenseService) : ICommandHandler + { + public string ScanDir { get; set; } + public string OutputFilename { get; set; } + public bool CopyToOutDir { get; set; } + public string Filter { get; set; } + public bool Merge { get; set; } + private readonly Dictionary> _licenseContents = []; + private readonly List _unresolvedFiles = []; + + public int Invoke(InvocationContext context) + { + return 0; + } + + public async Task InvokeAsync(InvocationContext context) + { + MSBuildLocator.RegisterDefaults(); + ScanDir ??= Directory.GetCurrentDirectory(); + var projectFilePaths = projectService.GetProjectFilePaths(ScanDir); + projectFilePaths = GetFilteredProjectPathes(projectFilePaths); + if (projectFilePaths.Length == 0) + { + logger.LogError("No C# or F# project file found in the directory"); + return 0; + } + foreach (var projectFilePath in projectFilePaths) + await ScanProjectAsync(projectFilePath); + if (!CopyToOutDir) + await GenerateOutputFileAsync(OutputFilename); + return 0; + } + + private string[] GetFilteredProjectPathes(string[] projectPathes) + { + if (string.IsNullOrEmpty(Filter)) + return projectPathes; + var filterRegex = new Regex(Filter, RegexOptions.None, TimeSpan.FromMilliseconds(300)); + return projectPathes.Where(x => filterRegex.IsMatch(x)).ToArray(); + } + + private async Task ScanProjectAsync(string projectFilePath) + { + var stopWatch = new Stopwatch(); + stopWatch.Start(); + logger.LogInformation("Resolving files for {ProjectName}...", Path.GetFileName(projectFilePath)); + var project = new Project(projectFilePath); + project.SetProperty("Configuration", "Release"); + project.SetProperty("DesignTimeBuild", "true"); + var resolvedFiles = projectService.ResolveFiles(project).ToList(); + logger.LogInformation("Resolved files count: {ResolvedFilesCount}", resolvedFiles.Count); + foreach (var resolvedFileInfo in resolvedFiles) + { + logger.LogInformation("Resolving license for {RelativeOutputPath}", resolvedFileInfo.RelativeOutputPath); + if (resolvedFileInfo.NuSpec != null) + { + logger.LogInformation("Package: {NuSpecId}", resolvedFileInfo.NuSpec.Id); + } + else + { + logger.LogWarning("Package not found"); + } + var licenseContent = await licenseService.ResolveFromResolvedFileInfo(resolvedFileInfo); + if (licenseContent == null) + { + _unresolvedFiles.Add(resolvedFileInfo); + logger.LogError("No license found for {RelativeOutputPath}. Source path: {SourcePath}. Verify this manually", resolvedFileInfo.RelativeOutputPath, resolvedFileInfo.SourcePath); + continue; + } + if (!_licenseContents.ContainsKey(licenseContent)) + _licenseContents[licenseContent] = []; + _licenseContents[licenseContent].Add(resolvedFileInfo); + } + stopWatch.Stop(); + logger.LogInformation("Project {ProjectName} resolved in {StopwatchElapsedMilliseconds}ms", Path.GetFileName(projectFilePath), stopWatch.ElapsedMilliseconds); + if (CopyToOutDir) + await GenerateOutputFileAsync(Path.Combine(ScanDir, project.GetPropertyValue("OutDir"), Path.GetFileName(OutputFilename))); + } + + private async Task GenerateOutputFileAsync(string outputFilePath) + { + logger.LogInformation("Resolved {LicenseContentsCount} licenses for {Sum}/{ResolvedFilesCount} files", _licenseContents.Count, _licenseContents.Values.Sum(v => v.Count), _licenseContents.Values.Sum(v => v.Count) + _unresolvedFiles.Count); + logger.LogInformation("Unresolved files: {UnresolvedFilesCount}", _unresolvedFiles.Count); + var stopWatch = new Stopwatch(); + stopWatch.Start(); + var stringBuilder = new StringBuilder(); + foreach (var (licenseContent, resolvedFileInfos) in _licenseContents) + { + var longestNameLen = 0; + foreach (var resolvedFileInfo in resolvedFileInfos) + { + var strLen = resolvedFileInfo.RelativeOutputPath.Length; + if (strLen > longestNameLen) + longestNameLen = strLen; + stringBuilder.AppendLine(resolvedFileInfo.RelativeOutputPath); + } + stringBuilder.AppendLine(new string('-', longestNameLen)); + stringBuilder.AppendLine(licenseContent); + stringBuilder.AppendLine(); + } + stopWatch.Stop(); + logger.LogInformation("Generate licenses in {StopwatchElapsedMilliseconds}ms", stopWatch.ElapsedMilliseconds); + if (stringBuilder.Length == 0) + return; + logger.LogInformation("Writing to {OutputFilename}...", outputFilePath); + await System.IO.File.WriteAllTextAsync(outputFilePath, stringBuilder.ToString()); + _licenseContents.Clear(); + _unresolvedFiles.Clear(); + } + } +} diff --git a/src/DotnetThirdPartyNotices/Directory.Packages.props b/src/DotnetThirdPartyNotices/Directory.Packages.props index 28afc6b..b7a153e 100644 --- a/src/DotnetThirdPartyNotices/Directory.Packages.props +++ b/src/DotnetThirdPartyNotices/Directory.Packages.props @@ -9,12 +9,18 @@ + + + + + + @@ -27,4 +33,4 @@ - + \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj b/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj index 28be4c2..90dbe0f 100644 --- a/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj +++ b/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj @@ -8,13 +8,18 @@ dotnet-thirdpartynotices DotnetThirdPartyNotices A .NET tool to generate file with third party legal notices - 0.2.8 + 0.2.9 MIT git https://github.com/bugproof/DotnetThirdPartyNotices + https://github.com/bugproof/DotnetThirdPartyNotices DotnetThirdPartyNotices DotnetThirdPartyNotices 1591 + bugproof + bugproof + en + True @@ -24,12 +29,10 @@ - - - - + + - + diff --git a/src/DotnetThirdPartyNotices/Extensions/AssemblyExtensions.cs b/src/DotnetThirdPartyNotices/Extensions/AssemblyExtensions.cs deleted file mode 100644 index 530a0c7..0000000 --- a/src/DotnetThirdPartyNotices/Extensions/AssemblyExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace DotnetThirdPartyNotices.Extensions; - -internal static class AssemblyExtensions -{ - public static IEnumerable GetInstances(this Assembly assembly) where T : class => assembly.GetTypes() - .Where(t => t.IsClass && typeof(T).IsAssignableFrom(t)) - .Select(Activator.CreateInstance) - .OfType(); -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Extensions/ProjectExtensions.cs b/src/DotnetThirdPartyNotices/Extensions/ProjectExtensions.cs deleted file mode 100644 index 6a9e051..0000000 --- a/src/DotnetThirdPartyNotices/Extensions/ProjectExtensions.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using DotnetThirdPartyNotices.Models; -using Microsoft.Build.Evaluation; -using Microsoft.Build.Execution; -using Microsoft.Build.Framework; -using Microsoft.Build.Logging; -using Serilog; -using ILogger = Microsoft.Build.Framework.ILogger; - -namespace DotnetThirdPartyNotices.Extensions; - -internal static class ProjectExtensions -{ - public static IEnumerable ResolveFiles(this Project project) - { - var targetFrameworksProperty = project.GetProperty("TargetFrameworks"); - if (targetFrameworksProperty != null) - { - var targetFrameworks = targetFrameworksProperty.EvaluatedValue.Split(';'); - project.SetProperty("TargetFramework", targetFrameworks[0]); - } - - var projectInstance = project.CreateProjectInstance(); - var targetFrameworkIdentifier = projectInstance.GetPropertyValue("TargetFrameworkIdentifier"); - - Log.Information("Target framework: {TargetFrameworkIdentifier}", targetFrameworkIdentifier); - - switch (targetFrameworkIdentifier) - { - case TargetFrameworkIdentifiers.NetCore: - case TargetFrameworkIdentifiers.NetStandard: - return ResolveFilesUsingComputeFilesToPublish(projectInstance); - case TargetFrameworkIdentifiers.NetFramework: - return ResolveFilesUsingResolveAssemblyReferences(projectInstance); - default: - throw new InvalidOperationException("Unsupported target framework."); - } - } - - private static IEnumerable ResolveFilesUsingResolveAssemblyReferences(ProjectInstance projectInstance) - { - var resolvedFileInfos = new List(); - - projectInstance.Build("ResolveAssemblyReferences", new ILogger[] { new ConsoleLogger(LoggerVerbosity.Minimal) }); - - foreach (var item in projectInstance.GetItems("ReferenceCopyLocalPaths")) - { - var assemblyPath = item.EvaluatedInclude; - var versionInfo = FileVersionInfo.GetVersionInfo(assemblyPath); - - var resolvedFileInfo = new ResolvedFileInfo - { - VersionInfo = versionInfo, - SourcePath = assemblyPath, - RelativeOutputPath = Path.GetFileName(assemblyPath) - }; - - if (item.GetMetadataValue("ResolvedFrom") == "{HintPathFromItem}" && item.GetMetadataValue("HintPath").StartsWith("..\\packages")) - { - var nuSpec = NuSpec.FromAssemble(assemblyPath) ?? throw new ApplicationException( $"Cannot find package path from assembly path ({assemblyPath})" ); - resolvedFileInfo.NuSpec = nuSpec; - resolvedFileInfos.Add(resolvedFileInfo); - } - else - { - resolvedFileInfos.Add(resolvedFileInfo); - } - } - - return resolvedFileInfos; - } - - private static IEnumerable ResolveFilesUsingComputeFilesToPublish(ProjectInstance projectInstance) - { - var resolvedFileInfos = new List(); - - projectInstance.Build("ComputeFilesToPublish", new ILogger[] { new ConsoleLogger(LoggerVerbosity.Minimal) }); - - foreach (var item in projectInstance.GetItems("ResolvedFileToPublish")) - { - var assemblyPath = item.EvaluatedInclude; - - var packageName = item.GetMetadataValue(item.HasMetadata("PackageName") ? "PackageName" : "NugetPackageId"); - var packageVersion = item.GetMetadataValue(item.HasMetadata("PackageName") ? "PackageVersion" : "NugetPackageVersion"); - if (packageName == string.Empty || packageVersion == string.Empty) - { - // Skip if it's not a NuGet package - continue; - } - var nuSpec = NuSpec.FromAssemble( assemblyPath ) ?? throw new ApplicationException( $"Cannot find package path from assembly path ({assemblyPath})" ); ; - - var relativePath = item.GetMetadataValue("RelativePath"); - var resolvedFileInfo = new ResolvedFileInfo - { - SourcePath = assemblyPath, - VersionInfo = FileVersionInfo.GetVersionInfo(assemblyPath), - NuSpec = nuSpec, - RelativeOutputPath = relativePath - }; - - resolvedFileInfos.Add(resolvedFileInfo); - } - - return resolvedFileInfos; - } -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Extensions/ResolvedFileInfoExtensions.cs b/src/DotnetThirdPartyNotices/Extensions/ResolvedFileInfoExtensions.cs deleted file mode 100644 index 998f7d4..0000000 --- a/src/DotnetThirdPartyNotices/Extensions/ResolvedFileInfoExtensions.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using DotnetThirdPartyNotices.LicenseResolvers.Interfaces; -using DotnetThirdPartyNotices.Models; - -namespace DotnetThirdPartyNotices.Extensions; - -internal static class ResolvedFileInfoExtensions -{ - // Create instances only once - private static readonly Lazy> LicenseResolvers = - new(() => GetInstancesFromExecutingAssembly().ToList()); - - private static readonly Lazy> LicenseUriLicenseResolvers = - new(() => LicenseResolvers.Value.OfType().ToList()); - - private static readonly Lazy> ProjectUriLicenseResolvers = - new(() => LicenseResolvers.Value.OfType().ToList()); - - private static readonly Lazy> RepositoryUriLicenseResolvers = - new(() => LicenseResolvers.Value.OfType().ToList()); - - private static readonly Lazy> FileVersionInfoLicenseResolvers = - new(() => LicenseResolvers.Value.OfType().ToList()); - - private static IEnumerable GetInstancesFromExecutingAssembly() where T : class - { - return Assembly.GetExecutingAssembly().GetInstances(); - } - - private static bool TryFindLicenseUriLicenseResolver(Uri licenseUri, out ILicenseUriLicenseResolver resolver) - { - resolver = LicenseUriLicenseResolvers.Value.FirstOrDefault(r => r.CanResolve(licenseUri)); - return resolver != null; - } - - private static bool TryFindRepositoryUriLicenseResolver( Uri licenseUri, out IRepositoryUriLicenseResolver resolver ) - { - resolver = RepositoryUriLicenseResolvers.Value.FirstOrDefault(r => r.CanResolve(licenseUri)); - return resolver != null; - } - - private static bool TryFindProjectUriLicenseResolver(Uri projectUri, out IProjectUriLicenseResolver resolver) - { - resolver = ProjectUriLicenseResolvers.Value.FirstOrDefault(r => r.CanResolve(projectUri)); - return resolver != null; - } - - private static bool TryFindFileVersionInfoLicenseResolver( - FileVersionInfo fileVersionInfo, out IFileVersionInfoLicenseResolver resolver) - { - resolver = FileVersionInfoLicenseResolvers.Value.FirstOrDefault(r => r.CanResolve(fileVersionInfo)); - return resolver != null; - } - - public static async Task ResolveLicense(this ResolvedFileInfo resolvedFileInfo) - { - if (resolvedFileInfo == null) throw new ArgumentNullException(nameof(resolvedFileInfo)); - string license = null; - if (resolvedFileInfo.NuSpec != null) - license = await ResolveLicenseFromNuspec(resolvedFileInfo); - - return license ?? await ResolveLicense(resolvedFileInfo.VersionInfo); - } - - private static readonly Dictionary LicenseCache = new(); - - private static async Task ResolveLicenseFromNuspec( ResolvedFileInfo resolvedFileInfo ) - { - var nuSpec = resolvedFileInfo.NuSpec; - if (LicenseCache.ContainsKey(nuSpec.Id)) - return LicenseCache[nuSpec.Id]; - - var licenseUrl = nuSpec.LicenseUrl; - var repositoryUrl = nuSpec.RepositoryUrl; - var projectUrl = nuSpec.ProjectUrl; - - if (!string.IsNullOrEmpty(nuSpec.LicenseRelativePath)) - { - if (LicenseCache.TryGetValue(nuSpec.LicenseRelativePath, out string value)) - return value; - var license3 = await ResolveLicenseFromRelativePath(resolvedFileInfo.VersionInfo, nuSpec.LicenseRelativePath); - if (license3 != null) - { - LicenseCache[nuSpec.Id] = license3; - LicenseCache[nuSpec.LicenseRelativePath] = license3; - return license3; - } - } - - // Try to get the license from license url - if (!string.IsNullOrEmpty(nuSpec.LicenseUrl)) - { - if (LicenseCache.TryGetValue(licenseUrl, out string value)) - return value; - - var license = await ResolveLicenseFromLicenseUri(new Uri(nuSpec.LicenseUrl)); - if (license != null) - { - LicenseCache[licenseUrl] = license; - LicenseCache[nuSpec.Id] = license; - return license; - } - } - - // Try to get the license from repository url - if (!string.IsNullOrEmpty(repositoryUrl)) - { - if (LicenseCache.TryGetValue(repositoryUrl, out string value)) - return value; - var license = await ResolveLicenseFromRepositoryUri(new Uri(repositoryUrl)); - if (license != null) - { - LicenseCache[repositoryUrl] = license; - LicenseCache[nuSpec.Id] = license; - return license; - } - } - - // Otherwise try to get the license from project url - if (string.IsNullOrEmpty(projectUrl)) - { - if (LicenseCache.TryGetValue(projectUrl, out string value)) - return value; - - var license2 = await ResolveLicenseFromProjectUri(new Uri(projectUrl)); - if (license2 != null) - { - LicenseCache[nuSpec.Id] = license2; - LicenseCache[nuSpec.ProjectUrl] = license2; - return license2; - } - } - - return null; - } - - private static async Task ResolveLicense(FileVersionInfo fileVersionInfo) - { - if (LicenseCache.ContainsKey(fileVersionInfo.FileName)) - return LicenseCache[fileVersionInfo.FileName]; - var license = await ResolveLicenseFromFileVersionInfo(fileVersionInfo); - if(license == null) - return null; - LicenseCache[fileVersionInfo.FileName] = license; - return license; - } - - private static async Task ResolveLicenseFromLicenseUri(Uri licenseUri) - { - if (TryFindLicenseUriLicenseResolver(licenseUri, out var licenseUriLicenseResolver)) - return await licenseUriLicenseResolver.Resolve(licenseUri); - - // TODO: redirect uris should be checked at the very end to save us from redundant requests (when no resolver for anything can be found) - var redirectUri = await licenseUri.GetRedirectUri(); - if (redirectUri != null) - return await ResolveLicenseFromLicenseUri(redirectUri); - - // Finally, if no license uri can be found despite all the redirects, try to blindly get it - return await licenseUri.GetPlainText(); - } - - private static async Task ResolveLicenseFromRepositoryUri(Uri repositoryUri) - { - if (TryFindRepositoryUriLicenseResolver(repositoryUri, out var repositoryUriLicenseResolver)) - return await repositoryUriLicenseResolver.Resolve(repositoryUri); - - // TODO: redirect uris should be checked at the very end to save us from redundant requests (when no resolver for anything can be found) - var redirectUri = await repositoryUri.GetRedirectUri(); - if (redirectUri != null) - return await ResolveLicenseFromLicenseUri(redirectUri); - - // Finally, if no license uri can be found despite all the redirects, try to blindly get it - return await repositoryUri.GetPlainText(); - } - - private static async Task ResolveLicenseFromRelativePath(FileVersionInfo fileVersionInfo, string relativePath) - { - var packagePath = Utils.GetPackagePath( fileVersionInfo.FileName ); - var licenseFullPath = Path.Combine( packagePath, relativePath ); - if (!licenseFullPath.EndsWith(".txt") && !licenseFullPath.EndsWith( ".md" ) || !File.Exists( licenseFullPath )) - return null; - return await File.ReadAllTextAsync( licenseFullPath ); - } - - private static async Task ResolveLicenseFromProjectUri(Uri projectUri) - { - if (TryFindProjectUriLicenseResolver(projectUri, out var projectUriLicenseResolver)) - return await projectUriLicenseResolver.Resolve(projectUri); - - // TODO: redirect uris should be checked at the very end to save us from redundant requests (when no resolver for anything can be found) - var redirectUri = await projectUri.GetRedirectUri(); - if (redirectUri != null) - return await ResolveLicenseFromProjectUri(redirectUri); - - return null; - } - - private static async Task ResolveLicenseFromFileVersionInfo(FileVersionInfo fileVersionInfo) - { - if (!TryFindFileVersionInfoLicenseResolver(fileVersionInfo, out var fileVersionInfoLicenseResolver)) - return null; - - return await fileVersionInfoLicenseResolver.Resolve(fileVersionInfo); - } -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Extensions/UriExtensions.cs b/src/DotnetThirdPartyNotices/Extensions/UriExtensions.cs deleted file mode 100644 index e05a1f1..0000000 --- a/src/DotnetThirdPartyNotices/Extensions/UriExtensions.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; - -namespace DotnetThirdPartyNotices.Extensions; - -internal static class UriExtensions -{ - public static bool IsGithubUri(this Uri uri) => uri.Host == "github.com"; - - public static Uri ToRawGithubUserContentUri(this Uri uri) - { - if (!IsGithubUri(uri)) - throw new InvalidOperationException(); - - var uriBuilder = new UriBuilder(uri) { Host = "raw.githubusercontent.com" }; - uriBuilder.Path = uriBuilder.Path.Replace("/blob", string.Empty); - return uriBuilder.Uri; - } - - public static async Task GetRedirectUri(this Uri uri) - { - using var httpClientHandler = new HttpClientHandler - { - AllowAutoRedirect = false - }; - using var httpClient = new HttpClient(httpClientHandler); - var httpResponseMessage = await httpClient.GetAsync(uri); - - var statusCode = (int)httpResponseMessage.StatusCode; - - if (statusCode is < 300 or > 399) - { - return null; - } - - var redirectUri = httpResponseMessage.Headers.Location; - if (!redirectUri.IsAbsoluteUri) - { - redirectUri = new Uri(httpResponseMessage.RequestMessage.RequestUri, redirectUri); - } - - return redirectUri; - } - - public static async Task GetPlainText(this Uri uri) - { - using var httpClient = new HttpClient(); - var httpResponseMessage = await httpClient.GetAsync(uri); - if (!httpResponseMessage.IsSuccessStatusCode && uri.AbsolutePath.EndsWith(".txt")) - { - // try without .txt extension - var fixedUri = new UriBuilder(uri); - fixedUri.Path = fixedUri.Path.Remove(fixedUri.Path.Length - 4); - httpResponseMessage = await httpClient.GetAsync(fixedUri.Uri); - if (!httpResponseMessage.IsSuccessStatusCode) - { - return null; - } - } - - if (httpResponseMessage.Content.Headers.ContentType.MediaType != "text/plain") - { - return null; - } - - return await httpResponseMessage.Content.ReadAsStringAsync(); - } -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/GithubService.cs b/src/DotnetThirdPartyNotices/GithubService.cs deleted file mode 100644 index 6f82241..0000000 --- a/src/DotnetThirdPartyNotices/GithubService.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using System.Text.Json; - -namespace DotnetThirdPartyNotices; - -internal class GithubService : IDisposable -{ - private readonly HttpClient _httpClient; - - public GithubService() - { - _httpClient = new HttpClient {BaseAddress = new Uri("https://api.github.com")}; - // https://developer.github.com/v3/#user-agent-required - _httpClient.DefaultRequestHeaders.Add("User-Agent", "DotnetLicense"); - } - - public void Dispose() - { - _httpClient?.Dispose(); - } - - public async Task GetLicenseContentFromId(string licenseId) - { - var json = await _httpClient.GetStringAsync($"licenses/{licenseId}"); - var jsonDocument = JsonDocument.Parse(json); - return jsonDocument.RootElement.GetProperty("body").GetString(); - } - - public async Task GetLicenseContentFromRepositoryPath(string repositoryPath) - { - repositoryPath = repositoryPath.TrimEnd('/'); - if (repositoryPath.EndsWith(".git")) - repositoryPath = repositoryPath[..^4]; - var response = await _httpClient.GetAsync($"repos{repositoryPath}/license"); - if (!response.IsSuccessStatusCode) - return null; - var json = await response.Content.ReadAsStringAsync(); - var jsonDocument = JsonDocument.Parse(json); - - var rootElement = jsonDocument.RootElement; - var encoding = rootElement.GetProperty("encoding").GetString(); - var content = rootElement.GetProperty("content").GetString(); - - if (encoding != "base64") return content; - - var bytes = Convert.FromBase64String(content); - return Encoding.UTF8.GetString(bytes); - } - -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/GithubLicenseResolver.cs b/src/DotnetThirdPartyNotices/LicenseResolvers/GithubLicenseResolver.cs deleted file mode 100644 index 7024455..0000000 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/GithubLicenseResolver.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Threading.Tasks; -using DotnetThirdPartyNotices.Extensions; -using DotnetThirdPartyNotices.LicenseResolvers.Interfaces; - -namespace DotnetThirdPartyNotices.LicenseResolvers; - -internal class GithubLicenseResolver : ILicenseUriLicenseResolver, IProjectUriLicenseResolver, IRepositoryUriLicenseResolver -{ - bool ILicenseUriLicenseResolver.CanResolve(Uri uri) => uri.IsGithubUri(); - bool IProjectUriLicenseResolver.CanResolve(Uri uri) => uri.IsGithubUri(); - bool IRepositoryUriLicenseResolver.CanResolve(Uri uri) => uri.IsGithubUri(); - - Task ILicenseUriLicenseResolver.Resolve(Uri licenseUri) - { - licenseUri = licenseUri.ToRawGithubUserContentUri(); - - return licenseUri.GetPlainText(); - } - - async Task IProjectUriLicenseResolver.Resolve(Uri projectUri) - { - using var githubService = new GithubService(); - return await githubService.GetLicenseContentFromRepositoryPath(projectUri.AbsolutePath); - } - - async Task IRepositoryUriLicenseResolver.Resolve(Uri projectUri) - { - using var githubService = new GithubService(); - return await githubService.GetLicenseContentFromRepositoryPath(projectUri.AbsolutePath); - } -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseResolver.cs b/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseResolver.cs deleted file mode 100644 index 552949d..0000000 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseResolver.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace DotnetThirdPartyNotices.LicenseResolvers.Interfaces; - -internal interface ILicenseResolver -{ -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseUriLicenseResolver.cs deleted file mode 100644 index 7d69c67..0000000 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/ILicenseUriLicenseResolver.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace DotnetThirdPartyNotices.LicenseResolvers.Interfaces; - -internal interface ILicenseUriLicenseResolver : ILicenseResolver -{ - bool CanResolve(Uri licenseUri); - Task Resolve(Uri licenseUri); -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IProjectUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IProjectUriLicenseResolver.cs deleted file mode 100644 index ee54616..0000000 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IProjectUriLicenseResolver.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace DotnetThirdPartyNotices.LicenseResolvers.Interfaces; - -internal interface IProjectUriLicenseResolver : ILicenseResolver -{ - bool CanResolve(Uri projectUri); - Task Resolve(Uri projectUri); -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IRepositoryUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IRepositoryUriLicenseResolver.cs deleted file mode 100644 index 4d395f7..0000000 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IRepositoryUriLicenseResolver.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace DotnetThirdPartyNotices.LicenseResolvers.Interfaces; - -internal interface IRepositoryUriLicenseResolver : ILicenseResolver -{ - bool CanResolve(Uri projectUri); - Task Resolve(Uri projectUri); -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/LocalPackageLicenseResolver.cs b/src/DotnetThirdPartyNotices/LicenseResolvers/LocalPackageLicenseResolver.cs deleted file mode 100644 index eaf96f3..0000000 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/LocalPackageLicenseResolver.cs +++ /dev/null @@ -1,33 +0,0 @@ -using DotnetThirdPartyNotices.LicenseResolvers.Interfaces; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace DotnetThirdPartyNotices.LicenseResolvers; - -internal class LocalPackageLicenseResolver : IFileVersionInfoLicenseResolver -{ - public bool CanResolve( FileVersionInfo fileVersionInfo ) => GetLicensePath(fileVersionInfo) != null; - - public async Task Resolve(FileVersionInfo fileVersionInfo) - { - var licensePath = GetLicensePath(fileVersionInfo); - if (licensePath == null) - return null; - return await File.ReadAllTextAsync( licensePath ); - } - - private string GetLicensePath( FileVersionInfo fileVersionInfo ) - { - var directoryPath = Utils.GetPackagePath( fileVersionInfo.FileName ) ?? Path.GetDirectoryName( fileVersionInfo.FileName ); - return Directory.EnumerateFiles( directoryPath, "license.*", new EnumerationOptions - { - MatchCasing = MatchCasing.CaseInsensitive, - RecurseSubdirectories = false - } ).FirstOrDefault( x => x.EndsWith( "\\license.txt", StringComparison.OrdinalIgnoreCase ) || x.EndsWith( "\\license.md", StringComparison.OrdinalIgnoreCase ) ); - } -} diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/OpenSourceOrgLicenseResolver.cs b/src/DotnetThirdPartyNotices/LicenseResolvers/OpenSourceOrgLicenseResolver.cs deleted file mode 100644 index d925a8a..0000000 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/OpenSourceOrgLicenseResolver.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Threading.Tasks; -using DotnetThirdPartyNotices.LicenseResolvers.Interfaces; - -namespace DotnetThirdPartyNotices.LicenseResolvers; - -internal class OpenSourceOrgLicenseResolver : ILicenseUriLicenseResolver -{ - public bool CanResolve(Uri licenseUri) => licenseUri.Host == "opensource.org"; - - public async Task Resolve(Uri licenseUri) - { - var s = licenseUri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - - if (s[0] != "licenses") return null; - - var licenseId = s[1]; - using var githubService = new GithubService(); - return await githubService.GetLicenseContentFromId(licenseId); - } -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Models/NuSpec.cs b/src/DotnetThirdPartyNotices/Models/NuSpec.cs new file mode 100644 index 0000000..b2b6020 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Models/NuSpec.cs @@ -0,0 +1,11 @@ +namespace DotnetThirdPartyNotices.Models; + +public record NuSpec +{ + public string Id { get; init; } + public string Version { get; init; } + public string LicenseUrl { get; init; } + public string ProjectUrl { get; init; } + public string RepositoryUrl { get; init; } + public string LicenseRelativePath { get; init; } +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs b/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs index bdcf646..d9467ce 100644 --- a/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs +++ b/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs @@ -8,4 +8,5 @@ internal class ResolvedFileInfo public string RelativeOutputPath { get; set; } public FileVersionInfo VersionInfo { get; set; } public NuSpec NuSpec { get; set; } + public string PackagePath { get; set; } } \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/TargetFrameworkIdentifiers.cs b/src/DotnetThirdPartyNotices/Models/TargetFrameworkIdentifiers.cs similarity index 82% rename from src/DotnetThirdPartyNotices/TargetFrameworkIdentifiers.cs rename to src/DotnetThirdPartyNotices/Models/TargetFrameworkIdentifiers.cs index b1ad56a..60ca410 100644 --- a/src/DotnetThirdPartyNotices/TargetFrameworkIdentifiers.cs +++ b/src/DotnetThirdPartyNotices/Models/TargetFrameworkIdentifiers.cs @@ -1,4 +1,4 @@ -namespace DotnetThirdPartyNotices; +namespace DotnetThirdPartyNotices.Models; internal static class TargetFrameworkIdentifiers { diff --git a/src/DotnetThirdPartyNotices/NuSpec.cs b/src/DotnetThirdPartyNotices/NuSpec.cs deleted file mode 100644 index 9d515e4..0000000 --- a/src/DotnetThirdPartyNotices/NuSpec.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Xml; -using System.Xml.Linq; - -namespace DotnetThirdPartyNotices; - -public record NuSpec -{ - public string Id { get; init; } - public string Version { get; init; } - public string LicenseUrl { get; init; } - public string ProjectUrl { get; init; } - public string RepositoryUrl { get; init; } - public string LicenseRelativePath { get; init; } - - private static NuSpec FromTextReader(TextReader streamReader) - { - using var xmlReader = XmlReader.Create(streamReader); - var xDocument = XDocument.Load(xmlReader); - if (xDocument.Root == null) return null; - var ns = xDocument.Root.GetDefaultNamespace(); - - var metadata = xDocument.Root.Element(ns + "metadata"); - if (metadata == null) return null; - - return new NuSpec - { - Id = metadata.Element(ns + "id")?.Value, - Version = metadata.Element(ns + "version")?.Value, - LicenseUrl = metadata.Element(ns + "licenseUrl")?.Value, - ProjectUrl = metadata.Element(ns + "projectUrl")?.Value, - RepositoryUrl = metadata.Element(ns + "repository")?.Attribute("url")?.Value, - LicenseRelativePath = metadata.Elements(ns + "license").Where(x => x.Attribute("type")?.Value == "file").FirstOrDefault()?.Value - }; - } - - public static NuSpec FromFile(string fileName) - { - ArgumentNullException.ThrowIfNull( fileName ); - using var xmlReader = new StreamReader(fileName); - return FromTextReader(xmlReader); - } - - public static NuSpec FromNupkg(string fileName) - { - ArgumentNullException.ThrowIfNull( fileName ); - using var zipToCreate = new FileStream(fileName, FileMode.Open, FileAccess.Read); - using var zip = new ZipArchive(zipToCreate, ZipArchiveMode.Read); - var zippedNuspec = zip.Entries.Single(e => e.FullName.EndsWith(".nuspec")); - using var stream = zippedNuspec.Open(); - using var streamReader = new StreamReader(stream); - return FromTextReader(streamReader); - } - - public static NuSpec FromAssemble(string assemblePath) - { - if (assemblePath == null) throw new ArgumentNullException(nameof(assemblePath)); - var nuspec = Utils.GetNuspecPath(assemblePath); - if (nuspec != null) - return FromFile( nuspec ); - var nupkg = Utils.GetNupkgPath(assemblePath); - if(nupkg != null) - return FromNupkg(nupkg); - return null; - } -} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Program.cs b/src/DotnetThirdPartyNotices/Program.cs index 954d109..43986ed 100644 --- a/src/DotnetThirdPartyNotices/Program.cs +++ b/src/DotnetThirdPartyNotices/Program.cs @@ -1,142 +1,52 @@ -using DotnetThirdPartyNotices.Extensions; -using DotnetThirdPartyNotices.Models; -using Microsoft.Build.Evaluation; -using Microsoft.Build.Locator; -using System.Collections.Generic; +using DotnetThirdPartyNotices.Commands; +using DotnetThirdPartyNotices.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; using System.CommandLine; -using System.Diagnostics; -using System.IO; +using System.CommandLine.Builder; +using System.CommandLine.Hosting; +using System.CommandLine.Parsing; using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Serilog; +using System.Reflection; Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() + .MinimumLevel.Warning() + .MinimumLevel.Override("DotnetThirdPartyNotices", LogEventLevel.Debug) + .Enrich.FromLogContext() .WriteTo.Console() .CreateLogger(); -var scanDirArgument = new Argument("scan-dir", "Path of the directory to look for projects (optional)") {Arity = ArgumentArity.ZeroOrOne}; -var outputFileOption = new Option( - "--output-filename", - () => "third-party-notices.txt", - "Output filename"); -var copyToOutDirOption = - new Option("--copy-to-outdir", () => false, "Copy to output directory in Release configuration"); - -var rootCommand = new RootCommand("A tool to generate file with third party legal notices for .NET projects") -{ - scanDirArgument, - outputFileOption, - copyToOutDirOption -}; - -rootCommand.SetHandler(async (scanDir, outputFilename, copyToOutDir) => -{ - MSBuildLocator.RegisterDefaults(); - - await Run(scanDir, outputFilename, copyToOutDir); -}, scanDirArgument, outputFileOption, copyToOutDirOption); - -return await rootCommand.InvokeAsync(args); - -static async Task Run(string scanDir, string outputFilename, bool copyToOutputDir) -{ - var scanDirectory = scanDir ?? Directory.GetCurrentDirectory(); - Log.Information("Scan directory {ScanDirectory}", scanDirectory); - var projectFilePath = Directory.GetFiles(scanDirectory, "*.*", SearchOption.TopDirectoryOnly) - .SingleOrDefault(s => s.EndsWith(".csproj") || s.EndsWith(".fsproj")); - if (projectFilePath == null) - { - Log.Error("No C# or F# project file found in the current directory"); - return; - } - - var project = new Project(projectFilePath); - project.SetProperty("Configuration", "Release"); - project.SetProperty("DesignTimeBuild", "true"); - - Log.Information("Resolving files..."); - - var stopwatch = new Stopwatch(); - - stopwatch.Start(); - - var licenseContents = new Dictionary>(); - var resolvedFiles = project.ResolveFiles().ToList(); - - Log.Information("Resolved files count: {ResolvedFilesCount}", resolvedFiles.Count); - - var unresolvedFiles = new List(); - - foreach (var resolvedFileInfo in resolvedFiles) - { - Log.Information("Resolving license for {RelativeOutputPath}", resolvedFileInfo.RelativeOutputPath); - if (resolvedFileInfo.NuSpec != null) - { - Log.Information("Package: {NuSpecId}", resolvedFileInfo.NuSpec.Id); - } - else - { - Log.Warning("Package not found"); - } - - var licenseContent = await resolvedFileInfo.ResolveLicense(); - if (licenseContent == null) - { - unresolvedFiles.Add(resolvedFileInfo); - Log.Error("No license found for {RelativeOutputPath}. Source path: {SourcePath}. Verify this manually", - resolvedFileInfo.RelativeOutputPath, resolvedFileInfo.SourcePath); - continue; - } - - if (!licenseContents.ContainsKey(licenseContent)) - licenseContents[licenseContent] = new List(); - - licenseContents[licenseContent].Add(resolvedFileInfo); - } - - stopwatch.Stop(); - - Log.Information("Resolved {LicenseContentsCount} licenses for {Sum}/{ResolvedFilesCount} files in {StopwatchElapsedMilliseconds}ms", licenseContents.Count, licenseContents.Values.Sum(v => v.Count), resolvedFiles.Count, stopwatch.ElapsedMilliseconds); - Log.Information("Unresolved files: {UnresolvedFilesCount}", unresolvedFiles.Count); - - stopwatch.Start(); - - var stringBuilder = new StringBuilder(); - - foreach (var (licenseContent, resolvedFileInfos) in licenseContents) - { - var longestNameLen = 0; - foreach (var resolvedFileInfo in resolvedFileInfos) - { - var strLen = resolvedFileInfo.RelativeOutputPath.Length; - if (strLen > longestNameLen) - longestNameLen = strLen; - - stringBuilder.AppendLine(resolvedFileInfo.RelativeOutputPath); - } - - stringBuilder.AppendLine(new string('-', longestNameLen)); - - stringBuilder.AppendLine(licenseContent); - stringBuilder.AppendLine(); - } - - stopwatch.Stop(); - - if (stringBuilder.Length > 0) +var builder = new CommandLineBuilder(new ScanCommand()) + .UseDefaults() + .UseHost(Host.CreateDefaultBuilder, builder => { - if (copyToOutputDir) + builder.UseCommandHandler(); + builder.ConfigureServices(x => { - outputFilename = Path.Combine( - Path.Combine(scanDirectory, project.GetPropertyValue("OutDir")), - Path.GetFileName(outputFilename)); - } - - Log.Information("Writing to {OutputFilename}...", outputFilename); - await File.WriteAllTextAsync(outputFilename, stringBuilder.ToString()); - - Log.Information("Done in {StopwatchElapsedMilliseconds}ms", stopwatch.ElapsedMilliseconds); - } -} \ No newline at end of file + x.AddLogging(x => + { + x.ClearProviders(); + x.AddSerilog(dispose: true); + }); + x.AddSingleton(); + x.AddSingleton(); + x.AddSingleton(); + x.AddHttpClient(); + var assembly = Assembly.GetExecutingAssembly(); + var types = assembly.GetTypes(); + foreach (var type in types.Where(t => typeof(ILicenseUriLicenseResolver).IsAssignableFrom(t) && !t.IsInterface)) + x.AddSingleton(typeof(ILicenseUriLicenseResolver), type); + foreach (var type in types.Where(t => typeof(IProjectUriLicenseResolver).IsAssignableFrom(t) && !t.IsInterface)) + x.AddSingleton(typeof(IProjectUriLicenseResolver), type); + foreach (var type in types.Where(t => typeof(IRepositoryUriLicenseResolver).IsAssignableFrom(t) && !t.IsInterface)) + x.AddSingleton(typeof(IRepositoryUriLicenseResolver), type); + foreach (var type in types.Where(t => typeof(IFileVersionInfoLicenseResolver).IsAssignableFrom(t) && !t.IsInterface)) + x.AddSingleton(typeof(IFileVersionInfoLicenseResolver), type); + }); + }) + .Build(); +await builder.InvokeAsync(args); +return 0; \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs new file mode 100644 index 0000000..243765f --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs @@ -0,0 +1,24 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace DotnetThirdPartyNotices.Services; + +internal class GithubRawLicenseResolver(IHttpClientFactory httpClientFactory) : ILicenseUriLicenseResolver +{ + public bool CanResolve(Uri uri) => uri.Host == "github.com"; + + public async Task Resolve(Uri uri) + { + var uriBuilder = new UriBuilder(uri) { Host = "raw.githubusercontent.com" }; + uriBuilder.Path = uriBuilder.Path.Replace("/blob", string.Empty); + var httpClient = httpClientFactory.CreateClient(); + // https://developer.github.com/v3/#user-agent-required + httpClient.DefaultRequestHeaders.Add("User-Agent", "DotnetLicense"); + var httpResponseMessage = await httpClient.GetAsync(uriBuilder.Uri); + if (!httpResponseMessage.IsSuccessStatusCode + || httpResponseMessage.Content.Headers.ContentType.MediaType != "text/plain") + return null; + return await httpResponseMessage.Content.ReadAsStringAsync(); + } +} diff --git a/src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs new file mode 100644 index 0000000..9e46aab --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs @@ -0,0 +1,38 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace DotnetThirdPartyNotices.Services; + +internal class GithubRepositoryLicenseResolver(IHttpClientFactory httpClientFactory) : IProjectUriLicenseResolver, IRepositoryUriLicenseResolver +{ + public bool CanResolve(Uri uri) => uri.Host == "github.com"; + + public async Task Resolve(Uri licenseUri) + { + var repositoryPath = licenseUri.AbsolutePath.TrimEnd('/'); + if (repositoryPath.EndsWith(".git")) + repositoryPath = repositoryPath[..^4]; + var httpClient = httpClientFactory.CreateClient(); + UriBuilder uriBuilder = new("https://api.github.com") + { + Path = $"repos{repositoryPath}/license" + }; + // https://developer.github.com/v3/#user-agent-required + httpClient.DefaultRequestHeaders.Add("User-Agent", "DotnetLicense"); + var response = await httpClient.GetAsync(uriBuilder.Uri); + if (!response.IsSuccessStatusCode) + return null; + var json = await response.Content.ReadAsStringAsync(); + var jsonDocument = JsonDocument.Parse(json); + var rootElement = jsonDocument.RootElement; + var encoding = rootElement.GetProperty("encoding").GetString(); + var content = rootElement.GetProperty("content").GetString(); + if (encoding != "base64") + return content; + var bytes = Convert.FromBase64String(content); + return Encoding.UTF8.GetString(bytes); + } +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IFileVersionInfoLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/IFileVersionInfoLicenseResolver.cs similarity index 79% rename from src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IFileVersionInfoLicenseResolver.cs rename to src/DotnetThirdPartyNotices/Services/IFileVersionInfoLicenseResolver.cs index 7645946..bc23c79 100644 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/Interfaces/IFileVersionInfoLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/IFileVersionInfoLicenseResolver.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using System.Threading.Tasks; -namespace DotnetThirdPartyNotices.LicenseResolvers.Interfaces; +namespace DotnetThirdPartyNotices.Services; internal interface IFileVersionInfoLicenseResolver : ILicenseResolver { diff --git a/src/DotnetThirdPartyNotices/Services/ILicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/ILicenseResolver.cs new file mode 100644 index 0000000..d2650bf --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/ILicenseResolver.cs @@ -0,0 +1,5 @@ +namespace DotnetThirdPartyNotices.Services; + +internal interface ILicenseResolver +{ +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/ILicenseService.cs b/src/DotnetThirdPartyNotices/Services/ILicenseService.cs new file mode 100644 index 0000000..baea27e --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/ILicenseService.cs @@ -0,0 +1,10 @@ +using DotnetThirdPartyNotices.Models; +using System.Threading.Tasks; + +namespace DotnetThirdPartyNotices.Services +{ + internal interface ILicenseService + { + Task ResolveFromResolvedFileInfo(ResolvedFileInfo resolvedFileInfo); + } +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/ILicenseUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/ILicenseUriLicenseResolver.cs new file mode 100644 index 0000000..d9dd6e2 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/ILicenseUriLicenseResolver.cs @@ -0,0 +1,6 @@ +namespace DotnetThirdPartyNotices.Services; + +internal interface ILicenseUriLicenseResolver : IUriLicenseResolver +{ + +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs b/src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs new file mode 100644 index 0000000..ed780eb --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs @@ -0,0 +1,16 @@ +using DotnetThirdPartyNotices.Models; +using System.IO; + +namespace DotnetThirdPartyNotices.Services +{ + public interface ILocalPackageService + { + string GetNupkgPathFromPackagePath(string packagePath); + NuSpec GetNuSpecFromNupkgPath(string path); + NuSpec GetNuSpecFromNuspecPath(string path); + NuSpec GetNuSpecFromPackagePath(string path); + NuSpec GetNuSpecFromStreamReader(TextReader textReader); + string GetNuspecPathFromPackagePath(string packagePath); + string GetPackagePathFromAssemblyPath(string assemblyPath); + } +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/IProjectService.cs b/src/DotnetThirdPartyNotices/Services/IProjectService.cs new file mode 100644 index 0000000..a93d908 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/IProjectService.cs @@ -0,0 +1,11 @@ +using DotnetThirdPartyNotices.Models; +using Microsoft.Build.Evaluation; +using System.Collections.Generic; + +namespace DotnetThirdPartyNotices.Services; + +internal interface IProjectService +{ + string[] GetProjectFilePaths(string directoryPath); + IEnumerable ResolveFiles(Project project); +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/IProjectUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/IProjectUriLicenseResolver.cs new file mode 100644 index 0000000..0f4a984 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/IProjectUriLicenseResolver.cs @@ -0,0 +1,6 @@ +namespace DotnetThirdPartyNotices.Services; + +internal interface IProjectUriLicenseResolver : IUriLicenseResolver +{ + +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/IRepositoryUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/IRepositoryUriLicenseResolver.cs new file mode 100644 index 0000000..3b19854 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/IRepositoryUriLicenseResolver.cs @@ -0,0 +1,6 @@ +namespace DotnetThirdPartyNotices.Services; + +internal interface IRepositoryUriLicenseResolver : IUriLicenseResolver +{ + +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs new file mode 100644 index 0000000..223297f --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace DotnetThirdPartyNotices.Services; + +internal interface IUriLicenseResolver +{ + bool CanResolve(Uri licenseUri); + Task Resolve(Uri licenseUri); +} diff --git a/src/DotnetThirdPartyNotices/Services/LicenseService.cs b/src/DotnetThirdPartyNotices/Services/LicenseService.cs new file mode 100644 index 0000000..44b8975 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/LicenseService.cs @@ -0,0 +1,206 @@ +using DotnetThirdPartyNotices.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace DotnetThirdPartyNotices.Services; + +internal class LicenseService(IEnumerable licenseUriLicenseResolvers, IEnumerable projectUriLicenseResolvers, IEnumerable repositoryUriLicenseResolvers, IEnumerable fileVersionInfoLicenseResolvers, IHttpClientFactory httpClientFactory) : ILicenseService +{ + private static readonly Dictionary LicenseCache = []; + + public async Task ResolveFromResolvedFileInfo(ResolvedFileInfo resolvedFileInfo) + { + ArgumentNullException.ThrowIfNull(resolvedFileInfo); + if (resolvedFileInfo.NuSpec != null && LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.Id, out string value)) + return value; + return (await ResolveFromLicenseRelativePathAsync(resolvedFileInfo)) + ?? (await ResolveFromLicenseUrlAsync(resolvedFileInfo, false)) + ?? (await ResolveFromRepositoryUrlAsync(resolvedFileInfo, false)) + ?? (await ResolveFromProjectUrlAsync(resolvedFileInfo, false)) + ?? (await ResolveFromPackagePathAsync(resolvedFileInfo)) + ?? (await ResolveFromAssemblyPathAsync(resolvedFileInfo)) + ?? (await ResolveFromFileVersionInfoAsync(resolvedFileInfo)) + ?? (await ResolveFromLicenseUrlAsync(resolvedFileInfo, true)) + ?? (await ResolveFromRepositoryUrlAsync(resolvedFileInfo, true)) + ?? (await ResolveFromProjectUrlAsync(resolvedFileInfo, true)); + } + + private async Task ResolveFromPackagePathAsync(ResolvedFileInfo resolvedFileInfo) + { + if (string.IsNullOrEmpty(resolvedFileInfo.PackagePath)) + return null; + if (LicenseCache.TryGetValue(resolvedFileInfo.PackagePath, out string value)) + return value; + var licensePath = Directory.EnumerateFiles(resolvedFileInfo.PackagePath, "license.*", new EnumerationOptions + { + MatchCasing = MatchCasing.CaseInsensitive, + RecurseSubdirectories = false + }).FirstOrDefault(x => x.EndsWith("\\license.txt", StringComparison.OrdinalIgnoreCase) || x.EndsWith("\\license.md", StringComparison.OrdinalIgnoreCase)); + if (licensePath == null) + return null; + var license = await File.ReadAllTextAsync(licensePath); + if (resolvedFileInfo.NuSpec != null) + LicenseCache[resolvedFileInfo.NuSpec.Id] = license; + LicenseCache[resolvedFileInfo.PackagePath] = license; + return license; + } + + private async Task ResolveFromAssemblyPathAsync(ResolvedFileInfo resolvedFileInfo) + { + if (string.IsNullOrEmpty(resolvedFileInfo.SourcePath)) + return null; + var assemblyPath = Path.GetDirectoryName(resolvedFileInfo.SourcePath); + if (LicenseCache.TryGetValue(assemblyPath, out string value)) + return value; + var licensePath = Directory.EnumerateFiles(assemblyPath, "license.*", new EnumerationOptions + { + MatchCasing = MatchCasing.CaseInsensitive, + RecurseSubdirectories = false + }).FirstOrDefault(x => x.EndsWith("\\license.txt", StringComparison.OrdinalIgnoreCase) || x.EndsWith("\\license.md", StringComparison.OrdinalIgnoreCase)); + if (licensePath == null) + return null; + var license = await File.ReadAllTextAsync(licensePath); + LicenseCache[assemblyPath] = license; + return license; + } + + private async Task ResolveFromFileVersionInfoAsync(ResolvedFileInfo resolvedFileInfo) + { + var fileVersionInfo = resolvedFileInfo.VersionInfo; + if (LicenseCache.TryGetValue(fileVersionInfo.FileName, out string value)) + return value; + foreach (var resolver in fileVersionInfoLicenseResolvers) + { + if (!resolver.CanResolve(fileVersionInfo)) + continue; + var license = await resolver.Resolve(fileVersionInfo); + if (license != null) + { + LicenseCache[fileVersionInfo.FileName] = license; + return license; + } + } + return null; + } + + private async Task ResolveFromLicenseRelativePathAsync(ResolvedFileInfo resolvedFileInfo) + { + if (string.IsNullOrEmpty(resolvedFileInfo.PackagePath) || string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.LicenseRelativePath)) + return null; + var licenseFullPath = Path.Combine(resolvedFileInfo.PackagePath, resolvedFileInfo.NuSpec.LicenseRelativePath); + if (LicenseCache.TryGetValue(licenseFullPath, out string value)) + return value; + if (!licenseFullPath.EndsWith(".txt") && !licenseFullPath.EndsWith(".md") || !File.Exists(licenseFullPath)) + return null; + var license = await File.ReadAllTextAsync(licenseFullPath); + if (string.IsNullOrEmpty(license)) + return null; + LicenseCache[resolvedFileInfo.NuSpec.Id] = license; + LicenseCache[licenseFullPath] = license; + return license; + } + + private async Task ResolveFromProjectUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) + { + if (string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.ProjectUrl)) + return null; + if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.ProjectUrl, out string value)) + return value; + if (!Uri.TryCreate(resolvedFileInfo.NuSpec.ProjectUrl, UriKind.Absolute, out var uri)) + return null; + var license = useFinalUrl + ? await ResolveFromFinalUrlAsync(uri, projectUriLicenseResolvers) + : await ResolveFromUrlAsync(uri, projectUriLicenseResolvers); + if (license != null) + { + LicenseCache[resolvedFileInfo.NuSpec.Id] = license; + LicenseCache[resolvedFileInfo.NuSpec.ProjectUrl] = license; + } + return license; + } + + private async Task ResolveFromUrlAsync(Uri uri, IEnumerable urlLicenseResolvers) + { + foreach (var resolver in urlLicenseResolvers) + { + if (!resolver.CanResolve(uri)) + continue; + var license = await resolver.Resolve(uri); + if (license != null) + return license; + } + return null; + } + + private async Task ResolveFromFinalUrlAsync(Uri uri, IEnumerable urlLicenseResolvers) + { + using var httpClient = httpClientFactory.CreateClient(); + var httpResponseMessage = await httpClient.GetAsync(uri); + if (!httpResponseMessage.IsSuccessStatusCode && uri.AbsolutePath.EndsWith(".txt")) + { + // try without .txt extension + var fixedUri = new UriBuilder(uri); + fixedUri.Path = fixedUri.Path.Remove(fixedUri.Path.Length - 4); + httpResponseMessage = await httpClient.GetAsync(fixedUri.Uri); + if (!httpResponseMessage.IsSuccessStatusCode) + return null; + } + if (httpResponseMessage.RequestMessage != null && httpResponseMessage.RequestMessage.RequestUri != null && httpResponseMessage.RequestMessage.RequestUri != uri) + { + foreach (var resolver in urlLicenseResolvers) + { + if (!resolver.CanResolve(httpResponseMessage.RequestMessage.RequestUri)) + continue; + var license = await resolver.Resolve(httpResponseMessage.RequestMessage.RequestUri); + if (license != null) + return license; + } + } + // Finally, if no license uri can be found despite all the redirects, try to blindly get it + if (httpResponseMessage.Content.Headers.ContentType.MediaType != "text/plain") + return null; + return await httpResponseMessage.Content.ReadAsStringAsync(); + } + + private async Task ResolveFromRepositoryUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) + { + if (string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.RepositoryUrl)) + return null; + if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.RepositoryUrl, out string value)) + return value; + if (!Uri.TryCreate(resolvedFileInfo.NuSpec.RepositoryUrl, UriKind.Absolute, out var uri)) + return null; + var license = useFinalUrl + ? await ResolveFromFinalUrlAsync(uri, repositoryUriLicenseResolvers) + : await ResolveFromUrlAsync(uri, repositoryUriLicenseResolvers); + if (license != null) + { + LicenseCache[resolvedFileInfo.NuSpec.Id] = license; + LicenseCache[resolvedFileInfo.NuSpec.RepositoryUrl] = license; + } + return license; + } + + private async Task ResolveFromLicenseUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) + { + if (string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.LicenseUrl)) + return null; + if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.LicenseUrl, out string value)) + return value; + if (!Uri.TryCreate(resolvedFileInfo.NuSpec.LicenseUrl, UriKind.Absolute, out var uri)) + return null; + var license = useFinalUrl + ? await ResolveFromFinalUrlAsync(uri, licenseUriLicenseResolvers) + : await ResolveFromUrlAsync(uri, licenseUriLicenseResolvers); + if (license != null) + { + LicenseCache[resolvedFileInfo.NuSpec.Id] = license; + LicenseCache[resolvedFileInfo.NuSpec.LicenseUrl] = license; + } + return license; + } +} diff --git a/src/DotnetThirdPartyNotices/Services/LocalPackageService.cs b/src/DotnetThirdPartyNotices/Services/LocalPackageService.cs new file mode 100644 index 0000000..4c4ac06 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/LocalPackageService.cs @@ -0,0 +1,99 @@ +using DotnetThirdPartyNotices.Models; +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace DotnetThirdPartyNotices.Services; + +public partial class LocalPackageService : ILocalPackageService +{ + public NuSpec GetNuSpecFromPackagePath(string path) + { + var nuspecPath = GetNuspecPathFromPackagePath(path); + if (nuspecPath != null) + return GetNuSpecFromNuspecPath(nuspecPath); + var nupkgPath = GetNupkgPathFromPackagePath(path); + if (nupkgPath != null) + return GetNuSpecFromNupkgPath(nupkgPath); + return null; + } + public NuSpec GetNuSpecFromNupkgPath(string path) + { + if (path == null) + return null; + using var zipToCreate = new FileStream(path, FileMode.Open, FileAccess.Read); + using var zip = new ZipArchive(zipToCreate, ZipArchiveMode.Read); + var zippedNuspec = zip.Entries.Single(e => e.FullName.EndsWith(".nuspec")); + using var stream = zippedNuspec.Open(); + using var streamReader = new StreamReader(stream); + return GetNuSpecFromStreamReader(streamReader); + } + + public NuSpec GetNuSpecFromNuspecPath(string path) + { + if (path == null) + return null; + using var streamReader = new StreamReader(path); + return GetNuSpecFromStreamReader(streamReader); + } + + public NuSpec GetNuSpecFromStreamReader(TextReader textReader) + { + var xDocument = XDocument.Load(textReader); + if (xDocument.Root == null) return null; + var ns = xDocument.Root.GetDefaultNamespace(); + + var metadata = xDocument.Root.Element(ns + "metadata"); + if (metadata == null) return null; + + return new NuSpec + { + Id = metadata.Element(ns + "id")?.Value, + Version = metadata.Element(ns + "version")?.Value, + LicenseUrl = metadata.Element(ns + "licenseUrl")?.Value, + ProjectUrl = metadata.Element(ns + "projectUrl")?.Value, + RepositoryUrl = metadata.Element(ns + "repository")?.Attribute("url")?.Value, + LicenseRelativePath = metadata.Elements(ns + "license").Where(x => x.Attribute("type")?.Value == "file").FirstOrDefault()?.Value + }; + } + + + public string GetNuspecPathFromPackagePath(string packagePath) + { + return packagePath != null + ? Directory.EnumerateFiles(packagePath, "*.nuspec", SearchOption.TopDirectoryOnly).FirstOrDefault() + : null; + } + + public string GetNupkgPathFromPackagePath(string packagePath) + { + return packagePath != null + ? Directory.EnumerateFiles(packagePath, "*.nupkg", SearchOption.TopDirectoryOnly).FirstOrDefault() + : null; + } + + public string GetPackagePathFromAssemblyPath(string assemblyPath) + { + var directoryParts = Path.GetDirectoryName(assemblyPath).Split('\\', StringSplitOptions.RemoveEmptyEntries); + // packages\{packageName}\{version}\lib\{targetFramework}\{packageName}.dll + // packages\{packageName}\{version}\runtimes\{runtime-identifier}\lib\{targetFramework}\{packageName}.dll + // packages\{packageName}\{version}\lib\{targetFramework}\{culture}\{packageName}.dll + var index = Array.FindLastIndex(directoryParts, x => NewNugetVersionRegex().IsMatch(x)); + if (index > -1) + return string.Join('\\', directoryParts.Take(index + 1)); + // packages\{packageName}.{version}\lib\{targetFramework}\{packageName}.dll + index = Array.FindLastIndex(directoryParts, x => OldNugetVersionRegex().IsMatch(x)); + if (index > -1) + return string.Join('\\', directoryParts.Take(index + 1)); + return null; + } + + [GeneratedRegex(@"^\d+.\d+.\d+\S*$", RegexOptions.None)] + private static partial Regex NewNugetVersionRegex(); + + [GeneratedRegex(@"^\S+\.\d+.\d+.\d+\S*$")] + private static partial Regex OldNugetVersionRegex(); +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/LicenseResolvers/NetFrameworkLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs similarity index 91% rename from src/DotnetThirdPartyNotices/LicenseResolvers/NetFrameworkLicenseResolver.cs rename to src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs index f61ee5f..46faa0e 100644 --- a/src/DotnetThirdPartyNotices/LicenseResolvers/NetFrameworkLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs @@ -3,9 +3,8 @@ using System.IO; using System.Reflection; using System.Threading.Tasks; -using DotnetThirdPartyNotices.LicenseResolvers.Interfaces; -namespace DotnetThirdPartyNotices.LicenseResolvers; +namespace DotnetThirdPartyNotices.Services; internal class NetFrameworkLicenseResolver : ILicenseUriLicenseResolver, IFileVersionInfoLicenseResolver { diff --git a/src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs new file mode 100644 index 0000000..f102729 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs @@ -0,0 +1,28 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; + +namespace DotnetThirdPartyNotices.Services; + +internal class OpenSourceOrgLicenseResolver : ILicenseUriLicenseResolver +{ + public bool CanResolve(Uri licenseUri) => licenseUri.Host == "opensource.org"; + + public async Task Resolve(Uri licenseUri) + { + var s = licenseUri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + if (s[0] != "licenses") return null; + var licenseId = s[1]; + HttpClient httpClient = new() { BaseAddress = new Uri("https://api.github.com") }; + // https://developer.github.com/v3/#user-agent-required + httpClient.DefaultRequestHeaders.Add("User-Agent", "DotnetLicense"); + var httpResponseMessage = await httpClient.GetAsync($"licenses/{licenseId}"); + if (!httpResponseMessage.IsSuccessStatusCode) + return null; + var content = await httpResponseMessage.Content.ReadAsStringAsync(); + var jsonDocument = JsonDocument.Parse(content); + return jsonDocument.RootElement.GetProperty("body").GetString(); + } +} \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/ProjectService.cs b/src/DotnetThirdPartyNotices/Services/ProjectService.cs new file mode 100644 index 0000000..8029770 --- /dev/null +++ b/src/DotnetThirdPartyNotices/Services/ProjectService.cs @@ -0,0 +1,117 @@ +using DotnetThirdPartyNotices.Models; +using Microsoft.Build.Construction; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Logging; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace DotnetThirdPartyNotices.Services; + +internal class ProjectService(ILogger logger, ILocalPackageService localPackageService) : IProjectService +{ + public IEnumerable ResolveFiles(Project project) + { + var targetFrameworksProperty = project.GetProperty("TargetFrameworks"); + if (targetFrameworksProperty != null) + { + var targetFrameworks = targetFrameworksProperty.EvaluatedValue.Split(';'); + project.SetProperty("TargetFramework", targetFrameworks[0]); + } + var projectInstance = project.CreateProjectInstance(); + var targetFrameworkIdentifier = projectInstance.GetPropertyValue("TargetFrameworkIdentifier"); + return targetFrameworkIdentifier switch + { + TargetFrameworkIdentifiers.NetCore or TargetFrameworkIdentifiers.NetStandard => ResolveFilesUsingComputeFilesToPublish(projectInstance), + TargetFrameworkIdentifiers.NetFramework => ResolveFilesUsingResolveAssemblyReferences(projectInstance), + _ => throw new InvalidOperationException("Unsupported target framework."), + }; + } + + private IEnumerable ResolveFilesUsingResolveAssemblyReferences(ProjectInstance projectInstance) + { + var resolvedFileInfos = new List(); + + if (!projectInstance.Build("ResolveAssemblyReferences", [new ConsoleLogger(LoggerVerbosity.Minimal)])) + return []; + + foreach (var item in projectInstance.GetItems("ReferenceCopyLocalPaths")) + { + var assemblyPath = item.EvaluatedInclude; + var versionInfo = FileVersionInfo.GetVersionInfo(assemblyPath); + var packagePath = localPackageService.GetPackagePathFromAssemblyPath(assemblyPath); + var resolvedFileInfo = new ResolvedFileInfo + { + SourcePath = assemblyPath, + VersionInfo = versionInfo, + RelativeOutputPath = Path.GetFileName(assemblyPath), + PackagePath = packagePath + }; + if (item.GetMetadataValue("ResolvedFrom") == "{HintPathFromItem}" && item.GetMetadataValue("HintPath").StartsWith("..\\packages")) + { + resolvedFileInfo.NuSpec = localPackageService.GetNuSpecFromPackagePath(packagePath) ?? throw new ApplicationException($"Cannot find package path from assembly path ({assemblyPath})"); + } + resolvedFileInfos.Add(resolvedFileInfo); + } + + return resolvedFileInfos; + } + + private IEnumerable ResolveFilesUsingComputeFilesToPublish(ProjectInstance projectInstance) + { + var resolvedFileInfos = new List(); + + projectInstance.Build("ComputeFilesToPublish", [new ConsoleLogger(LoggerVerbosity.Minimal)]); + + foreach (var item in projectInstance.GetItems("ResolvedFileToPublish")) + { + var assemblyPath = item.EvaluatedInclude; + + var packageName = item.GetMetadataValue(item.HasMetadata("PackageName") ? "PackageName" : "NugetPackageId"); + var packageVersion = item.GetMetadataValue(item.HasMetadata("PackageName") ? "PackageVersion" : "NugetPackageVersion"); + if (packageName == string.Empty || packageVersion == string.Empty) + { + // Skip if it's not a NuGet package + continue; + } + var relativePath = item.GetMetadataValue("RelativePath"); + var packagePath = localPackageService.GetPackagePathFromAssemblyPath(assemblyPath); + var nuSpec = localPackageService.GetNuSpecFromPackagePath(packagePath) ?? throw new ApplicationException($"Cannot find package path from assembly path ({assemblyPath})"); + var resolvedFileInfo = new ResolvedFileInfo + { + SourcePath = assemblyPath, + VersionInfo = FileVersionInfo.GetVersionInfo(assemblyPath), + NuSpec = nuSpec, + RelativeOutputPath = relativePath, + PackagePath = packagePath + }; + + resolvedFileInfos.Add(resolvedFileInfo); + } + + return resolvedFileInfos; + } + + public string[] GetProjectFilePaths(string directoryPath) + { + logger.LogInformation("Scan directory {ScanDirectory}", directoryPath); + var paths = Directory.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly) + .Where(s => s.EndsWith(".csproj") || s.EndsWith(".fsproj")) + .ToArray(); + if (paths.Length > 0) + return paths; + var solutionFilePath = Directory.EnumerateFiles(directoryPath, "*.sln", SearchOption.TopDirectoryOnly) + .SingleOrDefault(); + if (solutionFilePath == null) + return paths; + return SolutionFile.Parse(solutionFilePath).ProjectsInOrder + .Select(x => x.AbsolutePath) + .Where(x => x.EndsWith(".csproj") || x.EndsWith(".fsproj")) + .ToArray(); + } +} diff --git a/src/DotnetThirdPartyNotices/Utils.cs b/src/DotnetThirdPartyNotices/Utils.cs deleted file mode 100644 index ff18dfb..0000000 --- a/src/DotnetThirdPartyNotices/Utils.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; - -namespace DotnetThirdPartyNotices; - -internal static partial class Utils -{ - public static string GetNuspecPath( string assemblyPath ) - { - var package = GetPackagePath( assemblyPath ); - return package != null - ? Directory.EnumerateFiles( package, "*.nuspec", SearchOption.TopDirectoryOnly ).FirstOrDefault() - : null; - } - - public static string GetNupkgPath( string assemblyPath ) - { - var package = GetPackagePath( assemblyPath ); - return package != null - ? Directory.EnumerateFiles( package, "*.nupkg", SearchOption.TopDirectoryOnly ).FirstOrDefault() - : null; - } - - public static string GetPackagePath( string assemblyPath ) - { - var directoryParts = Path.GetDirectoryName( assemblyPath ).Split( '\\', StringSplitOptions.RemoveEmptyEntries ); - // packages\{packageName}\{version}\lib\{targetFramework}\{packageName}.dll - // packages\{packageName}\{version}\runtimes\{runtime-identifier}\lib\{targetFramework}\{packageName}.dll - // packages\{packageName}\{version}\lib\{targetFramework}\{culture}\{packageName}.dll - var index = Array.FindLastIndex( directoryParts, x => NewNugetVersionRegex().IsMatch(x)); - if (index > -1) - return string.Join('\\', directoryParts.Take(index + 1) ); - // packages\{packageName}.{version}\lib\{targetFramework}\{packageName}.dll - index = Array.FindLastIndex(directoryParts, x => OldNugetVersionRegex().IsMatch(x)); - if (index > -1) - return string.Join('\\', directoryParts.Take(index + 1)); - return null; - } - - [GeneratedRegex( @"^\d+.\d+.\d+\S*$", RegexOptions.None )] - private static partial Regex NewNugetVersionRegex(); - - [GeneratedRegex( @"^\S+\.\d+.\d+.\d+\S*$" )] - private static partial Regex OldNugetVersionRegex(); -} \ No newline at end of file From 1d27ad48abc1469b725af29d55ca7b7e27db0993 Mon Sep 17 00:00:00 2001 From: Daniel Klecha Date: Fri, 8 Mar 2024 20:31:23 +0100 Subject: [PATCH 2/3] fix NetFrameworkLicenseResolver --- .../Services/NetFrameworkLicenseResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs index 46faa0e..05d8bbb 100644 --- a/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs @@ -22,7 +22,7 @@ public async Task GetLicenseContent() return _licenseContent; var executingAssembly = Assembly.GetExecutingAssembly(); - await using var stream = executingAssembly.GetManifestResourceStream(typeof(Utils), "dotnet_library_license.txt"); + await using var stream = executingAssembly.GetManifestResourceStream(typeof(Program), "dotnet_library_license.txt"); using var streamReader = new StreamReader(stream); _licenseContent = await streamReader.ReadToEndAsync(); return _licenseContent; From 4e454ea88f18aefb19d7823dac41ca01567cb92e Mon Sep 17 00:00:00 2001 From: Daniel Klecha Date: Sun, 10 Mar 2024 15:57:39 +0100 Subject: [PATCH 3/3] enable nullable annotations --- .../Commands/ScanCommand.cs | 14 +++--- .../DotnetThirdPartyNotices.csproj | 2 + src/DotnetThirdPartyNotices/Models/NuSpec.cs | 17 ++++--- .../Models/ResolvedFileInfo.cs | 10 ++-- .../Services/GithubRawLicenseResolver.cs | 10 ++-- .../GithubRepositoryLicenseResolver.cs | 4 +- .../IFileVersionInfoLicenseResolver.cs | 2 +- .../Services/ILicenseService.cs | 10 ++-- .../Services/ILocalPackageService.cs | 22 ++++----- .../Services/IUriLicenseResolver.cs | 7 +-- .../Services/LicenseService.cs | 49 ++++++++++--------- .../Services/LocalPackageService.cs | 35 ++++++++----- .../Services/NetFrameworkLicenseResolver.cs | 11 +++-- .../Services/OpenSourceOrgLicenseResolver.cs | 10 ++-- .../Services/ProjectService.cs | 9 ++-- 15 files changed, 112 insertions(+), 100 deletions(-) diff --git a/src/DotnetThirdPartyNotices/Commands/ScanCommand.cs b/src/DotnetThirdPartyNotices/Commands/ScanCommand.cs index f7f6a4a..4491600 100644 --- a/src/DotnetThirdPartyNotices/Commands/ScanCommand.cs +++ b/src/DotnetThirdPartyNotices/Commands/ScanCommand.cs @@ -28,10 +28,10 @@ internal class ScanCommand : Command internal new class Handler(ILogger logger, IProjectService projectService, ILicenseService licenseService) : ICommandHandler { - public string ScanDir { get; set; } - public string OutputFilename { get; set; } + public string? ScanDir { get; set; } + public string? OutputFilename { get; set; } public bool CopyToOutDir { get; set; } - public string Filter { get; set; } + public string? Filter { get; set; } public bool Merge { get; set; } private readonly Dictionary> _licenseContents = []; private readonly List _unresolvedFiles = []; @@ -101,12 +101,14 @@ private async Task ScanProjectAsync(string projectFilePath) } stopWatch.Stop(); logger.LogInformation("Project {ProjectName} resolved in {StopwatchElapsedMilliseconds}ms", Path.GetFileName(projectFilePath), stopWatch.ElapsedMilliseconds); - if (CopyToOutDir) + if (CopyToOutDir && !string.IsNullOrEmpty(ScanDir) && !string.IsNullOrEmpty(OutputFilename)) await GenerateOutputFileAsync(Path.Combine(ScanDir, project.GetPropertyValue("OutDir"), Path.GetFileName(OutputFilename))); } - private async Task GenerateOutputFileAsync(string outputFilePath) + private async Task GenerateOutputFileAsync(string? outputFilePath) { + if (outputFilePath == null) + return; logger.LogInformation("Resolved {LicenseContentsCount} licenses for {Sum}/{ResolvedFilesCount} files", _licenseContents.Count, _licenseContents.Values.Sum(v => v.Count), _licenseContents.Values.Sum(v => v.Count) + _unresolvedFiles.Count); logger.LogInformation("Unresolved files: {UnresolvedFilesCount}", _unresolvedFiles.Count); var stopWatch = new Stopwatch(); @@ -117,7 +119,7 @@ private async Task GenerateOutputFileAsync(string outputFilePath) var longestNameLen = 0; foreach (var resolvedFileInfo in resolvedFileInfos) { - var strLen = resolvedFileInfo.RelativeOutputPath.Length; + var strLen = resolvedFileInfo.RelativeOutputPath?.Length ?? 0; if (strLen > longestNameLen) longestNameLen = strLen; stringBuilder.AppendLine(resolvedFileInfo.RelativeOutputPath); diff --git a/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj b/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj index 90dbe0f..d9070d4 100644 --- a/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj +++ b/src/DotnetThirdPartyNotices/DotnetThirdPartyNotices.csproj @@ -3,6 +3,8 @@ Exe net8.0 + enable + enable default true dotnet-thirdpartynotices diff --git a/src/DotnetThirdPartyNotices/Models/NuSpec.cs b/src/DotnetThirdPartyNotices/Models/NuSpec.cs index b2b6020..27fc7a8 100644 --- a/src/DotnetThirdPartyNotices/Models/NuSpec.cs +++ b/src/DotnetThirdPartyNotices/Models/NuSpec.cs @@ -2,10 +2,15 @@ public record NuSpec { - public string Id { get; init; } - public string Version { get; init; } - public string LicenseUrl { get; init; } - public string ProjectUrl { get; init; } - public string RepositoryUrl { get; init; } - public string LicenseRelativePath { get; init; } + public NuSpec(string id) + { + Id = id; + } + + public string Id { get; } + public string? Version { get; init; } + public string? LicenseUrl { get; init; } + public string? ProjectUrl { get; init; } + public string? RepositoryUrl { get; init; } + public string? LicenseRelativePath { get; init; } } \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs b/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs index d9467ce..12059da 100644 --- a/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs +++ b/src/DotnetThirdPartyNotices/Models/ResolvedFileInfo.cs @@ -4,9 +4,9 @@ namespace DotnetThirdPartyNotices.Models; internal class ResolvedFileInfo { - public string SourcePath { get; set; } - public string RelativeOutputPath { get; set; } - public FileVersionInfo VersionInfo { get; set; } - public NuSpec NuSpec { get; set; } - public string PackagePath { get; set; } + public string? SourcePath { get; init; } + public string? RelativeOutputPath { get; init; } + public FileVersionInfo? VersionInfo { get; init; } + public NuSpec? NuSpec { get; init; } + public string? PackagePath { get; init; } } \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs index 243765f..ba49b13 100644 --- a/src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/GithubRawLicenseResolver.cs @@ -1,14 +1,10 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; - -namespace DotnetThirdPartyNotices.Services; +namespace DotnetThirdPartyNotices.Services; internal class GithubRawLicenseResolver(IHttpClientFactory httpClientFactory) : ILicenseUriLicenseResolver { public bool CanResolve(Uri uri) => uri.Host == "github.com"; - public async Task Resolve(Uri uri) + public async Task Resolve(Uri uri) { var uriBuilder = new UriBuilder(uri) { Host = "raw.githubusercontent.com" }; uriBuilder.Path = uriBuilder.Path.Replace("/blob", string.Empty); @@ -17,7 +13,7 @@ public async Task Resolve(Uri uri) httpClient.DefaultRequestHeaders.Add("User-Agent", "DotnetLicense"); var httpResponseMessage = await httpClient.GetAsync(uriBuilder.Uri); if (!httpResponseMessage.IsSuccessStatusCode - || httpResponseMessage.Content.Headers.ContentType.MediaType != "text/plain") + || httpResponseMessage.Content.Headers.ContentType?.MediaType != "text/plain") return null; return await httpResponseMessage.Content.ReadAsStringAsync(); } diff --git a/src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs index 9e46aab..a8452de 100644 --- a/src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/GithubRepositoryLicenseResolver.cs @@ -10,7 +10,7 @@ internal class GithubRepositoryLicenseResolver(IHttpClientFactory httpClientFact { public bool CanResolve(Uri uri) => uri.Host == "github.com"; - public async Task Resolve(Uri licenseUri) + public async Task Resolve(Uri licenseUri) { var repositoryPath = licenseUri.AbsolutePath.TrimEnd('/'); if (repositoryPath.EndsWith(".git")) @@ -30,7 +30,7 @@ public async Task Resolve(Uri licenseUri) var rootElement = jsonDocument.RootElement; var encoding = rootElement.GetProperty("encoding").GetString(); var content = rootElement.GetProperty("content").GetString(); - if (encoding != "base64") + if (content == null || encoding != "base64") return content; var bytes = Convert.FromBase64String(content); return Encoding.UTF8.GetString(bytes); diff --git a/src/DotnetThirdPartyNotices/Services/IFileVersionInfoLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/IFileVersionInfoLicenseResolver.cs index bc23c79..01ae395 100644 --- a/src/DotnetThirdPartyNotices/Services/IFileVersionInfoLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/IFileVersionInfoLicenseResolver.cs @@ -6,5 +6,5 @@ namespace DotnetThirdPartyNotices.Services; internal interface IFileVersionInfoLicenseResolver : ILicenseResolver { bool CanResolve(FileVersionInfo fileVersionInfo); - Task Resolve(FileVersionInfo fileVersionInfo); + Task Resolve(FileVersionInfo fileVersionInfo); } \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/ILicenseService.cs b/src/DotnetThirdPartyNotices/Services/ILicenseService.cs index baea27e..db30bf8 100644 --- a/src/DotnetThirdPartyNotices/Services/ILicenseService.cs +++ b/src/DotnetThirdPartyNotices/Services/ILicenseService.cs @@ -1,10 +1,8 @@ using DotnetThirdPartyNotices.Models; -using System.Threading.Tasks; -namespace DotnetThirdPartyNotices.Services +namespace DotnetThirdPartyNotices.Services; + +internal interface ILicenseService { - internal interface ILicenseService - { - Task ResolveFromResolvedFileInfo(ResolvedFileInfo resolvedFileInfo); - } + Task ResolveFromResolvedFileInfo(ResolvedFileInfo resolvedFileInfo); } \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs b/src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs index ed780eb..8577aa7 100644 --- a/src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs +++ b/src/DotnetThirdPartyNotices/Services/ILocalPackageService.cs @@ -1,16 +1,14 @@ using DotnetThirdPartyNotices.Models; -using System.IO; -namespace DotnetThirdPartyNotices.Services +namespace DotnetThirdPartyNotices.Services; + +public interface ILocalPackageService { - public interface ILocalPackageService - { - string GetNupkgPathFromPackagePath(string packagePath); - NuSpec GetNuSpecFromNupkgPath(string path); - NuSpec GetNuSpecFromNuspecPath(string path); - NuSpec GetNuSpecFromPackagePath(string path); - NuSpec GetNuSpecFromStreamReader(TextReader textReader); - string GetNuspecPathFromPackagePath(string packagePath); - string GetPackagePathFromAssemblyPath(string assemblyPath); - } + string? GetNupkgPathFromPackagePath(string? packagePath); + NuSpec? GetNuSpecFromNupkgPath(string? path); + NuSpec? GetNuSpecFromNuspecPath(string? path); + NuSpec? GetNuSpecFromPackagePath(string? path); + NuSpec? GetNuSpecFromStreamReader(TextReader? textReader); + string? GetNuspecPathFromPackagePath(string? packagePath); + string? GetPackagePathFromAssemblyPath(string? assemblyPath); } \ No newline at end of file diff --git a/src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs index 223297f..f816542 100644 --- a/src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/IUriLicenseResolver.cs @@ -1,10 +1,7 @@ -using System; -using System.Threading.Tasks; - -namespace DotnetThirdPartyNotices.Services; +namespace DotnetThirdPartyNotices.Services; internal interface IUriLicenseResolver { bool CanResolve(Uri licenseUri); - Task Resolve(Uri licenseUri); + Task Resolve(Uri licenseUri); } diff --git a/src/DotnetThirdPartyNotices/Services/LicenseService.cs b/src/DotnetThirdPartyNotices/Services/LicenseService.cs index 44b8975..30086f4 100644 --- a/src/DotnetThirdPartyNotices/Services/LicenseService.cs +++ b/src/DotnetThirdPartyNotices/Services/LicenseService.cs @@ -12,10 +12,10 @@ internal class LicenseService(IEnumerable licenseUri { private static readonly Dictionary LicenseCache = []; - public async Task ResolveFromResolvedFileInfo(ResolvedFileInfo resolvedFileInfo) + public async Task ResolveFromResolvedFileInfo(ResolvedFileInfo resolvedFileInfo) { ArgumentNullException.ThrowIfNull(resolvedFileInfo); - if (resolvedFileInfo.NuSpec != null && LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.Id, out string value)) + if (resolvedFileInfo.NuSpec != null && LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.Id, out string? value)) return value; return (await ResolveFromLicenseRelativePathAsync(resolvedFileInfo)) ?? (await ResolveFromLicenseUrlAsync(resolvedFileInfo, false)) @@ -29,11 +29,11 @@ public async Task ResolveFromResolvedFileInfo(ResolvedFileInfo resolvedF ?? (await ResolveFromProjectUrlAsync(resolvedFileInfo, true)); } - private async Task ResolveFromPackagePathAsync(ResolvedFileInfo resolvedFileInfo) + private async Task ResolveFromPackagePathAsync(ResolvedFileInfo resolvedFileInfo) { if (string.IsNullOrEmpty(resolvedFileInfo.PackagePath)) return null; - if (LicenseCache.TryGetValue(resolvedFileInfo.PackagePath, out string value)) + if (LicenseCache.TryGetValue(resolvedFileInfo.PackagePath, out string? value)) return value; var licensePath = Directory.EnumerateFiles(resolvedFileInfo.PackagePath, "license.*", new EnumerationOptions { @@ -49,12 +49,14 @@ private async Task ResolveFromPackagePathAsync(ResolvedFileInfo resolved return license; } - private async Task ResolveFromAssemblyPathAsync(ResolvedFileInfo resolvedFileInfo) + private async Task ResolveFromAssemblyPathAsync(ResolvedFileInfo resolvedFileInfo) { if (string.IsNullOrEmpty(resolvedFileInfo.SourcePath)) return null; var assemblyPath = Path.GetDirectoryName(resolvedFileInfo.SourcePath); - if (LicenseCache.TryGetValue(assemblyPath, out string value)) + if(assemblyPath == null) + return null; + if (LicenseCache.TryGetValue(assemblyPath, out string? value)) return value; var licensePath = Directory.EnumerateFiles(assemblyPath, "license.*", new EnumerationOptions { @@ -68,31 +70,32 @@ private async Task ResolveFromAssemblyPathAsync(ResolvedFileInfo resolve return license; } - private async Task ResolveFromFileVersionInfoAsync(ResolvedFileInfo resolvedFileInfo) + private async Task ResolveFromFileVersionInfoAsync(ResolvedFileInfo resolvedFileInfo) { - var fileVersionInfo = resolvedFileInfo.VersionInfo; - if (LicenseCache.TryGetValue(fileVersionInfo.FileName, out string value)) + if(resolvedFileInfo.VersionInfo == null) + return null; + if (LicenseCache.TryGetValue(resolvedFileInfo.VersionInfo.FileName, out string? value)) return value; foreach (var resolver in fileVersionInfoLicenseResolvers) { - if (!resolver.CanResolve(fileVersionInfo)) + if (!resolver.CanResolve(resolvedFileInfo.VersionInfo)) continue; - var license = await resolver.Resolve(fileVersionInfo); + var license = await resolver.Resolve(resolvedFileInfo.VersionInfo); if (license != null) { - LicenseCache[fileVersionInfo.FileName] = license; + LicenseCache[resolvedFileInfo.VersionInfo.FileName] = license; return license; } } return null; } - private async Task ResolveFromLicenseRelativePathAsync(ResolvedFileInfo resolvedFileInfo) + private async Task ResolveFromLicenseRelativePathAsync(ResolvedFileInfo resolvedFileInfo) { if (string.IsNullOrEmpty(resolvedFileInfo.PackagePath) || string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.LicenseRelativePath)) return null; var licenseFullPath = Path.Combine(resolvedFileInfo.PackagePath, resolvedFileInfo.NuSpec.LicenseRelativePath); - if (LicenseCache.TryGetValue(licenseFullPath, out string value)) + if (LicenseCache.TryGetValue(licenseFullPath, out string? value)) return value; if (!licenseFullPath.EndsWith(".txt") && !licenseFullPath.EndsWith(".md") || !File.Exists(licenseFullPath)) return null; @@ -104,11 +107,11 @@ private async Task ResolveFromLicenseRelativePathAsync(ResolvedFileInfo return license; } - private async Task ResolveFromProjectUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) + private async Task ResolveFromProjectUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) { if (string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.ProjectUrl)) return null; - if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.ProjectUrl, out string value)) + if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.ProjectUrl, out string? value)) return value; if (!Uri.TryCreate(resolvedFileInfo.NuSpec.ProjectUrl, UriKind.Absolute, out var uri)) return null; @@ -123,7 +126,7 @@ private async Task ResolveFromProjectUrlAsync(ResolvedFileInfo resolvedF return license; } - private async Task ResolveFromUrlAsync(Uri uri, IEnumerable urlLicenseResolvers) + private async Task ResolveFromUrlAsync(Uri uri, IEnumerable urlLicenseResolvers) { foreach (var resolver in urlLicenseResolvers) { @@ -136,7 +139,7 @@ private async Task ResolveFromUrlAsync(Uri uri, IEnumerable ResolveFromFinalUrlAsync(Uri uri, IEnumerable urlLicenseResolvers) + private async Task ResolveFromFinalUrlAsync(Uri uri, IEnumerable urlLicenseResolvers) { using var httpClient = httpClientFactory.CreateClient(); var httpResponseMessage = await httpClient.GetAsync(uri); @@ -161,16 +164,16 @@ private async Task ResolveFromFinalUrlAsync(Uri uri, IEnumerable ResolveFromRepositoryUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) + private async Task ResolveFromRepositoryUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) { if (string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.RepositoryUrl)) return null; - if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.RepositoryUrl, out string value)) + if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.RepositoryUrl, out string? value)) return value; if (!Uri.TryCreate(resolvedFileInfo.NuSpec.RepositoryUrl, UriKind.Absolute, out var uri)) return null; @@ -185,11 +188,11 @@ private async Task ResolveFromRepositoryUrlAsync(ResolvedFileInfo resolv return license; } - private async Task ResolveFromLicenseUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) + private async Task ResolveFromLicenseUrlAsync(ResolvedFileInfo resolvedFileInfo, bool useFinalUrl) { if (string.IsNullOrEmpty(resolvedFileInfo.NuSpec?.LicenseUrl)) return null; - if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.LicenseUrl, out string value)) + if (LicenseCache.TryGetValue(resolvedFileInfo.NuSpec.LicenseUrl, out string? value)) return value; if (!Uri.TryCreate(resolvedFileInfo.NuSpec.LicenseUrl, UriKind.Absolute, out var uri)) return null; diff --git a/src/DotnetThirdPartyNotices/Services/LocalPackageService.cs b/src/DotnetThirdPartyNotices/Services/LocalPackageService.cs index 4c4ac06..2009fd6 100644 --- a/src/DotnetThirdPartyNotices/Services/LocalPackageService.cs +++ b/src/DotnetThirdPartyNotices/Services/LocalPackageService.cs @@ -10,8 +10,10 @@ namespace DotnetThirdPartyNotices.Services; public partial class LocalPackageService : ILocalPackageService { - public NuSpec GetNuSpecFromPackagePath(string path) + public NuSpec? GetNuSpecFromPackagePath(string? path) { + if (path == null) + return null; var nuspecPath = GetNuspecPathFromPackagePath(path); if (nuspecPath != null) return GetNuSpecFromNuspecPath(nuspecPath); @@ -20,7 +22,7 @@ public NuSpec GetNuSpecFromPackagePath(string path) return GetNuSpecFromNupkgPath(nupkgPath); return null; } - public NuSpec GetNuSpecFromNupkgPath(string path) + public NuSpec? GetNuSpecFromNupkgPath(string? path) { if (path == null) return null; @@ -32,7 +34,7 @@ public NuSpec GetNuSpecFromNupkgPath(string path) return GetNuSpecFromStreamReader(streamReader); } - public NuSpec GetNuSpecFromNuspecPath(string path) + public NuSpec? GetNuSpecFromNuspecPath(string? path) { if (path == null) return null; @@ -40,18 +42,21 @@ public NuSpec GetNuSpecFromNuspecPath(string path) return GetNuSpecFromStreamReader(streamReader); } - public NuSpec GetNuSpecFromStreamReader(TextReader textReader) + public NuSpec? GetNuSpecFromStreamReader(TextReader? textReader) { + if (textReader == null) + return null; var xDocument = XDocument.Load(textReader); if (xDocument.Root == null) return null; var ns = xDocument.Root.GetDefaultNamespace(); - var metadata = xDocument.Root.Element(ns + "metadata"); - if (metadata == null) return null; - - return new NuSpec + if (metadata == null) + return null; + var id = metadata.Element(ns + "id")?.Value; + if (id == null) + return null; + return new NuSpec(id) { - Id = metadata.Element(ns + "id")?.Value, Version = metadata.Element(ns + "version")?.Value, LicenseUrl = metadata.Element(ns + "licenseUrl")?.Value, ProjectUrl = metadata.Element(ns + "projectUrl")?.Value, @@ -61,23 +66,27 @@ public NuSpec GetNuSpecFromStreamReader(TextReader textReader) } - public string GetNuspecPathFromPackagePath(string packagePath) + public string? GetNuspecPathFromPackagePath(string? packagePath) { return packagePath != null ? Directory.EnumerateFiles(packagePath, "*.nuspec", SearchOption.TopDirectoryOnly).FirstOrDefault() : null; } - public string GetNupkgPathFromPackagePath(string packagePath) + public string? GetNupkgPathFromPackagePath(string? packagePath) { return packagePath != null ? Directory.EnumerateFiles(packagePath, "*.nupkg", SearchOption.TopDirectoryOnly).FirstOrDefault() : null; } - public string GetPackagePathFromAssemblyPath(string assemblyPath) + public string? GetPackagePathFromAssemblyPath(string? assemblyPath) { - var directoryParts = Path.GetDirectoryName(assemblyPath).Split('\\', StringSplitOptions.RemoveEmptyEntries); + if(assemblyPath == null) + return null; + var directoryParts = Path.GetDirectoryName(assemblyPath)?.Split('\\', StringSplitOptions.RemoveEmptyEntries); + if(directoryParts == null) + return null; // packages\{packageName}\{version}\lib\{targetFramework}\{packageName}.dll // packages\{packageName}\{version}\runtimes\{runtime-identifier}\lib\{targetFramework}\{packageName}.dll // packages\{packageName}\{version}\lib\{targetFramework}\{culture}\{packageName}.dll diff --git a/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs index 05d8bbb..f5ad7d3 100644 --- a/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/NetFrameworkLicenseResolver.cs @@ -8,21 +8,22 @@ namespace DotnetThirdPartyNotices.Services; internal class NetFrameworkLicenseResolver : ILicenseUriLicenseResolver, IFileVersionInfoLicenseResolver { - private static string _licenseContent; + private static string? _licenseContent; public bool CanResolve(Uri licenseUri) => licenseUri.ToString().Contains("LinkId=529443"); public bool CanResolve(FileVersionInfo fileVersionInfo) => fileVersionInfo.ProductName == "Microsoft® .NET Framework"; - public Task Resolve(Uri licenseUri) => GetLicenseContent(); - public Task Resolve(FileVersionInfo fileVersionInfo) => GetLicenseContent(); + public Task Resolve(Uri licenseUri) => GetLicenseContent(); + public Task Resolve(FileVersionInfo fileVersionInfo) => GetLicenseContent(); - public async Task GetLicenseContent() + public async Task GetLicenseContent() { if (_licenseContent != null) // small optimization: avoid getting the resource on every call return _licenseContent; - var executingAssembly = Assembly.GetExecutingAssembly(); await using var stream = executingAssembly.GetManifestResourceStream(typeof(Program), "dotnet_library_license.txt"); + if (stream == null) + return null; using var streamReader = new StreamReader(stream); _licenseContent = await streamReader.ReadToEndAsync(); return _licenseContent; diff --git a/src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs b/src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs index f102729..79683ba 100644 --- a/src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs +++ b/src/DotnetThirdPartyNotices/Services/OpenSourceOrgLicenseResolver.cs @@ -9,11 +9,13 @@ internal class OpenSourceOrgLicenseResolver : ILicenseUriLicenseResolver { public bool CanResolve(Uri licenseUri) => licenseUri.Host == "opensource.org"; - public async Task Resolve(Uri licenseUri) - { - var s = licenseUri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + internal static readonly char[] separator = ['/']; - if (s[0] != "licenses") return null; + public async Task Resolve(Uri licenseUri) + { + var s = licenseUri.AbsolutePath.Split(separator, StringSplitOptions.RemoveEmptyEntries); + if (s[0] != "licenses") + return null; var licenseId = s[1]; HttpClient httpClient = new() { BaseAddress = new Uri("https://api.github.com") }; // https://developer.github.com/v3/#user-agent-required diff --git a/src/DotnetThirdPartyNotices/Services/ProjectService.cs b/src/DotnetThirdPartyNotices/Services/ProjectService.cs index 8029770..477dc55 100644 --- a/src/DotnetThirdPartyNotices/Services/ProjectService.cs +++ b/src/DotnetThirdPartyNotices/Services/ProjectService.cs @@ -50,12 +50,11 @@ private IEnumerable ResolveFilesUsingResolveAssemblyReferences SourcePath = assemblyPath, VersionInfo = versionInfo, RelativeOutputPath = Path.GetFileName(assemblyPath), - PackagePath = packagePath + PackagePath = packagePath, + NuSpec = item.GetMetadataValue("ResolvedFrom") == "{HintPathFromItem}" && item.GetMetadataValue("HintPath").StartsWith("..\\packages") + ? (localPackageService.GetNuSpecFromPackagePath(packagePath) ?? throw new ApplicationException($"Cannot find package path from assembly path ({assemblyPath})")) + : null }; - if (item.GetMetadataValue("ResolvedFrom") == "{HintPathFromItem}" && item.GetMetadataValue("HintPath").StartsWith("..\\packages")) - { - resolvedFileInfo.NuSpec = localPackageService.GetNuSpecFromPackagePath(packagePath) ?? throw new ApplicationException($"Cannot find package path from assembly path ({assemblyPath})"); - } resolvedFileInfos.Add(resolvedFileInfo); }