1 /** 2 Management of packages on the local computer. 3 4 Copyright: © 2012-2013 rejectedsoftware e.K. 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Sönke Ludwig, Matthias Dondorff 7 */ 8 module dub.packagemanager; 9 10 import dub.dependency; 11 import dub.internal.utils; 12 import dub.internal.vibecompat.core.file; 13 import dub.internal.vibecompat.core.log; 14 import dub.internal.vibecompat.data.json; 15 import dub.internal.vibecompat.inet.path; 16 import dub.package_; 17 18 import std.algorithm : countUntil, filter, sort, canFind; 19 import std.array; 20 import std.conv; 21 import std.digest.sha; 22 import std.encoding : sanitize; 23 import std.exception; 24 import std.file; 25 import std.string; 26 import std.zip; 27 28 29 enum JournalJsonFilename = "journal.json"; 30 enum LocalPackagesFilename = "local-packages.json"; 31 32 33 private struct Repository { 34 Path path; 35 Path packagePath; 36 Path[] searchPath; 37 Package[] localPackages; 38 39 this(Path path) 40 { 41 this.path = path; 42 this.packagePath = path ~"packages/"; 43 } 44 } 45 46 enum LocalPackageType { 47 user, 48 system 49 } 50 51 /// The PackageManager can retrieve installed packages and install / uninstall 52 /// packages. 53 class PackageManager { 54 private { 55 Repository[LocalPackageType] m_repositories; 56 Path[] m_searchPath; 57 Package[][string] m_packages; 58 Package[] m_temporaryPackages; 59 } 60 61 this(Path user_path, Path system_path) 62 { 63 m_repositories[LocalPackageType.user] = Repository(user_path); 64 m_repositories[LocalPackageType.system] = Repository(system_path); 65 refresh(true); 66 } 67 68 @property void searchPath(Path[] paths) { m_searchPath = paths.dup; refresh(false); } 69 @property const(Path)[] searchPath() const { return m_searchPath; } 70 71 @property const(Path)[] completeSearchPath() 72 const { 73 auto ret = appender!(Path[])(); 74 ret.put(m_searchPath); 75 ret.put(m_repositories[LocalPackageType.user].searchPath); 76 ret.put(m_repositories[LocalPackageType.user].packagePath); 77 ret.put(m_repositories[LocalPackageType.system].searchPath); 78 ret.put(m_repositories[LocalPackageType.system].packagePath); 79 return ret.data; 80 } 81 82 Package getPackage(string name, Version ver) 83 { 84 foreach( p; getPackageIterator(name) ) 85 if( p.ver == ver ) 86 return p; 87 return null; 88 } 89 90 Package getPackage(string name, string ver, Path in_path) 91 { 92 return getPackage(name, Version(ver), in_path); 93 } 94 Package getPackage(string name, Version ver, Path in_path) 95 { 96 foreach( p; getPackageIterator(name) ) 97 if (p.ver == ver && p.path.startsWith(in_path)) 98 return p; 99 return null; 100 } 101 102 Package getPackage(string name, string ver) 103 { 104 foreach (ep; getPackageIterator(name)) { 105 if (ep.vers == ver) 106 return ep; 107 } 108 return null; 109 } 110 111 Package getFirstPackage(string name) 112 { 113 foreach (ep; getPackageIterator(name)) 114 return ep; 115 return null; 116 } 117 118 Package getPackage(Path path) 119 { 120 foreach (p; getPackageIterator()) 121 if (!p.basePackage && p.path == path) 122 return p; 123 auto p = new Package(path); 124 m_temporaryPackages ~= p; 125 return p; 126 } 127 128 Package getBestPackage(string name, string version_spec) 129 { 130 return getBestPackage(name, Dependency(version_spec)); 131 } 132 133 Package getBestPackage(string name, Dependency version_spec) 134 { 135 Package ret; 136 foreach( p; getPackageIterator(name) ) 137 if( version_spec.matches(p.ver) && (!ret || p.ver > ret.ver) ) 138 ret = p; 139 return ret; 140 } 141 142 int delegate(int delegate(ref Package)) getPackageIterator() 143 { 144 int iterator(int delegate(ref Package) del) 145 { 146 int handlePackage(Package p) { 147 if (auto ret = del(p)) return ret; 148 foreach (sp; p.subPackages) 149 if (auto ret = del(sp)) 150 return ret; 151 return 0; 152 } 153 154 foreach (tp; m_temporaryPackages) 155 if (auto ret = handlePackage(tp)) return ret; 156 157 // first search local packages 158 foreach (tp; LocalPackageType.min .. LocalPackageType.max+1) 159 foreach (p; m_repositories[cast(LocalPackageType)tp].localPackages) 160 if (auto ret = handlePackage(p)) return ret; 161 162 // and then all packages gathered from the search path 163 foreach( pl; m_packages ) 164 foreach( v; pl ) 165 if( auto ret = handlePackage(v) ) 166 return ret; 167 return 0; 168 } 169 170 return &iterator; 171 } 172 173 int delegate(int delegate(ref Package)) getPackageIterator(string name) 174 { 175 int iterator(int delegate(ref Package) del) 176 { 177 foreach (p; getPackageIterator()) 178 if (p.name == name) 179 if (auto ret = del(p)) return ret; 180 return 0; 181 } 182 183 return &iterator; 184 } 185 186 /// Installs the package supplied as a path to it's zip file to the 187 /// destination. 188 Package install(Path zip_file_path, Json package_info, Path destination) 189 { 190 auto package_name = package_info.name.get!string(); 191 auto package_version = package_info["version"].get!string(); 192 auto clean_package_version = package_version[package_version.startsWith("~") ? 1 : 0 .. $]; 193 194 logDiagnostic("Installing package '%s' version '%s' to location '%s' from file '%s'", 195 package_name, package_version, destination.toNativeString(), zip_file_path.toNativeString()); 196 197 if( existsFile(destination) ){ 198 throw new Exception(format("%s (%s) needs to be uninstalled from '%s' prior installation.", package_name, package_version, destination)); 199 } 200 201 // open zip file 202 ZipArchive archive; 203 { 204 logDebug("Opening file %s", zip_file_path); 205 auto f = openFile(zip_file_path, FileMode.Read); 206 scope(exit) f.close(); 207 archive = new ZipArchive(f.readAll()); 208 } 209 210 logDebug("Installing from zip."); 211 212 // In a github zip, the actual contents are in a subfolder 213 Path zip_prefix; 214 auto json_file = PathEntry(PackageJsonFilename); 215 foreach(ArchiveMember am; archive.directory) { 216 auto path = Path(am.name); 217 if (path.length == 2 && path.head == json_file && path.length) { 218 zip_prefix = path[0 .. $-1]; 219 break; 220 } 221 } 222 223 logDebug("zip root folder: %s", zip_prefix); 224 225 Path getCleanedPath(string fileName) { 226 auto path = Path(fileName); 227 if(zip_prefix != Path() && !path.startsWith(zip_prefix)) return Path(); 228 return path[zip_prefix.length..path.length]; 229 } 230 231 // install 232 mkdirRecurse(destination.toNativeString()); 233 auto journal = new Journal; 234 logDiagnostic("Copying all files..."); 235 int countFiles = 0; 236 foreach(ArchiveMember a; archive.directory) { 237 auto cleanedPath = getCleanedPath(a.name); 238 if(cleanedPath.empty) continue; 239 auto dst_path = destination~cleanedPath; 240 241 logDebug("Creating %s", cleanedPath); 242 if( dst_path.endsWithSlash ){ 243 if( !existsDirectory(dst_path) ) 244 mkdirRecurse(dst_path.toNativeString()); 245 journal.add(Journal.Entry(Journal.Type.Directory, cleanedPath)); 246 } else { 247 if( !existsDirectory(dst_path.parentPath) ) 248 mkdirRecurse(dst_path.parentPath.toNativeString()); 249 auto dstFile = openFile(dst_path, FileMode.CreateTrunc); 250 scope(exit) dstFile.close(); 251 dstFile.put(archive.expand(a)); 252 journal.add(Journal.Entry(Journal.Type.RegularFile, cleanedPath)); 253 ++countFiles; 254 } 255 } 256 logDiagnostic("%s file(s) copied.", to!string(countFiles)); 257 258 // overwrite package.json (this one includes a version field) 259 Json pi = jsonFromFile(destination~PackageJsonFilename); 260 pi["name"] = toLower(pi["name"].get!string()); 261 pi["version"] = package_info["version"]; 262 writeJsonFile(destination~PackageJsonFilename, pi); 263 264 // Write journal 265 logDebug("Saving installation journal..."); 266 journal.add(Journal.Entry(Journal.Type.RegularFile, Path(JournalJsonFilename))); 267 journal.save(destination ~ JournalJsonFilename); 268 269 if( existsFile(destination~PackageJsonFilename) ) 270 logInfo("%s has been installed with version %s", package_name, package_version); 271 272 auto pack = new Package(destination); 273 274 m_packages[package_name] ~= pack; 275 276 return pack; 277 } 278 279 /// Uninstalls the given the package. 280 void uninstall(in Package pack) 281 { 282 logDebug("Uninstall %s, version %s, path '%s'", pack.name, pack.vers, pack.path); 283 enforce(!pack.path.empty, "Cannot uninstall package "~pack.name~" without a path."); 284 285 // remove package from repositories' list 286 bool found = false; 287 bool removeFrom(Package[] packs, in Package pack) { 288 auto packPos = countUntil!("a.path == b.path")(packs, pack); 289 if(packPos != -1) { 290 packs = std.algorithm.remove(packs, packPos); 291 return true; 292 } 293 return false; 294 } 295 foreach(repo; m_repositories) { 296 if(removeFrom(repo.localPackages, pack)) { 297 found = true; 298 break; 299 } 300 } 301 if(!found) { 302 foreach(packsOfId; m_packages) { 303 if(removeFrom(packsOfId, pack)) { 304 found = true; 305 break; 306 } 307 } 308 } 309 enforce(found, "Cannot uninstall, package not found: '"~ pack.name ~"', path: " ~ to!string(pack.path)); 310 311 // delete package files physically 312 logDebug("Looking up journal"); 313 auto journalFile = pack.path~JournalJsonFilename; 314 if( !existsFile(journalFile) ) 315 throw new Exception("Uninstall failed, no installation journal found for '"~pack.name~"'. Please uninstall manually."); 316 317 auto packagePath = pack.path; 318 auto journal = new Journal(journalFile); 319 logDebug("Erasing files"); 320 foreach( Journal.Entry e; filter!((Journal.Entry a) => a.type == Journal.Type.RegularFile)(journal.entries)) { 321 logDebug("Deleting file '%s'", e.relFilename); 322 auto absFile = pack.path~e.relFilename; 323 if(!existsFile(absFile)) { 324 logWarn("Previously installed file not found for uninstalling: '%s'", absFile); 325 continue; 326 } 327 328 removeFile(absFile); 329 } 330 331 logDiagnostic("Erasing directories"); 332 Path[] allPaths; 333 foreach(Journal.Entry e; filter!((Journal.Entry a) => a.type == Journal.Type.Directory)(journal.entries)) 334 allPaths ~= pack.path~e.relFilename; 335 sort!("a.length>b.length")(allPaths); // sort to erase deepest paths first 336 foreach(Path p; allPaths) { 337 logDebug("Deleting folder '%s'", p); 338 if( !existsFile(p) || !isDir(p.toNativeString()) || !isEmptyDir(p) ) { 339 logError("Alien files found, directory is not empty or is not a directory: '%s'", p); 340 continue; 341 } 342 rmdir(p.toNativeString()); 343 } 344 345 // Erase .dub folder, this is completely erased. 346 auto dubDir = (pack.path ~ ".dub/").toNativeString(); 347 enforce(!existsFile(dubDir) || isDir(dubDir), ".dub should be a directory, but is a file."); 348 if(existsFile(dubDir) && isDir(dubDir)) { 349 logDebug(".dub directory found, removing directory including content."); 350 rmdirRecurse(dubDir); 351 } 352 353 logDebug("About to delete root folder for package '%s'.", pack.path); 354 if(!isEmptyDir(pack.path)) 355 throw new Exception("Alien files found in '"~pack.path.toNativeString()~"', needs to be deleted manually."); 356 357 rmdir(pack.path.toNativeString()); 358 logInfo("Uninstalled package: '"~pack.name~"'"); 359 } 360 361 Package addLocalPackage(in Path path, in Version ver, LocalPackageType type) 362 { 363 Package[]* packs = &m_repositories[type].localPackages; 364 auto info = jsonFromFile(path ~ PackageJsonFilename, false); 365 string name; 366 if( "name" !in info ) info["name"] = path.head.toString(); 367 info["version"] = ver.toString(); 368 369 // don't double-add packages 370 foreach( p; *packs ){ 371 if( p.path == path ){ 372 enforce(p.ver == ver, "Adding local twice with different versions is not allowed."); 373 logInfo("Package is already registered: %s (version: %s)", p.name, p.ver); 374 return p; 375 } 376 } 377 378 auto pack = new Package(info, path); 379 380 *packs ~= pack; 381 382 writeLocalPackageList(type); 383 384 logInfo("Registered package: %s (version: %s)", pack.name, pack.ver); 385 return pack; 386 } 387 388 void removeLocalPackage(in Path path, LocalPackageType type) 389 { 390 Package[]* packs = &m_repositories[type].localPackages; 391 size_t[] to_remove; 392 foreach( i, entry; *packs ) 393 if( entry.path == path ) 394 to_remove ~= i; 395 enforce(to_remove.length > 0, "No "~type.to!string()~" package found at "~path.toNativeString()); 396 397 string[Version] removed; 398 foreach_reverse( i; to_remove ) { 399 removed[(*packs)[i].ver] = (*packs)[i].name; 400 *packs = (*packs)[0 .. i] ~ (*packs)[i+1 .. $]; 401 } 402 403 writeLocalPackageList(type); 404 405 foreach(ver, name; removed) 406 logInfo("Unregistered package: %s (version: %s)", name, ver); 407 } 408 409 Package getTemporaryPackage(Path path, Version ver) 410 { 411 foreach (p; m_temporaryPackages) 412 if (p.path == path) { 413 enforce(p.ver == ver, format("Package in %s is refrenced with two conflicting versions: %s vs %s", path.toNativeString(), p.ver, ver)); 414 return p; 415 } 416 417 auto info = jsonFromFile(path ~ PackageJsonFilename, false); 418 string name; 419 if( "name" !in info ) info["name"] = path.head.toString(); 420 info["version"] = ver.toString(); 421 422 auto pack = new Package(info, path); 423 m_temporaryPackages ~= pack; 424 return pack; 425 } 426 427 /// For the given type add another path where packages will be looked up. 428 void addSearchPath(Path path, LocalPackageType type) 429 { 430 m_repositories[type].searchPath ~= path; 431 writeLocalPackageList(type); 432 } 433 434 /// Removes a search path from the given type. 435 void removeSearchPath(Path path, LocalPackageType type) 436 { 437 m_repositories[type].searchPath = m_repositories[type].searchPath.filter!(p => p != path)().array(); 438 writeLocalPackageList(type); 439 } 440 441 void refresh(bool refresh_existing_packages) 442 { 443 // load locally defined packages 444 void scanLocalPackages(LocalPackageType type) 445 { 446 Path list_path = m_repositories[type].packagePath; 447 Package[] packs; 448 Path[] paths; 449 try { 450 auto local_package_file = list_path ~ LocalPackagesFilename; 451 logDiagnostic("Looking for local package map at %s", local_package_file.toNativeString()); 452 if( !existsFile(local_package_file) ) return; 453 logDiagnostic("Try to load local package map at %s", local_package_file.toNativeString()); 454 auto packlist = jsonFromFile(list_path ~ LocalPackagesFilename); 455 enforce(packlist.type == Json.Type.Array, LocalPackagesFilename~" must contain an array."); 456 foreach( pentry; packlist ){ 457 try { 458 auto name = pentry.name.get!string(); 459 auto path = Path(pentry.path.get!string()); 460 if (name == "*") { 461 paths ~= path; 462 } else { 463 auto ver = pentry["version"].get!string(); 464 auto info = Json.EmptyObject; 465 if( existsFile(path ~ PackageJsonFilename) ) info = jsonFromFile(path ~ PackageJsonFilename); 466 if( "name" in info && info.name.get!string() != name ) 467 logWarn("Local package at %s has different name than %s (%s)", path.toNativeString(), name, info.name.get!string()); 468 info.name = name; 469 info["version"] = ver; 470 471 Package pp; 472 if (!refresh_existing_packages) 473 foreach (p; m_repositories[type].localPackages) 474 if (p.path == path) { 475 pp = p; 476 break; 477 } 478 if (!pp) pp = new Package(info, path); 479 packs ~= pp; 480 } 481 } catch( Exception e ){ 482 logWarn("Error adding local package: %s", e.msg); 483 } 484 } 485 } catch( Exception e ){ 486 logDiagnostic("Loading of local package list at %s failed: %s", list_path.toNativeString(), e.msg); 487 } 488 m_repositories[type].localPackages = packs; 489 m_repositories[type].searchPath = paths; 490 } 491 scanLocalPackages(LocalPackageType.system); 492 scanLocalPackages(LocalPackageType.user); 493 494 Package[][string] old_packages = m_packages; 495 496 // rescan the system and user package folder 497 void scanPackageFolder(Path path) 498 { 499 if( path.existsDirectory() ){ 500 logDebug("iterating dir %s", path.toNativeString()); 501 try foreach( pdir; iterateDirectory(path) ){ 502 logDebug("iterating dir %s entry %s", path.toNativeString(), pdir.name); 503 if( !pdir.isDirectory ) continue; 504 auto pack_path = path ~ pdir.name; 505 if( !existsFile(pack_path ~ PackageJsonFilename) ) continue; 506 Package p; 507 try { 508 if (!refresh_existing_packages) 509 foreach (plist; old_packages) 510 foreach (pp; plist) 511 if (pp.path == pack_path) { 512 p = pp; 513 break; 514 } 515 if (!p) p = new Package(pack_path); 516 m_packages[p.name] ~= p; 517 } catch( Exception e ){ 518 logError("Failed to load package in %s: %s", pack_path, e.msg); 519 logDiagnostic("Full error: %s", e.toString().sanitize()); 520 } 521 } 522 catch(Exception e) logDiagnostic("Failed to enumerate %s packages: %s", path.toNativeString(), e.toString()); 523 } 524 } 525 526 m_packages = null; 527 foreach (p; this.completeSearchPath) 528 scanPackageFolder(p); 529 } 530 531 alias ubyte[] Hash; 532 /// Generates a hash value for a given package. 533 /// Some files or folders are ignored during the generation (like .dub and 534 /// .svn folders) 535 Hash hashPackage(Package pack) 536 { 537 string[] ignored_directories = [".git", ".dub", ".svn"]; 538 // something from .dub_ignore or what? 539 string[] ignored_files = []; 540 SHA1 sha1; 541 foreach(file; dirEntries(pack.path.toNativeString(), SpanMode.depth)) { 542 if(file.isDir && ignored_directories.canFind(Path(file.name).head.toString())) 543 continue; 544 else if(ignored_files.canFind(Path(file.name).head.toString())) 545 continue; 546 547 sha1.put(cast(ubyte[])Path(file.name).head.toString()); 548 if(file.isDir) { 549 logDebug("Hashed directory name %s", Path(file.name).head); 550 } 551 else { 552 sha1.put(openFile(Path(file.name)).readAll()); 553 logDebug("Hashed file contents from %s", Path(file.name).head); 554 } 555 } 556 auto hash = sha1.finish(); 557 logDebug("Project hash: %s", hash); 558 return hash[0..$]; 559 } 560 561 private void writeLocalPackageList(LocalPackageType type) 562 { 563 Json[] newlist; 564 foreach (p; m_repositories[type].searchPath) { 565 auto entry = Json.EmptyObject; 566 entry.name = "*"; 567 entry.path = p.toNativeString(); 568 newlist ~= entry; 569 } 570 571 foreach (p; m_repositories[type].localPackages) { 572 auto entry = Json.EmptyObject; 573 entry["name"] = p.name; 574 entry["version"] = p.ver.toString(); 575 entry["path"] = p.path.toNativeString(); 576 newlist ~= entry; 577 } 578 579 Path path = m_repositories[type].packagePath; 580 if( !existsDirectory(path) ) mkdirRecurse(path.toNativeString()); 581 writeJsonFile(path ~ LocalPackagesFilename, Json(newlist)); 582 } 583 } 584 585 586 /** 587 Installation journal for later uninstallation, keeping track of installed 588 files. 589 Example Json: 590 { 591 "version": 1, 592 "files": { 593 "file1": "typeoffile1", 594 ... 595 } 596 } 597 */ 598 private class Journal { 599 private enum Version = 1; 600 601 enum Type { 602 RegularFile, 603 Directory, 604 Alien 605 } 606 607 struct Entry { 608 this( Type t, Path f ) { type = t; relFilename = f; } 609 Type type; 610 Path relFilename; 611 } 612 613 @property const(Entry[]) entries() const { return m_entries; } 614 615 this() {} 616 617 /// Initializes a Journal from a json file. 618 this(Path journalFile) { 619 auto jsonJournal = jsonFromFile(journalFile); 620 enforce(cast(int)jsonJournal["Version"] == Version, "Mismatched version: "~to!string(cast(int)jsonJournal["Version"]) ~ "vs. " ~to!string(Version)); 621 foreach(string file, type; jsonJournal["Files"]) 622 m_entries ~= Entry(to!Type(cast(string)type), Path(file)); 623 } 624 625 void add(Entry e) { 626 foreach(Entry ent; entries) { 627 if( e.relFilename == ent.relFilename ) { 628 enforce(e.type == ent.type, "Duplicate('"~to!string(e.relFilename)~"'), different types: "~to!string(e.type)~" vs. "~to!string(ent.type)); 629 return; 630 } 631 } 632 m_entries ~= e; 633 } 634 635 /// Save the current state to the path. 636 void save(Path path) { 637 Json jsonJournal = serialize(); 638 auto fileJournal = openFile(path, FileMode.CreateTrunc); 639 scope(exit) fileJournal.close(); 640 fileJournal.writePrettyJsonString(jsonJournal); 641 } 642 643 private Json serialize() const { 644 Json[string] files; 645 foreach(Entry e; m_entries) 646 files[to!string(e.relFilename)] = to!string(e.type); 647 Json[string] json; 648 json["Version"] = Version; 649 json["Files"] = files; 650 return Json(json); 651 } 652 653 private { 654 Entry[] m_entries; 655 } 656 }