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 ensureDirExists(string dir) {
88         std.file.mkdirRecurse(dir);
89         return dir;
90     }
91 
92     import core.sys.posix.sys.types;
93     import core.sys.posix.sys.stat;
94     import core.sys.posix.unistd;
95     import core.sys.posix.fcntl;
96 
97     @trusted string topDir(string path)
98     in {
99         assert(path.isAbsolute);
100     }
101     out(result) {
102         if (result.length) {
103             assert(result.isAbsolute);
104         }
105     }
106     body {
107         auto current = path;
108         stat_t currentStat;
109         if (stat(current.toStringz, &currentStat) != 0) {
110             return null;
111         }
112         stat_t parentStat;
113         while(current != "/") {
114             string parent = current.dirName;
115             if (lstat(parent.toStringz, &parentStat) != 0) {
116                 return null;
117             }
118             if (currentStat.st_dev != parentStat.st_dev) {
119                 return current;
120             }
121             current = parent;
122         }
123         return current;
124     }
125 
126     void checkDiskTrashMode(mode_t mode, const bool checkStickyBit = true)
127     {
128         enforce(!S_ISLNK(mode), "Top trash directory is a symbolic link");
129         enforce(S_ISDIR(mode), "Top trash path is not a directory");
130         if (checkStickyBit) {
131             enforce((mode & S_ISVTX) != 0, "Top trash directory does not have sticky bit");
132         }
133     }
134 
135     unittest
136     {
137         assertThrown(checkDiskTrashMode(S_IFLNK|S_ISVTX));
138         assertThrown(checkDiskTrashMode(S_IFDIR));
139         assertNotThrown(checkDiskTrashMode(S_IFDIR|S_ISVTX));
140         assertNotThrown(checkDiskTrashMode(S_IFDIR, false));
141     }
142 
143     @trusted string checkDiskTrash(string topdir, const bool checkStickyBit = true)
144     in {
145         assert(topdir.length);
146     }
147     body {
148         string trashDir = buildPath(topdir, ".Trash");
149         stat_t trashStat;
150         enforce(lstat(trashDir.toStringz, &trashStat) == 0, "Top trash directory does not exist");
151         checkDiskTrashMode(trashStat.st_mode, checkStickyBit);
152         return trashDir;
153     }
154 
155     string userTrashSubdir(string trashDir, uid_t uid) {
156         return buildPath(trashDir, format("%s", uid));
157     }
158 
159     unittest
160     {
161         assert(userTrashSubdir("/.Trash", 600) == buildPath("/.Trash", "600"));
162     }
163 
164     @trusted string ensureUserTrashSubdir(string trashDir)
165     {
166         return userTrashSubdir(trashDir, getuid()).ensureDirExists();
167     }
168 
169     string userTrashDir(string topdir, uid_t uid) {
170         return buildPath(topdir, format(".Trash-%s", uid));
171     }
172 
173     unittest
174     {
175         assert(userTrashDir("/topdir", 700) == buildPath("/topdir", ".Trash-700"));
176     }
177 
178     @trusted string ensureUserTrashDir(string topdir)
179     {
180         return userTrashDir(topdir, getuid()).ensureDirExists();
181     }
182 }
183 
184 version(OSX)
185 {
186 private:
187     import core.sys.posix.dlfcn;
188 
189     struct FSRef {
190         char[80] hidden;
191     };
192 
193     alias ubyte Boolean;
194     alias int OSStatus;
195     alias uint OptionBits;
196 
197     extern(C) @nogc @system OSStatus _dummy_FSPathMakeRefWithOptions(const(char)* path, OptionBits, FSRef*, Boolean*) nothrow {return 0;}
198     extern(C) @nogc @system OSStatus _dummy_FSMoveObjectToTrashSync(const(FSRef)*, FSRef*, OptionBits) nothrow {return 0;}
199 }
200 
201 /**
202  * Move file or directory to trash can.
203  * Params:
204  *  path = Path of item to remove. Must be absolute.
205  *  options = Control behavior of trashing on freedesktop environments.
206  * Throws:
207  *  Exception when given path is not absolute or does not exist or some error occured during operation.
208  */
209 @trusted void moveToTrash(string path, TrashOptions options = TrashOptions.all)
210 {
211     if (!path.isAbsolute) {
212         throw new Exception("Path must be absolute");
213     }
214     if (!path.exists) {
215         throw new Exception("Path does not exist");
216     }
217 
218     version(Windows) {
219         import core.sys.windows.shellapi;
220         import core.sys.windows.winbase;
221         import core.stdc.wchar_;
222         import std.windows.syserror;
223         import std.utf;
224 
225         SHFILEOPSTRUCTW fileOp;
226         fileOp.wFunc = FO_DELETE;
227         fileOp.fFlags = FOF_SILENT | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_NOCONFIRMMKDIR | FOF_ALLOWUNDO;
228         auto wFileName = (path ~ "\0\0").toUTF16();
229         fileOp.pFrom = wFileName.ptr;
230         int r = SHFileOperation(&fileOp);
231         if (r != 0) {
232             import std.format;
233             throw new Exception(format("SHFileOperation failed with error code %d", r));
234         }
235     } else version(OSX) {
236         void* handle = dlopen("CoreServices.framework/Versions/A/CoreServices", RTLD_NOW | RTLD_LOCAL);
237         if (handle !is null) {
238             scope(exit) dlclose(handle);
239 
240             auto ptrFSPathMakeRefWithOptions = cast(typeof(&_dummy_FSPathMakeRefWithOptions))dlsym(handle, "FSPathMakeRefWithOptions");
241             if (ptrFSPathMakeRefWithOptions is null) {
242                 throw new Exception(fromStringz(dlerror()).idup);
243             }
244 
245             auto ptrFSMoveObjectToTrashSync = cast(typeof(&_dummy_FSMoveObjectToTrashSync))dlsym(handle, "FSMoveObjectToTrashSync");
246             if (ptrFSMoveObjectToTrashSync is null) {
247                 throw new Exception(fromStringz(dlerror()).idup);
248             }
249 
250             FSRef source;
251             enforce(ptrFSPathMakeRefWithOptions(toStringz(path), 1, &source, null) == 0, "Could not make FSRef from path");
252             FSRef target;
253             enforce(ptrFSMoveObjectToTrashSync(&source, &target, 0) == 0, "Could not move path to trash");
254         } else {
255             throw new Exception(fromStringz(dlerror()).idup);
256         }
257     } else {
258         static if (isFreedesktop) {
259             import xdgpaths;
260 
261             string dataPath = xdgDataHome(null, true);
262             if (!dataPath.length) {
263                 throw new Exception("Could not access data folder");
264             }
265             dataPath = dataPath.absolutePath;
266 
267             string trashBasePath;
268             bool usingTopdir = false;
269             string fileTopDir;
270 
271             if ((options & TrashOptions.useTopDirs) != 0) {
272                 string dataTopDir = topDir(dataPath);
273                 fileTopDir = topDir(path);
274 
275                 enforce(fileTopDir.length, "Could not get topdir of file being trashed");
276                 enforce(dataTopDir.length, "Could not get topdir of home data directory");
277 
278                 if (dataTopDir != fileTopDir) {
279                     try {
280                         string diskTrash = checkDiskTrash(fileTopDir, (options & TrashOptions.checkStickyBit) != 0);
281                         trashBasePath = ensureUserTrashSubdir(diskTrash);
282                         usingTopdir = true;
283                     } catch(Exception e) {
284                         try {
285                             if ((options & TrashOptions.fallbackToUserDir) != 0) {
286                                 trashBasePath = ensureUserTrashDir(fileTopDir);
287                                 usingTopdir = true;
288                             } else {
289                                 throw e;
290                             }
291                         } catch(Exception e) {
292                             if (!(options & TrashOptions.fallbackToHomeDir)) {
293                                 throw e;
294                             }
295                         }
296                     }
297                 }
298             }
299 
300             if (trashBasePath is null) {
301                 trashBasePath = ensureDirExists(buildPath(dataPath, "Trash"));
302             }
303             enforce(trashBasePath.length, "Could not access base trash folder");
304 
305             string trashInfoDir = ensureDirExists(buildPath(trashBasePath, "info"));
306             string trashFilePathsDir = ensureDirExists(buildPath(trashBasePath, "files"));
307 
308             string trashInfoPath = buildPath(trashInfoDir, path.baseName ~ ".trashinfo");
309             string trashFilePath = buildPath(trashFilePathsDir, path.baseName);
310 
311             import std.datetime;
312             import std.conv : octal;
313             import std.uri;
314             import core.stdc.errno;
315 
316             auto currentTime = Clock.currTime;
317             currentTime.fracSecs = Duration.zero;
318             string timeString = currentTime.toISOExtString();
319             string contents = format("[Trash Info]\nPath=%s\nDeletionDate=%s\n", (usingTopdir ? path.relativePath(fileTopDir) : path).encode(), timeString);
320 
321             const mode = O_CREAT | O_WRONLY | O_EXCL;
322             int fd;
323             uint number = 1;
324             while(trashFilePath.exists || ((fd = .open(toStringz(trashInfoPath), mode, octal!666)) == -1 && errno == EEXIST)) {
325                 string baseName = numberedBaseName(path, number);
326                 trashFilePath = buildPath(trashFilePathsDir, baseName);
327                 trashInfoPath = buildPath(trashInfoDir, baseName ~ ".trashinfo");
328                 number++;
329             }
330             errnoEnforce(fd != -1);
331             scope(exit) .close(fd);
332             errnoEnforce(write(fd, contents.ptr, contents.length) == contents.length);
333 
334             path.rename(trashFilePath);
335         } else {
336             static assert("Unsupported platform");
337         }
338     }
339 }
340 
341 unittest
342 {
343     assertThrown(moveToTrash("notabsolute"));
344 }