1 module install;
2 
3 import std.algorithm;
4 import std.array;
5 import std.exception;
6 import std.file;
7 import std.path;
8 import std..string;
9 
10 import ae.sys.file;
11 import ae.utils.array;
12 import ae.utils.json;
13 
14 import common;
15 import config : config;
16 import custom;
17 
18 static import std.process;
19 
20 alias copy = std.file.copy; // https://issues.dlang.org/show_bug.cgi?id=14817
21 
22 version (Windows)
23 {
24 	enum string binExt = ".exe";
25 	enum string dmdConfigName = "sc.ini";
26 }
27 else
28 {
29 	enum string binExt = "";
30 	enum string dmdConfigName = "dmd.conf";
31 }
32 
33 version (Posix)
34 {
35 	import core.sys.posix.unistd;
36 	import std.conv : octal;
37 }
38 
39 version (Windows)
40 	enum string platformDir = "windows";
41 else
42 version (linux)
43 	enum string platformDir = "linux";
44 else
45 version (OSX)
46 	enum string platformDir = "osx";
47 else
48 version (FreeBSD)
49 	enum string platformDir = "freebsd";
50 else
51 	enum string platformDir = null;
52 
53 string[] findDMD()
54 {
55 	string[] result;
56 	foreach (pathEntry; std.process.environment.get("PATH", null).split(pathSeparator))
57 	{
58 		auto dmd = pathEntry.buildPath("dmd" ~ binExt);
59 		if (dmd.exists)
60 			result ~= dmd;
61 	}
62 	return result;
63 }
64 
65 string selectInstallPath(string location)
66 {
67     string[] candidates;
68     if (location)
69 	{
70 		auto dmd = location.absolutePath();
71 		@property bool ok() { return dmd.exists && dmd.isFile; }
72 
73 		if (!ok)
74 		{
75 			string newDir;
76 
77 			bool dirOK(string dir)
78 			{
79 				newDir = dmd.buildPath(dir);
80 				return newDir.exists && newDir.isDir;
81 			}
82 
83 			bool tryDir(string dir)
84 			{
85 				if (dirOK(dir))
86 				{
87 					dmd = newDir;
88 					return true;
89 				}
90 				return false;
91 			}
92 
93 			tryDir("dmd2");
94 
95 			static if (platformDir)
96 				tryDir(platformDir);
97 
98 			enforce(!dirOK("bin32") || !dirOK("bin64"),
99 				"Ambiguous model in path - please specify full path to DMD binary");
100 			tryDir("bin") || tryDir("bin32") || tryDir("bin64");
101 
102 			dmd = dmd.buildPath("dmd" ~ binExt);
103 			enforce(ok, "DMD installation not detected at " ~ location);
104 		}
105 
106 		candidates = [dmd];
107 	}
108 	else
109 	{
110 		candidates = findDMD();
111 		enforce(candidates.length, "DMD not found in PATH - " ~
112 			"add DMD to PATH or specify install location explicitly");
113 	}
114 
115 	foreach (candidate; candidates)
116 	{
117 		if (candidate.buildNormalizedPath.startsWith(config.local.workDir.buildNormalizedPath))
118 		{
119 			log("Skipping DMD installation under Digger workDir (" ~ config.local.workDir ~ "): " ~ candidate);
120 			continue;
121 		}
122 
123 		log("Found DMD executable: " ~ candidate);
124 		return candidate;
125 	}
126 
127 	throw new Exception("No suitable DMD installation found.");
128 }
129 
130 string findConfig(string dmdPath)
131 {
132 	string configPath;
133 
134 	bool pathOK(string path)
135 	{
136 		configPath = path.buildPath(dmdConfigName);
137 		return configPath.exists;
138 	}
139 
140 	if (pathOK(dmdPath.dirName))
141 		return configPath;
142 
143 	auto home = std.process.environment.get("HOME", null);
144 	if (home && pathOK(home))
145 		return configPath;
146 
147 	version (Posix)
148 		if (pathOK("/etc/"))
149 			return configPath;
150 
151 	throw new Exception("Can't find DMD configuration file %s " ~
152 		"corresponding to DMD located at %s".format(dmdConfigName, dmdPath));
153 }
154 
155 struct ComponentPaths
156 {
157 	string binPath;
158 	string libPath;
159 	string phobosPath;
160 	string druntimePath;
161 }
162 
163 string commonModel(ref BuildInfo buildInfo)
164 {
165 	enforce(buildInfo.config.components.common.models.length == 1, "Multi-model install is not yet supported");
166 	return buildInfo.config.components.common.models[0];
167 }
168 
169 ComponentPaths parseConfig(string dmdPath, BuildInfo buildInfo)
170 {
171 	auto configPath = findConfig(dmdPath);
172 	log("Found DMD configuration: " ~ configPath);
173 
174 	string[string] vars = std.process.environment.toAA();
175 	bool parsing = false;
176 	foreach (line; configPath.readText().splitLines())
177 	{
178 		if (line.startsWith("[") && line.endsWith("]"))
179 		{
180 			auto sectionName = line[1..$-1];
181 			parsing = sectionName == "Environment"
182 			       || sectionName == "Environment" ~ buildInfo.commonModel;
183 		}
184 		else
185 		if (parsing && line.canFind("="))
186 		{
187 			string name, value;
188 			list(name, null, value) = line.findSplit("=");
189 			auto parts = value.split("%");
190 			for (size_t n = 1; n < parts.length; n+=2)
191 				if (!parts[n].length)
192 					parts[n] = "%";
193 				else
194 				if (parts[n] == "@P")
195 					parts[n] = configPath.dirName();
196 				else
197 					parts[n] = vars.get(parts[n], parts[n]);
198 			value = parts.join();
199 			vars[name] = value;
200 		}
201 	}
202 
203 	string[] parseParameters(string s, char escape = '\\', char separator = ' ')
204 	{
205 		string[] result;
206 		while (s.length)
207 			if (s[0] == separator)
208 				s = s[1..$];
209 			else
210 			{
211 				string p;
212 				if (s[0] == '"')
213 				{
214 					s = s[1..$];
215 					bool escaping, end;
216 					while (s.length && !end)
217 					{
218 						auto c = s[0];
219 						s = s[1..$];
220 						if (!escaping && c == '"')
221 							end = true;
222 						else
223 						if (!escaping && c == escape)
224 							escaping = true;
225 						else
226 						{
227 							if (escaping && c != escape)
228 								p ~= escape;
229 							p ~= c;
230 							escaping = false;
231 						}
232 					}
233 				}
234 				else
235 					list(p, null, s) = s.findSplit([separator]);
236 				result ~= p;
237 			}
238 		return result;
239 	}
240 
241 	string[] dflags = parseParameters(vars.get("DFLAGS", null));
242 	string[] importPaths = dflags
243 		.filter!(s => s.startsWith("-I"))
244 		.map!(s => s[2..$].split(";"))
245 		.join();
246 
247 	version (Windows)
248 		string[] libPaths = parseParameters(vars.get("LIB", null), 0, ';');
249 	else
250 		string[] libPaths = dflags
251 			.filter!(s => s.startsWith("-L-L"))
252 			.map!(s => s[4..$])
253 			.array();
254 
255 	string findPath(string[] paths, string name, string[] testFiles)
256 	{
257 		auto results = paths.find!(path => testFiles.any!(testFile => path.buildPath(testFile).exists));
258 		enforce(!results.empty, "Can't find %s (%-(%s or %)). Looked in: %s".format(name, testFiles, paths));
259 		auto result = results.front.buildNormalizedPath();
260 		auto testFile = testFiles.find!(testFile => result.buildPath(testFile).exists).front;
261 		log("Found %s (%s): %s".format(name, testFile, result));
262 		return result;
263 	}
264 
265 	ComponentPaths result;
266 	result.binPath = dmdPath.dirName();
267 	result.libPath = findPath(libPaths, "Phobos static library", [getLibFileName(buildInfo)]);
268 	result.phobosPath = findPath(importPaths, "Phobos source code", ["std/stdio.d"]);
269 	result.druntimePath = findPath(importPaths, "Druntime import files", ["object.d", "object.di"]);
270 	return result;
271 }
272 
273 string getLibFileName(BuildInfo buildInfo)
274 {
275 	version (Windows)
276 	{
277 		auto model = buildInfo.commonModel;
278 		return "phobos%s.lib".format(model == "32" ? "" : model);
279 	}
280 	else
281 		return "libphobos2.a";
282 }
283 
284 struct InstalledObject
285 {
286 	/// File name in backup directory
287 	string name;
288 
289 	/// Original location.
290 	/// Path is relative to uninstall.json's directory.
291 	string path;
292 
293 	/// MD5 sum of the NEW object's contents
294 	/// (not the one in the install directory).
295 	/// For directories, this is the MD5 sum
296 	/// of all files sorted by name (see mdDir).
297 	string hash;
298 }
299 
300 struct UninstallData
301 {
302 	InstalledObject*[] objects;
303 }
304 
305 void install(bool yes, bool dryRun, string location = null)
306 {
307 	assert(!yes || !dryRun, "Mutually exclusive options");
308 	auto dmdPath = selectInstallPath(location);
309 
310 	auto buildInfoPath = resultDir.buildPath(buildInfoFileName);
311 	enforce(buildInfoPath.exists,
312 		buildInfoPath ~ " not found - please purge cache and rebuild");
313 	auto buildInfo = buildInfoPath.readText().jsonParse!BuildInfo();
314 
315 	auto componentPaths = parseConfig(dmdPath, buildInfo);
316 
317 	auto verb = dryRun ? "Would" : "Will";
318 	log("%s install:".format(verb));
319 	log(" - Binaries to:           " ~ componentPaths.binPath);
320 	log(" - Libraries to:          " ~ componentPaths.libPath);
321 	log(" - Phobos source code to: " ~ componentPaths.phobosPath);
322 	log(" - Druntime includes to:  " ~ componentPaths.druntimePath);
323 
324 	auto uninstallPath = buildPath(componentPaths.binPath, ".digger-install");
325 	auto uninstallFileName = buildPath(uninstallPath, "uninstall.json");
326 	bool updating = uninstallFileName.exists;
327 	if (updating)
328 	{
329 		log("Found previous installation data in " ~ uninstallPath);
330 		log("%s update existing installation.".format(verb));
331 	}
332 	else
333 	{
334 		log("This %s be a new Digger installation.".format(verb.toLower));
335 		log("Backups and uninstall data %s be saved in %s".format(verb.toLower, uninstallPath));
336 	}
337 
338 	auto libFileName = getLibFileName(buildInfo);
339 	auto libName = libFileName.stripExtension ~ "-" ~ buildInfo.commonModel ~ libFileName.extension;
340 
341 	static struct Item
342 	{
343 		string name, srcPath, dstPath;
344 	}
345 
346 	Item[] items =
347 	[
348 		Item("dmd"  ~ binExt, buildPath(resultDir, "bin", "dmd"  ~ binExt), dmdPath),
349 		Item("rdmd" ~ binExt, buildPath(resultDir, "bin", "rdmd" ~ binExt), buildPath(componentPaths.binPath, "rdmd" ~ binExt)),
350 		Item(libName        , buildPath(resultDir, "lib", libFileName)    , buildPath(componentPaths.libPath, libFileName)),
351 		Item("object.di"    , buildPath(resultDir, "import", "object.{d,di}").globFind,
352 		                                                                    buildPath(componentPaths.druntimePath, "object.{d,di}").globFind),
353 		Item("core"         , buildPath(resultDir, "import", "core")      , buildPath(componentPaths.druntimePath, "core")),
354 		Item("std"          , buildPath(resultDir, "import", "std")       , buildPath(componentPaths.phobosPath, "std")),
355 		Item("etc"          , buildPath(resultDir, "import", "etc")       , buildPath(componentPaths.phobosPath, "etc")),
356 	];
357 
358 	InstalledObject*[string] existingComponents;
359 	bool[string] updateNeeded;
360 
361 	UninstallData uninstallData;
362 
363 	if (updating)
364 	{
365 		uninstallData = uninstallFileName.readText.jsonParse!UninstallData;
366 		foreach (obj; uninstallData.objects)
367 			existingComponents[obj.name] = obj;
368 	}
369 
370 	log("Preparing object list...");
371 
372 	foreach (item; items)
373 	{
374 		log(" - " ~ item.name);
375 
376 		auto obj = new InstalledObject(item.name, item.dstPath.relativePath(uninstallPath), mdObject(item.srcPath));
377 		auto pexistingComponent = item.name in existingComponents;
378 		if (pexistingComponent)
379 		{
380 			auto existingComponent = *pexistingComponent;
381 
382 			enforce(existingComponent.path == obj.path,
383 				"Updated component has a different path (%s vs %s), aborting."
384 				.format(existingComponents[item.name].path, obj.path));
385 
386 			verifyObject(existingComponent, uninstallPath, "update");
387 
388 			updateNeeded[item.name] = existingComponent.hash != obj.hash;
389 			existingComponent.hash = obj.hash;
390 		}
391 		else
392 			uninstallData.objects ~= obj;
393 	}
394 
395 	log("Testing write access and filesystem boundaries:");
396 
397 	string[] dirs = items.map!(item => item.dstPath.dirName).array.sort().uniq().array;
398 	foreach (dir; dirs)
399 	{
400 		log(" - %s".format(dir));
401 		auto testPathA = dir.buildPath(".digger-test");
402 		auto testPathB = componentPaths.binPath.buildPath(".digger-test2");
403 
404 		std.file.write(testPathA, "test");
405 		{
406 			scope(failure) remove(testPathA);
407 			rename(testPathA, testPathB);
408 		}
409 		remove(testPathB);
410 	}
411 
412 	version (Posix)
413 	{
414 		int owner = dmdPath.getOwner();
415 		int group = dmdPath.getGroup();
416 		int mode = items.front.dstPath.getAttributes() & octal!666;
417 		log("UID=%d GID=%d Mode=%03o".format(owner, group, mode));
418 	}
419 
420 	log("Things to do:");
421 
422 	foreach (item; items)
423 	{
424 		enforce(item.srcPath.exists, "Can't find source for component %s: %s".format(item.name, item.srcPath));
425 		enforce(item.dstPath.exists, "Can't find target for component %s: %s".format(item.name, item.dstPath));
426 
427 		string action;
428 		if (updating && item.name in existingComponents)
429 			action = updateNeeded[item.name] ? "Update" : "Skip unchanged";
430 		else
431 			action = "Install";
432 		log(" - %s component %s from %s to %s".format(action, item.name, item.srcPath, item.dstPath));
433 	}
434 
435 	log("You %s be able to undo this action by running `digger uninstall`.".format(verb.toLower));
436 
437 	if (dryRun)
438 	{
439 		log("Dry run, exiting.");
440 		return;
441 	}
442 
443 	if (yes)
444 		log("Proceeding with installation.");
445 	else
446 	{
447 		import std.stdio : stdin, stderr;
448 
449 		string result;
450 		do
451 		{
452 			stderr.write("Proceed with installation? [Y/n] "); stderr.flush();
453 			result = stdin.readln().chomp().toLower();
454 		} while (result != "y" && result != "n" && result != "");
455 		if (result == "n")
456 			return;
457 	}
458 
459 	enforce(updating || !uninstallPath.exists, "Uninstallation directory exists without uninstall.json: " ~ uninstallPath);
460 
461 	log("Saving uninstall information...");
462 
463 	if (!updating)
464 		mkdir(uninstallPath);
465 	std.file.write(uninstallFileName, toJson(uninstallData));
466 
467 	log("Backing up original files...");
468 
469 	foreach (item; items)
470 		if (item.name !in existingComponents)
471 		{
472 			log(" - " ~ item.name);
473 			auto backupPath = buildPath(uninstallPath, item.name);
474 			rename(item.dstPath, backupPath);
475 		}
476 
477 	if (updating)
478 	{
479 		log("Cleaning up existing Digger-installed files...");
480 
481 		foreach (item; items)
482 			if (item.name in existingComponents && updateNeeded[item.name])
483 			{
484 				log(" - " ~ item.name);
485 				rmObject(item.dstPath);
486 			}
487 	}
488 
489 	log("Installing new files...");
490 
491 	foreach (item; items)
492 		if (item.name !in existingComponents || updateNeeded[item.name])
493 		{
494 			log(" - " ~ item.name);
495 			atomic!cpObject(item.srcPath, item.dstPath);
496 		}
497 
498 	version (Posix)
499 	{
500 		log("Applying attributes...");
501 
502 		bool isRoot = geteuid()==0;
503 
504 		foreach (item; items)
505 			if (item.name !in existingComponents || updateNeeded[item.name])
506 			{
507 				log(" - " ~ item.name);
508 				item.dstPath.recursive!setMode(mode);
509 
510 				if (isRoot)
511 					item.dstPath.recursive!setOwner(owner, group);
512 				else
513 					if (item.dstPath.getOwner() != owner || item.dstPath.getGroup() != group)
514 						log("Warning: UID/GID mismatch for " ~ item.dstPath);
515 			}
516 	}
517 
518 	log("Install OK.");
519 	log("You can undo this action by running `digger uninstall`.");
520 }
521 
522 void uninstall(bool dryRun, bool force, string location = null)
523 {
524 	string uninstallPath;
525 	if (location.canFind(".digger-install"))
526 		uninstallPath = location;
527 	else
528 	{
529 		auto dmdPath = selectInstallPath(location);
530 		auto binPath = dmdPath.dirName();
531 		uninstallPath = buildPath(binPath, ".digger-install");
532 	}
533 	auto uninstallFileName = buildPath(uninstallPath, "uninstall.json");
534 	enforce(uninstallFileName.exists, "Can't find uninstallation data: " ~ uninstallFileName);
535 	auto uninstallData = uninstallFileName.readText.jsonParse!UninstallData;
536 
537 	if (!force)
538 	{
539 		log("Verifying files to be uninstalled...");
540 
541 		foreach (obj; uninstallData.objects)
542 			verifyObject(obj, uninstallPath, "uninstall");
543 
544 		log("Verify OK.");
545 	}
546 
547 	log(dryRun ? "Actions to run:" : "Uninstalling...");
548 
549 	void runAction(void delegate() action)
550 	{
551 		if (!force)
552 			action();
553 		else
554 			try
555 				action();
556 			catch (Exception e)
557 				log("Ignoring error: " ~ e.msg);
558 	}
559 
560 	void uninstallObject(InstalledObject* obj)
561 	{
562 		auto src = buildNormalizedPath(uninstallPath, obj.name);
563 		auto dst = buildNormalizedPath(uninstallPath, obj.path);
564 
565 		if (!src.exists) // --force
566 		{
567 			log(" - %s component %s with no backup".format(dryRun ? "Would skip" : "Skipping", obj.name));
568 			return;
569 		}
570 
571 		if (dryRun)
572 		{
573 			log(" - Would remove " ~ dst);
574 			log("   Would move " ~ src ~ " to " ~ dst);
575 		}
576 		else
577 		{
578 			log(" - Removing " ~ dst);
579 			runAction({ rmObject(dst); });
580 			log("   Moving " ~ src ~ " to " ~ dst);
581 			runAction({ rename(src, dst); });
582 		}
583 	}
584 
585 	foreach (obj; uninstallData.objects)
586 		runAction({ uninstallObject(obj); });
587 
588 	if (dryRun)
589 		return;
590 
591 	remove(uninstallFileName);
592 
593 	if (!force)
594 		rmdir(uninstallPath); // should be empty now
595 	else
596 		rmdirRecurse(uninstallPath);
597 
598 	log("Uninstall OK.");
599 }
600 
601 string globFind(string path)
602 {
603 	auto results = dirEntries(path.dirName, path.baseName, SpanMode.shallow);
604 	enforce(!results.empty, "Can't find: " ~ path);
605 	auto result = results.front;
606 	results.popFront();
607 	enforce(results.empty, "Multiple matches: " ~ path);
608 	return result;
609 }
610 
611 void verifyObject(InstalledObject* obj, string uninstallPath, string verb)
612 {
613 	auto path = buildNormalizedPath(uninstallPath, obj.path);
614 	enforce(path.exists, "Can't find item to %s: %s".format(verb, path));
615 	auto hash = mdObject(path);
616 	enforce(hash == obj.hash,
617 		"Object changed since it was installed: %s\nPlease %s manually.".format(path, verb));
618 }
619 
620 version(Posix) bool attrIsExec(int attr) { return (attr & octal!111) != 0; }
621 
622 /// Set access modes while preserving executable bit.
623 version(Posix)
624 void setMode(string fn, int mode)
625 {
626 	auto attr = fn.getAttributes();
627 	mode |= attr & ~octal!777;
628 	if (attr.attrIsExec || attr.attrIsDir)
629 		mode = mode | ((mode & octal!444) >> 2); // executable iff readable
630 	fn.setAttributes(mode);
631 }
632 
633 /// Apply a function recursively to all files and directories under given path.
634 template recursive(alias fun)
635 {
636 	void recursive(Args...)(string fn, auto ref Args args)
637 	{
638 		fun(fn, args);
639 		if (fn.isDir)
640 			foreach (de; fn.dirEntries(SpanMode.shallow))
641 				recursive(de.name, args);
642 	}
643 }
644 
645 void rmObject(string path) { path.isDir ? path.rmdirRecurse() : path.remove(); }
646 
647 void cpObject(string src, string dst)
648 {
649 	if (src.isDir)
650 	{
651 		mkdir(dst);
652 		dst.setAttributes(src.getAttributes());
653 		foreach (de; src.dirEntries(SpanMode.shallow))
654 			cpObject(de.name, buildPath(dst, de.baseName));
655 	}
656 	else
657 	{
658 		src.copy(dst);
659 		dst.setAttributes(src.getAttributes());
660 	}
661 }
662 
663 string mdDir(string dir)
664 {
665 	import std.stdio : File;
666 	import std.digest.md;
667 
668 	auto dataChunks = dir
669 		.dirEntries(SpanMode.breadth)
670 		.filter!(de => de.isFile)
671 		.map!(de => de.name.replace(`\`, `/`))
672 		.array()
673 		.sort()
674 		.map!(name => File(name, "rb").byChunk(4096))
675 		.joiner();
676 
677 	MD5 digest;
678 	digest.start();
679 	foreach (chunk; dataChunks)
680 		digest.put(chunk);
681 	auto result = digest.finish();
682 	// https://issues.dlang.org/show_bug.cgi?id=9279
683 	auto str = result.toHexString();
684 	return str[].idup;
685 }
686 
687 string mdObject(string path)
688 {
689 	import std.digest.digest;
690 
691 	if (path.isDir)
692 		return path.mdDir();
693 	else
694 	{
695 		auto result = path.mdFile();
696 		// https://issues.dlang.org/show_bug.cgi?id=9279
697 		auto str = result.toHexString();
698 		return str[].idup;
699 	}
700 }