1 /**
2  * ae.sys.install.common
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <ae@cy.md>
12  */
13 
14 module ae.sys.install.common;
15 
16 import std.algorithm;
17 import std.array;
18 import std.exception;
19 import std.file;
20 import std.path;
21 import std.process : environment;
22 import std.string;
23 
24 import ae.net.ietf.url;
25 import ae.sys.archive;
26 import ae.sys.file;
27 import ae.sys.net;
28 import ae.sys.persistence;
29 import ae.utils.meta;
30 import ae.utils.path;
31 
32 /// Base class for an installer - a process to acquire and set up some
33 /// third-party software or component to some temporary location, so
34 /// that we can then invoke and use it.
35 class Installer
36 {
37 	/// Where all software will be unpacked
38 	/// (current directory, by default).
39 	static string installationDirectory = null;
40 
41 	/// Log sink
42 	static void delegate(string) logger;
43 
44 	static protected void log(string s)
45 	{
46 		if (logger) logger(s);
47 	}
48 
49 	/// Component name. Used for logging.
50 	@property string name() { return this.classinfo.name.split(".")[$-1].chomp("Installer"); }
51 
52 	/// The subdirectory where this component will be installed.
53 	@property string subdirectory() { return name.toLower(); }
54 
55 	/// Subdirectories (under the installation subdirectory)
56 	/// containing executables which need to be added to PATH.
57 	@property string[] binPaths() { return [""]; }
58 
59 	/// As above, but expanded to full absolute directory paths.
60 	@property final string[] binDirs() { return binPaths.map!(binPath => buildPath(directory, binPath)).array; }
61 
62 	deprecated("Please use ae.utils.path.pathDirs")
63 	@property static string[] pathDirs() { return ae.utils.path.pathDirs; }
64 
65 	/// The full installation directory.
66 	@property final string directory()
67 	{
68 		return buildPath(installationDirectory, subdirectory);
69 	}
70 
71 	/// The list of executable names required to be present.
72 	/// Null if this component is never considered already
73 	/// available on the system.
74 	@property string[] requiredExecutables() { return null; }
75 
76 	deprecated("Please use ae.utils.path.haveExecutable")
77 	/*protected*/ static bool haveExecutable(string name) { return ae.utils.path.haveExecutable(name); }
78 
79 	deprecated("Please use ae.utils.path.findExecutable")
80 	static string findExecutable(string name, string[] dirs) { return ae.utils.path.findExecutable(name, dirs); }
81 
82 	/// Get the full path to an executable.
83 	string exePath(string name)
84 	{
85 		return .findExecutable(name, installedLocally ? binDirs : .pathDirs);
86 	}
87 
88 	/// Whether the component is installed locally.
89 	@property bool installedLocally()
90 	{
91 		// The directory should only be created atomically upon
92 		// the end of a successful installation, so an exists
93 		// check is sufficient.
94 		return directory.exists;
95 	}
96 
97 	/// Whether the component is already present on the system.
98 	@property bool availableOnSystem()
99 	{
100 		if (requiredExecutables is null)
101 			return false;
102 
103 		return requiredExecutables.all!(.haveExecutable)();
104 	}
105 
106 	/// Whether the component is installed, locally or
107 	/// already present on the system.
108 	@property final bool available()
109 	{
110 		return installedLocally || availableOnSystem;
111 	}
112 
113 	/// Install this component if necessary.
114 	final void require()
115 	{
116 		if (!available)
117 			install();
118 
119 		assert(available);
120 
121 		if (installedLocally)
122 			addToPath();
123 	}
124 
125 	/// Install this component locally, if it isn't already installed.
126 	final void requireLocal(bool shouldAddToPath = true)
127 	{
128 		if (!installedLocally)
129 			install();
130 
131 		if (shouldAddToPath)
132 			addToPath();
133 	}
134 
135 	private bool addedToPath;
136 
137 	/// Change this process's PATH environment variable to include the
138 	/// path to this component's executable directories.
139 	void addToPath()
140 	{
141 		if (addedToPath)
142 			return;
143 		foreach (binPath; binPaths)
144 		{
145 			auto path = buildPath(directory, binPath).absolutePath();
146 			log("Adding " ~ path ~ " to PATH.");
147 			// Override any system installations
148 			environment["PATH"] = path ~ pathSeparator ~ environment["PATH"];
149 		}
150 		addedToPath = true;
151 	}
152 
153 	private void install()
154 	{
155 		mkdirRecurse(installationDirectory);
156 
157 		log("Installing " ~ name ~ " to " ~ directory ~ "...");
158 		atomicInstallImpl();
159 		log("Done installing " ~ name ~ ".");
160 	}
161 
162 	/// Install to `directory` atomically.
163 	protected void atomicInstallImpl()
164 	{
165 		void installProxy(string target) { installImpl(target); }
166 		atomic!installProxy(directory);
167 	}
168 
169 	protected void installImpl(string target)
170 	{
171 		uninstallable();
172 	}
173 
174 	// ----------------------------------------------------
175 
176 final:
177 protected:
178 	static string saveLocation(string url)
179 	{
180 		return buildPath(installationDirectory, url.fileNameFromURL());
181 	}
182 
183 	template cachedAction(alias fun, string fmt)
184 	{
185 		static void cachedAction(Args...)(Args args, string target)
186 		{
187 			if (target.exists)
188 				return;
189 			log(fmt.format(args, target));
190 			atomic!fun(args, target);
191 		}
192 	}
193 
194 	static string[string] urlDigests;
195 
196 	static void saveFile(string url, string target)
197 	{
198 		downloadFile(url, target);
199 		auto pDigest = url in urlDigests;
200 		if (pDigest)
201 		{
202 			auto digest = *pDigest;
203 			if (!digest)
204 				return;
205 			log("Verifying " ~ target.baseName() ~ "...");
206 
207 			import std.digest.sha, std.digest, std.stdio;
208 			SHA1 sha;
209 			sha.start();
210 			foreach (chunk; File(target, "rb").byChunk(0x10000))
211 				sha.put(chunk[]);
212 			auto hash = sha.finish();
213 			enforce(hash[].toHexString!(LetterCase.lower) == digest,
214 				"Could not verify integrity of " ~ target ~ ".\n" ~
215 				"Expected: " ~ digest ~ "\n" ~
216 				"Got     : " ~ hash.toHexString!(LetterCase.lower));
217 		}
218 		else
219 			log("WARNING: Not verifying integrity of " ~ url ~ ".");
220 	}
221 
222 	alias saveTo = cachedAction!(saveFile, "Downloading %s to %s...");
223 	alias save = withTarget!(saveLocation, saveTo);
224 
225 	static auto saveAs(string url, string fn)
226 	{
227 		auto target = buildPath(installationDirectory, fn);
228 		ensurePathExists(target);
229 		url.I!saveTo(target);
230 		return target;
231 	}
232 
233 	static string stripArchiveExtension(string fn)
234 	{
235 		fn = fn.stripExtension();
236 		if (fn.extension == ".tar")
237 			fn = fn.stripExtension();
238 		return fn;
239 	}
240 
241 	alias unpackTo = cachedAction!(ae.sys.archive.unpack, "Unpacking %s to %s...");
242 	alias unpack = withTarget!(stripArchiveExtension, unpackTo);
243 
244 	static string resolveRedirectImpl(string url)
245 	{
246 		return net.resolveRedirect(url);
247 	}
248 	static string resolveRedirect(string url)
249 	{
250 		alias P = PersistentMemoized!(resolveRedirectImpl, FlushPolicy.atThreadExit);
251 		static P* p;
252 		if (!p)
253 			p = new P(buildPath(installationDirectory, "redirects.json"));
254 		return (*p)(url);
255 	}
256 
257 	void windowsOnly()
258 	{
259 		version(Windows)
260 			return;
261 		else
262 		{
263 			log(name ~ " is not installable on this platform.");
264 			uninstallable();
265 		}
266 	}
267 
268 	void uninstallable()
269 	{
270 		throw new Exception("Please install " ~ name ~ " and make sure it is on your PATH.");
271 	}
272 }
273 
274 /// Move a directory and its contents into another directory recursively,
275 /// overwriting any existing files.
276 package void moveInto(string source, string target)
277 {
278 	foreach (de; source.dirEntries(SpanMode.shallow))
279 	{
280 		auto targetPath = target.buildPath(de.baseName);
281 		if (de.isDir && targetPath.exists)
282 			de.moveInto(targetPath);
283 		else
284 			de.name.rename(targetPath);
285 	}
286 	source.rmdir();
287 }
288 
289 /// As above, but do not leave behind partially-merged
290 /// directories. In case of failure, both source and target
291 /// are deleted.
292 package void atomicMoveInto(string source, string target)
293 {
294 	auto tmpSource = source ~ ".tmp";
295 	auto tmpTarget = target ~ ".tmp";
296 	if (tmpSource.exists) tmpSource.rmdirRecurse();
297 	if (tmpTarget.exists) tmpTarget.rmdirRecurse();
298 	source.rename(tmpSource);
299 	target.rename(tmpTarget);
300 	{
301 		scope(failure) tmpSource.rmdirRecurse();
302 		scope(failure) tmpTarget.rmdirRecurse();
303 		tmpSource.moveInto(tmpTarget);
304 	}
305 	tmpTarget.rename(target);
306 }