MatrixAI / js-encryptedfs

Encrypted Filesystem for TypeScript/JavaScript Applications
https://polykey.com
Apache License 2.0
10 stars 3 forks source link

Upgrading @matrixai/async-init, @matrixai/async-locks, @matrixai/db, @matrixai/errors, @matrixai/workers and integrating @matrixai/resources #63

Closed CMCDragonkai closed 2 years ago

CMCDragonkai commented 2 years ago

Description

Several core libraries have been updated and js-encryptedfs needs to start using them.

  1. @matrixai/async-init - no major changes
  2. @matrixai/async-locks - the INodeManager uses a collection of Mutex for mutual exclusion of inodes. This means operations to individual inodes may be blocked. We can switch this with Lock, and in the future optimise with RWLockWriter or RWLockReader
  3. @matrixai/db - the DB now supports proper DB transactions, we should switch the INodeManager API to fully support it
  4. @matrixai/errors - we make all of our errors extend AbstractError<T> and also provide static descriptions to all of them, as well as use the cause chain
  5. @matrixai/workers - no major changes here
  6. @matrixai/resources - since the @matrixai/db no longer does any locking, the acquisition of the DBTransaction and Lock has to be done together with withF or withG

Issues Fixed

Tasks

Final checklist

CMCDragonkai commented 2 years ago

This is a big upgrade... it would result in v3.5.0, external API doesn't really change, it's mostly internal things. Lots of patterns produced now.

CMCDragonkai commented 2 years ago

The INodeManager.gcAll originally used this.db.transact to lock up the DB every time it did any deletion operations. This should no longer be necessary with transactional iteration. No locks are even needed here since it's only called by INodeManager.start and INodeManager.stop.

CMCDragonkai commented 2 years ago

In integrating the @matrixai/db. I've found the NonEmptyArray type to be quite annoying. It requires alot of casting.

If we remove the requirement, and that would mean [] has to mean something. It could mean the key '' and thus be equivalent to [''].

That could simplify the code and remove all the as unknown as KeyPath.

CMCDragonkai commented 2 years ago

Only issue is that some places the keyPath is spread into other key paths like ['data', ...keyPath]. Prior to this happening, the an empty keypath must be turned into [''] to ensure that a keypath always has at least 1 entry.

CMCDragonkai commented 2 years ago

The @matrixai/db has been updated to 3.2.2 and now KeyPath [] becomes ['']. No more as unknown as KeyPath type casting required.

CMCDragonkai commented 2 years ago

When I removed the INodeManager.transact method I noticed quite an increase in verbosity. I realised the with* convenience wrappers like in RWLockWriter.withReadF() and RWLockWriter.withReadG() are actually quite nice here, and they can take over the transact method.

However I wanted to improve the API a little bit. Since we now know how to do NonEmptyArray, we can also create variadic argument types that specify the last type as a callback.

So now we have:

  public async withTransactionF<T>(
    ...args: [
      ...inos: INodeIndex[],
      f: (tran: DBTransaction) => Promise<T>
    ]
  ) {
    const f = args.pop() as (tran: DBTransaction) => Promise<T>;
    return withF(
      [
        this.db.transaction(),
        this.getLocks(...args as Array<INodeIndex>)
      ],
      ([tran]) => f(tran)
    );
  }

  public withTransactionG<T, TReturn, TNext>(
    ...args: [
      ...inos: INodeIndex[],
      g: (tran: DBTransaction) => AsyncGenerator<T, TReturn, TNext>,
    ]
  ): AsyncGenerator<T, TReturn, TNext> {
    const g = args.pop() as (tran: DBTransaction) => AsyncGenerator<T, TReturn, TNext>;
    return withG(
      [
        this.db.transaction(),
        this.getLocks(...args as Array<INodeIndex>)
      ],
      ([tran]) => g(tran)
    );
  }

This actually conveniently replaces the transact methods. And you get to state up front what inodes you need to lock if any.

This is nicer than having the inodes that you need to lock at the very end.

Furthermore, notice that I "specialise" the resources to just the transaction, because the user won't have any use for the lock objects.

The user can always still compose much more complex resource contexts by using the getLocks and putting together the ResourceAcquire.

CMCDragonkai commented 2 years ago

Also moving to selective imports so that tests can be run by themselves.

import { permissions } from '@';

To:

import * as permissions from '@/permissions';
CMCDragonkai commented 2 years ago

I tried getting a vscode regex to do a big find and replace. Couldn't work due to matches braces requirements.

CMCDragonkai commented 2 years ago

There are no functions that are exposing a generator interface atm. Even streams are using the FD abstraction, and they don't actually hold lock/transaction during their lifetime. This is because every write is a single operation. So for now there's no use of INodeManager.withTransactionG except internally inside INodeManager.

CMCDragonkai commented 2 years ago

I've found that the FileDescriptor methods of write and read aren't using one entire transaction for their operations. It seems like could be encapsulated in one transaction... should test this later.

CMCDragonkai commented 2 years ago

The EncryptedFS has been using alot of:

        fd ? [fd.ino] : [],

And also for target. They should be using fd != null or target != null. The ino could be 0 in its type, although this is prevented by starting the resource counter at 1. I'm changing over.

CMCDragonkai commented 2 years ago

Everything is migrated over, however I got a few test failures in EncryptedFS.

CMCDragonkai commented 2 years ago

The canary check logic should be moved into DB during await DB.create() and DB.start. It doesn't make sense for it to be in the EFS. Plus now that DB has "hidden" sublevels, this can be done in the root level.

CMCDragonkai commented 2 years ago

This is now done in js-db at 3.2.3. And the canary check has been removed. The ErrorEncryptedFSKey remains though and wraps around the ErrorDBKey when thrown.

CMCDragonkai commented 2 years ago

Current tests state, all passes except 2:

``` [nix-shell:~/Projects/js-encryptedfs]$ npm test > encryptedfs@3.4.3 test /home/cmcdragonkai/Projects/js-encryptedfs > jest Determining test suites to run... GLOBAL SETUP PASS tests/workers/efsWorker.test.ts EFS worker ✓ encryption and decryption (1082 ms) ✓ encryption and decryption within 1 call (99 ms) PASS tests/utils.test.ts utils ✓ random bytes generation (43 ms) ✓ key is randomly generated (4 ms) ✓ key generation from password is non-deterministic with random salt (766 ms) ✓ key generation is deterministic with a given salt (1360 ms) ✓ encryption and decryption (14 ms) ✓ block offset is position % block size (3 ms) ✓ number of blocks to be written ✓ block index start (1 ms) ✓ block index end (3 ms) ✓ block cursor (1 ms) ✓ buffer segmentation (6 ms) maybeCallback as a promise ✓ Should function ✓ Should throw error (18 ms) as a callback ✓ Should function (1 ms) ✓ Should throw error PASS tests/inodes/INodeManager.test.ts (8.642 s) INodeManager ✓ inode manager is persistent across restarts (400 ms) ✓ transactions are locked via inodes (34 ms) ✓ inodes can be scheduled for deletion when there are references to them (167 ms) PASS tests/fd/FileDescriptorManager.test.ts (8.91 s) File Descriptor Manager ✓ create a file descriptor manager (36 ms) ✓ create a file descriptor (18 ms) ✓ retreive a file descriptor (7 ms) ✓ delete a file descriptor (16 ms) ✓ duplicate a file descriptor (9 ms) ✓ read/write to fd when inode deleted from directory (210 ms) PASS tests/inodes/INodeManager.symlink.test.ts (9.017 s) INodeManager Symlink ✓ create and delete symlink (274 ms) PASS tests/inodes/INodeManager.file.test.ts (9.025 s) INodeManager File ✓ create a file (131 ms) ✓ create a file with supplied data (97 ms) ✓ write and read data from a file (62 ms) ✓ read a single block from a file (64 ms) ✓ write a single block from a file (41 ms) ✓ handle accessing blocks that the db does not have (45 ms) PASS tests/inodes/INodeManager.dir.test.ts (9.339 s) INodeManager Directory ✓ create root directory (256 ms) ✓ create subdirectory (142 ms) ✓ create subdirectories (239 ms) ✓ delete subdirectory (106 ms) ✓ rename directory entry (153 ms) ✓ iterate directory entries (129 ms) PASS tests/EncryptedFS.test.ts (9.573 s) EncryptedFS ✓ efs readiness (211 ms) ✓ efs is persistent across restarts (224 ms) ✓ creating fresh efs (192 ms) ✓ efs exposes constants (38 ms) ✓ validate key (49 ms) PASS tests/fd/FileDescriptor.test.ts (9.692 s) File Descriptor ✓ create a file descriptor (65 ms) ✓ can set flags (20 ms) ✓ can set position (126 ms) ✓ read all the data on the file iNode (62 ms) ✓ read with the file descriptor at a certain position (78 ms) ✓ read when the return buffer length is less than the data length (66 ms) ✓ write to an empty file iNode (79 ms) ✓ overwrite a single block of a file iNode (67 ms) ✓ overwrite at an offset to a file iNode (75 ms) ✓ write past the end of a file iNode (72 ms) ✓ append data to the file iNode (155 ms) PASS tests/EncryptedFS.streams.test.ts (10.779 s) EncryptedFS Streams readstream ✓ using 'for await' (335 ms) ✓ using 'event readable' (185 ms) ✓ using 'event data' (128 ms) ✓ respects start and end options (124 ms) ✓ respects the high watermark (131 ms) ✓ respects the start option (115 ms) ✓ end option is ignored without the start option (121 ms) ✓ can use a file descriptor (113 ms) ✓ with start option overrides the file descriptor position (111 ms) ✓ can handle errors asynchronously (36 ms) ✓ can compose with pipes (111 ms) writestream ✓ can compose with pipes (139 ms) ✓ can create and truncate files (140 ms) ✓ can be written into (110 ms) ✓ allow ignoring of the drain event, temporarily ignoring resource usage control (112 ms) ✓ can use the drain event to manage resource control (191 ms) ✓ can handle errors asynchronously (33 ms) PASS tests/EncryptedFS.nav.test.ts (12.528 s) EncryptedFS Navigation ✓ EFS using callback style functions (419 ms) ✓ should be able to restore state (141 ms) ✓ should be able to navigate before root (232 ms) ✓ trailing slash refers to the directory instead of a file (138 ms) ✓ trailing slash works for non-existent directories when intending to create them (82 ms) ✓ trailing `/.` for mkdir should result in errors (77 ms) ✓ navigating invalid paths (442 ms) ✓ various failure situations (253 ms) ✓ cwd returns the absolute fully resolved path (142 ms) ✓ cwd still works if the current directory is deleted (106 ms) ✓ deleted current directory can still use . and .. for traversal (128 ms) ✓ can still chdir when both current and parent directories are deleted (159 ms) ✓ cannot chdir into a directory without execute permissions (66 ms) ✓ should be able to access inodes inside chroot (297 ms) ✓ should not be able to access inodes outside chroot (129 ms) ✓ should not be able to access inodes outside chroot using symlink (134 ms) ✓ prevents users from changing current directory above the chroot (104 ms) ✓ can sustain a current directory inside a chroot (70 ms) ✓ can chroot, and then chroot again (100 ms) ✓ chroot returns a running efs instance (60 ms) ✓ chroot start & stop does not affect other efs instances (115 ms) ✓ root efs instance stops all chrooted instances (121 ms) ✓ destroying chroot is a noop (63 ms) PASS tests/EncryptedFS.perms.test.ts (14.801 s) EncryptedFS Permissions ✓ chown changes uid and gid (302 ms) ✓ chmod with 0 wipes out all permissions (150 ms) ✓ mkdir and chmod affects the mode (137 ms) ✓ umask is correctly applied (213 ms) ✓ non-root users can only chown uid if they own the file and they are chowning to themselves (213 ms) ✓ chmod only works if you are the owner of the file (97 ms) ✓ permissions are checked in stages of user, group then other (334 ms) ✓ permissions are checked in stages of user, group then other (using chown) (380 ms) ✓ --x-w-r-- permission staging (159 ms) ✓ file permissions --- (189 ms) ✓ file permissions r-- (193 ms) ✓ file permissions rw- (196 ms) ✓ file permissions rwx (187 ms) ✓ file permissions r-x (168 ms) ✓ file permissions -w- (176 ms) ✓ file permissions -wx (168 ms) ✓ file permissions --x (159 ms) ✓ directory permissions --- (203 ms) ✓ directory permissions r-- (242 ms) ✓ directory permissions rw- (221 ms) ✓ directory permissions rwx (224 ms) ✓ directory permissions r-x (347 ms) ✓ directory permissions -w- (146 ms) ✓ directory permissions -wx (254 ms) ✓ directory permissions --x (242 ms) ✓ permissions dont affect already opened fd (204 ms) ✓ chownr changes uid and gid recursively (284 ms) ✓ chown can change groups without any problem because we do not have a user group hierarchy (81 ms) ✓ --x-w-r-- do not provide read write and execute to the user due to permission staging (204 ms) PASS tests/EncryptedFS.links.test.ts (15.837 s) EncryptedFS Links ✓ Symlink stat makes sense (276 ms) symlink ✓ creates symbolic links (508 ms) ✓ paths can contain multiple slashes (211 ms) ✓ can resolve 1 symlink loop (67 ms) ✓ can resolve 2 symlink loops (143 ms) ✓ can be expanded by realpath (239 ms) ✓ cannot be traversed by rmdir (100 ms) ✓ is able to be added and traversed transitively (338 ms) ✓ is able to traverse relative symlinks (156 ms) ✓ returns EACCES when a component of the 2nd name path prefix denies search permission (200 ms) ✓ returns EACCES if the parent directory of the file to be created denies write permission (221 ms) ✓ returns ELOOP if too many symbolic links were encountered in translating the name2 path name (147 ms) ✓ returns EEXIST if the 2nd name argument already exists as a regular (68 ms) ✓ returns EEXIST if the 2nd name argument already exists as a dir (51 ms) ✓ returns EEXIST if the 2nd name argument already exists as a block (61 ms) ✓ returns EEXIST if the 2nd name argument already exists as a symlink (54 ms) unlink ✓ can remove a link to a regular (81 ms) ✓ can remove a link to a block (55 ms) ✓ successful updates ctime of a regular (122 ms) ✓ successful updates ctime of a block (122 ms) ✓ unsuccessful does not update ctime of a regular (111 ms) ✓ unsuccessful does not update ctime of a block (111 ms) ✓ does not traverse symlinks (198 ms) ✓ returns ENOTDIR if a component of the path prefix is not a directory (113 ms) ✓ returns ENOENT if the named file does not exist (78 ms) ✓ returns EACCES when search permission is denied for a component of the path prefix (122 ms) ✓ returns EACCES when write permission is denied on the directory containing the link to be removed (119 ms) ✓ returns ELOOP if too many symbolic links were encountered in translating the pathname (115 ms) ✓ returns EISDIR if the named file is a directory (65 ms) ✓ will not immeadiately free a file (172 ms) link ✓ creates hardlinks to regular (214 ms) ✓ creates hardlinks to block (243 ms) ✓ successful updates ctime of regular (118 ms) ✓ successful updates ctime of block (108 ms) ✓ unsuccessful does not update ctime of regular (123 ms) ✓ unsuccessful does not update ctime of block (121 ms) ✓ should not create hardlinks to directories (50 ms) ✓ can create multiple hardlinks to the same file (151 ms) ✓ returns ENOTDIR if a component of either path prefix is a regular (147 ms) ✓ returns ENOTDIR if a component of either path prefix is a dir (22 ms) ✓ returns ENOTDIR if a component of either path prefix is a block (120 ms) ✓ returns ENOTDIR if a component of either path prefix is a symlink (18 ms) ✓ returns EACCES when a component of either path prefix denies search permission (212 ms) ✓ returns EACCES when the requested link requires writing in a directory with a mode that denies write permission (199 ms) ✓ returns ELOOP if too many symbolic links were encountered in translating one of the pathnames (151 ms) ✓ returns ENOENT if the source file does not exist (103 ms) ✓ returns EEXIST if the destination regular does exist (110 ms) ✓ returns EEXIST if the destination dir does exist (94 ms) ✓ returns EEXIST if the destination block does exist (76 ms) ✓ returns EEXIST if the destination symlink does exist (79 ms) ✓ returns EPERM if the source file is a directory (86 ms) FAIL tests/EncryptedFS.files.test.ts (16.472 s) EncryptedFS Files ✓ File stat makes sense (331 ms) ✓ Uint8Array data support (242 ms) ✓ URL path support (240 ms) lseek ✓ can seek different parts of a file (134 ms) ✓ can seek beyond the file length and create a zeroed "sparse" file (143 ms) fallocate ✓ can extend the file length (94 ms) ✓ does not touch existing data (96 ms) ✓ will only change ctime (120 ms) truncate ✓ will change mtime and ctime (181 ms) ✕ truncates the fd position (204 ms) read ✓ can be called using different styles (126 ms) ✓ file can be called using different styles (132 ms) ✓ file moves with fd position (132 ms) ✓ moves with the fd position (104 ms) ✓ does not change fd position according to position parameter (313 ms) write ✓ can be called using different styles (104 ms) ✓ file can be called using different styles (259 ms) ✓ moves with the fd position (113 ms) ✓ can make 100 files (1760 ms) ✓ does not change fd position according to position parameter (97 ms) ✓ respects the mode (138 ms) ✓ file writes from the beginning, and does not move the fd position (91 ms) ✓ with O_APPEND always set their fd position to the end (159 ms) ✓ can copy files (152 ms) ✓ using append moves with the fd position (115 ms) open ✓ opens a file if O_CREAT is specified and the file doesn't exist (148 ms) ✓ updates parent directory ctime/mtime if file didn't exist (134 ms) ✓ doesn't update parent directory ctime/mtime if file existed (142 ms) ✓ returns ENOTDIR if a component of the path prefix is a regular (64 ms) ✓ returns ENOTDIR if a component of the path prefix is a block (60 ms) ✓ returns ENOENT if a component of the path name that must exist does not exist or O_CREAT is not set and the named file does not exist (88 ms) ✓ returns EACCES when search permission is denied for a component of the path prefix (145 ms) ✓ returns EACCES when the required permissions are denied for a regular file (476 ms) ✓ returns EACCES when the required permissions are denied for adirectory (290 ms) ✓ returns EACCES when O_TRUNC is specified and write permission is denied (210 ms) ✓ returns ELOOP if too many symbolic links were encountered in translating the pathname (84 ms) ✓ returns EISDIR when trying to open a directory for writing (71 ms) ✓ returns ELOOP when O_NOFOLLOW was specified and the target is a symbolic link (60 ms) ✓ returns EEXIST when O_CREAT and O_EXCL were specified and the regular exists (90 ms) ✓ returns EEXIST when O_CREAT and O_EXCL were specified and the dir exists (72 ms) ✓ returns EEXIST when O_CREAT and O_EXCL were specified and the block exists (78 ms) ✓ returns EEXIST when O_CREAT and O_EXCL were specified and the symlink exists (83 ms) ● EncryptedFS Files › truncate › truncates the fd position expect(received).toEqual(expected) // deep equality - Expected - 2 + Received + 2 Object { "data": Array [ 100, - 98, - 99, + 101, + 102, ], "type": "Buffer", } 183 | await efs.ftruncate(fd, 4); 184 | await efs.read(fd, buf, 0, buf.length); > 185 | expect(buf).toEqual(Buffer.from('dbc')); | ^ 186 | await efs.close(fd); 187 | }); 188 | }); at Object. (tests/EncryptedFS.files.test.ts:185:19) PASS tests/EncryptedFS.dirs.test.ts (17.557 s) EncryptedFS Directories ✓ Directory stat makes sense (319 ms) ✓ Empty root directory at startup (102 ms) file descriptors ✓ can change stats, permissions and flush data (215 ms) ✓ cannot perform read or write operations (186 ms) ✓ inode nlink becomes 0 after deletion of the directory (151 ms) rmdir ✓ should be able to remove directories (390 ms) ✓ cannot delete current directory using . (88 ms) ✓ cannot delete parent directory using .. even when current directory is deleted (174 ms) ✓ cannot create inodes within a deleted current directory (197 ms) ✓ returns ENOENT if the named directory does not exist (04) (95 ms) ✓ returns ELOOP if too many symbolic links were encountered in translating the pathname (175 ms) ✓ returns ENOTEMPTY if the named directory contains files other than '.' and '..' in it for regular (144 ms) ✓ returns ENOTEMPTY if the named directory contains files other than '.' and '..' in it for dir (106 ms) ✓ returns ENOTEMPTY if the named directory contains files other than '.' and '..' in it for block (115 ms) ✓ returns ENOTEMPTY if the named directory contains files other than '.' and '..' in it for symlink (109 ms) ✓ returns EACCES when search permission is denied for a component of the path prefix (213 ms) ✓ returns EACCES when write permission is denied on the directory containing the link to be removed (187 ms) ✓ recursively deletes the directory if it contains regular (135 ms) ✓ recursively deletes the directory if it contains dir (122 ms) ✓ recursively deletes the directory if it contains block (128 ms) ✓ recursively deletes the directory if it contains symlink (161 ms) ✓ recursively deletes a deep directory (684 ms) mkdir & mkdtemp ✓ is able to make directories (328 ms) ✓ can create temporary directories (114 ms) ✓ should not make the root directory (24 ms) ✓ trailing '/.' should not result in any errors (201 ms) ✓ returns EACCES when write permission is denied on the parent directory of the directory to be created (183 ms) ✓ returns EEXIST if the named regular exists (93 ms) ✓ returns EEXIST if the named dir exists (92 ms) ✓ returns EEXIST if the named block exists (77 ms) ✓ returns EEXIST if the named symlink exists (88 ms) rename ✓ can rename a directory (80 ms) ✓ cannot rename the current or parent directory to a subdirectory (117 ms) ✓ cannot rename where the old path is a strict prefix of the new path (166 ms) ✓ changes name but inode remains the same for regular (150 ms) ✓ changes name but inode remains the same for block (140 ms) ✓ changes name for dir (81 ms) ✓ changes name for regular file (135 ms) ✓ unsuccessful of regular does not update ctime (92 ms) ✓ unsuccessful of dir does not update ctime (86 ms) ✓ unsuccessful of block does not update ctime (76 ms) ✓ unsuccessful of symlink does not update ctime (76 ms) ✓ returns ENOENT if a component of the 'from' path does not exist, or a path prefix of 'to' does not exist (92 ms) ✓ returns EACCES when a component of either path prefix denies search permission (224 ms) ✓ returns EACCES when the requested link requires writing in a directory with a mode that denies write permission (220 ms) ✓ returns ELOOP if too many symbolic links were encountered in translating one of the pathnames (155 ms) ✓ returns ENOTDIR if a component of either path prefix is a regular (142 ms) ✓ returns ENOTDIR if a component of either path prefix is a block (117 ms) ✓ returns ENOTDIR when the 'from' argument is a directory, but 'to' is a regular (80 ms) ✓ returns ENOTDIR when the 'from' argument is a directory, but 'to' is a block (88 ms) ✓ returns ENOTDIR when the 'from' argument is a directory, but 'to' is a symlink (84 ms) ✓ returns EISDIR when the 'to' argument is a directory, but 'from' is a regular (86 ms) ✓ returns EISDIR when the 'to' argument is a directory, but 'from' is a block (76 ms) ✓ returns EISDIR when the 'to' argument is a directory, but 'from' is a symlink (86 ms) ✓ returns EINVAL when the 'from' argument is a parent directory of 'to' (93 ms) ✓ returns ENOTEMPTY if the 'to' argument is a directory and contains regular (107 ms) ✓ returns ENOTEMPTY if the 'to' argument is a directory and contains dir (102 ms) ✓ returns ENOTEMPTY if the 'to' argument is a directory and contains block (96 ms) ✓ returns ENOTEMPTY if the 'to' argument is a directory and contains symlink (97 ms) ✓ changes file ctime for regular (103 ms) ✓ changes file ctime for dir (84 ms) ✓ changes file ctime for block (77 ms) ✓ changes file ctime for symlink (80 ms) ✓ succeeds when destination regular is multiply linked (142 ms) ✓ succeeds when destination block is multiply linked (115 ms) FAIL tests/EncryptedFS.concurrent.test.ts (24.681 s) EncryptedFS Concurrency ✓ Renaming a directory at the same time with two different calls (355 ms) ✓ Reading a directory while adding/removing entries in the directory (246 ms) ✓ Reading a directory while removing the directory (277 ms) ✓ Reading a directory while renaming entries (278 ms) ✓ File metadata changes while reading/writing a file. (109 ms) ✓ Dir metadata changes while reading/writing a file. (177 ms) ✓ Read stream and write stream to same file (1158 ms) ✓ One write stream and one fd writing to the same file (88 ms) ✓ One read stream and one fd writing to the same file (176 ms) ✓ One write stream and one fd reading to the same file (78 ms) ✓ One read stream and one fd reading to the same file (168 ms) ✓ Two write streams to the same file (2097 ms) ✓ Writing a file and deleting the file at the same time using writeFile (68 ms) ✓ opening a file and deleting the file at the same time (71 ms) ✓ Writing a file and deleting the file at the same time for fd (148 ms) ✓ Writing a file and deleting the file at the same time for stream (125 ms) ✓ Appending to a file that is being written to for fd (1130 ms) ✕ Appending to a file that is being written for stream (86 ms) ✓ Copying a file that is being written to for fd (192 ms) ✓ Copying a file that is being written to for stream (284 ms) ✓ removing a dir while renaming it. (120 ms) concurrent file writes ✓ 10 short writes with efs.writeFile. (508 ms) ✓ 10 long writes with efs.writeFile. (2155 ms) ✓ 10 short writes with efs.write. (179 ms) ✓ 10 long writes with efs.write. (4299 ms) Allocating/truncating a file while writing (stream or fd) ✓ Allocating while writing to fd (93 ms) ✓ Truncating while writing to fd (157 ms) ✓ Allocating while writing to stream (100 ms) ✓ Truncating while writing to stream (115 ms) Changing fd location in a file (lseek) while writing/reading (and updating) fd pos ✓ Seeking while writing to file. (62 ms) ✓ Seeking while reading a file. (64 ms) ✓ Seeking while updating fd pos. (53 ms) checking if nlinks gets clobbered. ✓ when creating and removing the file. (215 ms) ✓ when creating and removing links. (251 ms) ● EncryptedFS Concurrency › Appending to a file that is being written for stream expect(received).toContain(expected) // indexOf Expected substring: "A" Received string: "BBBBBBBBBB" 820 | 821 | const fileContents = (await efs.readFile('file')).toString(); > 822 | expect(fileContents).toContain('A'); | ^ 823 | expect(fileContents).toContain('B'); 824 | expect(fileContents).toContain('AB'); 825 | at Object. (tests/EncryptedFS.concurrent.test.ts:822:26) Test Suites: 2 failed, 14 passed, 16 total Tests: 2 failed, 314 passed, 316 total Snapshots: 0 total Time: 25.165 s, estimated 26 s Ran all test suites. GLOBAL TEARDOWN npm ERR! Test failed. See above for more details. ```

First:


  ● EncryptedFS Files › truncate › truncates the fd position

    expect(received).toEqual(expected) // deep equality

    - Expected  - 2
    + Received  + 2

      Object {
        "data": Array [
          100,
    -     98,
    -     99,
    +     101,
    +     102,
        ],
        "type": "Buffer",
      }

      183 |       await efs.ftruncate(fd, 4);
      184 |       await efs.read(fd, buf, 0, buf.length);
    > 185 |       expect(buf).toEqual(Buffer.from('dbc'));
          |                   ^
      186 |       await efs.close(fd);
      187 |     });
      188 |   });

      at Object.<anonymous> (tests/EncryptedFS.files.test.ts:185:19)
  ● EncryptedFS Concurrency › Appending to a file that is being written for stream

    expect(received).toContain(expected) // indexOf

    Expected substring: "A"
    Received string:    "BBBBBBBBBB"

      820 | 
      821 |     const fileContents = (await efs.readFile('file')).toString();
    > 822 |     expect(fileContents).toContain('A');
          |                          ^
      823 |     expect(fileContents).toContain('B');
      824 |     expect(fileContents).toContain('AB');
      825 | 

      at Object.<anonymous> (tests/EncryptedFS.concurrent.test.ts:822:26)

Both appear to involve concurrent writing/reading to file blocks which is the trickiest part, and that which is impacted by https://github.com/MatrixAI/js-encryptedfs/pull/63#issuecomment-1094910309

CMCDragonkai commented 2 years ago

Replacing INodeManager's lock collection with LockBox from @matrixai/async-locks 2.2.0.

CMCDragonkai commented 2 years ago

No problems integrating LockBox.

In doing so I discovered that there's no need to remove entries from the lock collection since the lock entries are now removed when there is nothing holding them.

This does mean that it has to construct the lock object each time someone is trying to lock it. Which can be a bit inefficient.

But at the same time, not having to keep it around reduces programmer burden to figure out when to clear or delete a lock entry.

In the future, the LockBox can additionally be cached if necessary.

CMCDragonkai commented 2 years ago

For the first test failure involving truncates the fd position.

It appears the efs.ftruncate(fd, 4); isn't doing anything.

The data in teh /fdtest should be abcdef, and when truncating it should become abcd. The pointer should be at d, and therefore the end result should be dbc.

I confirmed this with a normal fs. However this is now broken. Need to trace through ftruncate.

CMCDragonkai commented 2 years ago

The ftruncate leads to the INodeManager.fileWriteBlock. At the very end, it says it's writing 4 bytes. However the resulting block is still abcdef and not abcd.

Need to compare with what is in master.

CMCDragonkai commented 2 years ago

In master, it doesn't go into that case at all. It instead goes to fileWriteBlock:

    if (!block) {
      await tran.put(dataDomain, key, data, true);
      bytesWritten = data.length;
    } else {

The reason is because:

    let block = await this.fileGetBlock(tran, ino, idx);

Is undefined. It is looking up ino = 2 and idx = 0.

This is quite strange. Because in master, the block doesn't exist, and thus fileGetBlock is undefined.

However in the current PR, it does exist.

Now should it exist or not? If you already have a file that has abcdef. If you open up a r+ descriptor, that doesn't clear the file.

Therefore it seems to make sense that the block would still exist, and the master is wrong to not have the buffer.

CMCDragonkai commented 2 years ago

Reduced this problem to the simplest reproduction:

import fs from 'fs/promises';

async function main () {

  await fs.writeFile('./tmp/fdtest', 'abcdef');
  const fd = await fs.open('./tmp/fdtest', 'r+');
  await fd.truncate(4);
  await fd.close();
  // File is abcd
  console.log(await fs.readFile('./tmp/fdtest', { encoding: 'utf-8' }));

}

main();

vs:

import os from 'os';
import fs from 'fs';
import pathNode from 'path';
import Logger, { StreamHandler, LogLevel } from '@matrixai/logger';
import { EncryptedFS, utils } from './src';

async function main () {
  const logger = new Logger('EncryptedFS Files', LogLevel.WARN, [
    new StreamHandler(),
  ]);
  const dataDir = await fs.promises.mkdtemp(
    pathNode.join(os.tmpdir(), 'encryptedfs-test-'),
  );
  const dbPath = `${dataDir}/db`;
  const dbKey: Buffer = utils.generateKeySync(256);
  const efs = await EncryptedFS.createEncryptedFS({
    dbKey,
    dbPath,
    umask: 0o022,
    logger,
  });

  await efs.writeFile('/fdtest', 'abcdef');

  // File is abcdef
  console.log(await efs.readFile('/fdtest', { encoding: 'utf-8'}));

  const fd = await efs.open('/fdtest', 'r+');

  // File is abcdef
  console.log(await efs.readFile('/fdtest', { encoding: 'utf-8'}));

  await efs.ftruncate(fd, 4);
  await efs.close(fd);

  // File should be abcd, but is instead abcdef
  console.log(await efs.readFile('/fdtest', { encoding: 'utf-8'}));

}

main();

In the current branch, the resulting file isn't truncated. It's still abcdef.

In the master branch it does it correctly to abcd just like the normal fs.

CMCDragonkai commented 2 years ago

The main difference is in INodeManager.fileWriteBlock.

When looking up the block during truncation, in master, we have undefined, which means it ends up writing the abcd.

However in the current branch, it does find the block which means it doesn't write a new block being just abcd, but instead it would "write-over" the abcdef, and thus end up with the same result.

The question is why does master see no block while this current branch does see the block.

CMCDragonkai commented 2 years ago

The fileGetBlocks has been changed from master using createReadStream, to now using the tran.iterator in this current branch.

This could mean that it is correctly acquiring the block in the current branch, because it is still abcdef.

I'm not sure if the fileGetBlocks is correct anyway since it has a while loop that as long as the buffer index is not equal to the block count, it yields empty blocks. But this while loop will never become true/false...?

CMCDragonkai commented 2 years ago

I noticed that ftruncate has to call await this.iNodeMgr.fileClearData(fd.ino, tran); prior to calling fileSetBlocks.

This would mean that it is intended that the block is undefined if fileClearData is meant to clear all the blocks in that sublevel.

However it appears that even though it is clearing the right key: [ 'INodeManager', 'data', '2', <Buffer 00> ], the block is still being found afterwards. This might be a bug in our branch then.

CMCDragonkai commented 2 years ago

I've checked, right after calling await tran.del([...dataPath, k]) I try to then await tran.get([...dataPath, k]); it appears the await tran.del has no effect. Possibly due to the tran.iterator.

I will need to investigate the js-db for a possible bug here.

CMCDragonkai commented 2 years ago

Confirmed this is a problem in js-db.

import Logger, { LogLevel, StreamHandler } from '@matrixai/logger';
import { DB } from './src';
import * as testUtils from './tests/utils';

async function main () {

  const logger = new Logger(`${DB.name} Test`, LogLevel.WARN, [
    new StreamHandler(),
  ]);

  const crypto = {
    key: testUtils.generateKeySync(256),
    ops: {
      encrypt: testUtils.encrypt,
      decrypt: testUtils.decrypt,
    },
  };

  const db = await DB.createDB({
    dbPath: './tmp/db',
    crypto,
    fresh: true,
    logger
  });

  const acquireTran = db.transaction();

  await db.put(
    ['INodeManager', 'data', '2', Buffer.from([0])],
    Buffer.from('abcdef'),
    true
  );

  let releaseTran, tran;

  [releaseTran, tran] = await acquireTran();

  console.log('BEFORE DELETE', await tran!.get(
    ['INodeManager', 'data', '2', Buffer.from([0])],
    true
  ));

  await tran!.del(
    ['INodeManager', 'data', '2', Buffer.from([0])],
  );

  console.log('AFTER DELETE', await tran!.get(
    ['INodeManager', 'data', '2', Buffer.from([0])],
    true
  ));

  await releaseTran();

  console.log('AFTER TRANSACTION', await db.get(
    ['INodeManager', 'data', '2', Buffer.from([0])],
    true
  ));

  await db.stop();

}

main();

This shows that when there's data in the database at a particular key. If I later start a transaction, acquire that data, but then delete it, if I get it again, this doesn't reflect the mutation I had just done. It thinks that data is still there.

Only after the transaction is committed, is the data fully removed. Which is what we want, but at the very least within the transaction we need to have some consistency in that within a transaction get after delete should be undefined.

CMCDragonkai commented 2 years ago

Additionally some improvements on DB is possible here:

CMCDragonkai commented 2 years ago

Upgrading to @matrixai/db to 3.3.0 has fixed this bug of get after delete consistency.

Furthermore I reckon the ftruncate isn't entirely efficient here. Right now it loads all the blocks into memory, and then proceeds to overwrite the existing blocks in the DB.

With our transactional iteration, we should be able to identify the block index we need to truncate to, and then do a more precise deletion of the blocks above, and the overwrite of that specific block that needs to be done. Will address this after all bugs are solved.

However the second bug is still a problem.

  ● EncryptedFS Concurrency › Appending to a file that is being written for stream

    expect(received).toContain(expected) // indexOf

    Expected substring: "A"
    Received string:    "BBBBBBBBBB"

      820 | 
      821 |     const fileContents = (await efs.readFile('file')).toString();
    > 822 |     expect(fileContents).toContain('A');
CMCDragonkai commented 2 years ago

Just discovered that createWriteStream and createReadStream seems to be incorrect. It's written asynchronously, but it is unnecessary to make these asynchronous as they just create a stream object.

CMCDragonkai commented 2 years ago

The stream test failing because if we have 2 concurrent operations, one that writes from a stream and another is appending to the file, we should see something like:

AAAAAAAAAABBBBBBBBBB

or

BBBBBBBBBBAAAAAAAAAA

However in our EFS we are currently only seeing: BBBBBBBBBB.

This probably has to do with our new DBTransaction.

import fs from 'fs';
import fsPromises from 'fs/promises';

async function main () {

  await fsPromises.writeFile('./tmp/streamtest', '');

  const writeStream = fs.createWriteStream('/tmp/streamtest');

  await Promise.all([
    new Promise((resolve) => {
      writeStream.write(Buffer.from('AAAAAAAAAA'), () => {
        writeStream.end(() => {
          resolve(null);
        });
      });
    }),
    fsPromises.appendFile('/tmp/streamtest', 'BBBBBBBBBB'),
  ]);

  // Shows AAAAAAAAAABBBBBBBBBB
  console.log((await fsPromises.readFile('/tmp/streamtest')).toString());

}

main();
CMCDragonkai commented 2 years ago

This is easily reproduced with just:

  await efs.writeFile('/fdtest', '');

  await Promise.all([
    efs.appendFile('/fdtest', 'AAA'),
    efs.appendFile('/fdtest', 'BBB'),
  ]);

  console.log(await efs.readFile('/fdtest', { encoding: 'utf-8' }));

  await efs.stop();

I think this might be a lost update problem.

Basically 2 transaction are committing to the same key. However they should be locking onto the same key and therefore the appends should end up being written one at a time.

CMCDragonkai commented 2 years ago

This may be related to task 5, that is a single transaction context for the entire write/read operation rather than how it is done now.

CMCDragonkai commented 2 years ago

I'm thinking that each FileDescriptor itself should have a lock. This is because FileDescriptor has the position state that is being mutated during read and write operations. It would make sense, that each individual read/write operations should be blocking each other.

Checking this https://stackoverflow.com/questions/5057737/simultaneous-read-on-file-descriptor-from-two-threads it seems POSIX is unspecified on this operation.

I need to check with normal fs, and see how concurrent read, or write works here.

A solution may turn out to be:

  1. Having a lock on each FileDescriptor instance to synchronise the read and write, setPos methods.
  2. Also ensuring that the transaction context covers the entire read, write and setPos operation.
CMCDragonkai commented 2 years ago

This script proves that at least for JS, concurrent reads and writes on a file descriptor is synchronised.

import fs from 'fs';
import fsPromises from 'fs/promises';

async function main () {

  await fsPromises.writeFile('./tmp/ctest', 'ABCDEFG');

  let fd = fs.openSync('./tmp/ctest', 'r');

  const b1 = Buffer.alloc(5);
  const b2 = Buffer.alloc(5);
  console.log(b1, b2);

  const results = await Promise.all([
    new Promise((resolve) => {
      // @ts-ignore
      fs.read(fd, { buffer: b1 }, (e, bytesRead, buf) => {
        console.log(e, bytesRead, buf);
        resolve(buf);
      });
    }),
    new Promise((resolve) => {
      // @ts-ignore
      fs.read(fd, { buffer: b2 }, (e, bytesRead, buf) => {
        console.log(e, bytesRead, buf);
        resolve(buf);
      });
    }),
  ]);

  fs.closeSync(fd);
  fd = fs.openSync('./tmp/ctest', 'w');

  // @ts-ignore
  console.log(results.map(b => b.toString()));

  await Promise.all([
    new Promise<void>((resolve) => {
      // @ts-ignore
      fs.write(fd, Buffer.from('aaa'), () => {
        resolve();
      });
    }),
    new Promise<void>((resolve) => {
      fs.write(fd, Buffer.from('bbb'), () => {
        resolve();
      });
    }),
  ]);

  console.log(await fsPromises.readFile('./tmp/ctest', { encoding: 'utf-8' }));

}

main();
CMCDragonkai commented 2 years ago

Since setPos has an optional tran parameter. I'm also adding the optional tran parameter to read and write. Although not all EFS methods would actually end up using it.

CMCDragonkai commented 2 years ago

In applying the lock and a single transaction context in task 5, we now fixed that specific test failure, however many other tests in the tests/EncryptedFS.concurrent.test.ts are broken. I believe this was due to an incorrect implementation done for the file descriptor from the beginning, so the tests for the file descriptors are themselves incorrect.

Need to review all the concurrent tests again. @tegefaulkes

CMCDragonkai commented 2 years ago

Some of the concurrent tests are quite brittle as they expect deterministic concurrent behaviour. This is incorrect, for example when reading a directory and deleting the directory, it should deal with the possibility that the directory is first deleted, or last deleted.

I'm simplifying these tests so that they actually test for non-determinism.

@tegefaulkes

CMCDragonkai commented 2 years ago

Another concurrency bug this time by doing 10 append file operations on a file that doesn't exist.

The problem here is that because the file doesn't exist, each append file operation is trying to create a new file, and therefore a new inode number.

Our locks on inode doesn't help here because it's all locking on different inode numbers.

If the file already did exist, then concurrent appendFile would work as they would all lock on the same inode number.

It seems what we need to do is to ensure that our open on the same "key" here is the same lock. In this case the key is the directory entry for that directory.

The same situation is actually happening with concurrent writeFile when the file doesn't exist, because each operation is technically creating new inodes.

See this:

    test.only('10 short writes with EncryptedFS.writeFile', async () => {
      const contents = [
        'one',
        'two',
        'three',
        'four',
        'five',
        'six',
        'seven',
        'eight',
        'nine',
        'ten',
      ];
      const promises: Array<any> = [];
      for (const content of contents) {
        promises.push(efs.writeFile('test', content));
      }
      await Promise.all(promises);
      // One of the writes wins
      expect(contents).toContainEqual(await efs.readFile('test', { encoding: 'utf-8' }));

      // Only ten or inode 11 is supposed to be left
      console.log(await efs.readFile('test', { encoding: 'utf-8' }));
      // However all of these inodes still exist (they are just gc-able)
      for (const i of [2,3,4,5,6,7,8,9,10,11]) {
        console.log(await iNodeMgr.get(i as INodeIndex));
      }
    });

This is basically the object-map locking problem (https://gist.github.com/CMCDragonkai/f58f08e7eaab0430ed4467ca35527a42). How do we ensure that the creation of a specific object is also locked.

When I developed the LockBox it was intended to also help this particular usecase, so we need to figure out how to resolve this in EFS, and it can be used in the rest of PK.

CMCDragonkai commented 2 years ago

The problem is that our lockbox is mapping inode index to locks, but at the abstraction level of EFS, it's trying to create the same file indexed by the file name/directory entry.

So while it is correct that we should be indexing locks by the inode index, we also need to index the inode index by the directory entry/file name.

This would imply that each directory should be locking the inode allocation for a given entry as well. So if there 10 async requests for a new inode for the entry file, then each request should be given the same inode index.

CMCDragonkai commented 2 years ago

There are potential solutions for this.

  1. Introduce a LockBox for each directory that is indexed on each directory entry. Remember LockBox are in-memory objects, and we no longer have a Directory object. It's all managed by INodeManager. Which means keeping references to the LockBox would have be done by the INodeManager which is kind of annoying. You'd have to create a LockBox for each directory inode possibly dynamically. This would enforce mutual exclusion at the directory-level so that creation operations involving the same entry can be blocked.
  2. Coalesce inode allocation for each directory such that allocating an inode for entry X, always results in Y. That is for the same X, get the same Y. Once you have the same Y which is the same inode index, then you can rely on the mutual exclusion already maintained by the INodeManager lockbox indexed by inode index. This can be done without introducing additional locks simply because inode allocation is synchronous operation on the js-resource-counter tree structure.

I prefer solution 2, and will be attempting this. This however requires an intermediate in-memory map data structure that maps directory entry names to allocated inode indexes. This would have to be maintained by INodeManager or EncryptedFS. It seems like a INodeManager problem. Because Directory objects do not exist, you still have to dynamically (synchronously) construct this map structure for when directory inodes are being interacted with. At the very least, solution 2 doesn't require using the LockBox, because inode allocations are synchronous.

CMCDragonkai commented 2 years ago

Under EncryptedFS._open, this line is where the node is allocated:

          if (target == null) {
            if (!navigated.remaining && openFlags & constants.O_CREAT) {
              let navigatedDirStat;
              const fileINode = this.iNodeMgr.inoAllocate();
              await this.iNodeMgr.withTransactionF(
                fileINode,
                navigated.dir,
                async (tran) => {

The indoe is allocated being locked.

We could use the navigated object to provide properties that index this inode allocation.

navigated.dir // directory inode index
navigated.target // target inode index (it is undefined)
navigated.name // directory entry name

We could pass this into inoAllocate(navigated.dir, navigated.name) and inoDeallocate(iNode, navigated.dir, navigated.name). There are only 5 places in EFS where inoAllocate is called, and it is always under the context of creating something under a directory EXCEPT for the root inode. Immediately afterwards a transaction context is created that does something like: withTransactionF(navigated.dir, targetInode). This should enable us to coalesce calls to allocate inodes. Since we would need both parameters like navigated.dir and navigated.name, we could pass an object or tuple {dir: navigated.dir, name: navigated.name} that is optional. The inoDeallocate right now just takes the inode index. If our coalescing structure is bimap, we could look up the inode as well and deallocate it rather than needing to pass the dir and name as well.

There is an additional issue here.

The _open will wrap the functionality in a transaction locked on the target inode if it existed prior. However if it didn't exist, and it was created then, the transaction context created for the new inode index doesn't wrap the subsequent file descriptor creation.

The subsequent code checks the permission of the created inode or existing inode and creates a file descriptor. The createFd doesn't actually have to be asynchronous atm. But the expectation is that the inode does exist though.

This has a race condition where it's possible that inode gets deleted before FileDescriptorManager.createFd is called. It's quite unlikely though because no other code knows about that inode index yet. But it is possible because another context might fetch all the inodes that directory.

It seems that would be safer to ensure that the subsequent operations at the end of _open should also be within the new transaction context involving the creation of a new inode.

CMCDragonkai commented 2 years ago

It seems we might need tran.queueFinally to be able to call inoAllocated(navigated) so that way the intermediate entries can be removed.

  /**
   * Allocates an INodeIndex
   * This is a purely functional method
   * If passed without the navigated parameter, it assumed that this is for the root INodeIndex
   * If passed with the navigated parameter, it will return the same INodeIndex for the same input parameters
   * If the inode allocation is unsuccessful, you must deallocate the inode
   * Not doing so will result in memory-leak of INodeIndexes
   */
  public inoAllocate(navigated?: Readonly<{ dir: INodeIndex, name: string }>): INodeIndex {
    let key: string;
    if (navigated == null) {
      key = ''
    } else {
      key = navigated.dir + navigated.name;
    }
    let iNodeIndex = this.iNodeAllocations.get(key);
    if (iNodeIndex == null) {
      iNodeIndex = this.iNodeCounter.allocate();
      this.iNodeAllocations.set(key, iNodeIndex!);
    }
    return iNodeIndex!;
    // return this.iNodeCounter.allocate();
  }

  /**
   * Remove the entry from the inodeAllocations
   * If you fail to do so, you should also delete it from the table
   */
  public inoAllocated(navigated?: Readonly<{ dir: INodeIndex, name: string }>): void {
    let key: string;
    if (navigated == null) {
      key = ''
    } else {
      key = navigated.dir + navigated.name;
    }
    this.iNodeAllocations.delete(key);
  }

  public inoDeallocate(ino: INodeIndex): void {
    return this.iNodeCounter.deallocate(ino);
  }

Notice that inoDeallocate is not involved here. Instead there is a inoAllocated that finishes by removing the mapping entry from the intermediate data structure.

However this exposes another bug, where the file no longer exists if there are multiple concurrent writeFile.

It doesn't quite make sense, because readdir is showing that the entry does exist.

This may be because navigatedFrom is also breaking up the transaction contexts, and we really need that to be within 1 transaction... I'm not sure if there was a good reason to break up the transaction contexts.

CMCDragonkai commented 2 years ago

Will need to review https://gitlab.com/MatrixAI/Engineering/Polykey/js-encryptedfs/-/merge_requests/46#note_649228698 for reasons for why the transaction contexts were broken up in navigateFrom.

CMCDragonkai commented 2 years ago

I'm thinking the inoAllocate and inoDeallocate can be part of the ResourceAcquire type as well. This way we can compose the inode allocation with lock allocation. Right now, we usually want to lock a given inode, but if we are creating a new inode (on specific name in a particular directory), then we also want to lock on that. I think there might be a way to incorporate this into the withTransactionF, by passing something like an undefined to represent the need for a new inode... This can avoid us having to use tran.queueFailure and tran.queueSuccess.

The main thing is that concurrent operations get the same inode when trying to create the same key. Afterwards, once the inode is created, the entry can removed, as all subsequent operations will find that inode and should not be trying to create the inode.

CMCDragonkai commented 2 years ago

Turns out the fileGetBlocks was buggy. It didn't handle the case of sparse blocks, where the file descriptor is set to a position beyond the end of the file. When returning the zeroed-out blocks, it's important to increment the blockCount otherwise it will result in an infinite loop.

I added a new test read sparse blocks from a file to tests/inodes/INodeManager.file.test.ts to cover this case.

Note that fallocate and ftruncate could create sparse files, but at the moment, the implementation doesn't do this. I believe it just writes zeroed-out blocks, but I haven't looked too deeply.

CMCDragonkai commented 2 years ago

At some point this the O_TRUNC was added in:

flags & (vfs.constants.O_WRONLY | (flags & (vfs.constants.O_RDWR | (flags & vfs.constants.O_TRUNC))))

From originally:

            flags & (vfs.constants.O_WRONLY | (flags & vfs.constants.O_RDWR))

I believe this was a linting change. I'm not sure if it has the same effect.

I think the original is much clearer. And I'd like to test if eslint is the one doing this.

CMCDragonkai commented 2 years ago

I have some success by restructuring the _open.

Note that INodeManager.inoAllocation() is now a method that returns ResourceAcquire<INodeIndex>. This enables futher composition to become withF.

Furthermore although ResourceAcquire is asynchronous. It is actually synchronous internally, there's no await internally. This means it really behaves synchronously because promises are eagerly-evaluated and not lazy-evaluated.

For example:

function doThisSync () {
  console.log('called do this sync');
  return 1;
}

async function doThis () {
  console.log('called do this async');
  return 1;
}
async function main () {
  const p = doThis();
  doThisSync();
  await p;
}

main();

Running this will show called do this async gets called first before called do this sync. In fact, anything synchronous before the first await in an asynchronous function would be synchronously executed.

This should mean that our mapping to inode indexes should work.

However we then need to check if the inode did in fact get created, and if so, we need to restart the opening procedure. I've done this by using a while loop. But it does involving releasing the transaction and going back to attempting to lock the transaction. This can mean things get out of order since we aren't preserving the same transaction. I'd like to preserve it, but this requires some thinking, but it may just be cosmetic. Further testing required.

CMCDragonkai commented 2 years ago

Additional issue is that the navigateFrom uses alot of transactional contexts. When I changed to bringing them into 1 transactional context, it resulted in not being able to find a particular target. A bit strange. So testing this.

CMCDragonkai commented 2 years ago

And now that we have switched over _open to use inoAllocation, the other methods of EncryptedFS that creates inodes will need to do the same such as mkdir, mknod, symlink.


The navigateFrom also should be restructured to directly use the ResourceAcquire types. Trying to nest itself within withTransactionF becomes problematic because it is a recursive function that sometimes calles this.navigate and this.navigateFrom. It also mutates it's parameters as a sort of reference variables that is used across the recursions. A hold over from the original VFS design.

If we are locking we can only be locking the current directory, and once we navigate out, we need to release our lock.

The _open was non-recursive which was a bit easier, whereas here, we don't actually want to maintain transaction context under the recursive call graph. Therefore as soon as we do a "tail call here" we want to release the transaction for the current directory.

CMCDragonkai commented 2 years ago

TODOs...

  1. [x] Restructuring _open still doesn't preserve the transaction object once the node is created, once it goes to the top of the loop, the same transaction context should be maintained.
  2. Investigate unclear linting change with the bitwise flag test in https://github.com/MatrixAI/js-encryptedfs/pull/63#issuecomment-1105028416
  3. [x] Restructure navigateFrom so it's using a single transaction context for the directory it's interrogating at 1 recursion level, but subsequent recursions should not hold the transaction context.
  4. [x] Switch all uses of inoAllocate to using inoAllocations resource context instead. This may need to be done in other situations too like in INodeManager tests, the inoAllocate may not be necessary anymore.
  5. [ ] Add additional concurrent tests that mix up mkdir, mknod, _open to see their effects.

Additionally note that concurrent calls to writeFile results in interleaving the results. According to Node FS docs, it is "unsafe" to call writeFile concurrently. So something like:

  await Promise.all([
    fsPromises.writeFile('./tmp/ctest', 'one'),
    fsPromises.writeFile('./tmp/ctest', 'two'),
    fsPromises.writeFile('./tmp/ctest', 'three'),
    fsPromises.writeFile('./tmp/ctest', 'ten'),
  ]);

  console.log(await fsPromises.readFile('./tmp/ctest', { encoding: 'utf-8'}));

In fact results in tenee.

EFS now mirrors this behaviour.