1 /**
2  * Contains type definition for the selections file
3  *
4  * The selections file, commonly known by its file name
5  * `dub.selections.json`, is used by Dub to store resolved
6  * dependencies. Its purpose is identical to other package
7  * managers' lock file.
8  */
9 module dub.recipe.selection;
10 
11 import dub.dependency;
12 import dub.internal.vibecompat.data.json : Json;
13 import dub.internal.vibecompat.inet.path : NativePath;
14 
15 import dub.internal.configy.attributes;
16 import dub.internal.dyaml.stdsumtype;
17 
18 import std.algorithm.iteration : each;
19 import std.algorithm.searching : canFind;
20 import std.exception;
21 import std.format : format;
22 import std.range : enumerate;
23 import std.string : indexOf;
24 
25 deprecated("Use either `Selections!1` or `SelectionsFile` instead")
26 public alias Selected = Selections!1;
27 
28 /**
29  * Top level type for `dub.selections.json`
30  *
31  * To support multiple version, we expose a `SumType` which
32  * contains the "real" version being parsed.
33  */
34 public struct SelectionsFile
35 {
36     /// Private alias to avoid repetition
37     private alias DataType = SumType!(Selections!0, Selections!1);
38 
39     /**
40      * Get the `fileVersion` of this selection file
41      *
42      * The `fileVersion` is always present, no matter the version.
43      * This is a convenience function that matches any version and allows
44      * one to retrieve it.
45      *
46      * Note that the `fileVersion` can be an unsupported version.
47      */
48     public uint fileVersion () const @safe pure nothrow @nogc
49     {
50         return this.content.match!((s) => s.fileVersion);
51     }
52 
53     /**
54      * Whether this dub.selections.json can be inherited by nested projects
55      * without local dub.selections.json
56      */
57     public bool inheritable () const @safe pure nothrow @nogc
58     {
59         return this.content.match!(
60             (const Selections!0 _) => false,
61             (const Selections!1 s) => s.inheritable,
62         );
63     }
64 
65     /**
66      * The content of this selections file
67      *
68      * The underlying content can be accessed using
69      * `dub.internal.yaml.stdsumtype : match`, for example:
70      * ---
71      * SelectionsFile file = readSelectionsFile();
72      * file.content.match!(
73      *     (Selections!0 s) => logWarn("Unsupported version: %s", s.fileVersion),
74      *     (Selections!1 s) => logWarn("Old version (1), please upgrade!"),
75      *     (Selections!2 s) => logInfo("You are up to date"),
76      * );
77      * ---
78      */
79     public DataType content;
80 
81     /**
82      * Deserialize the selections file according to its version
83      *
84      * This will first deserialize the `fileVersion` only, and then
85      * the expected version if it is supported. Unsupported versions
86      * will be returned inside a `Selections!0` struct,
87      * which only contains a `fileVersion`.
88      */
89     public static SelectionsFile fromConfig (scope ConfigParser parser)
90     {
91         import dub.internal.configy.read;
92 
93         static struct OnlyVersion { uint fileVersion; }
94 
95         auto vers = parseConfig!OnlyVersion(parser.node, StrictMode.Ignore);
96 
97         switch (vers.fileVersion) {
98         case 1:
99             return SelectionsFile(DataType(parser.parseAs!(Selections!1)));
100         default:
101             return SelectionsFile(DataType(Selections!0(vers.fileVersion)));
102         }
103     }
104 }
105 
106 /**
107  * A specific version of the selections file
108  *
109  * Currently, only two instantiations of this struct are possible:
110  * - `Selections!0` is an invalid/unsupported version;
111  * - `Selections!1` is the most widespread version;
112  */
113 public struct Selections (ushort Version)
114 {
115     ///
116     public uint fileVersion = Version;
117 
118     static if (Version == 0) { /* Invalid version */ }
119     else static if (Version == 1) {
120         /// The selected package and their matching versions
121         public SelectedDependency[string] versions;
122 
123         /// Whether this dub.selections.json can be inherited by nested projects
124         /// without local dub.selections.json
125         @Optional public bool inheritable;
126     }
127     else
128         static assert(false, "This version is not supported");
129 }
130 
131 
132 /// Wrapper around `SelectedDependency` to do deserialization but still provide
133 /// a `Dependency` object to client code.
134 package(dub) struct SelectedDependency
135 {
136     public Dependency actual;
137     alias actual this;
138     public IntegrityTag integrity;
139 
140     /// Constructor, used in `fromConfig`
141     public this (inout(Dependency) dep, const IntegrityTag tag = IntegrityTag.init)
142          inout @safe pure nothrow @nogc
143     {
144         this.actual = dep;
145         this.integrity = tag;
146     }
147 
148     /// Allow external code to assign to this object as if it was a `Dependency`
149     public ref SelectedDependency opAssign (Dependency dep) return pure nothrow @nogc
150     {
151         this.actual = dep;
152         this.integrity = IntegrityTag.init;
153         return this;
154     }
155 
156     /// Read a `Dependency` from the config file - Required to support both short and long form
157     static SelectedDependency fromConfig (scope ConfigParser p)
158     {
159         if (scope scalar = p.node.asScalar())
160             return SelectedDependency(Dependency(Version(scalar.str)));
161 
162         auto d = p.parseAs!YAMLFormat;
163         if (d.path.length)
164             return SelectedDependency(Dependency(NativePath(d.path)));
165         else
166         {
167             assert(d.version_.length);
168             if (d.repository.length)
169                 return SelectedDependency(Dependency(Repository(d.repository, d.version_)));
170             return SelectedDependency(Dependency(Version(d.version_)), d.integrity);
171         }
172     }
173 
174     /// Serializes a selected version to JSON for `dub.selections.json`
175     public Json toJsonDep () const {
176         version (none) {
177             // The following is not yet enabled, because we're currently only
178             // able to get an integrity tag value when the package is first
179             // downloaded. This is problematic as most of the time, we try
180             // to reuse packages, and most common use of `dub upgrade` would
181             // make the integrity tag flip between empty or not.
182             // However, with this code enabled, one may get an integrity tag
183             // written to their `dub.selections.json` under two conditions:
184             // 1) The package is not present on the file system;
185             // 2) The package is upgraded (e.g. `dub upgrade` would normally trigger);
186             if (this.integrity.value.length && this.actual.isExactVersion()) {
187                 const vers = this.actual.version_();
188                 Json result = Json.emptyObject;
189                 result["version"] = Json(vers.toString());
190                 result["integrity"] = Json(
191                     "%s-%s".format(this.integrity.algorithm, this.integrity.value));
192                 return result;
193             }
194         }
195         return this.actual.toJson(true);
196     }
197 
198 	/// In-file representation of a dependency as permitted in `dub.selections.json`
199 	private struct YAMLFormat
200 	{
201 		@Optional @Name("version") string version_;
202 		@Optional string path;
203 		@Optional string repository;
204 		@Optional IntegrityTag integrity;
205 
206 		public void validate () const scope @safe pure
207 		{
208 			enforce(this.version_.length || this.path.length || this.repository.length,
209 				"Need to provide a version string, or an object with one of the following fields: `version`, `path`, or `repository`");
210 			enforce(!this.path.length || !this.repository.length,
211 				"Cannot provide a `path` dependency if a repository dependency is used");
212 			enforce(!this.path.length || !this.version_.length,
213 				"Cannot provide a `path` dependency if a `version` dependency is used");
214 			enforce(!this.repository.length || this.version_.length,
215 				"Cannot provide a `repository` dependency without a `version`");
216 			enforce(!this.integrity.algorithm.length || (!this.path.length && !this.repository.length),
217 				"`integrity` property is only supported for `version` dependencies");
218 		}
219 	}
220 }
221 
222 /**
223  * A subresource integrity declaration
224  *
225  * Implement the SRI (Subresource Integrity) standard, used to validate that
226  * a given dependency is of the expected version.
227  *
228  * One may get an integrity tag in base64 using openssl:
229  * ```
230  * $ cat vibe.d-0.10.1.zip |  openssl dgst -binary -sha512 | base64
231  * vwQ9tYTjLb981j41+3GZZUgKXm/5PlKpmY2bplRSUM8ajL03++LGm/TcfFFarJrHex8CTb5ZLWdi
232  * Y1fFAOSkSw==
233  * ```
234  *
235  * See_Also:
236  *   https://w3c.github.io/webappsec-subresource-integrity/#the-integrity-attribute
237  */
238 public struct IntegrityTag
239 {
240 	/// The hash function to use
241 	public string algorithm;
242 	/// The value of the digest computed with `algorithm`, base64-encoded
243 	public string value;
244 
245 	/// Parses a string representation as an `IntegrityTag`
246 	public this (string value)
247 	{
248 		auto sep = indexOf(value, '-');
249 		enforce(sep > 0, `Expected a string in the form 'hash-algorithm "-" base64-value', e.g. 'sha512-...'`);
250 		this.algorithm = value[0 .. sep];
251 		this.value = value[sep + 1 .. $];
252 		switch (this.algorithm) {
253 		case "sha512":
254 			enforce(this.value.length == 88,
255 				"Excepted a base64-encoded sha512 digest of 88 characters, not %s"
256 				.format(this.value.length));
257 			break;
258 		case "sha384":
259 			enforce(this.value.length == 64,
260 				"Excepted a base64-encoded sha384 digest of 64 characters, not %s"
261 				.format(this.value.length));
262 			break;
263 		case "sha256":
264 			enforce(this.value.length == 40,
265 				"Excepted a base64-encoded sha256 digest of 40 characters, not %s"
266 				.format(this.value.length));
267 			break;
268 		default:
269 			throw new Exception("Algorithm '" ~ this.algorithm ~
270 				"' is not supported, expected one of: 'sha512', 'sha384', 'sha256'");
271 		}
272 		this.value.enumerate.each!((size_t idx, dchar c) {
273 			enforce("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".canFind(c),
274 				"Expected digest to be base64 encoded, found non-base64 character '%s' at index '%s'"
275 				.format(c, idx));
276 		});
277 	}
278 
279     /// Internal constructor for `IntegrityTag.make`
280     public this (string algorithm, string value) inout @safe pure nothrow @nogc {
281         this.algorithm = algorithm;
282         this.value = value;
283     }
284 
285     /**
286      * Verify if the data passed in parameter matches this `IntegrityTag`
287      *
288      * Params:
289      *   data = The content of the archive to check for a match
290      *
291      * Returns:
292      *   Whether the hash of `data` (using `this.algorithm)` matches
293      *   the value that is `base64` encoded.
294      */
295     public bool matches (in ubyte[] data) const @safe pure {
296         import std.base64;
297         import std.digest.sha;
298 
299         ubyte[64] buffer; // 32, 48, or 64 bytes used
300         auto decoded = Base64.decode(this.value, buffer[]);
301         switch (this.algorithm) {
302             case "sha512":
303                 return sha512Of(data) == decoded;
304             case "sha384":
305                 return sha384Of(data) == decoded;
306             case "sha256":
307                 return sha256Of(data) == decoded;
308             default:
309                 assert(0, "An `IntegrityTag` with non-supported algorithm was created: " ~ this.algorithm);
310         }
311     }
312 
313     /**
314      * Build and returns an `IntegrityTag`
315      *
316      * This is a convenience function to build an `IntegrityTag` from the
317      * archive data. Use sha512 by default.
318      *
319      * Params:
320      *   data = The content of the archive to check hash into a digest
321      *   algorithm = One of `sha256`, `sha384`, `sha512`. Default to the latter.
322      *
323      * Returns:
324      *   A populated `IntegrityTag`.
325      */
326     public static IntegrityTag make (in ubyte[] data, string algorithm = "sha512")
327 	    @safe pure {
328         import std.base64;
329         import std.digest.sha;
330 
331         switch (algorithm) {
332             case "sha512":
333                 return IntegrityTag(algorithm, Base64.encode(sha512Of(data)));
334             case "sha384":
335                 return IntegrityTag(algorithm, Base64.encode(sha384Of(data)));
336             case "sha256":
337                 return IntegrityTag(algorithm, Base64.encode(sha256Of(data)));
338             default:
339                 assert(0, "`IntegrityTag.make` was called with non-supported algorithm: " ~ algorithm);
340         }
341     }
342 }
343 
344 // Ensure we can read all type of dependencies
345 unittest
346 {
347     import dub.internal.configy.easy : parseConfigString;
348 
349     immutable string content = `{
350     "fileVersion": 1,
351     "versions": {
352         "simple": "1.5.6",
353         "complex": { "version": "1.2.3" },
354         "digest": { "version": "1.2.3", "integrity": "sha256-abcdefghijklmnopqrstuvwxyz0123456789+/==" },
355         "digest1": { "version": "1.2.3", "integrity": "sha384-Li9vy3DqF8tnTXuiaAJuML3ky+er10rcgNR/VqsVpcw+ThHmYcwiB1pbOxEbzJr7" },
356         "digest2": { "version": "1.2.3", "integrity": "sha512-Q2bFTOhEALkN8hOms2FKTDLy7eugP2zFZ1T8LCvX42Fp3WoNr3bjZSAHeOsHrbV1Fu9/A0EzCinRE7Af1ofPrw==" },
357         "branch": "~master",
358         "branch2": "~main",
359         "path": { "path": "../some/where" },
360         "repository": { "repository": "git+https://github.com/dlang/dub", "version": "123456123456123456" }
361     }
362 }`;
363 
364     auto file = parseConfigString!SelectionsFile(content, "/dev/null");
365     assert(file.fileVersion == 1);
366     auto s = file.content.match!(
367         (Selections!1 s) => s,
368         (s) { assert(0); return Selections!(1).init; },
369     );
370     assert(!s.inheritable);
371     assert(s.versions.length == 9);
372     assert(s.versions["simple"]     == Dependency(Version("1.5.6")));
373     assert(s.versions["complex"]    == Dependency(Version("1.2.3")));
374     assert(s.versions["digest"]     == Dependency(Version("1.2.3")));
375     assert(s.versions["digest1"]    == Dependency(Version("1.2.3")));
376     assert(s.versions["digest2"]    == Dependency(Version("1.2.3")));
377     assert(s.versions["branch"]     == Dependency(Version("~master")));
378     assert(s.versions["branch2"]    == Dependency(Version("~main")));
379     assert(s.versions["path"]       == Dependency(NativePath("../some/where")));
380     assert(s.versions["repository"] == Dependency(Repository("git+https://github.com/dlang/dub", "123456123456123456")));
381 }
382 
383 // with optional `inheritable` Boolean
384 unittest
385 {
386     import dub.internal.configy.easy : parseConfigString;
387 
388     immutable string content = `{
389     "fileVersion": 1,
390     "inheritable": true,
391     "versions": {
392         "simple": "1.5.6",
393     }
394 }`;
395 
396     auto s = parseConfigString!SelectionsFile(content, "/dev/null");
397     assert(s.inheritable);
398 }
399 
400 // Test reading an unsupported version
401 unittest
402 {
403     import dub.internal.configy.easy : parseConfigString;
404 
405     immutable string content = `{"fileVersion": 9999, "thisis": "notrecognized"}`;
406     auto s = parseConfigString!SelectionsFile(content, "/dev/null");
407     assert(s.fileVersion == 9999);
408 }