Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Swift Package Manager component detection support #1316

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions .github/workflows/snapshot-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Publish snapshot of test scan

env:
CD_DETECTOR_EXPERIMENTS: 1
PipReportSkipFallbackOnFailure: "true"
PIP_INDEX_URL: "https://pypi.python.org/simple"
raporpe marked this conversation as resolved.
Show resolved Hide resolved

on:
push:
Expand Down
23 changes: 23 additions & 0 deletions docs/detectors/swift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Swift Package Manager Detection

## Requirements

Swift Package Manager detection requires the following file to be present in the scan directory:

- `Package.resolved` file

## Detection strategy

The detector `SwiftResolvedComponentDetector` only parses the `Package.resolved` file to get the dependencies.
This file contains a json representation of the resolved dependencies of the project with the transitive dependencies.
The version, the url and commit hash of the dependencies are stored in this file.

[This is the only reference in the Apple documentation to the `Package.resolved` file.][1]

## Known limitations

Right now the detector does not support parsing `Package.swift` file to get the dependencies.
It only supports parsing `Package.resolved` file.
Some projects only commit the `Package.swift`, which is why it is planned to support parsing `Package.swift` in the future.

[1]: https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html#package-dependency
3 changes: 3 additions & 0 deletions src/Microsoft.ComponentDetection.Contracts/DetectorClass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,7 @@ public enum DetectorClass

/// <summary>Indicates a detector applies to Docker references.</summary>
DockerReference,

/// <summary> Indicates a detector applies to Swift packages.</summary>
Swift,
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@ public enum ComponentType : byte

[EnumMember]
Conan = 17,

[EnumMember]
Swift = 18,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
namespace Microsoft.ComponentDetection.Contracts.TypedComponent;

using System;
using System.Collections.Generic;
using PackageUrl;

/// <summary>
/// Represents a Swift package manager component.
/// </summary>
public class SwiftComponent : TypedComponent
{
private readonly string packageUrl;

private readonly string hash;

/// <summary>
/// Initializes a new instance of the <see cref="SwiftComponent"/> class.
/// </summary>
/// <param name="name">The name of the component.</param>
/// <param name="version">The version of the component.</param>
/// <param name="packageUrl">The package URL of the component.</param>
/// <param name="hash">The hash of the component.</param>
public SwiftComponent(string name, string version, string packageUrl, string hash)
{
this.Name = this.ValidateRequiredInput(name, nameof(name), nameof(ComponentType.Swift));
this.Version = this.ValidateRequiredInput(version, nameof(version), nameof(ComponentType.Swift));
this.packageUrl = this.ValidateRequiredInput(packageUrl, nameof(packageUrl), nameof(ComponentType.Swift));
this.hash = this.ValidateRequiredInput(hash, nameof(hash), nameof(ComponentType.Swift));
}

public string Name { get; }

public string Version { get; }

public override ComponentType Type => ComponentType.Swift;

public override string Id => $"{this.Name} {this.Version} - {this.Type}";

// The type is swiftpm
public PackageURL PackageURL => new PackageURL(
type: "swift",
@namespace: new Uri(this.packageUrl).Host,
name: this.Name,
version: this.hash, // Hash has priority over version when creating a PackageURL
Copy link
Contributor

Choose a reason for hiding this comment

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

have you confirmed in PURL spec hash is what is used as opposed to version? These are not meant to be arbitrary. We should be aligned with the spec has defined for Swift, I don't recall seeing hashes for purls in the past. See sample Swift PURL and purl-spec

qualifiers: new SortedDictionary<string, string>
{
{ "repository_url", this.packageUrl },
},
subpath: null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace Microsoft.ComponentDetection.Contracts.TypedComponent;

using System.Collections.Generic;
using Newtonsoft.Json;

/// <summary>
/// Represents a Swift Package Manager component.
/// </summary>
public class SwiftResolvedFile
{
[JsonProperty("pins")]
public IList<SwiftDependency> Pins { get; set; }

[JsonProperty("version")]
public int Version { get; set; }

public class SwiftDependency
{
// The name of the package
[JsonProperty("identity")]
public string Identity { get; set; }

// How the package is imported. Example: "remoteSourceControl"
// This is not an enum because the Swift contract does not specify the possible values.
[JsonProperty("kind")]
public string Kind { get; set; }

// The unique path to the repository where the package is located. Example: Git repo URL.
[JsonProperty("location")]
public string Location { get; set; }

// Data about the package version and commit hash.
[JsonProperty("state")]
public SwiftState State { get; set; }

public class SwiftState
{
// The commit hash of the package.
[JsonProperty("revision")]
public string Revision { get; set; }

// The version of the package. Might be missing.
[JsonProperty("version")]
public string Version { get; set; }

// The branch of the package. Might be missing.
[JsonProperty("branch")]
public string Branch { get; set; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
namespace Microsoft.ComponentDetection.Detectors.Swift;

using System;
using System.Collections.Generic;
using System.IO;
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;
using Newtonsoft.Json;

/// <summary>
/// Detects Swift Package Manager components.
/// </summary>
public class SwiftResolvedComponentDetector : FileComponentDetector, IDefaultOffComponentDetector
{
public SwiftResolvedComponentDetector(
IComponentStreamEnumerableFactory componentStreamEnumerableFactory,
IObservableDirectoryWalkerFactory walkerFactory,
ILogger<SwiftResolvedComponentDetector> logger)
{
this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory;
this.Scanner = walkerFactory;
this.Logger = logger;
}

public override string Id { get; } = "Swift";

public override IEnumerable<string> Categories => [Enum.GetName(DetectorClass.Swift)];

public override IList<string> SearchPatterns { get; } = ["Package.resolved"];

public override IEnumerable<ComponentType> SupportedComponentTypes => [ComponentType.Swift];

public override int Version => 2;
raporpe marked this conversation as resolved.
Show resolved Hide resolved

protected override Task OnFileFoundAsync(
ProcessRequest processRequest,
IDictionary<string, string> detectorArgs,
CancellationToken cancellationToken = default)
{
try
{
this.ProcessPackageResolvedFile(processRequest.SingleFileComponentRecorder, processRequest.ComponentStream);
}
catch (Exception exception)
{
this.Logger.LogError(exception, "SwiftComponentDetector: Error processing Package.resolved file: {Location}", processRequest.ComponentStream.Location);
}

return Task.CompletedTask;
}

private void ProcessPackageResolvedFile(ISingleFileComponentRecorder singleFileComponentRecorder, IComponentStream componentStream)
{
var parsedResolvedFile = this.ReadAndParseResolvedFile(componentStream.Stream);

foreach (var package in parsedResolvedFile.Pins)
{
// We are only interested in packages coming from remote sources such as git
// The Package Kind is not an enum because the Swift Package Manager contract does not specify the possible values.
var targetSwiftPackageKind = "remoteSourceControl";
raporpe marked this conversation as resolved.
Show resolved Hide resolved
if (package.Kind == targetSwiftPackageKind)
{
// The version of the package is not always available.
var version = package.State.Version ?? package.State.Branch ?? package.State.Revision;

var detectedSwiftComponent = new SwiftComponent(
name: package.Identity,
version: version,
packageUrl: package.Location,
hash: package.State.Revision);
var newDetectedSwiftComponent = new DetectedComponent(component: detectedSwiftComponent, detector: this);
singleFileComponentRecorder.RegisterUsage(newDetectedSwiftComponent);

// We also register a Git component for the same package so that the git URL is registered.
// Swift Package Manager directly downloads the package from the git URL.
var detectedGitComponent = new GitComponent(
repositoryUrl: new Uri(package.Location),
Copy link
Contributor

Choose a reason for hiding this comment

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

is this location guaranteed to be a valid Git Url? I ask because if this is missing or an unexpected location it will effectively break the parsing of all Swift components in that scan.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When developing the detectector I tried to look into the SwiftPM documentation and found not format guarantee that this is a git url. Swift expects a repository when the kind is "remoteSourceControl". Anyway, I'm going to take a look into the SwiftPM source code to see what it's actually doing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok I found the documentation that I needed:
Screenshot 2025-01-27 at 18 23 12

and

Screenshot 2025-01-27 at 18 19 45

and in the source code they are verifying that the URL is a valid git repository because SwiftPM depends on git tags to be able to download the specified version.

This is the source code where they are doing it:

Screenshot 2025-01-27 at 18 18 26 Screenshot 2025-01-27 at 18 18 43

So yes, I would say it's very very probable that this is almost always going to be a valid git url for most cases.

These are all the available "Kinds" of packages and I'm covering one of the most common, which is the git url. I think that SwiftPM registries are not that common.

Screenshot 2025-01-27 at 17 53 26

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As you told me in teams, I will make sure to enclose each for loop iteration with a try so that if a single dependency fails the rest of dependencies will be registered 👍🏼

commitHash: package.State.Revision,
tag: version);
var newDetectedGitComponent = new DetectedComponent(component: detectedGitComponent, detector: this);
singleFileComponentRecorder.RegisterUsage(newDetectedGitComponent);
}
}
}

/// <summary>
/// Reads the stream of the package resolved file and parses it.
/// </summary>
/// <param name="stream">The stream of the file to parse.</param>
/// <returns>The parsed object.</returns>
private SwiftResolvedFile ReadAndParseResolvedFile(Stream stream)
{
string resolvedFile;
using (var reader = new StreamReader(stream))
{
resolvedFile = reader.ReadToEnd();
}

return JsonConvert.DeserializeObject<SwiftResolvedFile>(resolvedFile);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions;
using Microsoft.ComponentDetection.Detectors.Ruby;
using Microsoft.ComponentDetection.Detectors.Rust;
using Microsoft.ComponentDetection.Detectors.Spdx;
using Microsoft.ComponentDetection.Detectors.Swift;
using Microsoft.ComponentDetection.Detectors.Vcpkg;
using Microsoft.ComponentDetection.Detectors.Yarn;
using Microsoft.ComponentDetection.Detectors.Yarn.Parsers;
Expand Down Expand Up @@ -140,6 +141,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s
services.AddSingleton<IYarnLockFileFactory, YarnLockFileFactory>();
services.AddSingleton<IComponentDetector, YarnLockComponentDetector>();

// Swift Package Manager
services.AddSingleton<IComponentDetector, SwiftResolvedComponentDetector>();

return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace Microsoft.ComponentDetection.Detectors.Tests.Swift;

using System;
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.ComponentDetection.Contracts.TypedComponent;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PackageUrl;

[TestClass]
public class SwiftComponentTests
{
[TestMethod]
public void Constructor_ShouldInitializeProperties()
{
var name = "alamofire";
var version = "5.9.1";
var packageUrl = "https://github.com/Alamofire/Alamofire";
var hash = "f455c2975872ccd2d9c81594c658af65716e9b9a";

var component = new SwiftComponent(name, version, packageUrl, hash);

component.Name.Should().Be(name);
component.Version.Should().Be(version);
component.Type.Should().Be(ComponentType.Swift);
component.Id.Should().Be($"{name} {version} - {component.Type}");
}

[TestMethod]
public void Constructor_ShouldThrowException_WhenNameIsNull()
{
Action action = () => new SwiftComponent(null, "5.9.1", "https://github.com/Alamofire/Alamofire", "f455c2975872ccd2d9c81594c658af65716e9b9a");
action.Should().Throw<ArgumentException>().WithMessage("*name*");
}

[TestMethod]
public void Constructor_ShouldThrowException_WhenVersionIsNull()
{
Action action = () => new SwiftComponent("alamofire", null, "https://github.com/Alamofire/Alamofire", "f455c2975872ccd2d9c81594c658af65716e9b9a");
action.Should().Throw<ArgumentException>().WithMessage("*version*");
}

[TestMethod]
public void Constructor_ShouldThrowException_WhenPackageUrlIsNull()
{
Action action = () => new SwiftComponent("alamofire", "5.9.1", null, "f455c2975872ccd2d9c81594c658af65716e9b9a");
action.Should().Throw<ArgumentException>().WithMessage("*packageUrl*");
}

[TestMethod]
public void Constructor_ShouldThrowException_WhenHashIsNull()
{
Action action = () => new SwiftComponent("alamofire", "5.9.1", "https://github.com/Alamofire/Alamofire", null);
action.Should().Throw<ArgumentException>().WithMessage("*hash*");
}

[TestMethod]
public void PackageURL_ShouldReturnCorrectPackageURL()
{
var name = "alamofire";
var version = "5.9.1";
var packageUrl = "https://github.com/Alamofire/Alamofire";
var hash = "f455c2975872ccd2d9c81594c658af65716e9b9a";

var component = new SwiftComponent(name, version, packageUrl, hash);

var expectedPackageURL = new PackageURL(
type: "swift",
@namespace: "github.com",
name: name,
version: hash,
qualifiers: new SortedDictionary<string, string>
{
{ "repository_url", packageUrl },
},
subpath: null);

component.PackageURL.Should().BeEquivalentTo(expectedPackageURL);
}
}
Loading
Loading