1 /**
2  * Moving files and directories to trash can.
3  * Copyright:
4  *  Roman Chistokhodov, 2016
5  * License: 
6  *  $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
7  */
8 
9 module trashcan;
10 
11 import std.path;
12 import std.string;
13 import std.file;
14 import std.exception;
15 
16 import isfreedesktop;
17 
18 /**
19  * Flags to rule the trashing behavior.
20  * 
21  * $(BLUE Valid only for freedesktop environments).
22  * 
23  * See_Also: $(LINK2 https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html, Trash specification).
24  */
25 enum TrashOptions : int
26 {
27     /**
28      * No options. Just move file to user home trash directory 
29      * not paying attention to partition where file resides.
30      */
31     none = 0,
32     /**
33      * If file that needs to be deleted resides on non-home partition 
34      * and top trash directory ($topdir/.Trash/$uid) failed some check, 
35      * fallback to user top trash directory ($topdir/.Trash-$uid).
36      * 
37      * Makes sense only in conjunction with $(D useTopDirs).
38      */
39     fallbackToUserDir = 1,
40     /**
41      * If file that needs to be deleted resides on non-home partition 
42      * and checks for top trash directories failed,
43      * fallback to home trash directory.
44      * 
45      * Makes sense only in conjunction with $(D useTopDirs).
46      */
47     fallbackToHomeDir = 2,
48     
49     /**
50      * Whether to use top trash directories at all.
51      * 
52      * If no $(D fallbackToUserDir) nor $(D fallbackToHomeDir) flags are set, 
53      * and file that needs to be deleted resides on non-home partition, 
54      * and top trash directory ($topdir/.Trash/$uid) failed some check, 
55      * exception will be thrown. This can be used to report errors to administrator or user.
56      */
57     useTopDirs = 4,
58     
59     /**
60      * Whether to check presence of 'sticky bit' on $topdir/.Trash directory.
61      * 
62      * Makes sense only in conjunction with $(D useTopDirs).
63      */
64     checkStickyBit = 8,
65     
66     /**
67      * All flags set.
68      */
69     all = (TrashOptions.fallbackToUserDir | TrashOptions.fallbackToHomeDir | TrashOptions.checkStickyBit | TrashOptions.useTopDirs)
70 }
71 
72 static if (isFreedesktop)
73 {
74 private:
75     import std.format : format;
76     
77     @trusted string numberedBaseName(string path, uint number) {
78         return format("%s %s%s", path.baseName.stripExtension, number, path.extension);
79     }
80     
81     unittest
82     {
83         assert(numberedBaseName("/root/file.ext", 1) == "file 1.ext");
84         assert(numberedBaseName("/root/file", 2) == "file 2");
85     }
86     
87     @trusted string escapeValue(string value) pure {
88         return value.replace("\\", `\\`).replace("\n", `\n`).replace("\r", `\r`).replace("\t", `\t`);
89     }
90     
91     unittest 
92     {
93         assert("a\\next\nline\top".escapeValue() == `a\\next\nline\top`);
94     }
95     
96     @trusted string ensureDirExists(string dir) {
97         std.file.mkdirRecurse(dir);
98         return dir;
99     }
100     
101     import core.sys.posix.sys.types;
102     import core.sys.posix.sys.stat;
103     import core.sys.posix.unistd;
104     import core.sys.posix.fcntl;
105     
106     @trusted string topDir(string path)
107     in {
108         assert(path.isAbsolute);
109     }
110     out(result) {
111         if (result.length) {
112             assert(result.isAbsolute);
113         }
114     }
115     body {
116         auto current = path;
117         stat_t currentStat;
118         if (stat(current.toStringz, &currentStat) != 0) {
119             return null;
120         }
121         stat_t parentStat;
122         while(current != "/") {
123             string parent = current.dirName;
124             if (lstat(parent.toStringz, &parentStat) != 0) {
125                 return null;
126             }
127             if (currentStat.st_dev != parentStat.st_dev) {
128                 return current;
129             }
130             current = parent;
131         }
132         return current;
133     }
134     
135     void checkDiskTrashMode(mode_t mode, const bool checkStickyBit = true)
136     {
137         enforce(!S_ISLNK(mode), "Top trash directory is a symbolic link");
138         enforce(S_ISDIR(mode), "Top trash path is not a directory");
139         if (checkStickyBit) {
140             enforce((mode & S_ISVTX) != 0, "Top trash directory does not have sticky bit");
141         }
142     }
143     
144     unittest
145     {
146         assertThrown(checkDiskTrashMode(S_IFLNK|S_ISVTX));
147         assertThrown(checkDiskTrashMode(S_IFDIR));
148         assertNotThrown(checkDiskTrashMode(S_IFDIR|S_ISVTX));
149         assertNotThrown(checkDiskTrashMode(S_IFDIR, false));
150     }
151     
152     @trusted string checkDiskTrash(string topdir, const bool checkStickyBit = true)
153     in {
154         assert(topdir.length);
155     }
156     body {
157         string trashDir = buildPath(topdir, ".Trash");
158         stat_t trashStat;
159         enforce(lstat(trashDir.toStringz, &trashStat) == 0, "Top trash directory does not exist");
160         checkDiskTrashMode(trashStat.st_mode);
161         return trashDir;
162     }
163     
164     string userTrashSubdir(string trashDir, uid_t uid) {
165         return buildPath(trashDir, format("%s", uid));
166     }
167     
168     unittest
169     {
170         assert(userTrashSubdir("/.Trash", 600) == buildPath("/.Trash", "600"));
171     }
172     
173     @trusted string ensureUserTrashSubdir(string trashDir)
174     {
175         return userTrashSubdir(trashDir, getuid()).ensureDirExists();
176     }
177     
178     string userTrashDir(string topdir, uid_t uid) {
179         return buildPath(topdir, format(".Trash-%s", uid));
180     }
181     
182     unittest
183     {
184         assert(userTrashDir("/topdir", 700) == buildPath("/topdir", ".Trash-700"));
185     }
186     
187     @trusted string ensureUserTrashDir(string topdir)
188     {
189         return userTrashDir(topdir, getuid()).ensureDirExists();
190     }
191 }
192 
193 version(OSX)
194 {
195 private:
196     import core.sys.posix.dlfcn;
197     
198     struct FSRef {
199         char[80] hidden;
200     };
201     
202     alias ubyte Boolean;
203     alias int OSStatus;
204     alias uint OptionBits;
205     
206     extern(C) @nogc @system OSStatus _dummy_FSPathMakeRefWithOptions(const(char)* path, OptionBits, FSRef*, Boolean*) nothrow {return 0;}
207     extern(C) @nogc @system OSStatus _dummy_FSMoveObjectToTrashSync(const(FSRef)*, FSRef*, OptionBits) nothrow {return 0;}
208 }
209 
210 /**
211  * Move file or directory to trash can. 
212  * Params:
213  *  path = Path of item to remove. Must be absolute.
214  *  options = Control behavior of trashing on freedesktop environments.
215  * Throws:
216  *  Exception when given path is not absolute or does not exist or some error occured during operation.
217  */
218 @trusted void moveToTrash(string path, TrashOptions options = TrashOptions.all)
219 {
220     if (!path.isAbsolute) {
221         throw new Exception("Path must be absolute");
222     }
223     if (!path.exists) {
224         throw new Exception("Path does not exist");
225     }
226     
227     version(Windows) {
228         import core.sys.windows.shellapi;
229         import core.sys.windows.winbase;
230         import core.stdc.wchar_;
231         import std.windows.syserror;
232         import std.utf;
233         
234         SHFILEOPSTRUCTW fileOp;
235         fileOp.wFunc = FO_DELETE;
236         fileOp.fFlags = FOF_SILENT | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_NOCONFIRMMKDIR | FOF_ALLOWUNDO;
237         auto wFileName = (path ~ "\0\0").toUTF16();
238         fileOp.pFrom = wFileName.ptr;
239         int r = SHFileOperation(&fileOp);
240         if (r != 0) {
241             wchar[1024] msg;
242             auto len = FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM, null, r, 0, msg.ptr, msg.length - 1, null);
243             
244             if (len) {
245                 throw new Exception(msg[0..len].toUTF8().stripRight);
246             } else {
247                 throw new Exception("File deletion error");
248             }
249         }
250     } else version(OSX) {
251         void* handle = dlopen("CoreServices.framework/Versions/A/CoreServices", RTLD_NOW | RTLD_LOCAL);
252         if (handle !is null) {
253             scope(exit) dlclose(handle);
254             
255             auto ptrFSPathMakeRefWithOptions = cast(typeof(&_dummy_FSPathMakeRefWithOptions))dlsym(handle, "FSPathMakeRefWithOptions");
256             if (ptrFSPathMakeRefWithOptions is null) {
257                 throw new Exception(fromStringz(dlerror()).idup);
258             }
259             
260             auto ptrFSMoveObjectToTrashSync = cast(typeof(&_dummy_FSMoveObjectToTrashSync))dlsym(handle, "FSMoveObjectToTrashSync");
261             if (ptrFSMoveObjectToTrashSync is null) {
262                 throw new Exception(fromStringz(dlerror()).idup);
263             }
264             
265             FSRef source;
266             enforce(ptrFSPathMakeRefWithOptions(toStringz(path), 1, &source, null) == 0, "Could not make FSRef from path");
267             FSRef target;
268             enforce(ptrFSMoveObjectToTrashSync(&source, &target, 0) == 0, "Could not move path to trash");
269         } else {
270             throw new Exception(fromStringz(dlerror()).idup);
271         }
272     } else {
273         static if (isFreedesktop) {
274             import xdgpaths;
275             
276             string dataPath = xdgDataHome(null, true);
277             if (!dataPath.length) {
278                 throw new Exception("Could not access data folder");
279             }
280             dataPath = dataPath.absolutePath;
281             
282             string trashBasePath;
283             
284             if ((options & TrashOptions.useTopDirs) != 0) {
285                 string dataTopDir = topDir(dataPath);
286                 string fileTopDir = topDir(path);
287                 
288                 enforce(fileTopDir.length, "Could not get topdir of file being trashed");
289                 enforce(dataTopDir.length, "Could not get topdir of home data directory");
290                 
291                 if (dataTopDir != fileTopDir) {
292                     try {
293                         string diskTrash = checkDiskTrash(fileTopDir, (options & TrashOptions.checkStickyBit) != 0);
294                         trashBasePath = ensureUserTrashSubdir(diskTrash);
295                     } catch(Exception e) {
296                         try {
297                             if ((options & TrashOptions.fallbackToUserDir) != 0) {
298                                 trashBasePath = ensureUserTrashDir(fileTopDir);
299                             } else {
300                                 throw e;
301                             }
302                         } catch(Exception e) {
303                             if (!(options & TrashOptions.fallbackToHomeDir)) {
304                                 throw e;
305                             }
306                         }
307                     }
308                 }
309             }
310             
311             if (trashBasePath is null) {
312                 trashBasePath = ensureDirExists(buildPath(dataPath, "Trash"));
313             }
314             enforce(trashBasePath.length, "Could not access base trash folder");
315             
316             string trashInfoDir = ensureDirExists(buildPath(trashBasePath, "info"));
317             string trashFilePathsDir = ensureDirExists(buildPath(trashBasePath, "files"));
318             
319             string trashInfoPath = buildPath(trashInfoDir, path.baseName ~ ".trashinfo");
320             string trashFilePath = buildPath(trashFilePathsDir, path.baseName);
321             uint number = 1;
322             
323             while(trashInfoPath.exists || trashFilePath.exists) {
324                 string baseName = numberedBaseName(path, number);
325                 trashInfoPath = buildPath(trashInfoDir, baseName ~ ".trashinfo");
326                 trashFilePath = buildPath(trashFilePathsDir, baseName);
327                 number++;
328             }
329             
330             import std.datetime;
331             import std.conv : octal;
332             
333             auto currentTime = Clock.currTime;
334             currentTime.fracSecs = Duration.zero;
335             string timeString = currentTime.toISOExtString();
336             string contents = format("[Trash Info]\nPath=%s\nDeletionDate=%s\n", path.escapeValue(), timeString);
337             
338             const mode = O_CREAT | O_WRONLY | O_EXCL;
339             auto fd = .open(toStringz(trashInfoPath), mode, octal!666);
340             errnoEnforce(fd != -1);
341             scope(exit) .close(fd);
342             errnoEnforce(write(fd, contents.ptr, contents.length) == contents.length);
343             
344             path.rename(trashFilePath);
345         } else {
346             static assert("Unsupported platform");
347         }
348     }
349 }
350 
351 unittest
352 {
353     assertThrown(moveToTrash("notabsolute"));
354 }