1 module dbuild.src;
2 
3 import std.digest.md : MD5;
4 
5 /// Source code interface
6 interface Source
7 {
8     /// Feeds the digest in a way that makes a unique build identifier.
9     void feedBuildId(ref MD5 digest);
10 
11     /// Obtain the source code.
12     /// Implementation for local directories will simply return the local source dir,
13     /// without consideration of the workDir.
14     /// Implementation of source fetching (archive or scm) will download and
15     /// extract (or clone) the source tree into workDir.
16     /// Params:
17     ///     workDir = work directory that should preferably be used to download
18     ///               and extract the source code.
19     /// Returns: the path to the src root directory.
20     string obtain(in string workDir);
21 
22     /// Add patches to the source code.
23     /// Patches are patch content, not patch filenames.
24     /// Patches are applied with Git which must be found in PATH
25     /// (even if source code is not a Git repo)
26     final Source withPatch(in string[] patches)
27     {
28         return new PatchedSource(this, patches);
29     }
30 }
31 
32 /// Returns a Source pointing to a local existing directory
33 Source localSource(in string dir)
34 {
35     return new LocalSource(dir);
36 }
37 
38 /// Returns a Source pointing to a local directory within $DUB_PACKAGE
39 Source dubPkgSource(in string subdir)
40 {
41     import std.path : buildPath;
42     import std.process : environment;
43 
44     return new LocalSource(buildPath(environment["DUB_PACKAGE"], subdir));
45 }
46 
47 /// Returns a Source that fetches and extract an archive into the work directory
48 /// md5 can be specified with an md5 checksum to check integrity of the downloaded
49 /// archive
50 Source archiveFetchSource(in string url, in string md5=null)
51 {
52     import std.exception : enforce;
53 
54     enforce(isSupportedArchiveExt(url), url~" is not a supported archive type");
55     return new ArchiveFetchSource(url, md5);
56 }
57 
58 /// Returns a Source that will clone a git repo and checkout a specified commit.
59 /// commitRef can be a branch, tag, or commit hash. Anything `git checkout` will understand.
60 Source gitSource(in string url, in string commitRef)
61 {
62     return new GitSource(url, commitRef);
63 }
64 
65 private class LocalSource : Source
66 {
67     string dir;
68     this (in string dir)
69     {
70         import std.exception : enforce;
71         import std.file : exists, isDir;
72 
73         enforce(exists(dir) && isDir(dir), dir~": no such directory!");
74         this.dir = dir;
75     }
76 
77     override void feedBuildId(ref MD5 digest)
78     {
79         import dbuild.util : feedDigestData;
80 
81         feedDigestData(digest, dir);
82     }
83 
84     string obtain(in string)
85     {
86         return this.dir;
87     }
88 }
89 
90 private string srcLockPath(in string workDir)
91 {
92     import std.path : buildPath;
93 
94     return buildPath(workDir, ".srcLock");
95 }
96 
97 private string patchLockPath(in string workDir, in string patch)
98 {
99     import dbuild.util : feedDigestData;
100     import std.digest : toHexString, LetterCase;
101     import std.digest.md : MD5;
102     import std.path : buildPath;
103 
104     MD5 md5;
105     feedDigestData(md5, patch);
106     const binHash = md5.finish();
107     const hash = toHexString!(LetterCase.lower)(binHash)[0 .. 7].idup;
108     return buildPath(workDir, "."~hash~".patchLock");
109 }
110 
111 private class ArchiveFetchSource : Source
112 {
113     string url;
114     string md5;
115     this (in string url, in string md5)
116     {
117         this.url = url;
118         this.md5 = md5;
119     }
120 
121     override void feedBuildId(ref MD5 digest)
122     {
123         import dbuild.util : feedDigestData;
124 
125         feedDigestData(digest, url);
126         feedDigestData(digest, md5);
127     }
128 
129     override string obtain(in string workDir)
130     {
131         import dbuild.util : lockFile;
132         import std.file : exists, isDir;
133         import std.path : buildPath;
134         import std.uri : decode;
135 
136         const decoded = decode(url);
137         const fn = urlLastComp(decoded);
138         const archive = buildPath(workDir, fn);
139 
140         const ldn = likelySrcDirName(archive);
141         if (exists(ldn) && isDir(ldn)) {
142             return ldn;
143         }
144 
145         auto lock = lockFile(srcLockPath(workDir));
146         downloadArchive(archive);
147         return extractArchive(archive, workDir);
148     }
149 
150     private void downloadArchive(in string archive)
151     {
152         import dbuild.util : checkMd5;
153         import std.exception : enforce;
154         import std.file : exists;
155         import std.net.curl : download;
156         import std.stdio : writefln;
157 
158         if (!exists(archive) || !(md5.length && checkMd5(archive, md5))) {
159             if (exists(archive)) {
160                 import std.file : remove;
161                 remove(archive);
162             }
163             writefln("downloading %s", url);
164             download(url, archive);
165 
166             enforce(!md5.length || checkMd5(archive, md5), "wrong md5 sum for "~archive);
167         }
168     }
169 
170     private string extractArchive(in string archive, in string workDir)
171     {
172         import std.file : remove;
173 
174         final switch(archiveFormat(archive)) {
175         case ArchiveFormat.targz:
176             const tarF = extractTarGz(archive);
177             const res = extractTar(tarF, workDir);
178             remove(tarF);
179             return res;
180         case ArchiveFormat.tar:
181             return extractTar(archive, workDir);
182         case ArchiveFormat.zip:
183             return extractZip(archive, workDir);
184         }
185     }
186 
187     /// extract .tar.gz to .tar, returns the .tar file path
188     private string extractTarGz(const string archive)
189     in { assert(archive[$-7 .. $] == ".tar.gz"); }
190     out(res) { assert(res[$-4 .. $] == ".tar"); }
191     body {
192         import std.algorithm : map;
193         import std.exception : enforce;
194         import std.file : exists, remove;
195         import std.stdio : File, writefln;
196         import std.zlib : UnCompress;
197 
198         writefln("extracting %s", archive);
199 
200         const tarFn = archive[0 .. $-3];
201         enforce(!exists(tarFn), tarFn~" already exists");
202 
203         auto inF = File(archive, "rb");
204         auto outF = File(tarFn, "wb");
205 
206         UnCompress decmp = new UnCompress;
207         foreach (chunk; inF.byChunk(4096).map!(x => decmp.uncompress(x)))
208         {
209             outF.rawWrite(chunk);
210         }
211 
212         return tarFn;
213     }
214 
215     private string extractTar(in string archive, in string workDir)
216     {
217         import dbuild.tar : extractTo, isSingleRootDir;
218         import std.exception : enforce;
219         import std.file : exists, isDir;
220         import std.path : buildPath;
221         import std.stdio : writefln;
222 
223         writefln("extracting %s", archive);
224 
225         string extractDir;
226         string srcDir;
227         string rootDir;
228         if (isSingleRootDir(archive, rootDir)) {
229             extractDir = workDir;
230             srcDir = buildPath(workDir, rootDir);
231         }
232         else {
233             extractDir = buildPath(workDir, "src");
234             srcDir = extractDir;
235         }
236 
237         // trusting src dir content?
238         if (!exists(srcDir)) {
239             extractTo(archive, extractDir);
240         }
241 
242         enforce(isDir(srcDir));
243         return srcDir;
244     }
245 
246     private string extractZip(in string archive, in string workDir)
247     {
248         import std.digest.crc : crc32Of;
249         import std.exception : enforce;
250         import std.file : exists, isDir, mkdirRecurse, read, write;
251         import std.path : buildNormalizedPath, buildPath, dirName, pathSplitter;
252         import std.stdio : writeln, writefln;
253         import std.zip : ZipArchive;
254 
255         writefln("extracting %s", archive);
256         auto zip = new ZipArchive(read(archive));
257         string extractDir;
258         string srcDir;
259         string rootDir;
260         bool singleRoot = true;
261 
262         foreach(n, m; zip.directory) {
263             const dir = pathSplitter(n).front;
264             if (rootDir && dir != rootDir) {
265                 singleRoot = false;
266                 break;
267             }
268             if (!rootDir) rootDir = dir;
269         }
270         if (singleRoot) {
271             extractDir = workDir;
272             srcDir = buildPath(workDir, rootDir);
273         }
274         else {
275             extractDir = buildPath(workDir, "src");
276             srcDir = extractDir;
277         }
278 
279         foreach(n, m; zip.directory) {
280             const file = buildNormalizedPath(extractDir, n);
281             if ((exists(file) && isDir(file)) || m.expandedSize == 0) continue;
282             mkdirRecurse(dirName(file));
283             zip.expand(m);
284             enforce(m.expandedData.length == cast(size_t)m.expandedSize, "zip data does not have expected size");
285             const crc32 = crc32Of(m.expandedData);
286             const crc32_ = *(cast(const(uint)*)&crc32[0]);
287             enforce(crc32_ == m.crc32, "CRC32 zip check failed");
288             write(file, m.expandedData);
289         }
290 
291         return srcDir;
292     }
293 }
294 
295 private class GitSource : Source
296 {
297     string url;
298     string commitRef;
299 
300     this (in string url, in string commitRef)
301     {
302         import dbuild.util : searchExecutable;
303         import std.exception : enforce;
304 
305         enforce(searchExecutable("git"), "could not find git in PATH!");
306         this.url = url;
307         this.commitRef = commitRef;
308     }
309 
310     override void feedBuildId(ref MD5 digest)
311     {
312         import dbuild.util : feedDigestData;
313 
314         feedDigestData(digest, url);
315         feedDigestData(digest, commitRef);
316     }
317     override string obtain(in string workDir)
318     {
319         import dbuild.util : runCommand;
320         import std.algorithm : endsWith;
321         import std.exception : enforce;
322         import std.file : exists, isDir;
323         import std.path : buildPath;
324         import std.process : pipeProcess, Redirect;
325         import std.uri : decode;
326 
327         enforce(commitRef.length, "must specify commitRef for git checkout");
328 
329         const decoded = decode(url);
330         auto dirName = urlLastComp(decoded);
331         if (dirName.endsWith(".git")) {
332             dirName = dirName[0 .. $-4];
333         }
334 
335         const srcDir = buildPath(workDir, dirName);
336 
337         if (!exists(srcDir)) {
338             runCommand(["git", "clone", url, dirName], workDir, false);
339         }
340 
341         enforce(isDir(srcDir));
342 
343         runCommand(["git", "checkout", commitRef], srcDir, false);
344 
345         return srcDir;
346     }
347 }
348 
349 private class PatchedSource : Source
350 {
351     const(string)[] patches;
352     Source impl;
353 
354     this(Source impl, in string[] patches)
355     {
356         import dbuild.util : searchExecutable;
357         import std.exception : enforce;
358 
359         enforce(searchExecutable("git"), "could not find git in PATH! Git is needed to apply patche");
360         this.impl = impl;
361         this.patches = patches;
362     }
363 
364     void feedBuildId(ref MD5 digest)
365     {
366         import dbuild.util : feedDigestData;
367 
368         impl.feedBuildId(digest);
369         feedDigestData(digest, patches);
370     }
371 
372     string obtain(in string workDir)
373     {
374         import dbuild.util : lockFile;
375         import std.process : Config, pipeProcess, Redirect, wait;
376         import std.stdio : writefln;
377         import std.file : exists;
378 
379         const dir = impl.obtain(workDir);
380 
381         writefln("Patching %s", dir);
382 
383         foreach (patch; patches) {
384             const lockPath = patchLockPath(workDir, patch);
385             if (exists(lockPath)) {
386                 continue;
387             }
388             const args = ["git", "apply", "-"];
389             auto pipes = pipeProcess(args, Redirect.stderr|Redirect.stdin, null, Config.none, dir);
390             pipes.stdin.write(patch);
391             pipes.stdin.flush();
392             pipes.stdin.close();
393             wait(pipes.pid);
394             auto lock = lockFile(lockPath);
395         }
396 
397         return dir;
398     }
399 }
400 
401 private enum ArchiveFormat
402 {
403     targz, tar, zip,
404 }
405 
406 private immutable(string[]) supportedArchiveExts = [
407     ".zip", ".tar.gz", ".tar"
408 ];
409 
410 private bool isSupportedArchiveExt(in string path)
411 {
412     import std.algorithm : endsWith;
413     import std.uni : toLower;
414 
415     const lpath = path.toLower;
416     return lpath.endsWith(".zip") || lpath.endsWith(".tar.gz") || lpath.endsWith(".tar");
417 }
418 
419 private ArchiveFormat archiveFormat(in string path)
420 {
421     import std.algorithm : endsWith;
422 
423     if (path.endsWith(".zip")) return ArchiveFormat.zip;
424     if (path.endsWith(".tar.gz")) return ArchiveFormat.targz;
425     if (path.endsWith(".tar")) return ArchiveFormat.tar;
426     assert(false);
427 }
428 
429 private string urlLastComp(in string url)
430 {
431     size_t ind = url.length - 1;
432     while (ind >= 0 && url[ind] != '/') {
433         ind--;
434     }
435     return url[ind+1 .. $];
436 }
437 
438 private string likelySrcDirName(in string archive)
439 {
440     import std.algorithm : endsWith;
441     import std.uni : toLower;
442 
443     assert(isSupportedArchiveExt(archive));
444 
445     foreach(ext; supportedArchiveExts) {
446         if (archive.toLower.endsWith(ext)) {
447             return archive[0 .. $-ext.length];
448         }
449     }
450     assert(false);
451 }
452 
453 unittest
454 {
455     assert(likelySrcDirName("/path/archivename.tar.gz") == "/path/archivename");
456 }