Skip to content

Commit

Permalink
Introduce pnpm lockfile v9 detector
Browse files Browse the repository at this point in the history
  • Loading branch information
FernandoRojo committed Oct 24, 2024
1 parent 402a932 commit 6b53421
Show file tree
Hide file tree
Showing 20 changed files with 473 additions and 218 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Microsoft.ComponentDetection.Detectors.Pnpm;

using YamlDotNet.Serialization;

/// <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; }
}
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
@@ -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("file:") || dependency.Value.StartsWith("file:") || dependency.Value.StartsWith("link:");
}

/// <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>(),
};
}

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)
{
// No version = not expected input
return (null, null);
}

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_@babel+core@7.10.5
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/chai@3.5.0+sinon@1.17.7
if (SemanticVersion.TryParse(pnpmComponentDefSections[lastIndex - 1], out var _))
{
return (pnpmComponentDefSections[lastIndex - 1], lastIndex - 1);
}

return (packageVersion, indexVersionIsAt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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: /webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack-dev-server@4.6.0)(webpack@5.89.0)
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.");
}

// 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));
}

public override 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,58 @@
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.
* 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: /webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack-dev-server@4.6.0)(webpack@5.89.0)
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));
}

public override bool IsPnpmPackageDevDependency(Package pnpmPackage)
{
// TODO: Implement
return false;
}

/// <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.Contains('@'))
{
return dependencyVersion;
}
else
{
return $"{dependencyName}@{dependencyVersion}";
}
}
}
18 changes: 12 additions & 6 deletions src/Microsoft.ComponentDetection.Detectors/pnpm/Pnpm5Detector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ namespace Microsoft.ComponentDetection.Detectors.Pnpm;
public class Pnpm5Detector : IPnpmDetector
{
public const string MajorVersion = "5";
private readonly PnpmParsingUtilitiesBase<PnpmYamlV5> pnpmParsingUtilities = PnpmParsingUtilitiesFactory.Create<PnpmYamlV5>();

public void RecordDependencyGraphFromFile(string yamlFileContent, ISingleFileComponentRecorder singleFileComponentRecorder)
{
var yaml = PnpmParsingUtilities.DeserializePnpmYamlV5File(yamlFileContent);
var yaml = this.pnpmParsingUtilities.DeserializePnpmYamlFile(yamlFileContent);

foreach (var packageKeyValue in yaml.Packages ?? Enumerable.Empty<KeyValuePair<string, Package>>())
{
Expand All @@ -20,8 +21,8 @@ public void RecordDependencyGraphFromFile(string yamlFileContent, ISingleFileCom
continue;
}

var parentDetectedComponent = PnpmParsingUtilities.CreateDetectedComponentFromPnpmPathV5(pnpmPackagePath: packageKeyValue.Key);
var isDevDependency = packageKeyValue.Value != null && PnpmParsingUtilities.IsPnpmPackageDevDependency(packageKeyValue.Value);
var parentDetectedComponent = this.pnpmParsingUtilities.CreateDetectedComponentFromPnpmPath(pnpmPackagePath: packageKeyValue.Key);
var isDevDependency = packageKeyValue.Value != null && this.pnpmParsingUtilities.IsPnpmPackageDevDependency(packageKeyValue.Value);
singleFileComponentRecorder.RegisterUsage(parentDetectedComponent, isDevelopmentDependency: isDevDependency);
parentDetectedComponent = singleFileComponentRecorder.GetComponent(parentDetectedComponent.Component.Id);

Expand All @@ -30,13 +31,13 @@ public void RecordDependencyGraphFromFile(string yamlFileContent, ISingleFileCom
foreach (var dependency in packageKeyValue.Value.Dependencies)
{
// Ignore local packages.
if (PnpmParsingUtilities.IsLocalDependency(dependency))
if (this.pnpmParsingUtilities.IsLocalDependency(dependency))
{
continue;
}

var childDetectedComponent = PnpmParsingUtilities.CreateDetectedComponentFromPnpmPathV5(
pnpmPackagePath: PnpmParsingUtilities.CreatePnpmPackagePathFromDependencyV5(dependency.Key, dependency.Value));
var childDetectedComponent = this.pnpmParsingUtilities.CreateDetectedComponentFromPnpmPath(
pnpmPackagePath: this.CreatePnpmPackagePathFromDependency(dependency.Key, dependency.Value));

// Older code used the root's dev dependency value. We're leaving this null until we do a second pass to look at each components' top level referrers.
singleFileComponentRecorder.RegisterUsage(childDetectedComponent, parentComponentId: parentDetectedComponent.Component.Id, isDevelopmentDependency: null);
Expand All @@ -55,4 +56,9 @@ public void RecordDependencyGraphFromFile(string yamlFileContent, ISingleFileCom
}
}
}

private string CreatePnpmPackagePathFromDependency(string dependencyName, string dependencyVersion)
{
return dependencyVersion.Contains('/') ? dependencyVersion : $"/{dependencyName}/{dependencyVersion}";
}
}
Loading

0 comments on commit 6b53421

Please sign in to comment.