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 }