Skip to content

Commit 9f24dfc

Browse files
Introduce pnpm lockfile v9 detector (#1283)
* Introduce pnpm lockfile v9 detector * Fix test build * Add tests and update comments * Update version * coverage update * requeue PR builds * Fix smoke test --------- Co-authored-by: Greg Villicana <58237075+grvillic@users.noreply.github.com>
1 parent 218b693 commit 9f24dfc

21 files changed

+681
-225
lines changed

src/Microsoft.ComponentDetection.Detectors/pnpm/Contracts/PnpmYamlVersion.cs renamed to src/Microsoft.ComponentDetection.Detectors/pnpm/Contracts/PnpmYaml.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ namespace Microsoft.ComponentDetection.Detectors.Pnpm;
22

33
using YamlDotNet.Serialization;
44

5-
public class PnpmYamlVersion
5+
/// <summary>
6+
/// Base class for all Pnpm lockfiles. Used for parsing the lockfile version.
7+
/// </summary>
8+
public class PnpmYaml
69
{
710
[YamlMember(Alias = "lockfileVersion")]
811
public string LockfileVersion { get; set; }

src/Microsoft.ComponentDetection.Detectors/pnpm/Contracts/PnpmYamlV5.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,11 @@ namespace Microsoft.ComponentDetection.Detectors.Pnpm;
66
/// <summary>
77
/// Format for a Pnpm lock file version 5 as defined in https://github.com/pnpm/spec/blob/master/lockfile/5.md.
88
/// </summary>
9-
public class PnpmYamlV5
9+
public class PnpmYamlV5 : PnpmYaml
1010
{
1111
[YamlMember(Alias = "dependencies")]
1212
public Dictionary<string, string> Dependencies { get; set; }
1313

1414
[YamlMember(Alias = "packages")]
1515
public Dictionary<string, Package> Packages { get; set; }
16-
17-
[YamlMember(Alias = "lockfileVersion")]
18-
public string LockfileVersion { get; set; }
1916
}

src/Microsoft.ComponentDetection.Detectors/pnpm/Contracts/PnpmHasDependenciesV6.cs renamed to src/Microsoft.ComponentDetection.Detectors/pnpm/Contracts/V6/PnpmHasDependenciesV6.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ namespace Microsoft.ComponentDetection.Detectors.Pnpm;
33
using System.Collections.Generic;
44
using YamlDotNet.Serialization;
55

6-
public class PnpmHasDependenciesV6
6+
public class PnpmHasDependenciesV6 : PnpmYaml
77
{
88
[YamlMember(Alias = "dependencies")]
99
public Dictionary<string, PnpmYamlV6Dependency> Dependencies { get; set; }

src/Microsoft.ComponentDetection.Detectors/pnpm/Contracts/PnpmYamlV6.cs renamed to src/Microsoft.ComponentDetection.Detectors/pnpm/Contracts/V6/PnpmYamlV6.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,4 @@ public class PnpmYamlV6 : PnpmHasDependenciesV6
1717

1818
[YamlMember(Alias = "packages")]
1919
public Dictionary<string, Package> Packages { get; set; }
20-
21-
[YamlMember(Alias = "lockfileVersion")]
22-
public string LockfileVersion { get; set; }
2320
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using System.Collections.Generic;
4+
using YamlDotNet.Serialization;
5+
6+
public class PnpmHasDependenciesV9 : PnpmYaml
7+
{
8+
[YamlMember(Alias = "dependencies")]
9+
public Dictionary<string, PnpmYamlV9Dependency> Dependencies { get; set; }
10+
11+
[YamlMember(Alias = "devDependencies")]
12+
public Dictionary<string, PnpmYamlV9Dependency> DevDependencies { get; set; }
13+
14+
[YamlMember(Alias = "optionalDependencies")]
15+
public Dictionary<string, PnpmYamlV9Dependency> OptionalDependencies { get; set; }
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using System.Collections.Generic;
4+
using YamlDotNet.Serialization;
5+
6+
/// <summary>
7+
/// There is still no official docs for the new v9 lock if format, so these parsing contracts are empirically based.
8+
/// Issue tracking v9 specs: https://github.com/pnpm/spec/issues/6
9+
/// Format should eventually get updated here: https://github.com/pnpm/spec/blob/master/lockfile/6.0.md.
10+
/// </summary>
11+
public class PnpmYamlV9 : PnpmHasDependenciesV9
12+
{
13+
[YamlMember(Alias = "importers")]
14+
public Dictionary<string, PnpmHasDependenciesV9> Importers { get; set; }
15+
16+
[YamlMember(Alias = "packages")]
17+
public Dictionary<string, Package> Packages { get; set; }
18+
19+
[YamlMember(Alias = "snapshots")]
20+
public Dictionary<string, Package> Snapshots { get; set; }
21+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using YamlDotNet.Serialization;
4+
5+
public class PnpmYamlV9Dependency
6+
{
7+
[YamlMember(Alias = "version")]
8+
public string Version { get; set; }
9+
}

src/Microsoft.ComponentDetection.Detectors/pnpm/IPnpmDetector.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,13 @@ public interface IPnpmDetector
1414
/// <param name="singleFileComponentRecorder">Component recorder to which to write the dependency graph.</param>
1515
public void RecordDependencyGraphFromFile(string yamlFileContent, ISingleFileComponentRecorder singleFileComponentRecorder);
1616
}
17+
18+
/// <summary>
19+
/// Constants used in Pnpm Detectors.
20+
/// </summary>
21+
public static class PnpmConstants
22+
{
23+
public const string PnpmFileDependencyPath = "file:";
24+
25+
public const string PnpmLinkDependencyPath = "link:";
26+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using Microsoft.ComponentDetection.Contracts;
7+
using YamlDotNet.Serialization;
8+
9+
public abstract class PnpmParsingUtilitiesBase<T>
10+
where T : PnpmYaml
11+
{
12+
public T DeserializePnpmYamlFile(string fileContent)
13+
{
14+
var deserializer = new DeserializerBuilder()
15+
.IgnoreUnmatchedProperties()
16+
.Build();
17+
return deserializer.Deserialize<T>(new StringReader(fileContent));
18+
}
19+
20+
public virtual bool IsPnpmPackageDevDependency(Package pnpmPackage)
21+
{
22+
ArgumentNullException.ThrowIfNull(pnpmPackage);
23+
24+
return string.Equals(bool.TrueString, pnpmPackage.Dev, StringComparison.InvariantCultureIgnoreCase);
25+
}
26+
27+
public bool IsLocalDependency(KeyValuePair<string, string> dependency)
28+
{
29+
// Local dependencies are dependencies that live in the file system
30+
// this requires an extra parsing that is not supported yet
31+
return dependency.Key.StartsWith(PnpmConstants.PnpmFileDependencyPath) || dependency.Value.StartsWith(PnpmConstants.PnpmFileDependencyPath) || dependency.Value.StartsWith(PnpmConstants.PnpmLinkDependencyPath);
32+
}
33+
34+
/// <summary>
35+
/// Parse a pnpm path of the form "/package-name/version".
36+
/// </summary>
37+
/// <param name="pnpmPackagePath">a pnpm path of the form "/package-name/version".</param>
38+
/// <returns>Data parsed from path.</returns>
39+
public abstract DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath);
40+
41+
public virtual string ReconstructPnpmDependencyPath(string dependencyName, string dependencyVersion)
42+
{
43+
if (dependencyVersion.StartsWith('/'))
44+
{
45+
return dependencyVersion;
46+
}
47+
else
48+
{
49+
return $"/{dependencyName}@{dependencyVersion}";
50+
}
51+
}
52+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using System.IO;
4+
using YamlDotNet.Serialization;
5+
6+
public static class PnpmParsingUtilitiesFactory
7+
{
8+
public static PnpmParsingUtilitiesBase<T> Create<T>()
9+
where T : PnpmYaml
10+
{
11+
return typeof(T).Name switch
12+
{
13+
nameof(PnpmYamlV5) => new PnpmV5ParsingUtilities<T>(),
14+
nameof(PnpmYamlV6) => new PnpmV6ParsingUtilities<T>(),
15+
nameof(PnpmYamlV9) => new PnpmV9ParsingUtilities<T>(),
16+
_ => new PnpmV5ParsingUtilities<T>(),
17+
};
18+
}
19+
20+
public static string DeserializePnpmYamlFileVersion(string fileContent)
21+
{
22+
var deserializer = new DeserializerBuilder()
23+
.IgnoreUnmatchedProperties()
24+
.Build();
25+
return deserializer.Deserialize<PnpmYaml>(new StringReader(fileContent))?.LockfileVersion;
26+
}
27+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using System.Linq;
4+
using global::NuGet.Versioning;
5+
using Microsoft.ComponentDetection.Contracts;
6+
using Microsoft.ComponentDetection.Contracts.TypedComponent;
7+
8+
public class PnpmV5ParsingUtilities<T> : PnpmParsingUtilitiesBase<T>
9+
where T : PnpmYaml
10+
{
11+
public override DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath)
12+
{
13+
var (parentName, parentVersion) = this.ExtractNameAndVersionFromPnpmPackagePath(pnpmPackagePath);
14+
return new DetectedComponent(new NpmComponent(parentName, parentVersion));
15+
}
16+
17+
private (string Name, string Version) ExtractNameAndVersionFromPnpmPackagePath(string pnpmPackagePath)
18+
{
19+
var pnpmComponentDefSections = pnpmPackagePath.Trim('/').Split('/');
20+
(var packageVersion, var indexVersionIsAt) = this.GetPackageVersion(pnpmComponentDefSections);
21+
if (indexVersionIsAt == -1)
22+
{
23+
// No version = not expected input
24+
return (null, null);
25+
}
26+
27+
var normalizedPackageName = string.Join("/", pnpmComponentDefSections.Take(indexVersionIsAt).ToArray());
28+
return (normalizedPackageName, packageVersion);
29+
}
30+
31+
private (string PackageVersion, int VersionIndex) GetPackageVersion(string[] pnpmComponentDefSections)
32+
{
33+
var indexVersionIsAt = -1;
34+
var packageVersion = string.Empty;
35+
var lastIndex = pnpmComponentDefSections.Length - 1;
36+
37+
// get version from packages with format /mute-stream/0.0.6
38+
if (SemanticVersion.TryParse(pnpmComponentDefSections[lastIndex], out var _))
39+
{
40+
return (pnpmComponentDefSections[lastIndex], lastIndex);
41+
}
42+
43+
// get version from packages with format /@babel/helper-compilation-targets/7.10.4_@babel+core@7.10.5
44+
var lastComponentSplit = pnpmComponentDefSections[lastIndex].Split("_");
45+
if (SemanticVersion.TryParse(lastComponentSplit[0], out var _))
46+
{
47+
return (lastComponentSplit[0], lastIndex);
48+
}
49+
50+
// get version from packages with format /sinon-chai/2.8.0/chai@3.5.0+sinon@1.17.7
51+
if (SemanticVersion.TryParse(pnpmComponentDefSections[lastIndex - 1], out var _))
52+
{
53+
return (pnpmComponentDefSections[lastIndex - 1], lastIndex - 1);
54+
}
55+
56+
return (packageVersion, indexVersionIsAt);
57+
}
58+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using System;
4+
using Microsoft.ComponentDetection.Contracts;
5+
using Microsoft.ComponentDetection.Contracts.TypedComponent;
6+
7+
public class PnpmV6ParsingUtilities<T> : PnpmParsingUtilitiesBase<T>
8+
where T : PnpmYaml
9+
{
10+
public override DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath)
11+
{
12+
/*
13+
* The format is documented at https://github.com/pnpm/spec/blob/master/dependency-path.md.
14+
* At the writing it does not seem to reflect changes which were made in lock file format v6:
15+
* See https://github.com/pnpm/spec/issues/5.
16+
*/
17+
18+
// Strip parenthesized suffices from package. These hold peed dep related information that is unneeded here.
19+
// 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)
20+
var fullPackageNameAndVersion = pnpmPackagePath.Split("(")[0];
21+
22+
var packageNameParts = fullPackageNameAndVersion.Split("@");
23+
24+
// If package name contains `@` this will reconstruct it:
25+
var fullPackageName = string.Join("@", packageNameParts[..^1]);
26+
27+
// Version is section after last `@`.
28+
var packageVersion = packageNameParts[^1];
29+
30+
// Check for leading `/` from pnpm.
31+
if (!fullPackageName.StartsWith('/'))
32+
{
33+
throw new FormatException("Found pnpm dependency path not starting with `/`. This case is currently unhandled.");
34+
}
35+
36+
// Strip leading `/`.
37+
// It is unclear if real packages could have a name starting with `/`, so avoid `TrimStart` that just in case.
38+
var normalizedPackageName = fullPackageName[1..];
39+
40+
return new DetectedComponent(new NpmComponent(normalizedPackageName, packageVersion));
41+
}
42+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace Microsoft.ComponentDetection.Detectors.Pnpm;
2+
3+
using Microsoft.ComponentDetection.Contracts;
4+
using Microsoft.ComponentDetection.Contracts.TypedComponent;
5+
6+
public class PnpmV9ParsingUtilities<T> : PnpmParsingUtilitiesBase<T>
7+
where T : PnpmYaml
8+
{
9+
public override DetectedComponent CreateDetectedComponentFromPnpmPath(string pnpmPackagePath)
10+
{
11+
/*
12+
* The format is documented at https://github.com/pnpm/spec/blob/master/dependency-path.md.
13+
* At the writing it does not seem to reflect changes which were made in lock file format v9:
14+
* See https://github.com/pnpm/spec/issues/5.
15+
* In general, the spec sheet for the v9 lockfile is not published, so parsing of this lockfile was emperically determined.
16+
* see https://github.com/pnpm/spec/issues/6
17+
*/
18+
19+
// Strip parenthesized suffices from package. These hold peed dep related information that is unneeded here.
20+
// 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)
21+
var fullPackageNameAndVersion = pnpmPackagePath.Split("(")[0];
22+
23+
var packageNameParts = fullPackageNameAndVersion.Split("@");
24+
25+
// If package name contains `@` this will reconstruct it:
26+
var fullPackageName = string.Join("@", packageNameParts[..^1]);
27+
28+
// Version is section after last `@`.
29+
var packageVersion = packageNameParts[^1];
30+
31+
return new DetectedComponent(new NpmComponent(fullPackageName, packageVersion));
32+
}
33+
34+
/// <summary>
35+
/// Combine the information from a dependency edge in the dependency graph encoded in the ymal file into a full pnpm dependency path.
36+
/// </summary>
37+
/// <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>
38+
/// <param name="dependencyVersion">The final resolved version of the package for this dependency edge.
39+
/// This includes details like which version of specific dependencies were specified as peer dependencies.
40+
/// In some edge cases, such as aliased packages, this version may be an absolute dependency path, but the leading slash has been removed.
41+
/// leaving the "dependencyName" unused, which is checked by whether there is an @ in the version. </param>
42+
/// <returns>A pnpm dependency path for the specified version of the named package.</returns>
43+
public override string ReconstructPnpmDependencyPath(string dependencyName, string dependencyVersion)
44+
{
45+
if (dependencyVersion.StartsWith('/') || dependencyVersion.Split("(")[0].Contains('@'))
46+
{
47+
return dependencyVersion;
48+
}
49+
else
50+
{
51+
return $"{dependencyName}@{dependencyVersion}";
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)