Set attributes when sending files
[fuse-aexplorer.git] / AEpy / AEFuse.py
1 import binascii
2 from errno import ENOENT, EINVAL, EIO, EROFS
3 from fusepy import FUSE, FuseOSError, Operations
4 from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IROTH, S_IXGRP, S_IXOTH, S_IFDIR, S_IFREG
5 import struct
6 import sys
7 from time import time
8
9 from AELink import AELinkSerial
10 from AESession import AESession
11 import AEMultipart
12 import AECmds
13 from FuseCache import FuseCache
14 import FusePatches
15
16
17 amiga_charset = 'iso8859-1'
18
19
20
21
22
23
24
25
26 class AEFuse(Operations):
27     def __init__(self, session):
28         self.session = session
29
30         self.cache = FuseCache()
31
32         self.readpath = None
33         self.readbuf = None
34         self.readmax = None # Total size of the file being read
35
36         self.writepath = None
37         self.writebuf = None
38         self.writemtime = None
39         self.writeattrs = None
40
41
42     def _mode_to_arwed(self, mode):
43         amigaattrs = 0
44
45         if not mode & S_IRUSR: amigaattrs |= 0x08
46         if not mode & S_IWUSR: amigaattrs |= 0x04
47         if not mode & S_IXUSR: amigaattrs |= 0x02
48
49         return amigaattrs
50
51
52     def getattr(self, path, fh=None):
53         print('AEFuse.getattr: ' + path)
54
55         dirpath = path[0:path.rfind('/')]  # Without the trailing slash
56         name = path[path.rfind('/') + 1:]  # Without a leading slash
57
58         st = self.cache.getattr(path)
59
60         if not st:
61             # If we have a cache miss, see if we've cached its parent
62             # directory.
63             #
64             # If not, then try to read the parent directory.
65             #
66             # Note that accessing a deep path will cause Linux to stat its
67             # parents recursively, which in turn will cause self.getattr()
68             # to call self.readdir() on them, filling all cache levels in
69             # between.
70             #
71             # https://sourceforge.net/p/fuse/mailman/message/19717106/
72             #
73             if self.cache.getkids(dirpath) == None:
74                 # We haven't read the directory yet
75                 if name in self.readdir(dirpath, fh):
76                     # If the dirlist succeeded, try again.
77                     #
78                     # Note that we're never asking the host for a deep path
79                     # that doesn't exist, since Linux will stat all parents,
80                     # thus listing directories at every level until the first
81                     # one that doesn't exist (see above).
82                     #
83                     # As a bonus, this automatically updates the cache if the
84                     # path now exists.
85                     return self.getattr(path, fh)
86
87             # Okay, the path doesn't exist.
88             # That means our file can't exist.
89             print('getaddr ENOENT: ' + path)
90             raise FuseOSError(ENOENT)
91
92         return st
93
94
95     def readdir(self, path, fh):
96         print("AEFuse.readdir: " + path)
97
98         self._cancel_read()
99         self._flush_write()
100
101         # Request directory listing
102         kids = AECmds.dir_list(self.session, path[1:].encode(amiga_charset))
103
104         # Update cache
105         self.cache.setkids(path, kids)
106
107         #return [(name, self.cache[name], 0) for name in self.cache.keys()]
108         return kids.keys()
109
110
111     def read(self, path, size, offset, fh):
112         print("AEFuse.read: " + path + ' -- ' + str(size) + ' @ ' + str(offset))
113
114         # Avoid inconsistency.
115         # If we don't do this, then reading the file while it's open
116         # returns something different from what is in the write buffer.
117         self._flush_write()
118
119         if self.readpath != path:
120             if self.readpath != None:
121                 self._cancel_read()
122
123             self.readpath = path
124             self.readbuf = b''
125
126             # Request file
127             self.readmax = AECmds.file_read_start(self.session, path[1:].encode(amiga_charset))
128
129             # TODO: Check for None returned, meaning that the path doesn't exist.
130
131         if (offset + size > len(self.readbuf)) \
132         and (offset < self.readmax) \
133         and (len(self.readbuf) < self.readmax):
134             # Get further file contents if we need it.
135             while len(self.readbuf) < min(offset + size, self.readmax):
136                 (blockOffset, blockData) = AECmds.file_read_next(self.session)
137
138                 if blockData == None:
139                     # Since we only enter this loop if there should be data
140                     # left to read, a None returned means an unexpected EOF
141                     # or more probably, the connection timed out.
142                     #
143                     # We operate under the illusion that we can keep the last
144                     # accessed file open wherever we were at, however after a
145                     # few seconds the host will close the file without us
146                     # knowing. In case that happens, let's reload the file.
147                     print('Unexpected error while reading next file block.')
148                     print('Re-requesting file.')
149
150                     # If we cancel the transfer ourselves, then the reply to
151                     # sendClose() will be 0x00 0x00 0x00 0x1c 0x00.
152                     #
153                     # Strange: If we cancel in the same situation, but without
154                     # having received the type 1 cancel message from the host,
155                     # the reply to sendClose() is normal zeroes.
156                     self._cancel_read()
157                     return self.read(path, size, offset, fh)
158
159                 assert blockOffset == len(self.readbuf)
160                 self.readbuf += blockData
161
162             # When done transferring a whole file, the Amiga side insists on
163             # sending its end-of-file type 4 message. So let's request it.
164             if len(self.readbuf) == self.readmax:
165                 (blockOffset, blockData) = AECmds.file_read_next(self.session)
166                 assert blockData == None
167
168         print("read: " + path + ' -- finished with len(self.readbuf) == ' + str(len(self.readbuf)))
169         return self.readbuf[offset:offset + size]
170
171
172     def _cancel_read(self):
173         # Cancel current read operation
174         if self.readpath != None:
175             if (len(self.readbuf) < self.readmax):
176                 print('_cancel_read: Cancelling.')
177                 AECmds.cancel(self.session)
178
179             AECmds.close(self.session)
180
181             self.readbuf = None
182             self.readpath = None
183
184
185     def open(self, path, flags):
186         print("AEFuse.open: " + path)
187
188         # Dummy function to allow file access.
189
190         return 0
191
192
193     def _prep_write(self, path, trunclen):
194         # We can't write in the virtual top level directory
195         if len(path) < 4:
196             raise FuseOSError(ENOENT)
197
198         self._cancel_read()
199
200         if self.writepath != path:
201             self._flush_write()
202
203             # If the file already exists, read its previous contents.
204             # Check cache, since FUSE/VFS will have filled it.
205             st = self.cache.getattr(path)
206             if st != None and st['st_size'] > 0:
207                 if trunclen != None:
208                     if trunclen > 0:
209                         self.writebuf = self.read(path, trunclen, 0, None)
210                 else:
211                     self.writebuf = self.read(path, st['st_size'], 0, None)
212                 self._cancel_read()
213
214             self.writepath = path
215             self.writeattrs = 0
216             if self.writebuf == None:
217                 self.writebuf = b''
218
219
220     def create(self, path, mode, fi=None):
221         print("AEFuse.create: " + path)
222
223         # We can't write in the virtual top level directory
224         if len(path) < 4:
225             raise FuseOSError(ENOENT)
226
227         # We can't create a file over an existing one.
228         # By now, the system will have primed our cache, so we won't ask
229         # the big fuseop self.getattr(). That wouldn't work anyway, since
230         # it raises a FuseOSError rather than returning None.
231         assert self.cache.getattr(path) == None
232         if self.writepath != None:
233             assert path != self.writepath
234
235         # Create a dummy file, so the path exists
236         AECmds.file_write(self.session,
237                           path[1:].encode(amiga_charset),
238                           self._mode_to_arwed(mode),
239                           time(),
240                           b'')
241
242         # Refresh cache so a subsequent getattr() succeeds
243         dirpath = path[0:path.rfind('/')]
244         self.readdir(dirpath, None)
245
246         return 0
247
248
249     def truncate(self, path, length, fh=None):
250         print("AEFuse.truncate: " + path + ' -- ' + str(length))
251
252         self._prep_write(path, length)
253
254         self.writebuf = self.writebuf[:length]
255         if length > len(self.writebuf):
256             # Fill up
257             self.writebuf = self.writebuf[:len(self.writebuf)] + (b'\0' * (length - len(self.writebuf)))
258
259         self.writemtime = time()
260
261         return 0
262
263
264
265     # WARNING: Due to buffering, we will NOT be able to report
266     #          a full disk as a response to the write call!
267     def write(self, path, data, offset, fh):
268         print("AEFuse.write: " + path + ' -- ' + str(len(data)) + ' @ ' + str(offset))
269
270         self._prep_write(path, None)
271
272         if offset > len(self.writebuf):
273             # Fill up
274             self.writebuf = self.writebuf[:len(self.writebuf)] + (b'\0' * (offset - len(self.writebuf)))
275
276         self.writebuf = self.writebuf[:offset] + data + self.writebuf[offset+len(data):]
277         self.writemtime = time()
278
279         # WARNING!
280         # We cannot check for a full disk here, so we're
281         # just assuming that buffering is fine.
282         return len(data)
283
284
285     def utimens(self, path, (atime, mtime)):
286         print("AEFuse.utimens: " + path + ' -- ' + str(mtime))
287
288         # We can only set the time when sending a complete file.
289         if self.writepath == path:
290             self.writemtime = mtime
291         else:
292             raise FuseOSError(EROFS)
293
294
295     def _flush_write(self):
296         if self.writepath == None:
297             return
298
299         # If this fails, there is nothing we can do.
300         # Except returning an error on close().
301         # But honestly, who checks for that?
302         AECmds.file_write(self.session,
303                           self.writepath[1:].encode(amiga_charset),
304                           self.writeattrs,
305                           self.writemtime, self.writebuf)
306
307         # Extract dirpath before we throw away self.writepath
308         dirpath = self.writepath[0:self.writepath.rfind('/')]
309
310         # Throw away the write buffer once done, so we don't cache full files.
311         self.writepath = None
312         self.writebuf = None
313         self.writemtime = None
314         self.writeattrs = None
315
316         # Refresh cache so subsequent accesses are correct
317         self.readdir(dirpath, None)
318
319
320     # Called on close()
321     def flush(self, path, fh):
322         print("AEFuse.flush: " + path)
323
324         # Flush any remaining write buffer
325         self._flush_write()
326
327         print("AEFuse.flush: finished: " + path)
328
329
330     def unlink(self, path):
331         print("AEFuse.unlink: " + path)
332
333         # TODO: Handle file that is currently open for reading/writing
334
335         self.session.sendMsg(0x67, path[1:].encode(amiga_charset) + b'\x00')
336
337         (type, _) = self.session.recvMsg()
338
339         self.session.sendClose()
340
341         # Refresh cache so a subsequent getattr() is up to date.
342         # We refresh even in case the delete failed, because it
343         # indicates our cache being out of date.
344         dirpath = path[0:path.rfind('/')]
345         self.readdir(dirpath, None)
346
347         if type == 0:
348             return 0  # File deleted successfully
349         else:
350             raise FuseOSError(EIO)
351
352
353     def rmdir(self, path):
354         # TODO: Refuse if the directory is not empty
355         return self.unlink(path)
356
357
358     def ae_mkdir(self, path):
359         self._cancel_read()
360
361         amigaattrs = 0
362
363         _utime = time() - (2922*24*60*60) # Amiga epoch starts 1978-01-01
364         mdays = int(_utime / (24*60*60))  # Days since 1978-01-01
365         _utime -= mdays * (24*60*60)
366         mmins = int(_utime / 60)          # Minutes
367         _utime -= mmins * 60
368         mticks = _utime * 50              # Ticks of 1/50 sec
369
370         filetype = 2 # Folder
371
372         data = struct.pack('!LLLLLLLB',
373                            29 + len(path) + 6,  # Length of this message, really
374                            0,
375                            0,
376                            amigaattrs,
377                            mdays,
378                            mmins,
379                            mticks,
380                            filetype)
381         data += path.encode(amiga_charset)
382         data += b'\0\0\0\0\0\0'  # No idea what this is for
383         self.session.sendMsg(0x66, data)
384
385         # TODO: Handle target FS full
386         # TODO: Handle permission denied on target FS
387
388         self.session.sendClose()
389
390
391     def mkdir(self, path, mode):
392         print('AEFuse.mkdir: ' + path)
393
394         self.ae_mkdir(path[1:].encode(amiga_charset))
395
396         # Refresh cache so a subsequent getattr() succeeds
397         dirpath = path[0:path.rfind('/')]
398         self.readdir(dirpath, None)
399
400
401     def rename(self, old, new):
402         print('AEFuse.rename: ' + old + ' - ' + new)
403         dirpath_old = old[0:old.rfind('/')]
404         dirpath_new = new[0:new.rfind('/')]
405         filename_old = old[old.rfind('/') + 1:]
406         filename_new = new[new.rfind('/') + 1:]
407
408         # If the file already exists, delete it.
409         # Check cache, since FUSE/VFS will have filled it.
410         st = self.cache.getattr(new)
411         if st != None:
412             self.unlink(new)
413
414         if dirpath_new == dirpath_old:
415             AECmds.rename(self.session,
416                       old[1:].encode(amiga_charset),
417                       filename_new.encode(amiga_charset))
418         else:
419             # Move in 3 steps:
420             # 1. Rename file to a temporary name
421             #    (NOTE: We hope it doesn't exist!)
422             # 2. Move the file to the new folder
423             # 3. Rename the file to the target file name
424             # The reason for this is that the old file name may exist in
425             # the new directory, and the new file name may exist in the
426             # old directory. Thus, if we were to do only one renaming,
427             # either order of rename+move or move+rename could be
428             # problematic.
429
430             assert (self.cache.getattr(dirpath_old + '/_fatemp_') == None)
431             assert (self.cache.getattr(dirpath_new + '/_fatemp_') == None)
432
433             AECmds.rename(self.session,
434                       old[1:].encode(amiga_charset),
435                       '_fatemp_'.encode(amiga_charset))
436             AECmds.move(self.session,
437                         (dirpath_old + '/_fatemp_')[1:].encode(amiga_charset),
438                         dirpath_new[1:].encode(amiga_charset))
439             AECmds.rename(self.session,
440                       (dirpath_new + '/_fatemp_')[1:].encode(amiga_charset),
441                       filename_new.encode(amiga_charset))
442
443         # Refresh cache so a subsequent getattr() succeeds
444         self.readdir(dirpath_old, None)
445         self.readdir(dirpath_new, None)
446
447
448     def chmod(self, path, mode):
449         print("AEFuse.chmod: " + path + ' -- ' + str(mode))
450
451         # Apparently we don't have to worry about directory flags
452         amigaattrs = self._mode_to_arwed(mode)
453
454         if path == self.writepath:
455             self.writeattrs = amigaattrs;
456         else:
457             AECmds.setattr(self.session,
458                            amigaattrs,
459                            path[1:].encode(amiga_charset),
460                            '')
461
462
463     def chown(self, path, uid, gid):
464         # AmigaOS does not know users/groups.
465         raise FuseOSError(EROFS)
466
467
468     # Called on umount
469     def destroy(self, path):
470         print('AEFuse.destroy: ' + path)
471
472         # Flush any remaining write buffer
473         self._cancel_read()
474         self._flush_write()