/** Management of packages on the local computer. Copyright: © 2012 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.installation; 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 dub.utils; 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 } class PackageManager { private { Repository[LocalPackageType] m_repositories; Path[] m_searchPath; Package[][string] 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 getBestPackage(string name, string version_spec) { return getBestPackage(name, new Dependency(version_spec)); } Package getBestPackage(string name, in Dependency version_spec) { Package ret; foreach( p; getPackageIterator(name) ) if( version_spec.matches(p.ver) && (!ret || p.ver > ret.ver) ) ret = p; return ret; } 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( pl; m_packages ) foreach( v; pl ) if( auto ret = handlePackage(v) ) 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; } Package install(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 .. $]; logDebug("Installing 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 uninstalled prior installation.", package_name, package_version)); } // open zip file ZipArchive archive; { logTrace("Opening file %s", zip_file_path); auto f = openFile(zip_file_path, FileMode.Read); scope(exit) f.close(); archive = new ZipArchive(f.readAll()); } logTrace("Installing from zip."); // In a github zip, the actual contents are in a subfolder Path zip_prefix; foreach(ArchiveMember am; archive.directory) { if( Path(am.name).head == PathEntry(PackageJsonFilename) ){ zip_prefix = Path(am.name)[0 .. 1]; break; } } if( zip_prefix.empty ){ // not correct zip packages HACK Path minPath; foreach(ArchiveMember am; archive.directory) if( isPathFromZip(am.name) && (minPath == Path() || minPath.startsWith(Path(am.name))) ) zip_prefix = Path(am.name); } logTrace("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]; } // install mkdirRecurse(destination.toNativeString()); auto journal = new Journal; logDebug("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; logTrace("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; } } logDebug("%s file(s) copied.", to!string(countFiles)); // overwrite package.json (this one includes a version field) Json pi = jsonFromFile(destination~PackageJsonFilename); pi["name"] = toLower(pi["name"].get!string()); pi["version"] = package_info["version"]; writeJsonFile(destination~PackageJsonFilename, pi); // Write journal logTrace("Saving installation journal..."); journal.add(Journal.Entry(Journal.Type.RegularFile, Path(JournalJsonFilename))); journal.save(destination ~ JournalJsonFilename); if( existsFile(destination~PackageJsonFilename) ) logInfo("%s has been installed with version %s", package_name, package_version); auto pack = new Package(destination); m_packages[package_name] ~= pack; return pack; } void uninstall(in Package pack) { logTrace("Uninstall %s, version %s, path '%s'", pack.name, pack.vers, pack.path); enforce(!pack.path.empty, "Cannot uninstall 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) { foreach(packsOfId; m_packages) { if(removeFrom(packsOfId, pack)) { found = true; break; } } } enforce(found, "Cannot uninstall, package not found: '"~ pack.name ~"', path: " ~ to!string(pack.path)); // delete package files physically logTrace("Looking up journal"); auto journalFile = pack.path~JournalJsonFilename; if( !existsFile(journalFile) ) throw new Exception("Uninstall failed, no installation journal found for '"~pack.name~"'. Please uninstall manually."); auto packagePath = pack.path; auto journal = new Journal(journalFile); logTrace("Erasing files"); foreach( Journal.Entry e; filter!((Journal.Entry a) => a.type == Journal.Type.RegularFile)(journal.entries)) { logTrace("Deleting file '%s'", e.relFilename); auto absFile = pack.path~e.relFilename; if(!existsFile(absFile)) { logWarn("Previously installed file not found for uninstalling: '%s'", absFile); continue; } removeFile(absFile); } logDebug("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) { logTrace("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)) { logTrace(".dub directory found, removing directory including content."); rmdirRecurse(dubDir); } logTrace("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("Uninstalled package: '"~pack.name~"'"); } Package addLocalPackage(in Path path, in Version ver, LocalPackageType type) { Package[]* packs = &m_repositories[type].localPackages; auto info = jsonFromFile(path ~ PackageJsonFilename, false); string name; if( "name" !in info ) info["name"] = path.head.toString(); info["version"] = ver.toString(); // don't double-add packages foreach( p; *packs ){ if( p.path == path ){ enforce(p.ver == ver, "Adding local twice with different versions is not allowed."); return p; } } auto pack = new Package(info, path); *packs ~= pack; writeLocalPackageList(type); 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()); foreach_reverse( i; to_remove ) *packs = (*packs)[0 .. i] ~ (*packs)[i+1 .. $]; writeLocalPackageList(type); } Package addTemporaryPackage(Path path, Version ver) { auto info = jsonFromFile(path ~ PackageJsonFilename, false); string name; if( "name" !in info ) info["name"] = path.head.toString(); info["version"] = ver.toString(); auto pack = new Package(info, path); m_temporaryPackages ~= pack; return pack; } void addSearchPath(Path path, LocalPackageType type) { m_repositories[type].searchPath ~= path; writeLocalPackageList(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 { logDebug("Looking for local package map at %s", list_path.toNativeString()); if( !existsFile(list_path ~ LocalPackagesFilename) ) return; logDebug("Try to load local package map at %s", list_path.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 = pentry["version"].get!string(); auto info = Json.EmptyObject; if( existsFile(path ~ PackageJsonFilename) ) info = jsonFromFile(path ~ PackageJsonFilename); if( "name" in info && info.name.get!string() != name ) logWarn("Local package at %s has different name than %s (%s)", path.toNativeString(), name, info.name.get!string()); info.name = name; info["version"] = ver; Package pp; if (!refresh_existing_packages) foreach (p; m_repositories[type].localPackages) if (p.path == path) { pp = p; break; } if (!pp) pp = new Package(info, path); packs ~= pp; } } catch( Exception e ){ logWarn("Error adding local package: %s", e.msg); } } } catch( Exception e ){ logDebug("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); Package[][string] old_packages = m_packages; // rescan the system and user package folder void scanPackageFolder(Path path) { if( path.existsDirectory() ){ logTrace("iterating dir %s", path.toNativeString()); try foreach( pdir; iterateDirectory(path) ){ logTrace("iterating dir %s entry %s", path.toNativeString(), pdir.name); if( !pdir.isDirectory ) continue; auto pack_path = path ~ pdir.name; if( !existsFile(pack_path ~ PackageJsonFilename) ) continue; Package p; try { if (!refresh_existing_packages) foreach (plist; old_packages) foreach (pp; plist) if (pp.path == pack_path) { p = pp; break; } if (!p) p = new Package(pack_path); m_packages[p.name] ~= p; } catch( Exception e ){ logError("Failed to load package in %s: %s", pack_path, e.msg); logDebug("Full error: %s", e.toString().sanitize()); } } catch(Exception e) logDebug("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) { logTrace("Hashed directory name %s", Path(file.name).head); } else { sha1.put(openFile(Path(file.name)).readAll()); logTrace("Hashed file contents from %s", Path(file.name).head); } } auto hash = sha1.finish(); logTrace("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)); } }