22import atexit
33import errno
44import os
5- import re
65import stat
76import sys
87import subprocess
1312from cryptography.hazmat.primitives.ciphers import Cipher
1413from cryptography.hazmat.primitives.ciphers.algorithms import ChaCha20
1514
16- CHUNK_SIZE = 4096
15+ CHUNK_SIZE = 64000
1716
18- UUID4_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', re.IGNORECASE)
19-
20- def is_valid_uuid4(filename):
21- """
22- Validates if the given filename follows the UUIDv4 format.
23-
24- :param filename: The name of the file.
25- :return: True if the filename is a valid UUIDv4, otherwise False.
26- """
27- return bool(UUID4_PATTERN.match(filename))
2817
2918def is_mount_point(path):
3019 """
@@ -51,21 +40,20 @@ def unmount_if_mounted(path):
5140 subprocess.run(['fusermount', '-u', path])
5241
5342class EphemeralFile:
43+ fd = enc = dec = None
44+ position = 0
45+
5446 def __init__(self, filesdir, filename=None):
5547 """
5648 Initializes an ephemeral file with ChaCha20 encryption.
5749 Creates a new random file path and generates a unique encryption key and nonce.
5850
5951 :param filesdir: The directory where the ephemeral file will be stored.
60- :param filenames : Optional filename. If not provided, a UUID4 is used.
52+ :param filename : Optional filename. If not provided, a UUID4 is used.
6153 """
6254 filename = filename or str(uuid.uuid4()) # If filenames is None, generate a random UUID as a string
6355 self.filepath = os.path.join(filesdir, filename)
64- self.cipher = Cipher(ChaCha20(os.urandom(32), uuid.UUID(filename).bytes[:16]), mode=None)
65- self.enc = self.cipher.encryptor()
66- self.dec = self.cipher.decryptor()
67-
68- self.fd = None
56+ self.cipher = Cipher(ChaCha20(os.urandom(32), os.urandom(16)), mode=None)
6957
7058 def __getattribute__(self, name):
7159 """
@@ -87,15 +75,24 @@ def __getattribute__(self, name):
8775 # For everything else, defer to the default behavior
8876 return super().__getattribute__(name)
8977
90- def open(self, flags, mode=0o660 ):
78+ def open(self, mode='r' ):
9179 """
9280 Opens the ephemeral file for reading or writing.
9381
9482 :param mode: 'w' for writing, 'r' for reading.
9583 :return: The file object.
9684 """
97- self.fd = os.open(self.filepath, os.O_RDWR | os.O_CREAT | os.O_APPEND, mode)
98- os.chmod(self.filepath, mode)
85+ if not self.fd:
86+ self.fd = os.open(self.filepath, os.O_RDWR | os.O_CREAT | os.O_APPEND)
87+
88+ self.enc = self.cipher.encryptor()
89+ self.dec = self.cipher.decryptor()
90+
91+ if mode == 'r':
92+ self.seek(0)
93+ else:
94+ self.seek(self.size)
95+
9996 return self
10097
10198 def write(self, data):
@@ -105,6 +102,7 @@ def write(self, data):
105102 :param data: Data to write to the file, can be a string or bytes.
106103 """
107104 os.write(self.fd, self.enc.update(data))
105+ self.position += len(data)
108106
109107 def read(self, size=None):
110108 """
@@ -125,7 +123,9 @@ def read(self, size=None):
125123 break
126124
127125 data += self.dec.update(chunk)
128- bytes_read += len(chunk)
126+ chunk_length = len(chunk)
127+ bytes_read += chunk_length
128+ self.position += chunk_length
129129
130130 if size is not None and bytes_read >= size:
131131 break
@@ -138,32 +138,40 @@ def seek(self, offset):
138138
139139 :param offset: The offset to seek to.
140140 """
141- position = 0
142- self.dec = self.cipher.decryptor()
143- self.enc = self.cipher.encryptor()
144- os.lseek(self.fd, 0, os.SEEK_SET)
145- discard_size = offset - position
141+ if offset < self.position:
142+ self.position = 0
143+ self.dec = self.cipher.decryptor()
144+ self.enc = self.cipher.encryptor()
145+ os.lseek(self.fd, 0, os.SEEK_SET)
146+ discard_size = offset
147+ else:
148+ discard_size = offset - self.position
149+
146150 while discard_size > 0:
147151 to_read = min(CHUNK_SIZE, discard_size)
148152 data = self.dec.update(os.read(self.fd, to_read))
149153 data = self.enc.update(data)
150154 discard_size -= to_read
151155
156+ self.position = offset
157+
152158 def tell(self):
153159 """
154160 Returns the current position in the file.
155161
156162 :return: The current position in the file.
157163 """
158- return os.lseek( self.fd, 0, os.SEEK_CUR)
164+ return self.position
159165
160166 def close(self):
161167 """
162168 Closes the file descriptor.
163169 """
164- if self.fd is not None :
170+ if self.fd:
165171 os.close(self.fd)
166- self.fd = None
172+ del self.enc
173+ del self.dec
174+ self.fd = self.enc = self.dec = None
167175
168176 def __enter__(self):
169177 """
@@ -197,9 +205,28 @@ def __init__(self, storage_directory=None):
197205 """
198206 self.storage_directory = storage_directory if storage_directory is not None else mkdtemp()
199207 self.files = {} # Track open files and their secure temporary file handlers
208+ self.directories = {'/': set()}
200209 self.mutex = threading.Lock()
201210
211+ def _split_path(self, path):
212+ """
213+ Splits a given path into its parent directory and final component.
214+
215+ :param path: The full file or directory path.
216+ :return: A tuple (parent_path, name), where parent_path is the directory
217+ and name is the last part of the path.
218+ """
219+ parts = path.strip('/').split('/')
220+ return '/' + '/'.join(parts[:-1]) if len(parts) > 1 else '/', parts[-1]
221+
202222 def get_file(self, path):
223+ """
224+ Retrieves the file object associated with the specified path.
225+
226+ :param path: The full path to the file.
227+ :return: The file object stored at the given path.
228+ :raises FuseOSError: If the file does not exist.
229+ """
203230 file = self.files.get(path)
204231 if file is None:
205232 raise FuseOSError(errno.ENOENT)
@@ -214,20 +241,17 @@ def getattr(self, path, fh=None):
214241 :return: A dictionary of file attributes.
215242 """
216243 with self.mutex:
217- if path == '/' :
244+ if path in self.directories :
218245 return {'st_mode': (stat.S_IFDIR | 0o750), 'st_nlink': 2}
219246
220247 file = self.get_file(path)
221248
222- file_stat = os.stat(file.filepath)
223-
224- st = {key: getattr(file_stat, key) for key in dir(file_stat) if not key.startswith('__')}
225-
226- st['st_mode'] |= stat.S_IFDIR if stat.S_ISDIR(file_stat.st_mode) else 0
227- st['st_mode'] |= stat.S_IFREG if stat.S_ISREG(file_stat.st_mode) else 0
228- st['st_mode'] |= stat.S_IFLNK if stat.S_ISLNK(file_stat.st_mode) else 0
229-
230- return st
249+ st = os.stat(file.filepath)
250+ return {
251+ 'st_mode': (stat.S_IFREG | 0o600),
252+ 'st_nlink': 1,
253+ 'st_size': st.st_size,
254+ }
231255
232256 def readdir(self, path, fh=None):
233257 """
@@ -238,7 +262,46 @@ def readdir(self, path, fh=None):
238262 :return: A list of directory contents.
239263 """
240264 with self.mutex:
241- return ['.', '..'] + [os.path.basename(f) for f in self.files]
265+ if path not in self.directories:
266+ raise FuseOSError(errno.ENOENT)
267+ entries = list(self.directories.get(path, set()))
268+ seen = set(entries)
269+ for f in self.files:
270+ if os.path.dirname(f) == path:
271+ name = os.path.basename(f)
272+ if name not in seen:
273+ entries.append(name)
274+ seen.add(name)
275+ return ['.', '..'] + entries
276+
277+ def mkdir(self, path, mode):
278+ """
279+ Creates a new directory at the specified path.
280+
281+ :param path: The full path of the directory to create.
282+ :param mode: Permission mode for the new directory (not used in this implementation).
283+ :raises FuseOSError: If the parent directory does not exist.
284+ """
285+ with self.mutex:
286+ parent, name = self._split_path(path)
287+ if parent not in self.directories:
288+ raise FuseOSError(errno.ENOENT)
289+ self.directories[parent].add(name)
290+ self.directories[path] = set()
291+
292+ def rmdir(self, path):
293+ """
294+ Removes an existing directory at the specified path.
295+
296+ :param path: The full path of the directory to remove.
297+ :raises FuseOSError: If the directory does not exist or is not empty.
298+ """
299+ with self.mutex:
300+ if path not in self.directories or self.directories[path]:
301+ raise FuseOSError(errno.ENOTEMPTY)
302+ parent, name = self._split_path(path)
303+ self.directories[parent].remove(name)
304+ del self.directories[path]
242305
243306 def create(self, path, mode):
244307 """
@@ -248,14 +311,16 @@ def create(self, path, mode):
248311 :param mode: The mode in which the file will be opened.
249312 :return: The file descriptor.
250313 """
251- filename = os.path.basename(path)
252- if not is_valid_uuid4(filename):
253- raise FuseOSError(errno.ENOENT)
314+ parent, name = self._split_path(path)
254315
255316 with self.mutex:
256- file = EphemeralFile(self.storage_directory, filename)
257- file.open('w', mode)
258- self.files[path] = file
317+ if parent not in self.directories:
318+ raise FuseOSError(errno.ENOENT)
319+ self.directories[parent].add(name)
320+ full_path = os.path.join(parent, name)
321+ file = EphemeralFile(self.storage_directory, str(uuid.uuid4()))
322+ file.open('w')
323+ self.files[full_path] = file
259324 return file.fd
260325
261326 def open(self, path, flags):
@@ -298,6 +363,7 @@ def write(self, path, data, offset, fh=None):
298363 """
299364 with self.mutex:
300365 file = self.get_file(path)
366+ file.seek(offset)
301367 file.write(data)
302368 return len(data)
303369
@@ -308,10 +374,12 @@ def unlink(self, path):
308374 :param path: The file path to remove.
309375 """
310376 with self.mutex:
377+ parent, name = self._split_path(path)
311378 file = self.files.pop(path, None)
312379 if file:
313380 file.close()
314381 os.unlink(file.filepath)
382+ self.directories[parent].discard(name)
315383
316384 def release(self, path, fh=None):
317385 """
@@ -342,35 +410,11 @@ def truncate(self, path, length, fh=None):
342410 file.seek(length)
343411
344412 if length > file.size:
345- length = length - file.size
346- bytes_written = 0
347- while bytes_written < length:
348- to_write = min(CHUNK_SIZE, length - bytes_written)
413+ pad_len = length - file.size
414+ while pad_len > 0:
415+ to_write = min(CHUNK_SIZE, pad_len)
349416 file.write(b'\0' * to_write)
350- bytes_written += to_write
351-
352- def chmod(self, path, mode):
353- """
354- Changes the permissions of the file at the specified path.
355-
356- :param path: The file path whose permissions will be changed.
357- :param mode: The new permissions mode (e.g., 0o777 for full permissions).
358- :raises FuseOSError: If the file does not exist.
359- """
360- file = self.get_file(path)
361- return os.chmod(file.filepath, mode)
362-
363- def chown(self, path, uid, gid):
364- """
365- Changes the ownership of the file at the specified path.
366-
367- :param path: The file path whose ownership will be changed.
368- :param uid: The user ID (uid) to set as the new owner.
369- :param gid: The group ID (gid) to set as the new group owner.
370- :raises FuseOSError: If the file does not exist.
371- """
372- file = self.get_file(path)
373- return os.chown(file.filepath, uid, gid)
417+ pad_len -= to_write
374418
375419def mount_globaleaks_eph_fs(mount_point, storage_directory=None, foreground=False):
376420 """
0 commit comments