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