1 //
2 // Getdown - application installer, patcher and launcher
3 // Copyright (C) 2004-2018 Getdown authors
4 // https://github.com/threerings/getdown/blob/master/LICENSE
5 
6 package com.threerings.getdown.data;
7 
8 import java.io.*;
9 import java.net.URL;
10 import java.nio.file.Files;
11 import java.nio.file.Paths;
12 import java.security.MessageDigest;
13 import java.util.Collections;
14 import java.util.Comparator;
15 import java.util.EnumSet;
16 import java.util.List;
17 import java.util.Locale;
18 import java.util.jar.JarEntry;
19 import java.util.jar.JarFile;
20 
21 import com.threerings.getdown.util.FileUtil;
22 import com.threerings.getdown.util.ProgressObserver;
23 import com.threerings.getdown.util.StringUtil;
24 
25 import static com.threerings.getdown.Log.log;
26 
27 /**
28  * Models a single file resource used by an {@link Application}.
29  */
30 public class Resource implements Comparable<Resource>
31 {
32     /** Defines special attributes for resources. */
33     public static enum Attr {
34         /** Indicates that the resource should be unpacked. */
35         UNPACK,
36         /** If present, when unpacking a resource, any directories created by the newly
37           * unpacked resource will first be cleared of files before unpacking. */
38         CLEAN,
39         /** Indicates that the resource should be marked executable. */
40         EXEC,
41         /** Indicates that the resource should be downloaded before a UI is displayed. */
42         PRELOAD,
43         /** Indicates that the resource is a jar containing native libs. */
44         NATIVE
45     };
46 
47     public static final EnumSet<Attr> NORMAL  = EnumSet.noneOf(Attr.class);
48     public static final EnumSet<Attr> UNPACK  = EnumSet.of(Attr.UNPACK);
49     public static final EnumSet<Attr> EXEC    = EnumSet.of(Attr.EXEC);
50     public static final EnumSet<Attr> PRELOAD = EnumSet.of(Attr.PRELOAD);
51     public static final EnumSet<Attr> NATIVE  = EnumSet.of(Attr.NATIVE);
52 
53     /**
54      * Computes the MD5 hash of the supplied file.
55      * @param version the version of the digest protocol to use.
56      */
computeDigest(int version, File target, MessageDigest md, ProgressObserver obs)57     public static String computeDigest (int version, File target, MessageDigest md,
58                                         ProgressObserver obs)
59         throws IOException
60     {
61         md.reset();
62         byte[] buffer = new byte[DIGEST_BUFFER_SIZE];
63         int read;
64 
65         boolean isJar = isJar(target.getPath());
66         boolean isPacked200Jar = isPacked200Jar(target.getPath());
67 
68         // if this is a jar, we need to compute the digest in a "timestamp and file order" agnostic
69         // manner to properly correlate jardiff patched jars with their unpatched originals
70         if (isJar || isPacked200Jar){
71             File tmpJarFile = null;
72             JarFile jar = null;
73             try {
74                 // if this is a compressed jar file, uncompress it to compute the jar file digest
75                 if (isPacked200Jar){
76                     tmpJarFile = new File(target.getPath() + ".tmp");
77                     FileUtil.unpackPacked200Jar(target, tmpJarFile);
78                     jar = new JarFile(tmpJarFile);
79                 } else{
80                     jar = new JarFile(target);
81                 }
82 
83                 List<JarEntry> entries = Collections.list(jar.entries());
84                 Collections.sort(entries, ENTRY_COMP);
85 
86                 int eidx = 0;
87                 for (JarEntry entry : entries) {
88                     // old versions of the digest code skipped metadata
89                     if (version < 2) {
90                         if (entry.getName().startsWith("META-INF")) {
91                             updateProgress(obs, eidx, entries.size());
92                             continue;
93                         }
94                     }
95 
96                     try (InputStream in = jar.getInputStream(entry)) {
97                         while ((read = in.read(buffer)) != -1) {
98                             md.update(buffer, 0, read);
99                         }
100                     }
101 
102                     updateProgress(obs, eidx, entries.size());
103                 }
104 
105             } finally {
106                 if (jar != null) {
107                     try {
108                         jar.close();
109                     } catch (IOException ioe) {
110                         log.warning("Error closing jar", "path", target, "jar", jar, "error", ioe);
111                     }
112                 }
113                 if (tmpJarFile != null) {
114                     FileUtil.deleteHarder(tmpJarFile);
115                 }
116             }
117 
118         } else {
119             long totalSize = target.length(), position = 0L;
120             try (FileInputStream fin = new FileInputStream(target)) {
121                 while ((read = fin.read(buffer)) != -1) {
122                     md.update(buffer, 0, read);
123                     position += read;
124                     updateProgress(obs, position, totalSize);
125                 }
126             }
127         }
128         return StringUtil.hexlate(md.digest());
129     }
130 
131     /**
132      * Creates a resource with the supplied remote URL and local path.
133      */
Resource(String path, URL remote, File local, EnumSet<Attr> attrs)134     public Resource (String path, URL remote, File local, EnumSet<Attr> attrs)
135     {
136         _path = path;
137         _remote = remote;
138         _local = local;
139         _localNew = new File(local.toString() + "_new");
140         String lpath = _local.getPath();
141         _marker = new File(lpath + "v");
142 
143         _attrs = attrs;
144         _isJar = isJar(lpath);
145         _isPacked200Jar = isPacked200Jar(lpath);
146         boolean unpack = attrs.contains(Attr.UNPACK);
147         if (unpack && _isJar) {
148             _unpacked = _local.getParentFile();
149         } else if(unpack && _isPacked200Jar) {
150             String dotJar = ".jar", lname = _local.getName();
151             String uname = lname.substring(0, lname.lastIndexOf(dotJar) + dotJar.length());
152             _unpacked = new File(_local.getParent(), uname);
153         }
154     }
155 
156     /**
157      * Returns the path associated with this resource.
158      */
getPath()159     public String getPath ()
160     {
161         return _path;
162     }
163 
164     /**
165      * Returns the local location of this resource.
166      */
getLocal()167     public File getLocal ()
168     {
169         return _local;
170     }
171 
172     /**
173      * Returns the location of the to-be-installed new version of this resource.
174      */
getLocalNew()175     public File getLocalNew ()
176     {
177         return _localNew;
178     }
179 
180     /**
181      *  Returns the location of the unpacked resource.
182      */
getUnpacked()183     public File getUnpacked ()
184     {
185         return _unpacked;
186     }
187 
188     /**
189      *  Returns the final target of this resource, whether it has been unpacked or not.
190      */
getFinalTarget()191     public File getFinalTarget ()
192     {
193         return shouldUnpack() ? getUnpacked() : getLocal();
194     }
195 
196     /**
197      * Returns the remote location of this resource.
198      */
getRemote()199     public URL getRemote ()
200     {
201         return _remote;
202     }
203 
204     /**
205      * Returns true if this resource should be unpacked as a part of the validation process.
206      */
shouldUnpack()207     public boolean shouldUnpack ()
208     {
209         return _attrs.contains(Attr.UNPACK) && !SysProps.noUnpack();
210     }
211 
212     /**
213      * Returns true if this resource should be pre-downloaded.
214      */
shouldPredownload()215     public boolean shouldPredownload ()
216     {
217         return _attrs.contains(Attr.PRELOAD);
218     }
219 
220     /**
221      * Returns true if this resource is a native lib jar.
222      */
isNative()223     public boolean isNative ()
224     {
225         return _attrs.contains(Attr.NATIVE);
226     }
227 
228     /**
229      * Computes the MD5 hash of this resource's underlying file.
230      * <em>Note:</em> This is both CPU and I/O intensive.
231      * @param version the version of the digest protocol to use.
232      */
computeDigest(int version, MessageDigest md, ProgressObserver obs)233     public String computeDigest (int version, MessageDigest md, ProgressObserver obs)
234         throws IOException
235     {
236         File file;
237         if (_local.toString().toLowerCase(Locale.ROOT).endsWith(Application.CONFIG_FILE)) {
238             file = _local;
239         } else {
240             file = _localNew.exists() ? _localNew : _local;
241         }
242         return computeDigest(version, file, md, obs);
243     }
244 
245     /**
246      * Returns true if this resource has an associated "validated" marker
247      * file.
248      */
isMarkedValid()249     public boolean isMarkedValid ()
250     {
251         if (!_local.exists()) {
252             clearMarker();
253             return false;
254         }
255         return _marker.exists();
256     }
257 
258     /**
259      * Creates a "validated" marker file for this resource to indicate
260      * that its MD5 hash has been computed and compared with the value in
261      * the digest file.
262      *
263      * @throws IOException if we fail to create the marker file.
264      */
markAsValid()265     public void markAsValid ()
266         throws IOException
267     {
268         _marker.createNewFile();
269     }
270 
271     /**
272      * Removes any "validated" marker file associated with this resource.
273      */
clearMarker()274     public void clearMarker ()
275     {
276         if (_marker.exists() && !FileUtil.deleteHarder(_marker)) {
277             log.warning("Failed to erase marker file '" + _marker + "'.");
278         }
279     }
280 
281     /**
282      * Installs the {@code getLocalNew} version of this resource to {@code getLocal}.
283      * @param validate whether or not to mark the resource as valid after installing.
284      */
install(boolean validate)285     public void install (boolean validate) throws IOException {
286         File source = getLocalNew(), dest = getLocal();
287         log.info("- " + source);
288         if (!FileUtil.renameTo(source, dest)) {
289             throw new IOException("Failed to rename " + source + " to " + dest);
290         }
291         applyAttrs();
292         if (validate) {
293             markAsValid();
294         }
295     }
296 
297     /**
298      * Unpacks this resource file into the directory that contains it.
299      */
unpack()300     public void unpack () throws IOException
301     {
302         // sanity check
303         if (!_isJar && !_isPacked200Jar) {
304             throw new IOException("Requested to unpack non-jar file '" + _local + "'.");
305         }
306         if (_isJar) {
307             try (JarFile jar = new JarFile(_local)) {
308                 FileUtil.unpackJar(jar, _unpacked, _attrs.contains(Attr.CLEAN));
309             }
310         } else {
311             FileUtil.unpackPacked200Jar(_local, _unpacked);
312         }
313     }
314 
315     /**
316      * Applies this resources special attributes: unpacks this resource if needed, marks it as
317      * executable if needed.
318      */
applyAttrs()319     public void applyAttrs () throws IOException {
320         if (shouldUnpack()) {
321             unpack();
322         }
323         if (_attrs.contains(Attr.EXEC)) {
324             FileUtil.makeExecutable(_local);
325         }
326     }
327 
328     /**
329      * Wipes this resource file along with any "validated" marker file that may be associated with
330      * it.
331      */
erase()332     public void erase ()
333     {
334         clearMarker();
335         if (_local.exists() && !FileUtil.deleteHarder(_local)) {
336             log.warning("Failed to erase resource '" + _local + "'.");
337         }
338     }
339 
compareTo(Resource other)340     @Override public int compareTo (Resource other) {
341         return _path.compareTo(other._path);
342     }
343 
equals(Object other)344     @Override public boolean equals (Object other)
345     {
346         if (other instanceof Resource) {
347             return _path.equals(((Resource)other)._path);
348         } else {
349             return false;
350         }
351     }
352 
hashCode()353     @Override public int hashCode ()
354     {
355         return _path.hashCode();
356     }
357 
toString()358     @Override public String toString ()
359     {
360         return _path;
361     }
362 
363     /** Helper function to simplify the process of reporting progress. */
updateProgress(ProgressObserver obs, long pos, long total)364     protected static void updateProgress (ProgressObserver obs, long pos, long total)
365     {
366         if (obs != null) {
367             obs.progress((int)(100 * pos / total));
368         }
369     }
370 
isJar(String path)371     protected static boolean isJar (String path)
372     {
373         return path.endsWith(".jar") || path.endsWith(".jar_new");
374     }
375 
isPacked200Jar(String path)376     protected static boolean isPacked200Jar (String path)
377     {
378         return path.endsWith(".jar.pack") || path.endsWith(".jar.pack_new") ||
379             path.endsWith(".jar.pack.gz")|| path.endsWith(".jar.pack.gz_new");
380     }
381 
382     protected String _path;
383     protected URL _remote;
384     protected File _local, _localNew, _marker, _unpacked;
385     protected EnumSet<Attr> _attrs;
386     protected boolean _isJar, _isPacked200Jar;
387 
388     /** Used to sort the entries in a jar file. */
389     protected static final Comparator<JarEntry> ENTRY_COMP = new Comparator<JarEntry>() {
390         @Override public int compare (JarEntry e1, JarEntry e2) {
391             return e1.getName().compareTo(e2.getName());
392         }
393     };
394 
395     protected static final int DIGEST_BUFFER_SIZE = 5 * 1025;
396 }
397