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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
<PackageVersion Include="DotNet.Glob" Version="2.1.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
<PackageVersion Include="MinVer" Version="5.0.0" />
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="morelinq" Version="4.4.0" />
Expand Down
94 changes: 94 additions & 0 deletions src/Microsoft.ComponentDetection.Common/LinuxDistribution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
namespace Microsoft.ComponentDetection.Common;

using System;
using System.Collections.Generic;

/// <summary>
/// Represents Linux distribution information parsed from /etc/os-release or /usr/lib/os-release.
/// </summary>
public sealed class LinuxDistribution
{
/// <summary>
/// Gets the lower-case operating system identifier (e.g., "ubuntu", "rhel", "fedora").
/// </summary>
public string Id { get; init; }

/// <summary>
/// Gets the operating system version number or identifier.
/// </summary>
public string VersionId { get; init; }

/// <summary>
/// Gets the operating system name without version information.
/// </summary>
public string Name { get; init; }

/// <summary>
/// Gets a human-readable operating system name with version.
/// </summary>
public string PrettyName { get; init; }

/// <summary>
/// Parses an os-release file content and returns a LinuxDistribution object.
/// The os-release format is defined at https://www.freedesktop.org/software/systemd/man/os-release.html.
/// </summary>
/// <param name="content">The content of the os-release file.</param>
/// <returns>A LinuxDistribution object or null if parsing fails.</returns>
public static LinuxDistribution ParseOsRelease(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return null;
}

var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

foreach (var line in content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
var trimmedLine = line.Trim();

// Skip comments and empty lines
if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith('#'))
{
continue;
}

var parts = trimmedLine.Split('=', 2);
if (parts.Length != 2)
{
continue;
}

var key = parts[0].Trim();
var value = parts[1].Trim();

// Remove quotes if present
if (
value.Length >= 2
&& (
(value.StartsWith('\"') && value.EndsWith('\"'))
|| (value.StartsWith('\'') && value.EndsWith('\''))
)
)
{
value = value[1..^1];
}

values[key] = value;
}

Comment on lines +44 to +79
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.

Suggested change
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
var trimmedLine = line.Trim();
// Skip comments and empty lines
if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith('#'))
{
continue;
}
var parts = trimmedLine.Split('=', 2);
if (parts.Length != 2)
{
continue;
}
var key = parts[0].Trim();
var value = parts[1].Trim();
// Remove quotes if present
if (
value.Length >= 2
&& (
(value.StartsWith('\"') && value.EndsWith('\"'))
|| (value.StartsWith('\'') && value.EndsWith('\''))
)
)
{
value = value[1..^1];
}
values[key] = value;
}
var values = content
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.Where(trimmedLine => !string.IsNullOrEmpty(trimmedLine) && !trimmedLine.StartsWith('#'))
.Select(trimmedLine =>
{
var parts = trimmedLine.Split('=', 2);
if (parts.Length != 2)
{
return null;
}
var key = parts[0].Trim();
var value = parts[1].Trim();
// Remove quotes if present
if (
value.Length >= 2
&& (
(value.StartsWith('\"') && value.EndsWith('\"'))
|| (value.StartsWith('\'') && value.EndsWith('\''))
)
)
{
value = value[1..^1];
}
return new { key, value };
})
.Where(x => x != null)
.ToDictionary(x => x.key, x => x.value, StringComparer.OrdinalIgnoreCase);

Copilot uses AI. Check for mistakes.
// At minimum, we need an ID field
if (!values.ContainsKey("ID"))
{
return null;
}

return new LinuxDistribution
{
Id = values.GetValueOrDefault("ID"),
VersionId = values.GetValueOrDefault("VERSION_ID"),
Name = values.GetValueOrDefault("NAME"),
PrettyName = values.GetValueOrDefault("PRETTY_NAME"),
};
}
}
269 changes: 269 additions & 0 deletions src/Microsoft.ComponentDetection.Common/SystemPackageDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
namespace Microsoft.ComponentDetection.Common;

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.Internal;
using Microsoft.ComponentDetection.Contracts.TypedComponent;
using Microsoft.Extensions.Logging;

/// <summary>
/// Abstract base class for system package detectors (RPM, APK, DPKG, etc.).
/// </summary>
public abstract class SystemPackageDetector : FileComponentDetector
{
/// <inheritdoc />
protected override async Task OnFileFoundAsync(
ProcessRequest processRequest,
IDictionary<string, string> detectorArgs,
CancellationToken cancellationToken = default
)
{
// Only run on Linux
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
this.Logger.LogDebug("Skipping {DetectorId} - not running on Linux", this.Id);
return;
}

var file = processRequest.ComponentStream;
var recorder = processRequest.SingleFileComponentRecorder;

try
{
// Find the Linux distribution
var distro = await this.FindDistributionAsync().ConfigureAwait(false);

if (distro == null)
{
this.Logger.LogWarning(
"Could not determine Linux distribution for {FilePath}, using 'linux' as default namespace",
file.Location
);
}

// Parse packages from the database
var packages = await this.ParsePackagesAsync(file.Stream, file.Location, distro)
.ConfigureAwait(false);

if (packages.Count == 0)
{
this.Logger.LogDebug("No packages found in {FilePath}", file.Location);
return;
}

// Build dependency graph and register components
this.BuildDependencyGraph(packages, recorder, distro);
}
catch (Exception ex)
{
this.Logger.LogError(
ex,
"Error processing system package database at {FilePath}",
file.Location
);
throw;
}
}

/// <summary>
/// Parses packages from the system package database.
/// </summary>
/// <param name="dbStream">The database file stream.</param>
/// <param name="location">The location of the database file.</param>
/// <param name="distro">The detected Linux distribution.</param>
/// <returns>A list of parsed package information.</returns>
protected abstract Task<List<SystemPackageInfo>> ParsePackagesAsync(
Stream dbStream,
string location,
LinuxDistribution distro
);

/// <summary>
/// Creates a TypedComponent from system package information.
/// </summary>
/// <param name="package">The package information.</param>
/// <param name="distro">The Linux distribution.</param>
/// <returns>A TypedComponent representing the package.</returns>
protected abstract TypedComponent CreateComponent(
SystemPackageInfo package,
LinuxDistribution distro
);

/// <summary>
/// Finds the Linux distribution by looking for os-release files relative to the database location.
/// </summary>
/// <returns>A LinuxDistribution object or null if not found.</returns>
protected virtual async Task<LinuxDistribution> FindDistributionAsync()
{
// Try common os-release locations relative to the database
var possiblePaths = new[] { "/etc/os-release", "/usr/lib/os-release" };

foreach (var path in possiblePaths)
{
try
{
if (File.Exists(path))
{
var content = await File.ReadAllTextAsync(path).ConfigureAwait(false);
var distro = LinuxDistribution.ParseOsRelease(content);
if (distro is not null)
{
this.Logger.LogDebug(
"Found Linux distribution: {Id} {VersionId} at {Path}",
distro.Id,
distro.VersionId,
path
);
return distro;
}
}
}
catch (Exception ex)
{
this.Logger.LogTrace(ex, "Failed to read os-release file at {Path}", path);
}
Comment on lines +126 to +129
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Suggested change
catch (Exception ex)
{
this.Logger.LogTrace(ex, "Failed to read os-release file at {Path}", path);
}
catch (IOException ex)
{
this.Logger.LogTrace(ex, "Failed to read os-release file at {Path}", path);
}
catch (UnauthorizedAccessException ex)
{
this.Logger.LogTrace(ex, "Failed to read os-release file at {Path}", path);
}
catch (FormatException ex)
{
this.Logger.LogTrace(ex, "Failed to parse os-release file at {Path}", path);
}
catch (InvalidOperationException ex)
{
this.Logger.LogTrace(ex, "Failed to parse os-release file at {Path}", path);
}

Copilot uses AI. Check for mistakes.
}

return null;
}

/// <summary>
/// Builds the dependency graph from package information using Provides/Requires relationships.
/// </summary>
/// <param name="packages">The list of packages to process.</param>
/// <param name="recorder">The component recorder.</param>
/// <param name="distro">The Linux distribution.</param>
protected virtual void BuildDependencyGraph(
List<SystemPackageInfo> packages,
ISingleFileComponentRecorder recorder,
LinuxDistribution distro
)
{
// Create a provides index: capability -> list of packages that provide it
var providesIndex = new Dictionary<string, List<SystemPackageInfo>>(packages.Count);

// Index all packages by what they provide
foreach (var pkg in packages)
{
// Package name is always a "provides"
if (!providesIndex.TryGetValue(pkg.Name, out var pkgList))
{
pkgList = [];
providesIndex[pkg.Name] = pkgList;
}

pkgList.Add(pkg);

// Add explicit provides
if (pkg.Provides is not null)
{
foreach (var provides in pkg.Provides)
{
if (string.IsNullOrWhiteSpace(provides))
{
continue;
}

if (!providesIndex.TryGetValue(provides, out var providesList))
{
providesList = [];
providesIndex[provides] = providesList;
}

providesList.Add(pkg);
}
}
}

// Create components and track them by package name
var componentsByPackageName = new Dictionary<string, DetectedComponent>(packages.Count);

// First pass: register all components as root dependencies
foreach (var pkg in packages)
{
var component = new DetectedComponent(this.CreateComponent(pkg, distro));
recorder.RegisterUsage(component, isExplicitReferencedDependency: true);
componentsByPackageName[pkg.Name] = component;
}

// Second pass: add dependency relationships
foreach (var pkg in packages)
{
if (!componentsByPackageName.TryGetValue(pkg.Name, out var childComponent))
{
continue;
}

if (pkg.Requires is not null)
{
foreach (var require in pkg.Requires)
{
if (string.IsNullOrWhiteSpace(require))
{
continue;
}

// Skip boolean expressions (not supported)
if (require.TrimStart().StartsWith('('))
{
continue;
}

// Find packages that provide this requirement
if (providesIndex.TryGetValue(require, out var providers))
{
foreach (var provider in providers)
{
// Skip self-references
if (provider.Name == pkg.Name)
{
continue;
}

if (
componentsByPackageName.TryGetValue(
provider.Name,
out var parentComponent
)
)
{
// Register the dependency relationship
recorder.RegisterUsage(
childComponent,
isExplicitReferencedDependency: false,
parentComponentId: parentComponent.Component.Id
Comment on lines +237 to +239
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency relationship is inverted. When package A requires capability X (provided by package B), package B is a dependency of package A. Therefore, B should be registered with parentComponentId: A.Id, not the other way around.

Current code registers:

recorder.RegisterUsage(childComponent, // pkg that requires
    isExplicitReferencedDependency: false,
    parentComponentId: parentComponent.Component.Id); // provider

It should be:

recorder.RegisterUsage(parentComponent, // provider (the dependency)
    isExplicitReferencedDependency: false,
    parentComponentId: childComponent.Component.Id); // pkg that requires (the parent)

This follows the pattern used in other detectors where dependencies are registered with their dependent as the parent.

Suggested change
childComponent,
isExplicitReferencedDependency: false,
parentComponentId: parentComponent.Component.Id
parentComponent,
isExplicitReferencedDependency: false,
parentComponentId: childComponent.Component.Id

Copilot uses AI. Check for mistakes.
);
}
}
}
}
}
}

this.Logger.LogInformation(
"Registered {PackageCount} packages with dependency relationships",
packages.Count
);
}

/// <summary>
/// Represents package information extracted from a system package database.
/// </summary>
protected class SystemPackageInfo
{
public required string Name { get; init; }

public required string Version { get; init; }

public List<string> Provides { get; init; } = [];

public List<string> Requires { get; init; } = [];

public object Metadata { get; init; }
}
}
3 changes: 3 additions & 0 deletions src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,7 @@ public enum DetectorClass

/// <summary> Indicates a detector applies to Swift packages.</summary>
Swift,

/// <summary>Indicates a detector applies to system packages (RPM, APK, DPKG, etc.).</summary>
SystemPackages,
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,7 @@ public enum ComponentType : byte

[EnumMember]
DotNet = 19,

[EnumMember]
Rpm = 20,
}
Loading
Loading