/** Management of packages on the local computer. Copyright: © 2012-2013 rejectedsoftware e.K. License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. Authors: Sönke Ludwig, Matthias Dondorff */ module dub.packagemanager; import dub.dependency; import dub.internal.utils; import dub.internal.vibecompat.core.file; import dub.internal.vibecompat.core.log; import dub.internal.vibecompat.data.json; import dub.internal.vibecompat.inet.path; import dub.package_; import std.algorithm : countUntil, filter, sort, canFind; import std.array; import std.conv; import std.digest.sha; import std.encoding : sanitize; import std.exception; import std.file; import std.string; import std.zip; enum JournalJsonFilename = "journal.json"; enum LocalPackagesFilename = "local-packages.json"; private struct Repository { Path path; Path packagePath; Path[] searchPath; Package[] localPackages; this(Path path) { this.path = path; this.packagePath = path ~"packages/"; } } enum LocalPackageType { user, system } /// The PackageManager can retrieve present packages and get / remove /// packages. class PackageManager { private { Repository[LocalPackageType] m_repositories; Path[] m_searchPath; Package[] m_packages; Package[] m_temporaryPackages; } this(Path user_path, Path system_path) { m_repositories[LocalPackageType.user] = Repository(user_path); m_repositories[LocalPackageType.system] = Repository(system_path); refresh(true); } @property void searchPath(Path[] paths) { m_searchPath = paths.dup; refresh(false); } @property const(Path)[] searchPath() const { return m_searchPath; } @property const(Path)[] completeSearchPath() const { auto ret = appender!(Path[])(); ret.put(m_searchPath); ret.put(m_repositories[LocalPackageType.user].searchPath); ret.put(m_repositories[LocalPackageType.user].packagePath); ret.put(m_repositories[LocalPackageType.system].searchPath); ret.put(m_repositories[LocalPackageType.system].packagePath); return ret.data; } Package getPackage(string name, Version ver) { foreach( p; getPackageIterator(name) ) if( p.ver == ver ) return p; return null; } Package getPackage(string name, string ver, Path in_path) { return getPackage(name, Version(ver), in_path); } Package getPackage(string name, Version ver, Path in_path) { foreach( p; getPackageIterator(name) ) if (p.ver == ver && p.path.startsWith(in_path)) return p; return null; } Package getPackage(string name, string ver) { foreach (ep; getPackageIterator(name)) { if (ep.vers == ver) return ep; } return null; } Package getFirstPackage(string name) { foreach (ep; getPackageIterator(name)) return ep; return null; } Package getPackage(Path path) { foreach (p; getPackageIterator()) if (!p.parentPackage && p.path == path) return p; auto pack = new Package(path); addPackages(m_temporaryPackages, pack); return pack; } Package getBestPackage(string name, string version_spec) { return getBestPackage(name, Dependency(version_spec)); } Package getBestPackage(string name, Dependency version_spec) { Package ret; foreach( p; getPackageIterator(name) ) if( version_spec.matches(p.ver) && (!ret || p.ver > ret.ver) ) ret = p; return ret; } /** Determines if a package is managed by DUB. Managed packages can be upgraded and removed. */ bool isManagedPackage(Package pack) const { auto ppath = pack.basePackage.path; foreach (rep; m_repositories) { auto rpath = rep.packagePath; if (ppath.startsWith(rpath)) return true; } return false; } int delegate(int delegate(ref Package)) getPackageIterator() { int iterator(int delegate(ref Package) del) { int handlePackage(Package p) { if (auto ret = del(p)) return ret; foreach (sp; p.subPackages) if (auto ret = del(sp)) return ret; return 0; } foreach (tp; m_temporaryPackages) if (auto ret = handlePackage(tp)) return ret; // first search local packages foreach (tp; LocalPackageType.min .. LocalPackageType.max+1) foreach (p; m_repositories[cast(LocalPackageType)tp].localPackages) if (auto ret = handlePackage(p)) return ret; // and then all packages gathered from the search path foreach( p; m_packages ) if( auto ret = handlePackage(p) ) return ret; return 0; } return &iterator; } int delegate(int delegate(ref Package)) getPackageIterator(string name) { int iterator(int delegate(ref Package) del) { foreach (p; getPackageIterator()) if (p.name == name) if (auto ret = del(p)) return ret; return 0; } return &iterator; } /// Extracts the package supplied as a path to it's zip file to the /// destination and sets a version field in the package description. Package storeFetchedPackage(Path zip_file_path, Json package_info, Path destination) { auto package_name = package_info.name.get!string(); auto package_version = package_info["version"].get!string(); auto clean_package_version = package_version[package_version.startsWith("~") ? 1 : 0 .. $]; logDiagnostic("Placing package '%s' version '%s' to location '%s' from file '%s'", package_name, package_version, destination.toNativeString(), zip_file_path.toNativeString()); if( existsFile(destination) ){ throw new Exception(format("%s (%s) needs to be removed from '%s' prior placement.", package_name, package_version, destination)); } // open zip file ZipArchive archive; { logDebug("Opening file %s", zip_file_path); auto f = openFile(zip_file_path, FileMode.Read); scope(exit) f.close(); archive = new ZipArchive(f.readAll()); } logDebug("Extracting from zip."); // In a github zip, the actual contents are in a subfolder Path zip_prefix; outer: foreach(ArchiveMember am; archive.directory) { auto path = Path(am.name); foreach (fil; packageInfoFilenames) if (path.length == 2 && path.head.toString == fil) { zip_prefix = path[0 .. $-1]; break outer; } } logDebug("zip root folder: %s", zip_prefix); Path getCleanedPath(string fileName) { auto path = Path(fileName); if(zip_prefix != Path() && !path.startsWith(zip_prefix)) return Path(); return path[zip_prefix.length..path.length]; } // extract & place mkdirRecurse(destination.toNativeString()); auto journal = new Journal; logDiagnostic("Copying all files..."); int countFiles = 0; foreach(ArchiveMember a; archive.directory) { auto cleanedPath = getCleanedPath(a.name); if(cleanedPath.empty) continue; auto dst_path = destination~cleanedPath; logDebug("Creating %s", cleanedPath); if( dst_path.endsWithSlash ){ if( !existsDirectory(dst_path) ) mkdirRecurse(dst_path.toNativeString()); journal.add(Journal.Entry(Journal.Type.Directory, cleanedPath)); } else { if( !existsDirectory(dst_path.parentPath) ) mkdirRecurse(dst_path.parentPath.toNativeString()); auto dstFile = openFile(dst_path, FileMode.CreateTrunc); scope(exit) dstFile.close(); dstFile.put(archive.expand(a)); journal.add(Journal.Entry(Journal.Type.RegularFile, cleanedPath)); ++countFiles; } } logDiagnostic("%s file(s) copied.", to!string(countFiles)); // overwrite package.json (this one includes a version field) auto pack = new Package(destination); pack.info.version_ = package_info["version"].get!string; if (pack.packageInfoFile.head != defaultPackageFilename()) { // Storeinfo saved a default file, this could be different to the file from the zip. removeFile(pack.packageInfoFile); journal.remove(Journal.Entry(Journal.Type.RegularFile, Path(pack.packageInfoFile.head))); journal.add(Journal.Entry(Journal.Type.RegularFile, Path(defaultPackageFilename()))); } pack.storeInfo(); // Write journal logDebug("Saving retrieval action journal..."); journal.add(Journal.Entry(Journal.Type.RegularFile, Path(JournalJsonFilename))); journal.save(destination ~ JournalJsonFilename); addPackages(m_packages, pack); return pack; } /// Removes the given the package. void remove(in Package pack) { logDebug("Remove %s, version %s, path '%s'", pack.name, pack.vers, pack.path); enforce(!pack.path.empty, "Cannot remove package "~pack.name~" without a path."); // remove package from repositories' list bool found = false; bool removeFrom(Package[] packs, in Package pack) { auto packPos = countUntil!("a.path == b.path")(packs, pack); if(packPos != -1) { packs = std.algorithm.remove(packs, packPos); return true; } return false; } foreach(repo; m_repositories) { if(removeFrom(repo.localPackages, pack)) { found = true; break; } } if(!found) found = removeFrom(m_packages, pack); enforce(found, "Cannot remove, package not found: '"~ pack.name ~"', path: " ~ to!string(pack.path)); // delete package files physically logDebug("Looking up journal"); auto journalFile = pack.path~JournalJsonFilename; if (!existsFile(journalFile)) throw new Exception("Removal failed, no retrieval journal found for '"~pack.name~"'. Please remove the folder '%s' manually.", pack.path.toNativeString()); auto packagePath = pack.path; auto journal = new Journal(journalFile); logDebug("Erasing files"); foreach( Journal.Entry e; filter!((Journal.Entry a) => a.type == Journal.Type.RegularFile)(journal.entries)) { logDebug("Deleting file '%s'", e.relFilename); auto absFile = pack.path~e.relFilename; if(!existsFile(absFile)) { logWarn("Previously retrieved file not found for removal: '%s'", absFile); continue; } removeFile(absFile); } logDiagnostic("Erasing directories"); Path[] allPaths; foreach(Journal.Entry e; filter!((Journal.Entry a) => a.type == Journal.Type.Directory)(journal.entries)) allPaths ~= pack.path~e.relFilename; sort!("a.length>b.length")(allPaths); // sort to erase deepest paths first foreach(Path p; allPaths) { logDebug("Deleting folder '%s'", p); if( !existsFile(p) || !isDir(p.toNativeString()) || !isEmptyDir(p) ) { logError("Alien files found, directory is not empty or is not a directory: '%s'", p); continue; } rmdir(p.toNativeString()); } // Erase .dub folder, this is completely erased. auto dubDir = (pack.path ~ ".dub/").toNativeString(); enforce(!existsFile(dubDir) || isDir(dubDir), ".dub should be a directory, but is a file."); if(existsFile(dubDir) && isDir(dubDir)) { logDebug(".dub directory found, removing directory including content."); rmdirRecurse(dubDir); } logDebug("About to delete root folder for package '%s'.", pack.path); if(!isEmptyDir(pack.path)) throw new Exception("Alien files found in '"~pack.path.toNativeString()~"', needs to be deleted manually."); rmdir(pack.path.toNativeString()); logInfo("Removed package: '"~pack.name~"'"); } Package addLocalPackage(in Path path, string verName, LocalPackageType type) { auto pack = new Package(path); enforce(pack.name.length, "The package has no name, defined in: " ~ path.toString()); if (verName.length) pack.ver = Version(verName); // don't double-add packages Package[]* packs = &m_repositories[type].localPackages; foreach (p; *packs) { if (p.path == path) { enforce(p.ver == pack.ver, "Adding the same local package twice with differing versions is not allowed."); logInfo("Package is already registered: %s (version: %s)", p.name, p.ver); return p; } } addPackages(*packs, pack); writeLocalPackageList(type); logInfo("Registered package: %s (version: %s)", pack.name, pack.ver); return pack; } void removeLocalPackage(in Path path, LocalPackageType type) { Package[]* packs = &m_repositories[type].localPackages; size_t[] to_remove; foreach( i, entry; *packs ) if( entry.path == path ) to_remove ~= i; enforce(to_remove.length > 0, "No "~type.to!string()~" package found at "~path.toNativeString()); string[Version] removed; foreach_reverse( i; to_remove ) { removed[(*packs)[i].ver] = (*packs)[i].name; *packs = (*packs)[0 .. i] ~ (*packs)[i+1 .. $]; } writeLocalPackageList(type); foreach(ver, name; removed) logInfo("Unregistered package: %s (version: %s)", name, ver); } Package getTemporaryPackage(Path path, Version ver) { foreach (p; m_temporaryPackages) if (p.path == path) { enforce(p.ver == ver, format("Package in %s is refrenced with two conflicting versions: %s vs %s", path.toNativeString(), p.ver, ver)); return p; } auto pack = new Package(path); enforce(pack.name.length, "The package has no name, defined in: " ~ path.toString()); pack.ver = ver; addPackages(m_temporaryPackages, pack); return pack; } /// For the given type add another path where packages will be looked up. void addSearchPath(Path path, LocalPackageType type) { m_repositories[type].searchPath ~= path; writeLocalPackageList(type); } /// Removes a search path from the given type. void removeSearchPath(Path path, LocalPackageType type) { m_repositories[type].searchPath = m_repositories[type].searchPath.filter!(p => p != path)().array(); writeLocalPackageList(type); } void refresh(bool refresh_existing_packages) { // load locally defined packages void scanLocalPackages(LocalPackageType type) { Path list_path = m_repositories[type].packagePath; Package[] packs; Path[] paths; try { auto local_package_file = list_path ~ LocalPackagesFilename; logDiagnostic("Looking for local package map at %s", local_package_file.toNativeString()); if( !existsFile(local_package_file) ) return; logDiagnostic("Try to load local package map at %s", local_package_file.toNativeString()); auto packlist = jsonFromFile(list_path ~ LocalPackagesFilename); enforce(packlist.type == Json.Type.array, LocalPackagesFilename~" must contain an array."); foreach( pentry; packlist ){ try { auto name = pentry.name.get!string(); auto path = Path(pentry.path.get!string()); if (name == "*") { paths ~= path; } else { auto ver = Version(pentry["version"].get!string()); Package pp; if (!refresh_existing_packages) { foreach (p; m_repositories[type].localPackages) if (p.path == path) { pp = p; break; } } if (!pp) { if (Package.isPackageAt(path)) pp = new Package(path); else { logWarn("Locally registered package at %s was not found.", path.toNativeString()); auto info = Json.emptyObject; info.name = name; pp = new Package(info, path); } } if (pp.name != name) logWarn("Local package at %s has different name than %s (%s)", path.toNativeString(), name, pp.name); pp.ver = ver; addPackages(packs, pp); } } catch( Exception e ){ logWarn("Error adding local package: %s", e.msg); } } } catch( Exception e ){ logDiagnostic("Loading of local package list at %s failed: %s", list_path.toNativeString(), e.msg); } m_repositories[type].localPackages = packs; m_repositories[type].searchPath = paths; } scanLocalPackages(LocalPackageType.system); scanLocalPackages(LocalPackageType.user); auto old_packages = m_packages; // rescan the system and user package folder void scanPackageFolder(Path path) { if( path.existsDirectory() ){ logDebug("iterating dir %s", path.toNativeString()); try foreach( pdir; iterateDirectory(path) ){ logDebug("iterating dir %s entry %s", path.toNativeString(), pdir.name); if( !pdir.isDirectory ) continue; auto pack_path = path ~ pdir.name; if (!Package.isPackageAt(pack_path)) continue; Package p; try { if (!refresh_existing_packages) foreach (pp; old_packages) if (pp.path == pack_path) { p = pp; break; } if (!p) p = new Package(pack_path); addPackages(m_packages, p); } catch( Exception e ){ logError("Failed to load package in %s: %s", pack_path, e.msg); logDiagnostic("Full error: %s", e.toString().sanitize()); } } catch(Exception e) logDiagnostic("Failed to enumerate %s packages: %s", path.toNativeString(), e.toString()); } } m_packages = null; foreach (p; this.completeSearchPath) scanPackageFolder(p); } alias ubyte[] Hash; /// Generates a hash value for a given package. /// Some files or folders are ignored during the generation (like .dub and /// .svn folders) Hash hashPackage(Package pack) { string[] ignored_directories = [".git", ".dub", ".svn"]; // something from .dub_ignore or what? string[] ignored_files = []; SHA1 sha1; foreach(file; dirEntries(pack.path.toNativeString(), SpanMode.depth)) { if(file.isDir && ignored_directories.canFind(Path(file.name).head.toString())) continue; else if(ignored_files.canFind(Path(file.name).head.toString())) continue; sha1.put(cast(ubyte[])Path(file.name).head.toString()); if(file.isDir) { logDebug("Hashed directory name %s", Path(file.name).head); } else { sha1.put(openFile(Path(file.name)).readAll()); logDebug("Hashed file contents from %s", Path(file.name).head); } } auto hash = sha1.finish(); logDebug("Project hash: %s", hash); return hash[0..$]; } private void writeLocalPackageList(LocalPackageType type) { Json[] newlist; foreach (p; m_repositories[type].searchPath) { auto entry = Json.emptyObject; entry.name = "*"; entry.path = p.toNativeString(); newlist ~= entry; } foreach (p; m_repositories[type].localPackages) { auto entry = Json.emptyObject; entry["name"] = p.name; entry["version"] = p.ver.toString(); entry["path"] = p.path.toNativeString(); newlist ~= entry; } Path path = m_repositories[type].packagePath; if( !existsDirectory(path) ) mkdirRecurse(path.toNativeString()); writeJsonFile(path ~ LocalPackagesFilename, Json(newlist)); } /// Adds the package and scans for subpackages. private void addPackages(ref Package[] dst_repos, Package pack) const { // Add the main package. dst_repos ~= pack; // Additionally to the internally defined subpackages, whose metadata // is loaded with the main package.json, load all externally defined // packages after the package is available with all the data. foreach ( sub_path; pack.exportedPackages ) { auto path = pack.path ~ sub_path; if ( !existsFile(path) ) { logError("Package %s declared a sub-package, definition file is missing: %s", pack.name, path.toNativeString()); continue; } // Add the subpackage. try { dst_repos ~= new Package(path, pack); } catch( Exception e ){ logError("Package '%s': Failed to load sub-package in %s, error: %s", pack.name, path.toNativeString(), e.msg); logDiagnostic("Full error: %s", e.toString().sanitize()); } } } } /** Retrieval journal for later removal, keeping track of placed files files. Example Json: { "version": 1, "files": { "file1": "typeoffile1", ... } } */ private class Journal { private enum Version = 1; enum Type { RegularFile, Directory, Alien } struct Entry { this( Type t, Path f ) { type = t; relFilename = f; } Type type; Path relFilename; } @property const(Entry[]) entries() const { return m_entries; } this() {} /// Initializes a Journal from a json file. this(Path journalFile) { auto jsonJournal = jsonFromFile(journalFile); enforce(cast(int)jsonJournal["Version"] == Version, "Mismatched version: "~to!string(cast(int)jsonJournal["Version"]) ~ "vs. " ~to!string(Version)); foreach(string file, type; jsonJournal["Files"]) m_entries ~= Entry(to!Type(cast(string)type), Path(file)); } void add(Entry e) { foreach(Entry ent; entries) { if( e.relFilename == ent.relFilename ) { enforce(e.type == ent.type, "Duplicate('"~to!string(e.relFilename)~"'), different types: "~to!string(e.type)~" vs. "~to!string(ent.type)); return; } } m_entries ~= e; } void remove(Entry e) { foreach(i, Entry ent; entries) { if( e.relFilename == ent.relFilename ) { m_entries = std.algorithm.remove(m_entries, i); return; } } enforce(false, "Cannot remove entry, not available: " ~ e.relFilename.toNativeString()); } /// Save the current state to the path. void save(Path path) { Json jsonJournal = serialize(); auto fileJournal = openFile(path, FileMode.CreateTrunc); scope(exit) fileJournal.close(); fileJournal.writePrettyJsonString(jsonJournal); } private Json serialize() const { Json[string] files; foreach(Entry e; m_entries) files[to!string(e.relFilename)] = to!string(e.type); Json[string] json; json["Version"] = Version; json["Files"] = files; return Json(json); } private { Entry[] m_entries; } }