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