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

Introduce pnpm lockfile v9 detector #1283

Merged
merged 11 commits into from
Dec 2, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using YamlDotNet.Serialization;

public class PnpmYamlVersion
/// <summary>
/// Base class for all Pnpm lockfiles. Used for parsing the lockfile version.
/// </summary>
public class PnpmYaml
{
[YamlMember(Alias = "lockfileVersion")]
public string LockfileVersion { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ namespace Microsoft.ComponentDetection.Detectors.Pnpm;
/// <summary>
/// Format for a Pnpm lock file version 5 as defined in https://github.com/pnpm/spec/blob/master/lockfile/5.md.
/// </summary>
public class PnpmYamlV5
public class PnpmYamlV5 : PnpmYaml
{
[YamlMember(Alias = "dependencies")]
public Dictionary<string, string> Dependencies { get; set; }

[YamlMember(Alias = "packages")]
public Dictionary<string, Package> Packages { get; set; }

[YamlMember(Alias = "lockfileVersion")]
public string LockfileVersion { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace Microsoft.ComponentDetection.Detectors.Pnpm;
using System.Collections.Generic;
using YamlDotNet.Serialization;

public class PnpmHasDependenciesV6
public class PnpmHasDependenciesV6 : PnpmYaml
{
[YamlMember(Alias = "dependencies")]
public Dictionary<string, PnpmYamlV6Dependency> Dependencies { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,4 @@ public class PnpmYamlV6 : PnpmHasDependenciesV6

[YamlMember(Alias = "packages")]
public Dictionary<string, Package> Packages { get; set; }

[YamlMember(Alias = "lockfileVersion")]
public string LockfileVersion { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using System.Collections.Generic;
using YamlDotNet.Serialization;

public class PnpmHasDependenciesV9 : PnpmYaml
{
[YamlMember(Alias = "dependencies")]
public Dictionary<string, PnpmYamlV9Dependency> Dependencies { get; set; }

[YamlMember(Alias = "devDependencies")]
public Dictionary<string, PnpmYamlV9Dependency> DevDependencies { get; set; }

[YamlMember(Alias = "optionalDependencies")]
public Dictionary<string, PnpmYamlV9Dependency> OptionalDependencies { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using System.Collections.Generic;
using YamlDotNet.Serialization;

/// <summary>
/// There is still no official docs for the new v9 lock if format, so these parsing contracts are empirically based.
/// Issue tracking v9 specs: https://github.com/pnpm/spec/issues/6
/// Format should eventually get updated here: https://github.com/pnpm/spec/blob/master/lockfile/6.0.md.
/// </summary>
public class PnpmYamlV9 : PnpmHasDependenciesV9
{
[YamlMember(Alias = "importers")]
public Dictionary<string, PnpmHasDependenciesV9> Importers { get; set; }

[YamlMember(Alias = "packages")]
public Dictionary<string, Package> Packages { get; set; }

[YamlMember(Alias = "snapshots")]
public Dictionary<string, Package> Snapshots { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using YamlDotNet.Serialization;

public class PnpmYamlV9Dependency
{
[YamlMember(Alias = "version")]
public string Version { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@ public interface IPnpmDetector
/// <param name="singleFileComponentRecorder">Component recorder to which to write the dependency graph.</param>
public void RecordDependencyGraphFromFile(string yamlFileContent, ISingleFileComponentRecorder singleFileComponentRecorder);
}

/// <summary>
/// Constants used in Pnpm Detectors.
/// </summary>
public static class PnpmConstants
{
public const string PnpmFileDependencyPath = "file:";

public const string PnpmLinkDependencyPath = "link:";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.ComponentDetection.Contracts;
using YamlDotNet.Serialization;

public abstract class PnpmParsingUtilitiesBase<T>
where T : PnpmYaml
{
public T DeserializePnpmYamlFile(string fileContent)
{
var deserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
return deserializer.Deserialize<T>(new StringReader(fileContent));
}

public virtual bool IsPnpmPackageDevDependency(Package pnpmPackage)
{
ArgumentNullException.ThrowIfNull(pnpmPackage);

return string.Equals(bool.TrueString, pnpmPackage.Dev, StringComparison.InvariantCultureIgnoreCase);
}

public bool IsLocalDependency(KeyValuePair<string, string> dependency)
{
// Local dependencies are dependencies that live in the file system
// this requires an extra parsing that is not supported yet
return dependency.Key.StartsWith(PnpmConstants.PnpmFileDependencyPath) || dependency.Value.StartsWith(PnpmConstants.PnpmFileDependencyPath) || dependency.Value.StartsWith(PnpmConstants.PnpmLinkDependencyPath);
}

/// <summary>
/// Parse a pnpm path of the form "/package-name/version".
/// </summary>
/// <param name="pnpmPackagePath">a pnpm path of the form "/package-name/version".</param>
/// <returns>Data parsed from path.</returns>
public abstract DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath);

public virtual string ReconstructPnpmDependencyPath(string dependencyName, string dependencyVersion)
{
if (dependencyVersion.StartsWith('/'))
{
return dependencyVersion;
}
else
{
return $"/{dependencyName}@{dependencyVersion}";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using System.IO;
using YamlDotNet.Serialization;

public static class PnpmParsingUtilitiesFactory
{
public static PnpmParsingUtilitiesBase<T> Create<T>()
where T : PnpmYaml
{
return typeof(T).Name switch
{
nameof(PnpmYamlV5) => new PnpmV5ParsingUtilities<T>(),
nameof(PnpmYamlV6) => new PnpmV6ParsingUtilities<T>(),
nameof(PnpmYamlV9) => new PnpmV9ParsingUtilities<T>(),
_ => new PnpmV5ParsingUtilities<T>(),

Check warning on line 16 in src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmParsingUtilitiesFactory.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmParsingUtilitiesFactory.cs#L16

Added line #L16 was not covered by tests
};
}

public static string DeserializePnpmYamlFileVersion(string fileContent)
{
var deserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
return deserializer.Deserialize<PnpmYaml>(new StringReader(fileContent))?.LockfileVersion;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using System.Linq;
using global::NuGet.Versioning;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.TypedComponent;

public class PnpmV5ParsingUtilities<T> : PnpmParsingUtilitiesBase<T>
where T : PnpmYaml
{
public override DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath)
{
var (parentName, parentVersion) = this.ExtractNameAndVersionFromPnpmPackagePath(pnpmPackagePath);
return new DetectedComponent(new NpmComponent(parentName, parentVersion));
}

private (string Name, string Version) ExtractNameAndVersionFromPnpmPackagePath(string pnpmPackagePath)
{
var pnpmComponentDefSections = pnpmPackagePath.Trim('/').Split('/');
(var packageVersion, var indexVersionIsAt) = this.GetPackageVersion(pnpmComponentDefSections);
if (indexVersionIsAt == -1)
{

Check warning on line 22 in src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV5ParsingUtilities.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV5ParsingUtilities.cs#L22

Added line #L22 was not covered by tests
// No version = not expected input
return (null, null);

Check warning on line 24 in src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV5ParsingUtilities.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV5ParsingUtilities.cs#L24

Added line #L24 was not covered by tests
}

var normalizedPackageName = string.Join("/", pnpmComponentDefSections.Take(indexVersionIsAt).ToArray());
return (normalizedPackageName, packageVersion);
}

private (string PackageVersion, int VersionIndex) GetPackageVersion(string[] pnpmComponentDefSections)
{
var indexVersionIsAt = -1;
var packageVersion = string.Empty;
var lastIndex = pnpmComponentDefSections.Length - 1;

// get version from packages with format /mute-stream/0.0.6
if (SemanticVersion.TryParse(pnpmComponentDefSections[lastIndex], out var _))
{
return (pnpmComponentDefSections[lastIndex], lastIndex);
}

// get version from packages with format /@babel/helper-compilation-targets/7.10.4_@[email protected]
var lastComponentSplit = pnpmComponentDefSections[lastIndex].Split("_");
if (SemanticVersion.TryParse(lastComponentSplit[0], out var _))
{
return (lastComponentSplit[0], lastIndex);
}

// get version from packages with format /sinon-chai/2.8.0/[email protected][email protected]
if (SemanticVersion.TryParse(pnpmComponentDefSections[lastIndex - 1], out var _))
{
return (pnpmComponentDefSections[lastIndex - 1], lastIndex - 1);
}

return (packageVersion, indexVersionIsAt);

Check warning on line 56 in src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV5ParsingUtilities.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV5ParsingUtilities.cs#L56

Added line #L56 was not covered by tests
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using System;
using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.TypedComponent;

public class PnpmV6ParsingUtilities<T> : PnpmParsingUtilitiesBase<T>
where T : PnpmYaml
{
public override DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath)
{
/*
* The format is documented at https://github.com/pnpm/spec/blob/master/dependency-path.md.
* At the writing it does not seem to reflect changes which were made in lock file format v6:
* See https://github.com/pnpm/spec/issues/5.
*/

// Strip parenthesized suffices from package. These hold peed dep related information that is unneeded here.
// An example of a dependency path with these: /[email protected]([email protected])([email protected])([email protected])
var fullPackageNameAndVersion = pnpmPackagePath.Split("(")[0];

var packageNameParts = fullPackageNameAndVersion.Split("@");

// If package name contains `@` this will reconstruct it:
var fullPackageName = string.Join("@", packageNameParts[..^1]);

// Version is section after last `@`.
var packageVersion = packageNameParts[^1];

// Check for leading `/` from pnpm.
if (!fullPackageName.StartsWith('/'))
{
throw new FormatException("Found pnpm dependency path not starting with `/`. This case is currently unhandled.");

Check warning on line 33 in src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV6ParsingUtilities.cs

View check run for this annotation

Codecov / codecov/patch

src/Microsoft.ComponentDetection.Detectors/pnpm/ParsingUtilities/PnpmV6ParsingUtilities.cs#L32-L33

Added lines #L32 - L33 were not covered by tests
}

// Strip leading `/`.
// It is unclear if real packages could have a name starting with `/`, so avoid `TrimStart` that just in case.
var normalizedPackageName = fullPackageName[1..];

return new DetectedComponent(new NpmComponent(normalizedPackageName, packageVersion));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using Microsoft.ComponentDetection.Contracts;
using Microsoft.ComponentDetection.Contracts.TypedComponent;

public class PnpmV9ParsingUtilities<T> : PnpmParsingUtilitiesBase<T>
where T : PnpmYaml
{
public override DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath)
{
/*
* The format is documented at https://github.com/pnpm/spec/blob/master/dependency-path.md.
FernandoRojo marked this conversation as resolved.
Show resolved Hide resolved
* At the writing it does not seem to reflect changes which were made in lock file format v9:
* See https://github.com/pnpm/spec/issues/5.
* In general, the spec sheet for the v9 lockfile is not published, so parsing of this lockfile was emperically determined.
* see https://github.com/pnpm/spec/issues/6
*/

// Strip parenthesized suffices from package. These hold peed dep related information that is unneeded here.
// An example of a dependency path with these: /[email protected]([email protected])([email protected])([email protected])
var fullPackageNameAndVersion = pnpmPackagePath.Split("(")[0];

var packageNameParts = fullPackageNameAndVersion.Split("@");

// If package name contains `@` this will reconstruct it:
var fullPackageName = string.Join("@", packageNameParts[..^1]);

// Version is section after last `@`.
var packageVersion = packageNameParts[^1];

return new DetectedComponent(new NpmComponent(fullPackageName, packageVersion));
}

/// <summary>
/// Combine the information from a dependency edge in the dependency graph encoded in the ymal file into a full pnpm dependency path.
/// </summary>
/// <param name="dependencyName">The name of the dependency, as used as as the dictionary key in the yaml file when referring to the dependency.</param>
/// <param name="dependencyVersion">The final resolved version of the package for this dependency edge.
/// This includes details like which version of specific dependencies were specified as peer dependencies.
/// In some edge cases, such as aliased packages, this version may be an absolute dependency path, but the leading slash has been removed.
/// leaving the "dependencyName" unused, which is checked by whether there is an @ in the version. </param>
/// <returns>A pnpm dependency path for the specified version of the named package.</returns>
public override string ReconstructPnpmDependencyPath(string dependencyName, string dependencyVersion)
{
if (dependencyVersion.StartsWith('/') || dependencyVersion.Split("(")[0].Contains('@'))
{
return dependencyVersion;
}
else
{
return $"{dependencyName}@{dependencyVersion}";
}
}
}
Loading
Loading