Skip to content

Commit 73653f4

Browse files
committed
Add support for directories and subdirectories
1 parent db87620 commit 73653f4

File tree

2 files changed

+146
-113
lines changed

2 files changed

+146
-113
lines changed

src/globaleaks_eph_fs/__init__.py

Lines changed: 119 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import atexit
33
import errno
44
import os
5-
import re
65
import stat
76
import sys
87
import subprocess
@@ -13,18 +12,8 @@
1312
from cryptography.hazmat.primitives.ciphers import Cipher
1413
from 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

2918
def is_mount_point(path):
3019
"""
@@ -51,21 +40,20 @@ def unmount_if_mounted(path):
5140
subprocess.run(['fusermount', '-u', path])
5241

5342
class 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

375419
def mount_globaleaks_eph_fs(mount_point, storage_directory=None, foreground=False):
376420
"""

0 commit comments

Comments
 (0)