using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
///
/// Loads buildx plug-in manifests from the restart-time plug-in directory.
///
public sealed class BuildxPluginManifestLoader
{
public const string DefaultSearchPattern = "*.manifest.json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true
};
private readonly string manifestDirectory;
private readonly string searchPattern;
public BuildxPluginManifestLoader(string manifestDirectory, string? searchPattern = null)
{
if (string.IsNullOrWhiteSpace(manifestDirectory))
{
throw new ArgumentException("Manifest directory is required.", nameof(manifestDirectory));
}
this.manifestDirectory = Path.GetFullPath(manifestDirectory);
this.searchPattern = string.IsNullOrWhiteSpace(searchPattern)
? DefaultSearchPattern
: searchPattern;
}
///
/// Loads all manifests in the configured directory.
///
public async Task> LoadAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(manifestDirectory))
{
return Array.Empty();
}
var manifests = new List();
foreach (var file in Directory.EnumerateFiles(manifestDirectory, searchPattern, SearchOption.TopDirectoryOnly))
{
if (IsHiddenPath(file))
{
continue;
}
var manifest = await DeserializeManifestAsync(file, cancellationToken).ConfigureAwait(false);
manifests.Add(manifest);
}
return manifests
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
///
/// Loads the manifest with the specified identifier.
///
public async Task LoadByIdAsync(string manifestId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(manifestId))
{
throw new ArgumentException("Manifest identifier is required.", nameof(manifestId));
}
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
var manifest = manifests.FirstOrDefault(m => string.Equals(m.Id, manifestId, StringComparison.OrdinalIgnoreCase));
if (manifest is null)
{
throw new BuildxPluginException($"Buildx plug-in manifest '{manifestId}' was not found in '{manifestDirectory}'.");
}
return manifest;
}
///
/// Loads the first available manifest.
///
public async Task LoadDefaultAsync(CancellationToken cancellationToken)
{
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
if (manifests.Count == 0)
{
throw new BuildxPluginException($"No buildx plug-in manifests were discovered under '{manifestDirectory}'.");
}
return manifests[0];
}
private static bool IsHiddenPath(string path)
{
var directory = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(directory))
{
var segment = Path.GetFileName(directory);
if (segment.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static async Task DeserializeManifestAsync(string file, CancellationToken cancellationToken)
{
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
BuildxPluginManifest? manifest;
try
{
manifest = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException ex)
{
throw new BuildxPluginException($"Failed to parse manifest '{file}'.", ex);
}
if (manifest is null)
{
throw new BuildxPluginException($"Manifest '{file}' is empty or invalid.");
}
ValidateManifest(manifest, file);
var directory = Path.GetDirectoryName(file);
return manifest with
{
SourcePath = file,
SourceDirectory = directory
};
}
private static void ValidateManifest(BuildxPluginManifest manifest, string file)
{
if (!string.Equals(manifest.SchemaVersion, BuildxPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
{
throw new BuildxPluginException(
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{BuildxPluginManifest.CurrentSchemaVersion}'.");
}
if (string.IsNullOrWhiteSpace(manifest.Id))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a non-empty 'id'.");
}
if (manifest.EntryPoint is null)
{
throw new BuildxPluginException($"Manifest '{file}' must specify an 'entryPoint'.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Executable))
{
throw new BuildxPluginException($"Manifest '{file}' must specify an executable entry point.");
}
if (!manifest.RequiresRestart)
{
throw new BuildxPluginException($"Manifest '{file}' must enforce restart-required activation.");
}
if (manifest.Cas is null)
{
throw new BuildxPluginException($"Manifest '{file}' must define CAS defaults.");
}
if (string.IsNullOrWhiteSpace(manifest.Cas.DefaultRoot))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a CAS default root directory.");
}
}
}