1 /**
2  * This module provides class for loading and validating icon theme caches.
3  *
4  * Icon theme cache may be stored in icon-theme.cache files located in icon theme directory along with index.theme file.
5  * These files are usually generated by $(LINK2 https://developer.gnome.org/gtk3/stable/gtk-update-icon-cache.html, gtk-update-icon-cache).
6  * Icon theme cache can be used for faster and cheeper lookup of icons since it contains information about which icons exist in which sub directories.
7  *
8  * Authors:
9  *  $(LINK2 https://github.com/FreeSlave, Roman Chistokhodov)
10  * Copyright:
11  *  Roman Chistokhodov, 2016
12  * License:
13  *  $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
14  * See_Also:
15  *  $(LINK2 https://github.com/GNOME/gtk/blob/master/gtk/gtkiconcachevalidator.c, GTK icon cache validator source code)
16  * Note:
17  *  I could not find any specification on icon theme cache, so I merely use gtk source code as reference to reimplement parsing of icon-theme.cache files.
18  */
19 
20 
21 module icontheme.cache;
22 
23 package {
24     import std.algorithm;
25     import std.bitmanip;
26     import std.exception;
27     import std.file;
28     import std.mmfile;
29     import std.path;
30     import std.range;
31     import std.system;
32     import std.typecons;
33     import std.traits;
34 
35     import std.datetime : SysTime;
36 
37     static if( __VERSION__ < 2066 ) enum nogc = 1;
38 }
39 
40 private @nogc @trusted void swapByteOrder(T)(ref T t) nothrow pure  {
41 
42     static if( __VERSION__ < 2067 ) { //swapEndian was not @nogc
43         ubyte[] bytes = (cast(ubyte*)&t)[0..T.sizeof];
44         for (size_t i=0; i<bytes.length/2; ++i) {
45             ubyte tmp = bytes[i];
46             bytes[i] = bytes[T.sizeof-1-i];
47             bytes[T.sizeof-1-i] = tmp;
48         }
49     } else {
50         t = swapEndian(t);
51     }
52 }
53 
54 /**
55  * Error occured while parsing icon theme cache.
56  */
57 class IconThemeCacheException : Exception
58 {
59     this(string msg, string context = null, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
60         super(msg, file, line, next);
61         _context = context;
62     }
63 
64     /**
65      * Context where error occured. Usually it's the name of value that could not be read or is invalid.
66      */
67     @nogc @safe string context() const nothrow {
68         return _context;
69     }
70 private:
71     string _context;
72 }
73 
74 /**
75  * Class representation of icon-theme.cache file contained icon theme cache.
76  */
77 final class IconThemeCache
78 {
79     /**
80      * Read icon theme cache from memory mapped file and validate it.
81      * Throws:
82      *  $(B FileException) if could mmap file.
83      *  $(D IconThemeCacheException) if icon theme file is invalid.
84      */
85     @trusted this(string fileName) {
86         _mmapped = new MmFile(fileName);
87         this(_mmapped[], fileName, 0);
88     }
89 
90     /**
91      * Read icon theme cache from data and validate it.
92      * Throws:
93      *  $(D IconThemeCacheException) if icon theme file is invalid.
94      */
95     @safe this(immutable(void)[] data, string fileName) {
96         this(data, fileName, 0);
97     }
98 
99     private @trusted this(const(void)[] data, string fileName, int /* To avoid ambiguity */) {
100         _data = data;
101         _fileName = fileName;
102 
103         _header.majorVersion = readValue!ushort(0, "major version");
104         if (_header.majorVersion != 1) {
105             throw new IconThemeCacheException("Unsupported version or the file is not icon theme cache", "major version");
106         }
107 
108         _header.minorVersion = readValue!ushort(2, "minor version");
109         if (_header.minorVersion != 0) {
110             throw new IconThemeCacheException("Unsupported version or the file is not icon theme cache", "minor version");
111         }
112 
113         _header.hashOffset = readValue!uint(4, "hash offset");
114         _header.directoryListOffset = readValue!uint(8, "directory list offset");
115 
116         _bucketCount = iconOffsets().length;
117         _directoryCount = directories().length;
118 
119         //Validate other data
120         foreach(dir; directories()) {
121             //pass
122         }
123 
124         foreach(info; iconInfos) {
125             foreach(im; imageInfos(info.imageListOffset)) {
126 
127             }
128         }
129     }
130 
131     /**
132      * Sub directories of icon theme listed in cache.
133      * Returns: Range of directory const(char)[] names listed in cache.
134      */
135     @trusted auto directories() const {
136         auto directoryCount = readValue!uint(_header.directoryListOffset, "directory count");
137 
138         return iota(directoryCount)
139                 .map!(i => _header.directoryListOffset + uint.sizeof + i*uint.sizeof)
140                 .map!(offset => readValue!uint(offset, "directory offset"))
141                 .map!(offset => readString(offset, "directory name"));
142     }
143 
144     /**
145      * Test if icon is listed in cache.
146      */
147     @trusted bool containsIcon(const(char)[] iconName) const
148     {
149         IconInfo info;
150         return findIconInfo(info, iconName);
151     }
152 
153     /**
154      * Test if icon is listed in cache and belongs to specified subdirectory.
155      */
156     @trusted bool containsIcon(const(char)[] iconName, const(char)[] directory) const {
157         auto index = iconDirectories(iconName).countUntil(directory);
158         return index != -1;
159     }
160 
161     /**
162      * Find all sub directories the icon belongs to according to cache.
163      * Returns: Range of directory const(char)[] names the icon belongs to.
164      */
165     @trusted auto iconDirectories(const(char)[] iconName) const
166     {
167         IconInfo info;
168         auto dirs = directories();
169         bool found = findIconInfo(info, iconName);
170         return imageInfos(info.imageListOffset, found).map!(delegate(ImageInfo im) {
171             if (im.index < dirs.length) {
172                 return dirs[im.index];
173             } else {
174                 throw new IconThemeCacheException("Invalid directory index", "directory index");
175             }
176         });
177     }
178 
179     /**
180      * Path of cache file.
181      */
182     @nogc @safe fileName() const nothrow {
183         return _fileName;
184     }
185 
186     /**
187      * Test if icon theme file is outdated, i.e. modification time of cache file is older than modification time of icon theme directory.
188      * Throws:
189      *  $(B FileException) on error accessing the file.
190      */
191     @trusted bool isOutdated() const {
192         return isOutdated(fileName());
193     }
194 
195     /**
196      * Test if icon theme file is outdated, i.e. modification time of cache file is older than modification time of icon theme directory.
197      *
198      * This function is static and therefore can be used before actual reading and validating cache file.
199      * Throws:
200      *  $(B FileException) on error accessing the file.
201      */
202     static @trusted bool isOutdated(string fileName)
203     {
204         if (fileName.empty) {
205             throw new FileException("File name is empty, can't check if the cache is outdated");
206         }
207 
208         SysTime pathAccessTime, pathModificationTime;
209         SysTime fileAccessTime, fileModificationTime;
210 
211         getTimes(fileName, fileAccessTime, fileModificationTime);
212         getTimes(fileName.dirName, pathAccessTime, pathModificationTime);
213 
214         return fileModificationTime < pathModificationTime;
215     }
216 
217     unittest
218     {
219         assertThrown!FileException(isOutdated(""));
220     }
221 
222     /**
223      * All icon names listed in cache.
224      * Returns: Range of icon const(char)[] names listed in cache.
225      */
226     @trusted auto icons() const {
227         return iconInfos().map!(info => info.name);
228     }
229 
230 private:
231     alias Tuple!(uint, "chainOffset", const(char)[], "name", uint, "imageListOffset") IconInfo;
232     alias Tuple!(ushort, "index", ushort, "flags", uint, "dataOffset") ImageInfo;
233 
234     static struct IconThemeCacheHeader
235     {
236         ushort majorVersion;
237         ushort minorVersion;
238         uint hashOffset;
239         uint directoryListOffset;
240     }
241 
242     @trusted auto iconInfos() const {
243         import std.typecons;
244 
245         static struct IconInfos
246         {
247             this(const(IconThemeCache) cache)
248             {
249                 _cache = rebindable(cache);
250                 _iconInfos = _cache.bucketIconInfos();
251                 _chainOffset = _iconInfos.front().chainOffset;
252                 _fromChain = false;
253             }
254 
255             bool empty()
256             {
257                 return _iconInfos.empty;
258             }
259 
260             auto front()
261             {
262                 if (_fromChain) {
263                     auto info = _cache.iconInfo(_chainOffset);
264                     return info;
265                 } else {
266                     auto info = _iconInfos.front;
267                     return info;
268                 }
269             }
270 
271             void popFront()
272             {
273                 if (_fromChain) {
274                     auto info = _cache.iconInfo(_chainOffset);
275                     if (info.chainOffset != 0xffffffff) {
276                         _chainOffset = info.chainOffset;
277                     } else {
278                         _iconInfos.popFront();
279                         _fromChain = false;
280                     }
281                 } else {
282                     auto info = _iconInfos.front;
283                     if (info.chainOffset != 0xffffffff) {
284                         _chainOffset = info.chainOffset;
285                         _fromChain = true;
286                     } else {
287                         _iconInfos.popFront();
288                     }
289                 }
290             }
291 
292             auto save() const {
293                 return this;
294             }
295 
296             uint _chainOffset;
297             bool _fromChain;
298             typeof(_cache.bucketIconInfos()) _iconInfos;
299             Rebindable!(const(IconThemeCache)) _cache;
300         }
301 
302         return IconInfos(this);
303     }
304 
305     @nogc @trusted static uint iconNameHash(const(char)[] iconName) pure nothrow
306     {
307         if (iconName.length == 0) {
308             return 0;
309         }
310 
311         uint h = cast(uint)iconName[0];
312         if (h) {
313             for (size_t i = 1; i != iconName.length; i++) {
314                 h = (h << 5) - h + cast(uint)iconName[i];
315             }
316         }
317         return h;
318     }
319 
320     bool findIconInfo(out IconInfo info, const(char)[] iconName) const {
321         uint hash = iconNameHash(iconName) % _bucketCount;
322         uint chainOffset = readValue!uint(_header.hashOffset + uint.sizeof + uint.sizeof * hash, "chain offset");
323 
324         while(chainOffset != 0xffffffff) {
325             auto curInfo = iconInfo(chainOffset);
326             if (curInfo.name == iconName) {
327                 info = curInfo;
328                 return true;
329             }
330             chainOffset = curInfo.chainOffset;
331         }
332         return false;
333     }
334 
335     @trusted auto bucketIconInfos() const {
336         return iconOffsets().filter!(offset => offset != 0xffffffff).map!(offset => iconInfo(offset));
337     }
338 
339     @trusted auto iconOffsets() const {
340         auto bucketCount = readValue!uint(_header.hashOffset, "bucket count");
341 
342         return iota(bucketCount)
343                 .map!(i => _header.hashOffset + uint.sizeof + i*uint.sizeof)
344                 .map!(offset => readValue!uint(offset, "icon offset"));
345     }
346 
347     @trusted auto iconInfo(size_t iconOffset) const {
348         return IconInfo(
349             readValue!uint(iconOffset, "icon chain offset"),
350             readString(readValue!uint(iconOffset + uint.sizeof, "icon name offset"), "icon name"),
351             readValue!uint(iconOffset + uint.sizeof*2, "image list offset"));
352     }
353 
354     @trusted auto imageInfos(size_t imageListOffset, bool found = true) const {
355 
356         uint imageCount = found ? readValue!uint(imageListOffset, "image count") : 0;
357         return iota(imageCount)
358                 .map!(i => imageListOffset + uint.sizeof + i*(uint.sizeof + ushort.sizeof + ushort.sizeof))
359                 .map!(offset => ImageInfo(
360                             readValue!ushort(offset, "image index"),
361                             readValue!ushort(offset + ushort.sizeof, "image flags"),
362                             readValue!uint(offset + ushort.sizeof*2, "image data offset"))
363                      );
364     }
365 
366     @trusted T readValue(T)(size_t offset, string context = null) const if (isIntegral!T || isSomeChar!T)
367     {
368         if (_data.length >= offset + T.sizeof) {
369             T value = *(cast(const(T)*)_data[offset..(offset+T.sizeof)].ptr);
370             static if (endian == Endian.littleEndian) {
371                 swapByteOrder(value);
372             }
373             return value;
374         } else {
375             throw new IconThemeCacheException("Value is out of bounds", context);
376         }
377     }
378 
379     @trusted auto readString(size_t offset, string context = null) const {
380         if (offset > _data.length) {
381             throw new IconThemeCacheException("Beginning of string is out of bounds", context);
382         }
383 
384         auto str = cast(const(char[]))_data[offset.._data.length];
385 
386         size_t len = 0;
387         while (len<str.length && str[len] != '\0') {
388             ++len;
389         }
390         if (len == str.length) {
391             throw new IconThemeCacheException("String is not zero terminated", context);
392         }
393 
394         return str[0..len];
395     }
396 
397     IconThemeCacheHeader _header;
398     size_t _directoryCount;
399     size_t _bucketCount;
400 
401     MmFile _mmapped;
402     string _fileName;
403     const(void)[] _data;
404 }
405 
406 ///
407 version(iconthemeFileTest) unittest
408 {
409     string cachePath = "./test/Tango/icon-theme.cache";
410     assert(cachePath.exists);
411 
412     const(IconThemeCache) cache = new IconThemeCache(cachePath);
413     assert(cache.fileName == cachePath);
414     assert(cache.containsIcon("folder"));
415     assert(cache.containsIcon("folder", "24x24/places"));
416     assert(cache.containsIcon("edit-copy", "32x32/actions"));
417     assert(cache.iconDirectories("text-x-generic").canFind("32x32/mimetypes"));
418     assert(cache.directories().canFind("32x32/devices"));
419 
420     auto icons = cache.icons();
421     assert(icons.canFind("folder"));
422     assert(icons.canFind("text-x-generic"));
423 
424     try {
425         SysTime pathAccessTime, pathModificationTime;
426         SysTime fileAccessTime, fileModificationTime;
427 
428         getTimes(cachePath, fileAccessTime, fileModificationTime);
429         getTimes(cachePath.dirName, pathAccessTime, pathModificationTime);
430 
431         setTimes(cachePath, pathAccessTime, pathModificationTime);
432         assert(!IconThemeCache.isOutdated(cachePath));
433     }
434     catch(Exception e) {
435         // some environmental error, just ignore
436     }
437 
438     try {
439         auto fileData = assumeUnique(std.file.read(cachePath));
440         assertNotThrown(new IconThemeCache(fileData, cachePath));
441     } catch(FileException e) {
442 
443     }
444 
445     immutable(ubyte)[] data = [0,2,0,0];
446     IconThemeCacheException thrown = collectException!IconThemeCacheException(new IconThemeCache(data, cachePath));
447     assert(thrown !is null, "Invalid cache must throw");
448     assert(thrown.context == "major version");
449 
450     data = [0,1,0,1];
451     thrown = collectException!IconThemeCacheException(new IconThemeCache(data, cachePath));
452     assert(thrown !is null, "Invalid cache must throw");
453     assert(thrown.context == "minor version");
454 }