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 }