From a7cd8c4ec0a4cfae7080e5253a6c18cc8219e642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=9A=93?= Date: Fri, 22 Dec 2023 12:50:34 -0500 Subject: [PATCH] Implement writing to archives (#78) * feat: implement temporary file creation (native and web) * perf: back archiver files with temp files instead of cursors * perf: archiver now uses a trie to cache paths * fix: trie needs to create parent when removing dirs * style: remove redundant `use` Blame my language server for whatever this is. * fix: fix trie edge cases * style: remove `Arc` from `FileSystemTrie` * refactor: temporary file creation now returns `std::io::Result` * feat: implement archive file writing for RMXP and RMVX * fix: archive file flush now preserves temp file stream pos * style: `decrypt_file` now returns a file instead of an iterator * chore: fix web build compilation errors * fix: fix list filesystem's file creation semantics The list filesystem should only create files in filesystems where the parent directory of the file exists. * feat: implement file truncation * perf: optimize operations needed to write to archives * style: rename misleading argument in `read_file_xor` * perf: don't flush archives unless modified * refactor: `File::metadata` now returns `std::io::Result` * refactor: remove all unsafe unwraps from the web filesystem * feat: implement `ExactSizeIterator` for `FileSystemTrieIter` * feat: implement archive file writing for RMVXA * feat: update the trie after writing to the archive * chore: fix misleading comment in trie.rs * fix: mark files as modified when truncating * feat: implement file creation in archives * refactor: move archive file truncation into utility function * refactor: add utility function `as_file` to files * refactor: store file header offsets * fix: fix edge cases for RMVXA archives in `move_file_and_truncate` * feat: implement removing files and dirs in archives * fix: fix edge case in `FileSystemTrieIter` * fix: don't allow file truncation without write access * feat: implement renaming files in archives * feat: implement directory creation in archives * refactor: split archiver filesystem into smaller files * style: remove unnecessary generics * chore: satiate clippy, the all-seeing * style: `&mut impl Read` -> `impl Read` * style: remove `&mut` from `read_file_xor` as well --- Cargo.lock | 40 ++ crates/filesystem/Cargo.toml | 13 +- crates/filesystem/src/archiver.rs | 343 ---------- crates/filesystem/src/archiver/file.rs | 224 +++++++ crates/filesystem/src/archiver/filesystem.rs | 656 +++++++++++++++++++ crates/filesystem/src/archiver/mod.rs | 37 ++ crates/filesystem/src/archiver/util.rs | 295 +++++++++ crates/filesystem/src/erased.rs | 6 +- crates/filesystem/src/lib.rs | 18 +- crates/filesystem/src/list.rs | 3 +- crates/filesystem/src/native.rs | 55 +- crates/filesystem/src/project.rs | 9 +- crates/filesystem/src/trie.rs | 392 +++++++++++ crates/filesystem/src/web/events.rs | 88 ++- crates/filesystem/src/web/mod.rs | 32 +- crates/filesystem/src/web/util.rs | 37 ++ 16 files changed, 1881 insertions(+), 367 deletions(-) delete mode 100644 crates/filesystem/src/archiver.rs create mode 100644 crates/filesystem/src/archiver/file.rs create mode 100644 crates/filesystem/src/archiver/filesystem.rs create mode 100644 crates/filesystem/src/archiver/mod.rs create mode 100644 crates/filesystem/src/archiver/util.rs create mode 100644 crates/filesystem/src/trie.rs diff --git a/Cargo.lock b/Cargo.lock index e1136fc7..65c12cbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2553,6 +2553,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "iter-read" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a598c1abae8e3456ebda517868b254b6bc2a9bb6501ffd5b9d0875bf332e048b" + [[package]] name = "itertools" version = "0.11.0" @@ -3040,6 +3046,7 @@ dependencies = [ "egui", "flume", "indexed_db_futures", + "iter-read", "itertools", "js-sys", "luminol-config", @@ -3047,12 +3054,14 @@ dependencies = [ "once_cell", "oneshot", "parking_lot", + "qp-trie", "rand", "rfd", "ron", "rust-ini", "serde", "slab", + "tempfile", "thiserror", "tracing", "wasm-bindgen", @@ -3471,6 +3480,12 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + [[package]] name = "nix" version = "0.23.2" @@ -4327,6 +4342,16 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qp-trie" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec628a7d1fc2c5f5a551eb34e01e08df62d55203640959a79a9a2859c797a97" +dependencies = [ + "new_debug_unreachable", + "unreachable", +] + [[package]] name = "quick-xml" version = "0.31.0" @@ -5819,6 +5844,15 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "unreachable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" +dependencies = [ + "void", +] + [[package]] name = "url" version = "2.5.0" @@ -5897,6 +5931,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "vtparse" version = "0.6.2" diff --git a/crates/filesystem/Cargo.toml b/crates/filesystem/Cargo.toml index 53f3f209..74f471bd 100644 --- a/crates/filesystem/Cargo.toml +++ b/crates/filesystem/Cargo.toml @@ -39,12 +39,19 @@ tracing.workspace = true luminol-config.workspace = true +rand.workspace = true + +iter-read = "1.0.1" +qp-trie = "0.8.2" + [target.'cfg(windows)'.dependencies] winreg = "0.51.0" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3.8.1" + [target.'cfg(target_arch = "wasm32")'.dependencies] once_cell.workspace = true -rand.workspace = true slab.workspace = true luminol-web = { version = "0.4.0", path = "../web/" } @@ -69,4 +76,8 @@ web-sys = { version = "0.3", features = [ "FileSystemRemoveOptions", "FileSystemWritableFileStream", "WritableStream", + + "Navigator", + "StorageManager", + "Window", ] } diff --git a/crates/filesystem/src/archiver.rs b/crates/filesystem/src/archiver.rs deleted file mode 100644 index f18a1726..00000000 --- a/crates/filesystem/src/archiver.rs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (C) 2023 Lily Lyons -// -// This file is part of Luminol. -// -// Luminol is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Luminol is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Luminol. If not, see . - -use itertools::Itertools; -use std::io::{prelude::*, Cursor, SeekFrom}; - -use crate::{DirEntry, Error, Metadata, OpenFlags}; - -#[derive(Debug, Default)] -pub struct FileSystem -where - T: Read + Write + Seek + Send + Sync, -{ - files: dashmap::DashMap, - directories: dashmap::DashMap>, - archive: parking_lot::Mutex, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct Entry { - offset: u64, - size: u64, - start_magic: u32, -} - -const MAGIC: u32 = 0xDEADCAFE; -const HEADER: &[u8] = b"RGSSAD\0"; - -impl FileSystem -where - T: Read + Write + Seek + Send + Sync, -{ - pub fn new(mut file: T) -> Result { - let version = Self::read_header(&mut file)?; - - let files = dashmap::DashMap::new(); - let directories = dashmap::DashMap::new(); - - fn read_u32(file: &mut F) -> Result - where - F: Read + Write + Seek + Send + Sync, - { - let mut buffer = [0; 4]; - file.read_exact(&mut buffer)?; - Ok(u32::from_le_bytes(buffer)) - } - - fn read_u32_xor(file: &mut F, key: u32) -> Result - where - F: Read + Write + Seek + Send + Sync, - { - let result = read_u32(file)?; - Ok(result ^ key) - } - - match version { - 1 | 2 => { - let mut magic = MAGIC; - - while let Ok(name_len) = read_u32_xor(&mut file, Self::advance_magic(&mut magic)) { - let mut name = vec![0; name_len as usize]; - file.read_exact(&mut name).unwrap(); - for byte in name.iter_mut() { - let char = *byte ^ Self::advance_magic(&mut magic) as u8; - if char == b'\\' { - *byte = b'/'; - } else { - *byte = char; - } - } - let name = camino::Utf8PathBuf::from(String::from_utf8(name)?); - - Self::process_path(&directories, &name); - - let entry_len = read_u32_xor(&mut file, Self::advance_magic(&mut magic))?; - - let entry = Entry { - size: entry_len as u64, - offset: file.stream_position()?, - start_magic: magic, - }; - - files.insert(name, entry); - - file.seek(SeekFrom::Start(entry.offset + entry.size))?; - } - } - 3 => { - let mut u32_buf = [0; 4]; - file.read_exact(&mut u32_buf)?; - - let base_magic = u32::from_le_bytes(u32_buf); - let base_magic = (base_magic * 9) + 3; - - while let Ok(offset) = read_u32_xor(&mut file, base_magic) { - if offset == 0 { - break; - } - - let entry_len = read_u32_xor(&mut file, base_magic)?; - let magic = read_u32_xor(&mut file, base_magic)?; - let name_len = read_u32_xor(&mut file, base_magic)?; - - let mut name = vec![0; name_len as usize]; - file.read_exact(&mut name)?; - for (i, byte) in name.iter_mut().enumerate() { - let char = *byte ^ (base_magic >> (8 * (i % 4))) as u8; - if char == b'\\' { - *byte = b'/'; - } else { - *byte = char; - } - } - let name = camino::Utf8PathBuf::from(String::from_utf8(name)?); - - Self::process_path(&directories, &name); - - let entry = Entry { - size: entry_len as u64, - offset: offset as u64, - start_magic: magic, - }; - files.insert(name, entry); - } - } - _ => return Err(Error::InvalidHeader), - } - - /* - for dir in directories.iter() { - println!("==========="); - println!("{}", dir.key()); - for i in dir.value().iter() { - println!("{}", &*i); - } - println!("----------"); - } - */ - - Ok(FileSystem { - files, - directories, - archive: parking_lot::Mutex::new(file), - }) - } - - fn process_path( - directories: &dashmap::DashMap>, - path: impl AsRef, - ) { - for (a, b) in path.as_ref().ancestors().tuple_windows() { - directories - .entry(b.to_path_buf()) - .or_default() - .insert(a.strip_prefix(b).unwrap_or(a).to_path_buf()); - } - } - - fn advance_magic(magic: &mut u32) -> u32 { - let old = *magic; - - *magic = magic.wrapping_mul(7).wrapping_add(3); - - old - } - - fn read_header(file: &mut T) -> Result { - let mut header_buf = [0; 8]; - - file.read_exact(&mut header_buf)?; - - if !header_buf.starts_with(HEADER) { - return Err(Error::InvalidHeader); - } - - Ok(header_buf[7]) - } -} - -#[derive(Debug)] -pub struct File { - cursor: Cursor>, // TODO WRITE -} - -impl std::io::Write for File { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.cursor.write(buf) - } - - fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result { - self.cursor.write_vectored(bufs) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -impl std::io::Read for File { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - self.cursor.read(buf) - } - - fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result { - self.cursor.read_vectored(bufs) - } - - fn read_exact(&mut self, buf: &mut [u8]) -> std::io::Result<()> { - self.cursor.read_exact(buf) - } -} - -impl std::io::Seek for File { - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { - self.cursor.seek(pos) - } - - fn stream_position(&mut self) -> std::io::Result { - self.cursor.stream_position() - } -} - -impl crate::File for File { - fn metadata(&self) -> crate::Result { - Ok(Metadata { - is_file: true, - size: self.cursor.get_ref().len() as u64, - }) - } -} - -impl crate::FileSystem for FileSystem -where - T: Read + Write + Seek + Send + Sync + 'static, -{ - type File = File; - - fn open_file( - &self, - path: impl AsRef, - flags: OpenFlags, - ) -> Result { - if flags.contains(OpenFlags::Write) { - return Err(Error::NotSupported); - } - - let entry = self.files.get(path.as_ref()).ok_or(Error::NotExist)?; - let mut buf = vec![0; entry.size as usize]; - - { - let mut archive = self.archive.lock(); - archive.seek(SeekFrom::Start(entry.offset))?; - archive.read_exact(&mut buf)?; - } - - let mut magic = entry.start_magic; - let mut j = 0; - for byte in buf.iter_mut() { - if j == 4 { - j = 0; - magic = magic.wrapping_mul(7).wrapping_add(3); - } - - *byte ^= magic.to_le_bytes()[j]; - - j += 1; - } - - let cursor = Cursor::new(buf); - Ok(File { cursor }) - } - - fn metadata(&self, path: impl AsRef) -> Result { - let path = path.as_ref(); - if let Some(entry) = self.files.get(path) { - return Ok(Metadata { - is_file: true, - size: entry.size, - }); - } - - if let Some(directory) = self.directories.get(path) { - return Ok(Metadata { - is_file: false, - size: directory.len() as u64, - }); - } - - Err(Error::NotExist) - } - - fn rename( - &self, - _from: impl AsRef, - _to: impl AsRef, - ) -> std::result::Result<(), Error> { - Err(Error::NotSupported) - } - - fn create_dir(&self, _path: impl AsRef) -> Result<(), Error> { - Err(Error::NotSupported) - } - - fn exists(&self, path: impl AsRef) -> Result { - let path = path.as_ref(); - Ok(self.files.contains_key(path) || self.directories.contains_key(path)) - } - - fn remove_dir(&self, _path: impl AsRef) -> Result<(), Error> { - Err(Error::NotSupported) - } - - fn remove_file(&self, _path: impl AsRef) -> Result<(), Error> { - Err(Error::NotSupported) - } - - fn read_dir(&self, path: impl AsRef) -> Result, Error> { - let path = path.as_ref(); - let directory = self.directories.get(path).ok_or(Error::NotExist)?; - directory - .iter() - .map(|entry| { - let path = path.join(&*entry); - let metadata = self.metadata(&path)?; - Ok(DirEntry { path, metadata }) - }) - .try_collect() - } -} diff --git a/crates/filesystem/src/archiver/file.rs b/crates/filesystem/src/archiver/file.rs new file mode 100644 index 00000000..0a761d76 --- /dev/null +++ b/crates/filesystem/src/archiver/file.rs @@ -0,0 +1,224 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +use std::io::{ + prelude::*, + BufReader, + ErrorKind::{InvalidData, PermissionDenied}, + SeekFrom, +}; + +use super::util::{move_file_and_truncate, read_file_xor, regress_magic}; +use super::Trie; +use crate::File as _; +use crate::Metadata; + +#[derive(Debug)] +pub struct File +where + T: crate::File, +{ + pub(super) archive: Option>>, + pub(super) trie: Option>>, + pub(super) path: camino::Utf8PathBuf, + pub(super) read_allowed: bool, + pub(super) tmp: crate::host::File, + pub(super) modified: parking_lot::Mutex, + pub(super) version: u8, + pub(super) base_magic: u32, +} + +impl Drop for File +where + T: crate::File, +{ + fn drop(&mut self) { + if self.archive.is_some() { + let _ = self.flush(); + } + } +} + +impl std::io::Write for File +where + T: crate::File, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.archive.is_some() { + let mut modified = self.modified.lock(); + *modified = true; + let count = self.tmp.write(buf)?; + Ok(count) + } else { + Err(PermissionDenied.into()) + } + } + + fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result { + if self.archive.is_some() { + let mut modified = self.modified.lock(); + *modified = true; + let count = self.tmp.write_vectored(bufs)?; + Ok(count) + } else { + Err(PermissionDenied.into()) + } + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut modified = self.modified.lock(); + if !*modified { + return Ok(()); + } + + let Some(archive) = &self.archive else { + return Err(PermissionDenied.into()); + }; + let Some(trie) = &self.trie else { + return Err(PermissionDenied.into()); + }; + let mut archive = archive.lock(); + let mut trie = trie.write(); + let archive_len = archive.metadata()?.size; + + let tmp_stream_position = self.tmp.stream_position()?; + self.tmp.flush()?; + self.tmp.seek(SeekFrom::Start(0))?; + + // If the size of the file has changed, rotate the archive to place the file at the end of + // the archive before writing the new contents of the file + let mut entry = *trie.get_file(&self.path).ok_or(InvalidData)?; + let old_size = entry.size; + let new_size = self.tmp.metadata()?.size; + if old_size != new_size { + move_file_and_truncate( + &mut archive, + &mut trie, + &self.path, + self.version, + self.base_magic, + )?; + entry = *trie.get_file(&self.path).ok_or(InvalidData)?; + + // Write the new length of the file to the archive + match self.version { + 1 | 2 => { + let mut magic = entry.start_magic; + regress_magic(&mut magic); + archive.seek(SeekFrom::Start( + entry.body_offset.checked_sub(4).ok_or(InvalidData)?, + ))?; + archive.write_all(&(new_size as u32 ^ magic).to_le_bytes())?; + } + + 3 => { + archive.seek(SeekFrom::Start(entry.header_offset + 4))?; + archive.write_all(&(new_size as u32 ^ self.base_magic).to_le_bytes())?; + } + + _ => return Err(InvalidData.into()), + } + + // Write the new length of the file to the trie + trie.get_mut_file(&self.path).ok_or(InvalidData)?.size = new_size; + } + + // Now write the new contents of the file + archive.seek(SeekFrom::Start(entry.body_offset))?; + let mut reader = BufReader::new(&mut self.tmp); + std::io::copy( + &mut read_file_xor(&mut reader, entry.start_magic), + archive.as_file(), + )?; + drop(reader); + self.tmp.seek(SeekFrom::Start(tmp_stream_position))?; + + if old_size > new_size { + archive.set_len( + archive_len + .checked_sub(old_size) + .ok_or(InvalidData)? + .checked_add(new_size) + .ok_or(InvalidData)?, + )?; + } + archive.flush()?; + *modified = false; + Ok(()) + } +} + +impl std::io::Read for File +where + T: crate::File, +{ + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.read_allowed { + self.tmp.read(buf) + } else { + Err(PermissionDenied.into()) + } + } + + fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result { + if self.read_allowed { + self.tmp.read_vectored(bufs) + } else { + Err(PermissionDenied.into()) + } + } + + fn read_exact(&mut self, buf: &mut [u8]) -> std::io::Result<()> { + if self.read_allowed { + self.tmp.read_exact(buf) + } else { + Err(PermissionDenied.into()) + } + } +} + +impl std::io::Seek for File +where + T: crate::File, +{ + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + self.tmp.seek(pos) + } + + fn stream_position(&mut self) -> std::io::Result { + self.tmp.stream_position() + } +} + +impl crate::File for File +where + T: crate::File, +{ + fn metadata(&self) -> std::io::Result { + self.tmp.metadata() + } + + fn set_len(&self, new_size: u64) -> std::io::Result<()> { + if self.archive.is_some() { + let mut modified = self.modified.lock(); + *modified = true; + self.tmp.set_len(new_size) + } else { + Err(PermissionDenied.into()) + } + } +} diff --git a/crates/filesystem/src/archiver/filesystem.rs b/crates/filesystem/src/archiver/filesystem.rs new file mode 100644 index 00000000..f63c7c37 --- /dev/null +++ b/crates/filesystem/src/archiver/filesystem.rs @@ -0,0 +1,656 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +use itertools::Itertools; +use rand::Rng; +use std::io::{ + prelude::*, + BufReader, BufWriter, + ErrorKind::{AlreadyExists, InvalidData}, + SeekFrom, +}; + +use super::util::{ + advance_magic, move_file_and_truncate, read_file_xor, read_header, read_u32_xor, regress_magic, +}; +use super::{Entry, File, Trie, MAGIC}; +use crate::{DirEntry, Error, Metadata, OpenFlags}; + +#[derive(Debug, Default)] +pub struct FileSystem { + pub(super) trie: std::sync::Arc>, + pub(super) archive: std::sync::Arc>, + pub(super) version: u8, + pub(super) base_magic: u32, +} + +impl FileSystem +where + T: crate::File, +{ + pub fn new(mut file: T) -> Result { + let mut reader = BufReader::new(&mut file); + + let version = read_header(&mut reader)?; + + let mut trie = crate::FileSystemTrie::new(); + + let mut base_magic = MAGIC; + + match version { + 1 | 2 => { + let mut magic = MAGIC; + + while let Ok(path_len) = read_u32_xor(&mut reader, advance_magic(&mut magic)) { + let mut path = vec![0; path_len as usize]; + reader.read_exact(&mut path)?; + for byte in path.iter_mut() { + let char = *byte ^ advance_magic(&mut magic) as u8; + if char == b'\\' { + *byte = b'/'; + } else { + *byte = char; + } + } + let path = camino::Utf8PathBuf::from(String::from_utf8(path)?); + + let entry_len = read_u32_xor(&mut reader, advance_magic(&mut magic))?; + + let stream_position = reader.stream_position()?; + let entry = Entry { + size: entry_len as u64, + header_offset: stream_position + .checked_sub(path_len as u64 + 8) + .ok_or(Error::IoError(InvalidData.into()))?, + body_offset: stream_position, + start_magic: magic, + }; + + trie.create_file(path, entry); + + reader.seek(SeekFrom::Start(entry.body_offset + entry.size))?; + } + } + 3 => { + let mut u32_buf = [0; 4]; + reader.read_exact(&mut u32_buf)?; + + base_magic = u32::from_le_bytes(u32_buf); + base_magic = (base_magic * 9) + 3; + + while let Ok(body_offset) = read_u32_xor(&mut reader, base_magic) { + if body_offset == 0 { + break; + } + let header_offset = reader + .stream_position()? + .checked_sub(4) + .ok_or(Error::IoError(InvalidData.into()))?; + + let entry_len = read_u32_xor(&mut reader, base_magic)?; + let magic = read_u32_xor(&mut reader, base_magic)?; + let path_len = read_u32_xor(&mut reader, base_magic)?; + + let mut path = vec![0; path_len as usize]; + reader.read_exact(&mut path)?; + for (i, byte) in path.iter_mut().enumerate() { + let char = *byte ^ (base_magic >> (8 * (i % 4))) as u8; + if char == b'\\' { + *byte = b'/'; + } else { + *byte = char; + } + } + let path = camino::Utf8PathBuf::from(String::from_utf8(path)?); + + let entry = Entry { + size: entry_len as u64, + header_offset, + body_offset: body_offset as u64, + start_magic: magic, + }; + trie.create_file(path, entry); + } + } + _ => return Err(Error::InvalidHeader), + } + + Ok(FileSystem { + trie: std::sync::Arc::new(parking_lot::RwLock::new(trie)), + archive: std::sync::Arc::new(parking_lot::Mutex::new(file)), + version, + base_magic, + }) + } +} + +impl crate::FileSystem for FileSystem +where + T: crate::File, +{ + type File = File; + + fn open_file( + &self, + path: impl AsRef, + flags: OpenFlags, + ) -> Result { + let path = path.as_ref(); + let mut tmp = crate::host::File::new()?; + let mut created = false; + + { + let mut archive = self.archive.lock(); + let mut trie = self.trie.write(); + let entry = *trie.get_file(path).ok_or(Error::NotExist)?; + archive.seek(SeekFrom::Start(entry.body_offset))?; + + if flags.contains(OpenFlags::Create) && !trie.contains_file(path) { + created = true; + match self.version { + 1 | 2 => { + archive.seek(SeekFrom::Start(8))?; + let mut reader = BufReader::new(archive.as_file()); + let mut magic = MAGIC; + while let Ok(path_len) = + read_u32_xor(&mut reader, advance_magic(&mut magic)) + { + for _ in 0..path_len { + advance_magic(&mut magic); + } + reader.seek(SeekFrom::Current(path_len as i64))?; + let entry_len = read_u32_xor(&mut reader, advance_magic(&mut magic))?; + reader.seek(SeekFrom::Current(entry_len as i64))?; + } + drop(reader); + + let archive_len = archive.seek(SeekFrom::End(0))?; + let mut writer = BufWriter::new(archive.as_file()); + writer.write_all( + &(path.as_str().bytes().len() as u32 ^ advance_magic(&mut magic)) + .to_le_bytes(), + )?; + writer.write_all( + &path + .as_str() + .bytes() + .map(|b| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ advance_magic(&mut magic) as u8 + }) + .collect_vec(), + )?; + writer.write_all(&advance_magic(&mut magic).to_le_bytes())?; + writer.flush()?; + drop(writer); + + trie.create_file( + path, + Entry { + header_offset: archive_len, + body_offset: archive_len + path.as_str().bytes().len() as u64 + 8, + size: 0, + start_magic: magic, + }, + ); + } + + 3 => { + let mut tmp = crate::host::File::new()?; + + let extra_data_len = path.as_str().bytes().len() as u32 + 16; + let mut headers = Vec::new(); + + archive.seek(SeekFrom::Start(12))?; + let mut reader = BufReader::new(archive.as_file()); + let mut position = 12; + while let Ok(offset) = read_u32_xor(&mut reader, self.base_magic) { + if offset == 0 { + break; + } + headers.push((position, offset)); + reader.seek(SeekFrom::Current(8))?; + let path_len = read_u32_xor(&mut reader, self.base_magic)?; + position = reader.seek(SeekFrom::Current(path_len as i64))?; + } + drop(reader); + + archive.seek(SeekFrom::Start(position))?; + std::io::copy(archive.as_file(), &mut tmp)?; + tmp.flush()?; + + let magic: u32 = rand::thread_rng().gen(); + let archive_len = archive.metadata()?.size as u32 + extra_data_len; + let mut writer = BufWriter::new(archive.as_file()); + for (position, offset) in headers { + writer.seek(SeekFrom::Start(position))?; + writer.write_all( + &((offset + extra_data_len) ^ self.base_magic).to_le_bytes(), + )?; + } + writer.seek(SeekFrom::Start(position))?; + writer.write_all(&(archive_len ^ self.base_magic).to_le_bytes())?; + writer.write_all(&self.base_magic.to_le_bytes())?; + writer.write_all(&(magic ^ self.base_magic).to_le_bytes())?; + writer.write_all( + &(path.as_str().bytes().len() as u32 ^ self.base_magic).to_le_bytes(), + )?; + writer.write_all( + &path + .as_str() + .bytes() + .enumerate() + .map(|(i, b)| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ (self.base_magic >> (8 * (i % 4))) as u8 + }) + .collect_vec(), + )?; + tmp.seek(SeekFrom::Start(0))?; + std::io::copy(&mut tmp, &mut writer)?; + writer.flush()?; + drop(writer); + + trie.create_file( + path, + Entry { + header_offset: position, + body_offset: archive_len as u64, + size: 0, + start_magic: magic, + }, + ); + } + + _ => return Err(Error::NotSupported), + } + } else if !flags.contains(OpenFlags::Truncate) { + let mut adapter = BufReader::new(archive.as_file().take(entry.size)); + std::io::copy( + &mut read_file_xor(&mut adapter, entry.start_magic), + &mut tmp, + )?; + tmp.flush()?; + } + } + + tmp.seek(SeekFrom::Start(0))?; + Ok(File { + archive: flags + .contains(OpenFlags::Write) + .then(|| self.archive.clone()), + trie: flags.contains(OpenFlags::Write).then(|| self.trie.clone()), + path: path.to_owned(), + read_allowed: flags.contains(OpenFlags::Read), + tmp, + modified: parking_lot::Mutex::new( + !created && flags.contains(OpenFlags::Write) && flags.contains(OpenFlags::Truncate), + ), + version: self.version, + base_magic: self.base_magic, + }) + } + + fn metadata(&self, path: impl AsRef) -> Result { + let path = path.as_ref(); + let trie = self.trie.read(); + if let Some(entry) = trie.get_file(path) { + Ok(Metadata { + is_file: true, + size: entry.size, + }) + } else if let Some(size) = trie.get_dir_size(path) { + Ok(Metadata { + is_file: false, + size: size as u64, + }) + } else { + Err(Error::NotExist) + } + } + + fn rename( + &self, + from: impl AsRef, + to: impl AsRef, + ) -> std::result::Result<(), Error> { + let from = from.as_ref(); + let to = to.as_ref(); + + let mut archive = self.archive.lock(); + let mut trie = self.trie.write(); + + if trie.contains_dir(from) { + return Err(Error::NotSupported); + } + if trie.contains(to) { + return Err(Error::IoError(AlreadyExists.into())); + } + if !trie.contains_dir(from.parent().ok_or(Error::NotExist)?) { + return Err(Error::NotExist); + } + let Some(old_entry) = trie.get_file(from).copied() else { + return Err(Error::NotExist); + }; + + let archive_len = archive.metadata()?.size; + let from_len = from.as_str().bytes().len(); + let to_len = to.as_str().bytes().len(); + + if from_len != to_len { + match self.version { + 1 | 2 => { + // Move the file contents into a temporary file + let mut tmp = crate::host::File::new()?; + archive.seek(SeekFrom::Start(old_entry.body_offset))?; + let mut reader = BufReader::new(archive.as_file().take(old_entry.size)); + std::io::copy( + &mut read_file_xor(&mut reader, old_entry.start_magic), + &mut tmp, + )?; + tmp.flush()?; + drop(reader); + + // Move the file to the end so that we can change the header size + move_file_and_truncate( + &mut archive, + &mut trie, + from, + self.version, + self.base_magic, + )?; + let mut new_entry = *trie + .get_file(from) + .ok_or(Error::IoError(InvalidData.into()))?; + trie.remove_file(from) + .ok_or(Error::IoError(InvalidData.into()))?; + new_entry.size = old_entry.size; + + let mut magic = new_entry.start_magic; + regress_magic(&mut magic); + regress_magic(&mut magic); + for _ in from.as_str().bytes() { + regress_magic(&mut magic); + } + + // Regenerate the header + archive.seek(SeekFrom::Start(new_entry.header_offset))?; + let mut writer = BufWriter::new(archive.as_file()); + writer.write_all(&(to_len as u32 ^ advance_magic(&mut magic)).to_le_bytes())?; + writer.write_all( + &to.as_str() + .bytes() + .map(|b| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ advance_magic(&mut magic) as u8 + }) + .collect_vec(), + )?; + writer.write_all( + &(old_entry.size as u32 ^ advance_magic(&mut magic)).to_le_bytes(), + )?; + + new_entry.start_magic = magic; + + // Move the file contents to the end + tmp.seek(SeekFrom::Start(0))?; + let mut reader = BufReader::new(&mut tmp); + std::io::copy(&mut read_file_xor(&mut reader, magic), &mut writer)?; + writer.flush()?; + drop(writer); + + trie.create_file(to, new_entry); + } + + 3 => { + // Move everything after the header into a temporary file + let mut tmp = crate::host::File::new()?; + archive.seek(SeekFrom::Start( + old_entry.header_offset + from_len as u64 + 16, + ))?; + std::io::copy(archive.as_file(), &mut tmp)?; + tmp.flush()?; + + // Change the path + archive.seek(SeekFrom::Start(old_entry.header_offset + 12))?; + let mut writer = BufWriter::new(archive.as_file()); + writer.write_all(&(to_len as u32 ^ self.base_magic).to_le_bytes())?; + writer.write_all( + &to.as_str() + .bytes() + .enumerate() + .map(|(i, b)| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ (self.base_magic >> (8 * (i % 4))) as u8 + }) + .collect_vec(), + )?; + trie.remove_file(from) + .ok_or(Error::IoError(InvalidData.into()))?; + trie.create_file(to, old_entry); + + // Move everything else back + tmp.seek(SeekFrom::Start(0))?; + std::io::copy(&mut tmp, &mut writer)?; + writer.flush()?; + drop(writer); + + // Update all of the offsets in the headers + archive.seek(SeekFrom::Start(12))?; + let mut reader = BufReader::new(archive.as_file()); + let mut headers = Vec::new(); + while let Ok(current_body_offset) = read_u32_xor(&mut reader, self.base_magic) { + if current_body_offset == 0 { + break; + } + let current_header_offset = reader + .stream_position()? + .checked_sub(4) + .ok_or(Error::IoError(InvalidData.into()))?; + reader.seek(SeekFrom::Current(8))?; + let current_path_len = read_u32_xor(&mut reader, self.base_magic)?; + + let mut current_path = vec![0; current_path_len as usize]; + reader.read_exact(&mut current_path)?; + for (i, byte) in current_path.iter_mut().enumerate() { + let char = *byte ^ (self.base_magic >> (8 * (i % 4))) as u8; + if char == b'\\' { + *byte = b'/'; + } else { + *byte = char; + } + } + let current_path = String::from_utf8(current_path) + .map_err(|_| Error::IoError(InvalidData.into()))?; + + let current_body_offset = (current_body_offset as u64) + .checked_add_signed(to_len as i64 - from_len as i64) + .ok_or(Error::IoError(InvalidData.into()))?; + trie.get_mut_file(current_path) + .ok_or(Error::IoError(InvalidData.into()))? + .body_offset = current_body_offset; + headers.push((current_header_offset, current_body_offset as u32)); + } + drop(reader); + let mut writer = BufWriter::new(archive.as_file()); + for (position, offset) in headers { + writer.seek(SeekFrom::Start(position))?; + writer.write_all(&(offset ^ self.base_magic).to_le_bytes())?; + } + writer.flush()?; + drop(writer); + } + + _ => return Err(Error::IoError(InvalidData.into())), + } + + if to_len < from_len { + archive.set_len( + archive_len + .checked_add_signed(to_len as i64 - from_len as i64) + .ok_or(Error::IoError(InvalidData.into()))?, + )?; + archive.flush()?; + } + } else { + match self.version { + 1 | 2 => { + let mut magic = old_entry.start_magic; + for _ in from.as_str().bytes() { + regress_magic(&mut magic); + } + archive.seek(SeekFrom::Start(old_entry.header_offset + 4))?; + archive.write_all( + &to.as_str() + .bytes() + .map(|b| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ advance_magic(&mut magic) as u8 + }) + .collect_vec(), + )?; + archive.flush()?; + } + + 3 => { + archive.seek(SeekFrom::Start(old_entry.header_offset + 16))?; + archive.write_all( + &to.as_str() + .bytes() + .enumerate() + .map(|(i, b)| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ (self.base_magic >> (8 * (i % 4))) as u8 + }) + .collect_vec(), + )?; + archive.flush()?; + } + + _ => return Err(Error::IoError(InvalidData.into())), + } + } + + Ok(()) + } + + fn create_dir(&self, path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + let mut trie = self.trie.write(); + if trie.contains(path) { + return Err(Error::IoError(AlreadyExists.into())); + } + if let Some(parent) = path.parent() { + if !trie.contains_dir(parent) { + return Err(Error::NotExist); + } + } + trie.create_dir(path); + Ok(()) + } + + fn exists(&self, path: impl AsRef) -> Result { + let trie = self.trie.read(); + Ok(trie.contains(path)) + } + + fn remove_dir(&self, path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + if !self.trie.read().contains_dir(path) { + return Err(Error::NotExist); + } + + let paths = self + .trie + .read() + .iter_prefix(path) + .ok_or(Error::NotExist)? + .map(|(k, _)| k) + .collect_vec(); + for file_path in paths { + self.remove_file(file_path)?; + } + + self.trie + .write() + .remove_dir(path) + .then_some(()) + .ok_or(Error::NotExist)?; + Ok(()) + } + + fn remove_file(&self, path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + let path_len = path.as_str().bytes().len() as u64; + let mut archive = self.archive.lock(); + let mut trie = self.trie.write(); + + let entry = *trie.get_file(path).ok_or(Error::NotExist)?; + let archive_len = archive.metadata()?.size; + + move_file_and_truncate(&mut archive, &mut trie, path, self.version, self.base_magic)?; + + match self.version { + 1 | 2 => { + archive.set_len( + archive_len + .checked_sub(entry.size + path_len + 8) + .ok_or(Error::IoError(InvalidData.into()))?, + )?; + archive.flush()?; + } + + 3 => { + // Remove the header of the deleted file + let mut tmp = crate::host::File::new()?; + archive.seek(SeekFrom::Start(entry.header_offset + path_len + 16))?; + std::io::copy(archive.as_file(), &mut tmp)?; + tmp.flush()?; + tmp.seek(SeekFrom::Start(0))?; + archive.seek(SeekFrom::Start(entry.header_offset))?; + std::io::copy(&mut tmp, archive.as_file())?; + + archive.set_len( + archive_len + .checked_sub(entry.size + path_len + 16) + .ok_or(Error::IoError(InvalidData.into()))?, + )?; + archive.flush()?; + } + + _ => return Err(Error::NotSupported), + } + + trie.remove_file(path); + Ok(()) + } + + fn read_dir(&self, path: impl AsRef) -> Result, Error> { + let path = path.as_ref(); + let trie = self.trie.read(); + if let Some(iter) = trie.iter_dir(path) { + iter.map(|(name, _)| { + let path = path.join(name); + let metadata = self.metadata(&path)?; + Ok(DirEntry { path, metadata }) + }) + .try_collect() + } else { + Err(Error::NotExist) + } + } +} diff --git a/crates/filesystem/src/archiver/mod.rs b/crates/filesystem/src/archiver/mod.rs new file mode 100644 index 00000000..a47e2d20 --- /dev/null +++ b/crates/filesystem/src/archiver/mod.rs @@ -0,0 +1,37 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +mod filesystem; +pub use filesystem::FileSystem; + +mod file; +pub use file::File; + +mod util; + +type Trie = crate::FileSystemTrie; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct Entry { + header_offset: u64, + body_offset: u64, + size: u64, + start_magic: u32, +} + +const MAGIC: u32 = 0xDEADCAFE; +const HEADER: &[u8] = b"RGSSAD\0"; diff --git a/crates/filesystem/src/archiver/util.rs b/crates/filesystem/src/archiver/util.rs new file mode 100644 index 00000000..5ebdfb93 --- /dev/null +++ b/crates/filesystem/src/archiver/util.rs @@ -0,0 +1,295 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +use itertools::Itertools; +use std::io::{prelude::*, BufReader, BufWriter, ErrorKind::InvalidData, SeekFrom}; + +use super::{Trie, HEADER}; +use crate::Error; + +fn read_u32(mut file: impl Read) -> std::io::Result { + let mut buffer = [0; 4]; + file.read_exact(&mut buffer)?; + Ok(u32::from_le_bytes(buffer)) +} + +pub(super) fn read_u32_xor(file: impl Read, key: u32) -> std::io::Result { + let result = read_u32(file)?; + Ok(result ^ key) +} + +pub(super) fn read_file_xor(file: impl Read, start_magic: u32) -> impl Read { + let iter = file.bytes().scan((start_magic, 0), |state, maybe_byte| { + let Ok(byte) = maybe_byte else { return None }; + let (mut magic, mut j) = *state; + + if j == 4 { + j = 0; + magic = magic.wrapping_mul(7).wrapping_add(3); + } + let byte = byte ^ magic.to_le_bytes()[j]; + j += 1; + + *state = (magic, j); + Some(byte) + }); + iter_read::IterRead::new(iter) +} + +pub(super) fn advance_magic(magic: &mut u32) -> u32 { + let old = *magic; + + *magic = magic.wrapping_mul(7).wrapping_add(3); + + old +} + +pub(super) fn regress_magic(magic: &mut u32) -> u32 { + let old = *magic; + + *magic = magic.wrapping_sub(3).wrapping_mul(3067833783); + + old +} + +pub(super) fn read_header(mut file: impl Read) -> Result { + let mut header_buf = [0; 8]; + + file.read_exact(&mut header_buf)?; + + if !header_buf.starts_with(HEADER) { + return Err(Error::InvalidHeader); + } + + Ok(header_buf[7]) +} + +/// Moves a file within an archive to the end of the archive and truncates the file's length to 0. +/// Does NOT truncate the actual archive to the correct length afterwards. +pub(super) fn move_file_and_truncate( + archive: &mut parking_lot::MutexGuard<'_, T>, + trie: &mut parking_lot::RwLockWriteGuard<'_, Trie>, + path: impl AsRef, + version: u8, + base_magic: u32, +) -> std::io::Result<()> +where + T: crate::File, +{ + let path = path.as_ref(); + let path_len = path.as_str().bytes().len() as u64; + let archive_len = archive.metadata()?.size; + archive.seek(SeekFrom::Start(0))?; + + let mut entry = *trie.get_file(path).ok_or(InvalidData)?; + match version { + 1 | 2 => { + let mut tmp = crate::host::File::new()?; + archive.seek(SeekFrom::Start(entry.body_offset + entry.size))?; + let mut reader = BufReader::new(archive.as_file()); + let mut writer = BufWriter::new(&mut tmp); + + let mut reader_magic = entry.start_magic; + + // Determine what the magic value was for the beginning of the modified file's + // header + let mut writer_magic = entry.start_magic; + regress_magic(&mut writer_magic); + regress_magic(&mut writer_magic); + for _ in path.as_str().bytes() { + regress_magic(&mut writer_magic); + } + + // Re-encrypt the headers and data for the files after the modified file into a + // temporary file + while let Ok(current_path_len) = + read_u32_xor(&mut reader, advance_magic(&mut reader_magic)) + { + let mut current_path = vec![0; current_path_len as usize]; + reader.read_exact(&mut current_path)?; + for byte in current_path.iter_mut() { + let char = *byte ^ advance_magic(&mut reader_magic) as u8; + if char == b'\\' { + *byte = b'/'; + } else { + *byte = char; + } + } + let current_path = String::from_utf8(current_path).map_err(|_| InvalidData)?; + let current_entry = trie.get_mut_file(¤t_path).ok_or(InvalidData)?; + reader.seek(SeekFrom::Start(current_entry.body_offset))?; + advance_magic(&mut reader_magic); + + writer.write_all( + &(current_path_len ^ advance_magic(&mut writer_magic)).to_le_bytes(), + )?; + writer.write_all( + ¤t_path + .as_str() + .bytes() + .map(|b| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ advance_magic(&mut writer_magic) as u8 + }) + .collect_vec(), + )?; + writer.write_all( + &(current_entry.size as u32 ^ advance_magic(&mut writer_magic)).to_le_bytes(), + )?; + std::io::copy( + &mut read_file_xor( + &mut read_file_xor( + &mut (&mut reader).take(current_entry.size), + reader_magic, + ), + writer_magic, + ), + &mut writer, + )?; + + current_entry.header_offset = current_entry + .header_offset + .checked_sub(entry.size + path_len + 8) + .ok_or(InvalidData)?; + current_entry.body_offset = current_entry + .body_offset + .checked_sub(entry.size + path_len + 8) + .ok_or(InvalidData)?; + current_entry.start_magic = writer_magic; + } + + // Write the header of the modified file at the end of the temporary file + writer + .write_all(&(path_len as u32 ^ advance_magic(&mut writer_magic)).to_le_bytes())?; + writer.write_all( + &path + .as_str() + .bytes() + .map(|b| { + let b = if b == b'/' { b'\\' } else { b }; + b ^ advance_magic(&mut writer_magic) as u8 + }) + .collect_vec(), + )?; + writer.write_all(&advance_magic(&mut writer_magic).to_le_bytes())?; + writer.flush()?; + drop(reader); + drop(writer); + + // Write the contents of the temporary file into the archive, starting from + // where the modified file's header was + tmp.seek(SeekFrom::Start(0))?; + archive.seek(SeekFrom::Start(entry.header_offset))?; + std::io::copy(&mut tmp, archive.as_file())?; + + entry.start_magic = writer_magic; + entry.body_offset = archive_len.checked_sub(entry.size).ok_or(InvalidData)?; + entry.header_offset = entry + .body_offset + .checked_sub(path_len + 8) + .ok_or(InvalidData)?; + entry.size = 0; + *trie.get_mut_file(path).ok_or(InvalidData)? = entry; + + Ok(()) + } + + 3 => { + let mut tmp = crate::host::File::new()?; + + // Copy the contents of the files after the modified file into a temporary file + archive.seek(SeekFrom::Start(entry.body_offset + entry.size))?; + std::io::copy(archive.as_file(), &mut tmp)?; + tmp.flush()?; + + // Copy the contents of the temporary file back into the archive starting from where + // the modified file was + tmp.seek(SeekFrom::Start(0))?; + archive.seek(SeekFrom::Start(entry.body_offset))?; + std::io::copy(&mut tmp, archive.as_file())?; + + // Find all of the files in the archive with offsets greater than the original + // offset of the modified file and decrement them accordingly + archive.seek(SeekFrom::Start(12))?; + let mut reader = BufReader::new(archive.as_file()); + let mut headers = Vec::new(); + while let Ok(current_body_offset) = read_u32_xor(&mut reader, base_magic) { + if current_body_offset == 0 { + break; + } + let current_header_offset = reader + .stream_position()? + .checked_sub(4) + .ok_or(InvalidData)?; + let current_body_offset = current_body_offset as u64; + reader.seek(SeekFrom::Current(8))?; + let current_path_len = read_u32_xor(&mut reader, base_magic)?; + let should_truncate = current_header_offset == entry.header_offset; + if current_body_offset <= entry.body_offset && !should_truncate { + reader.seek(SeekFrom::Current(current_path_len as i64))?; + continue; + } + + let mut current_path = vec![0; current_path_len as usize]; + reader.read_exact(&mut current_path)?; + for (i, byte) in current_path.iter_mut().enumerate() { + let char = *byte ^ (base_magic >> (8 * (i % 4))) as u8; + if char == b'\\' { + *byte = b'/'; + } else { + *byte = char; + } + } + let current_path = String::from_utf8(current_path).map_err(|_| InvalidData)?; + + let current_body_offset = if should_truncate { + archive_len.checked_sub(entry.size).ok_or(InvalidData)? + } else { + current_body_offset + .checked_sub(entry.size) + .ok_or(InvalidData)? + }; + + trie.get_mut_file(current_path) + .ok_or(InvalidData)? + .body_offset = current_body_offset; + headers.push(( + current_header_offset, + current_body_offset as u32, + should_truncate, + )); + } + drop(reader); + let mut writer = BufWriter::new(archive.as_file()); + for (position, offset, should_truncate) in headers { + writer.seek(SeekFrom::Start(position))?; + writer.write_all(&(offset ^ base_magic).to_le_bytes())?; + if should_truncate { + writer.write_all(&base_magic.to_le_bytes())?; + } + } + writer.flush()?; + drop(writer); + + trie.get_mut_file(path).ok_or(InvalidData)?.size = 0; + + Ok(()) + } + + _ => Err(InvalidData.into()), + } +} diff --git a/crates/filesystem/src/erased.rs b/crates/filesystem/src/erased.rs index e92b79a4..606dfb6c 100644 --- a/crates/filesystem/src/erased.rs +++ b/crates/filesystem/src/erased.rs @@ -98,9 +98,13 @@ where } impl File for Box { - fn metadata(&self) -> Result { + fn metadata(&self) -> std::io::Result { self.as_ref().metadata() } + + fn set_len(&self, new_size: u64) -> std::io::Result<()> { + self.as_ref().set_len(new_size) + } } impl crate::FileSystem for dyn ErasedFilesystem { diff --git a/crates/filesystem/src/lib.rs b/crates/filesystem/src/lib.rs index e970e54d..7871edaa 100644 --- a/crates/filesystem/src/lib.rs +++ b/crates/filesystem/src/lib.rs @@ -22,6 +22,9 @@ pub mod list; pub mod path_cache; pub mod project; +mod trie; +pub use trie::*; + #[cfg(not(target_arch = "wasm32"))] pub mod native; #[cfg(target_arch = "wasm32")] @@ -111,7 +114,20 @@ bitflags::bitflags! { } pub trait File: std::io::Read + std::io::Write + std::io::Seek + Send + Sync + 'static { - fn metadata(&self) -> Result; + fn metadata(&self) -> std::io::Result; + + /// Truncates or extends the size of the file. If the file is extended, the file will be + /// null-padded at the end. The file cursor never changes when truncating or extending, even if + /// the cursor would be put outside the file bounds by this operation. + fn set_len(&self, new_size: u64) -> std::io::Result<()>; + + /// Casts a mutable reference to this file into `&mut luminol_filesystem::File`. + fn as_file(&mut self) -> &mut Self + where + Self: Sized, + { + self + } } pub trait FileSystem: Send + Sync + 'static { diff --git a/crates/filesystem/src/list.rs b/crates/filesystem/src/list.rs index 63608d2b..57b3259a 100644 --- a/crates/filesystem/src/list.rs +++ b/crates/filesystem/src/list.rs @@ -42,8 +42,9 @@ impl crate::FileSystem for FileSystem { flags: OpenFlags, ) -> Result { let path = path.as_ref(); + let parent = path.parent().unwrap_or(path); for fs in self.filesystems.iter() { - if fs.exists(path)? || flags.contains(OpenFlags::Create) { + if fs.exists(path)? || (flags.contains(OpenFlags::Create) && fs.exists(parent)?) { return fs.open_file(path, flags); } } diff --git a/crates/filesystem/src/native.rs b/crates/filesystem/src/native.rs index cbda94cf..31e9b326 100644 --- a/crates/filesystem/src/native.rs +++ b/crates/filesystem/src/native.rs @@ -16,13 +16,16 @@ // along with Luminol. If not, see . use itertools::Itertools; -use crate::{DirEntry, File, Metadata, OpenFlags, Result}; +use crate::{DirEntry, Metadata, OpenFlags, Result}; #[derive(Debug, Clone)] pub struct FileSystem { root_path: camino::Utf8PathBuf, } +#[derive(Debug)] +pub struct File(std::fs::File); + impl FileSystem { pub fn new(root_path: impl AsRef) -> Self { Self { @@ -61,7 +64,7 @@ impl FileSystem { } impl crate::FileSystem for FileSystem { - type File = std::fs::File; + type File = File; fn open_file( &self, @@ -76,6 +79,7 @@ impl crate::FileSystem for FileSystem { .truncate(flags.contains(OpenFlags::Truncate)) .open(path) .map_err(Into::into) + .map(File) } fn metadata(&self, path: impl AsRef) -> Result { @@ -139,12 +143,53 @@ impl crate::FileSystem for FileSystem { } } -impl File for std::fs::File { - fn metadata(&self) -> Result { - let metdata = self.metadata()?; +impl File { + /// Creates a new empty temporary file with read-write permissions. + pub fn new() -> std::io::Result { + tempfile::tempfile().map(File) + } +} + +impl crate::File for File { + fn metadata(&self) -> std::io::Result { + let metdata = self.0.metadata()?; Ok(Metadata { is_file: metdata.is_file(), size: metdata.len(), }) } + + fn set_len(&self, new_size: u64) -> std::io::Result<()> { + self.0.set_len(new_size) + } +} + +impl std::io::Read for File { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } + + fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result { + self.0.read_vectored(bufs) + } +} + +impl std::io::Write for File { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.0.flush() + } + + fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result { + self.0.write_vectored(bufs) + } +} + +impl std::io::Seek for File { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + self.0.seek(pos) + } } diff --git a/crates/filesystem/src/project.rs b/crates/filesystem/src/project.rs index 6fd164f6..74f114be 100644 --- a/crates/filesystem/src/project.rs +++ b/crates/filesystem/src/project.rs @@ -558,12 +558,19 @@ impl std::io::Seek for File { } impl crate::File for File { - fn metadata(&self) -> Result { + fn metadata(&self) -> std::io::Result { match self { File::Host(h) => crate::File::metadata(h), File::Loaded(l) => l.metadata(), } } + + fn set_len(&self, new_size: u64) -> std::io::Result<()> { + match self { + File::Host(f) => f.set_len(new_size), + File::Loaded(f) => f.set_len(new_size), + } + } } impl crate::FileSystem for FileSystem { diff --git a/crates/filesystem/src/trie.rs b/crates/filesystem/src/trie.rs new file mode 100644 index 00000000..26ec283b --- /dev/null +++ b/crates/filesystem/src/trie.rs @@ -0,0 +1,392 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . + +use qp_trie::wrapper::{BStr, BString}; + +type Trie = qp_trie::Trie>; +type DirTrie = qp_trie::Trie>; + +pub struct FileSystemTrieDirIter<'a, T>(FileSystemTrieDirIterInner<'a, T>); + +enum FileSystemTrieDirIterInner<'a, T> { + Direct(qp_trie::Iter<'a, BString, Option>, usize), + Prefix(std::iter::Once<(&'a str, Option<&'a T>)>), +} + +impl<'a, T> std::iter::FusedIterator for FileSystemTrieDirIter<'a, T> {} + +impl<'a, T> std::iter::ExactSizeIterator for FileSystemTrieDirIter<'a, T> { + fn len(&self) -> usize { + match &self.0 { + FileSystemTrieDirIterInner::Direct(_, len) => *len, + FileSystemTrieDirIterInner::Prefix(_) => 1, + } + } +} + +impl<'a, T> Iterator for FileSystemTrieDirIter<'a, T> { + type Item = (&'a str, Option<&'a T>); + fn next(&mut self) -> Option { + match &mut self.0 { + FileSystemTrieDirIterInner::Direct(iter, _) => iter + .next() + .map(|(key, value)| (key.as_str(), value.as_ref())), + FileSystemTrieDirIterInner::Prefix(iter) => iter.next(), + } + } +} + +pub struct FileSystemTrieIter<'a, T> { + path: camino::Utf8PathBuf, + trie: &'a Trie, + root_done: bool, + trie_iter: Option>>, + dir_iter: Option<(camino::Utf8PathBuf, qp_trie::Iter<'a, BString, Option>)>, +} + +impl<'a, T> std::iter::FusedIterator for FileSystemTrieIter<'a, T> {} + +impl<'a, T> Iterator for FileSystemTrieIter<'a, T> { + type Item = (camino::Utf8PathBuf, &'a T); + fn next(&mut self) -> Option { + loop { + if let Some((prefix, dir_iter)) = &mut self.dir_iter { + match dir_iter.next() { + Some((filename, Some(value))) => { + return Some((prefix.join(filename.as_str()), value)); + } + None => { + self.dir_iter = None; + self.root_done = true; + } + _ => {} + } + } else if let Some(trie_iter) = &mut self.trie_iter { + let (prefix, dir_trie) = trie_iter.next()?; + self.dir_iter = Some((prefix.as_str().to_owned().into(), dir_trie.iter())); + } else if self.path.as_str() == "" { + self.root_done = true; + self.trie_iter = Some(self.trie.iter()) + } else if self.root_done { + self.trie_iter = Some( + self.trie + .iter_prefix::(format!("{}/", self.path).as_str().into()), + ); + } else { + self.dir_iter = self + .trie + .get_str(self.path.as_str()) + .map(|t| (self.path.clone(), t.iter())); + } + } + } +} + +/// A container for a directory tree-like cache where the "files" are a data type of your choice. +#[derive(Debug, Clone)] +pub struct FileSystemTrie(Trie); + +impl Default for FileSystemTrie { + fn default() -> Self { + Self(Default::default()) + } +} + +impl FileSystemTrie { + pub fn new() -> Self { + Default::default() + } + + /// Adds a directory to the trie. If parent directories do not exist, they will be created. + pub fn create_dir(&mut self, path: impl AsRef) { + let path = path.as_ref(); + + // Nothing to do if the path is already in the trie + if self.0.contains_key_str(path.as_str()) { + return; + } + + // Otherwise, find the longest prefix in the trie that is shared with some directory + // path that is in the trie + let prefix = self.get_dir_prefix(path).as_str().to_string(); + + // Check if the trie already contains an entry for this prefix, and if not, create one + if !self.0.contains_key_str(&prefix) { + let mut dir_trie = DirTrie::new(); + let prefix_with_trailing_slash = format!("{}/", &prefix); + if let Some((child_path, _)) = self + .0 + .iter_prefix_str(if prefix.is_empty() { + "" + } else { + &prefix_with_trailing_slash + }) + .nth(1) + { + // If there is a different path in the trie that has this as a prefix (there can be + // at most one), add it to this directory entry + dir_trie.insert_str( + camino::Utf8Path::new(child_path.as_str()) + .strip_prefix(&prefix) + .unwrap() + .iter() + .next() + .unwrap(), + None, + ); + } + self.0.insert_str(&prefix, dir_trie); + } + + // Add the new path to the entry at this prefix + let prefix_trie = self.0.get_mut_str(&prefix).unwrap(); + prefix_trie.insert_str( + path.strip_prefix(&prefix).unwrap().iter().next().unwrap(), + None, + ); + + // Add the actual entry for the new path + self.0.insert_str(path.as_str(), DirTrie::new()); + } + + /// Adds a file to the trie with the given value. If parent directories do not exist, they will + /// be created. If there already was a file at the given path, the original value will be + /// returned. + pub fn create_file(&mut self, path: impl AsRef, value: T) -> Option { + let path = path.as_ref().to_owned(); + + let dir = path.parent().unwrap_or(camino::Utf8Path::new("")); + let filename = path.iter().next_back().unwrap(); + + // Add the parent directory to the trie + self.create_dir(dir); + let dir_trie = self.0.get_mut_str(dir.as_str()).unwrap(); + + if let Some(option) = dir_trie.get_mut_str(filename) { + // If the path is already in the trie, replace the value and return the original + option.replace(value) + } else { + // Add the file to the parent directory's entry + dir_trie.insert_str(filename, Some(value)); + None + } + } + + /// Returns whether or not a directory exists at the given path. + pub fn contains_dir(&self, path: impl AsRef) -> bool { + let path = path.as_ref().as_str(); + if path.is_empty() { + return true; + } + self.0.contains_key_str(path) + || (path.is_empty() && self.0.count() != 0) + || self + .0 + .longest_common_prefix::(format!("{path}/").as_str().into()) + .as_str() + .len() + == path.len() + 1 + } + + /// Returns whether or not a file exists at the given path. + pub fn contains_file(&self, path: impl AsRef) -> bool { + let path = path.as_ref(); + let Some(filename) = path.iter().next_back() else { + return false; + }; + let dir = path.parent().unwrap_or(camino::Utf8Path::new("")); + self.0 + .get_str(dir.as_str()) + .map_or(false, |dir_trie| dir_trie.contains_key_str(filename)) + } + + /// Returns whether or not a file or directory exists at the given path. + pub fn contains(&self, path: impl AsRef) -> bool { + self.contains_file(&path) || self.contains_dir(&path) + } + + /// Gets the number of direct children in the directory at the given path, if it exists. + pub fn get_dir_size(&self, path: impl AsRef) -> Option { + let path = path.as_ref().as_str(); + if let Some(dir_trie) = self.0.get_str(path) { + Some(dir_trie.count()) + } else if (path.is_empty() && self.0.count() != 0) + || self + .0 + .longest_common_prefix::(format!("{path}/").as_str().into()) + .as_str() + .len() + == path.len() + 1 + { + Some(1) + } else { + None + } + } + + /// Gets an immutable reference to the value in the file at the given path, if it exists. + pub fn get_file(&self, path: impl AsRef) -> Option<&T> { + let path = path.as_ref(); + let Some(filename) = path.iter().next_back() else { + return None; + }; + let dir = path.parent().unwrap_or(camino::Utf8Path::new("")); + self.0 + .get_str(dir.as_str()) + .and_then(|dir_trie| dir_trie.get_str(filename)) + .and_then(|o| o.as_ref()) + } + + /// Gets a mutable reference to the value in the file at the given path, if it exists. + pub fn get_mut_file(&mut self, path: impl AsRef) -> Option<&mut T> { + let path = path.as_ref(); + let Some(filename) = path.iter().next_back() else { + return None; + }; + let dir = path.parent().unwrap_or(camino::Utf8Path::new("")); + self.0 + .get_mut_str(dir.as_str()) + .and_then(|dir_trie| dir_trie.get_mut_str(filename)) + .and_then(|o| o.as_mut()) + } + + /// Gets the longest prefix of the given path that is the path of a directory in the trie. + pub fn get_dir_prefix(&self, path: impl AsRef) -> &camino::Utf8Path { + let path = path.as_ref().as_str(); + if self.0.contains_key_str(path) { + return self + .0 + .longest_common_prefix::(path.into()) + .as_str() + .into(); + } + let prefix = self + .0 + .longest_common_prefix::(format!("{path}/").as_str().into()) + .as_str(); + let prefix = if !self.0.contains_key_str(prefix) { + prefix.rsplit_once('/').map(|(s, _)| s).unwrap_or(prefix) + } else { + prefix + }; + prefix.into() + } + + /// Removes the file at the given path if it exists, and returns the original value. + pub fn remove_file(&mut self, path: impl AsRef) -> Option { + let path = path.as_ref(); + let Some(filename) = path.iter().next_back() else { + return None; + }; + let dir = path.parent().unwrap_or(camino::Utf8Path::new("")); + self.0 + .get_mut_str(dir.as_str()) + .and_then(|dir_trie| dir_trie.remove_str(filename)) + .flatten() + } + + /// Removes the directory at the given path and all of its contents if it exists, and returns + /// whether or not it existed. + pub fn remove_dir(&mut self, path: impl AsRef) -> bool { + let path = path.as_ref(); + let path_str = path.as_str(); + if path_str.is_empty() { + self.0.clear(); + true + } else if self.0.contains_key_str(path_str) { + self.0.remove_prefix_str(&format!("{path_str}/")); + self.0.remove_str(path_str); + if let Some(parent) = path.parent() { + self.create_dir(parent); + } + true + } else if self + .0 + .longest_common_prefix::(format!("{path_str}/").as_str().into()) + .as_str() + .len() + == path_str.len() + 1 + { + self.0.remove_prefix_str(&format!("{path_str}/")); + if let Some(parent) = path.parent() { + self.create_dir(parent); + } + true + } else { + false + } + } + + /// Given the path to a directory, returns an iterator over its children if it exists. + /// The iterator's items are of the form `(key, value)` where `key` is the name of the child as + /// `&str` and `value` is the data of the child if it's a file, as `Option<&T>`. + pub fn iter_dir( + &self, + path: impl AsRef, + ) -> Option> { + let path = path.as_ref(); + let prefix_with_trailing_slash = format!("{}/", path.as_str()); + if let Some(dir_trie) = self.0.get_str(path.as_str()) { + Some(FileSystemTrieDirIter(FileSystemTrieDirIterInner::Direct( + dir_trie.iter(), + dir_trie.count(), + ))) + } else if let Some((key, _)) = self + .0 + .iter_prefix_str(if path.as_str().is_empty() { + "" + } else { + &prefix_with_trailing_slash + }) + .next() + { + Some(FileSystemTrieDirIter(FileSystemTrieDirIterInner::Prefix( + std::iter::once(( + camino::Utf8Path::new(key.as_str()) + .strip_prefix(path) + .unwrap() + .iter() + .next() + .unwrap(), + None, + )), + ))) + } else { + None + } + } + + /// Given the path to a directory, returns an iterator over immutable references to its + /// descendant files if it exists. + pub fn iter_prefix( + &self, + path: impl AsRef, + ) -> Option> { + let path = path.as_ref(); + if self.contains_dir(path) { + Some(FileSystemTrieIter { + path: path.to_owned(), + trie: &self.0, + root_done: false, + trie_iter: None, + dir_iter: None, + }) + } else { + None + } + } +} diff --git a/crates/filesystem/src/web/events.rs b/crates/filesystem/src/web/events.rs index ac1eb71d..f65df28a 100644 --- a/crates/filesystem/src/web/events.rs +++ b/crates/filesystem/src/web/events.rs @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with Luminol. If not, see . -use super::util::{generate_key, get_subdir, handle_event, idb, to_future}; +use super::util::{generate_key, get_subdir, get_tmp_dir, handle_event, idb, to_future}; use super::FileSystemCommand; use crate::{DirEntry, Error, Metadata, OpenFlags}; use indexed_db_futures::prelude::*; @@ -24,7 +24,10 @@ use wasm_bindgen::prelude::*; pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { wasm_bindgen_futures::spawn_local(async move { - web_sys::window().expect("cannot run `setup_main_thread_hooks()` outside of main thread"); + let storage = web_sys::window() + .expect("cannot run `setup_main_thread_hooks()` outside of main thread") + .navigator() + .storage(); struct FileHandle { offset: usize, @@ -402,8 +405,12 @@ pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { let mut vec = Vec::new(); loop { - let Ok(entry) = - to_future::(entry_iter.next().unwrap()).await + let Ok(entry) = to_future::( + entry_iter + .next() + .map_err(|_| Error::IoError(PermissionDenied.into()))?, + ) + .await else { break; }; @@ -462,6 +469,53 @@ pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { handle_event(tx, async { dirs.insert(dirs.get(key).unwrap().clone()) }).await; } + FileSystemCommand::FileCreateTemp(tx) => { + handle_event(tx, async { + let tmp_dir = get_tmp_dir(&storage).await.ok_or(PermissionDenied)?; + + let filename = generate_key(); + + let mut options = web_sys::FileSystemGetFileOptions::new(); + options.create(true); + let file_handle = to_future::( + tmp_dir.get_file_handle_with_options(&filename, &options), + ) + .await + .map_err(|_| PermissionDenied)?; + + let write_handle = to_future(file_handle.create_writable()) + .await + .map_err(|_| PermissionDenied)?; + + Ok(( + files.insert(FileHandle { + offset: 0, + file_handle, + read_allowed: true, + write_handle: Some(write_handle), + }), + filename, + )) + }) + .await; + } + + FileSystemCommand::FileSetLength(key, new_size, tx) => { + handle_event(tx, async { + let file = files.get_mut(key).unwrap(); + let write_handle = file.write_handle.as_ref().ok_or(PermissionDenied)?; + to_future::( + write_handle + .truncate_with_f64(new_size as f64) + .map_err(|_| PermissionDenied)?, + ) + .await + .map_err(|_| PermissionDenied)?; + Ok(()) + }) + .await; + } + FileSystemCommand::FileRead(key, max_length, tx) => { handle_event(tx, async { let file = files.get_mut(key).unwrap(); @@ -504,12 +558,16 @@ pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { js_sys::Uint8Array::new(&JsValue::from_f64(vec.len() as f64)); u8_array.copy_from(&vec[..]); if to_future::( - write_handle.seek_with_f64(file.offset as f64).unwrap(), + write_handle + .seek_with_f64(file.offset as f64) + .map_err(|_| PermissionDenied)?, ) .await .is_ok() && to_future::( - write_handle.write_with_buffer_source(&u8_array).unwrap(), + write_handle + .write_with_buffer_source(&u8_array) + .map_err(|_| PermissionDenied)?, ) .await .is_ok() @@ -529,9 +587,11 @@ pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { // Closing and reopening the handle is the only way to flush if file.write_handle.is_none() - || to_future::(file.write_handle.as_ref().unwrap().close()) - .await - .is_err() + || to_future::( + file.write_handle.as_ref().ok_or(PermissionDenied)?.close(), + ) + .await + .is_err() { return Err(PermissionDenied.into()); } @@ -588,7 +648,7 @@ pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { .await; } - FileSystemCommand::FileDrop(key, tx) => { + FileSystemCommand::FileDrop(key, temp_file_name, tx) => { handle_event(tx, async { if files.contains(key) { let file = files.remove(key); @@ -597,6 +657,14 @@ pub fn setup_main_thread_hooks(main_channels: super::MainChannels) { if let Some(write_handle) = &file.write_handle { let _ = to_future::(write_handle.close()).await; } + + if let Some(temp_file_name) = temp_file_name { + if let Some(tmp_dir) = get_tmp_dir(&storage).await { + let _ = + to_future::(tmp_dir.remove_entry(&temp_file_name)) + .await; + } + } true } else { false diff --git a/crates/filesystem/src/web/mod.rs b/crates/filesystem/src/web/mod.rs index 6e1e07d1..d8a5fc36 100644 --- a/crates/filesystem/src/web/mod.rs +++ b/crates/filesystem/src/web/mod.rs @@ -58,6 +58,7 @@ pub struct FileSystem { #[derive(Debug)] pub struct File { key: usize, + temp_file_name: Option, } #[derive(Debug)] @@ -93,6 +94,8 @@ enum FileSystemCommand { ), DirDrop(usize, oneshot::Sender), DirClone(usize, oneshot::Sender), + FileCreateTemp(oneshot::Sender>), + FileSetLength(usize, u64, oneshot::Sender>), FileRead(usize, usize, oneshot::Sender>>), FileWrite(usize, Vec, oneshot::Sender>), FileFlush(usize, oneshot::Sender>), @@ -102,7 +105,7 @@ enum FileSystemCommand { oneshot::Sender>, ), FileSize(usize, oneshot::Sender>), - FileDrop(usize, oneshot::Sender), + FileDrop(usize, Option, oneshot::Sender), } fn worker_channels_or_die() -> &'static WorkerChannels { @@ -203,7 +206,10 @@ impl FileSystemTrait for FileSystem { send_and_recv(|tx| { FileSystemCommand::DirOpenFile(self.key, path.as_ref().to_path_buf(), flags, tx) }) - .map(|key| File { key }) + .map(|key| File { + key, + temp_file_name: None, + }) } fn metadata(&self, path: impl AsRef) -> Result { @@ -249,20 +255,38 @@ impl FileSystemTrait for FileSystem { } } +impl File { + /// Creates a new empty temporary file with read-write permissions. + pub fn new() -> std::io::Result { + send_and_recv(|tx| FileSystemCommand::FileCreateTemp(tx)).map(|(key, temp_file_name)| { + Self { + key, + temp_file_name: Some(temp_file_name), + } + }) + } +} + impl Drop for File { fn drop(&mut self) { - let _ = send_and_recv(|tx| FileSystemCommand::FileDrop(self.key, tx)); + let _ = send_and_recv(|tx| { + FileSystemCommand::FileDrop(self.key, self.temp_file_name.take(), tx) + }); } } impl crate::File for File { - fn metadata(&self) -> crate::Result { + fn metadata(&self) -> std::io::Result { let size = send_and_recv(|tx| FileSystemCommand::FileSize(self.key, tx))?; Ok(Metadata { is_file: true, size, }) } + + fn set_len(&self, new_size: u64) -> std::io::Result<()> { + send_and_recv(|tx| FileSystemCommand::FileSetLength(self.key, new_size, tx)) + } } impl std::io::Read for File { diff --git a/crates/filesystem/src/web/util.rs b/crates/filesystem/src/web/util.rs index 60335cd5..a3d9b5da 100644 --- a/crates/filesystem/src/web/util.rs +++ b/crates/filesystem/src/web/util.rs @@ -32,6 +32,8 @@ where } /// Returns a subdirectory of a given directory given the relative path of the subdirectory. +/// If one of the parent directories along the path does not exist, this will return `None`. +/// Use `get_subdir_create` if you want to create the missing parent directories. pub(super) async fn get_subdir( dir: &web_sys::FileSystemDirectoryHandle, path_iter: &mut camino::Iter<'_>, @@ -49,6 +51,41 @@ pub(super) async fn get_subdir( } } +/// Returns a subdirectory of a given directory given the relative path of the subdirectory. +/// If one of the parent directories along the path does not exist, this will create the missing +/// directories. +pub(super) async fn get_subdir_create( + dir: &web_sys::FileSystemDirectoryHandle, + path_iter: &mut camino::Iter<'_>, +) -> Option { + let mut dir = dir.clone(); + loop { + let Some(path_element) = path_iter.next() else { + return Some(dir); + }; + let mut options = web_sys::FileSystemGetDirectoryOptions::new(); + options.create(true); + if let Ok(subdir) = + to_future(dir.get_directory_handle_with_options(path_element, &options)).await + { + dir = subdir; + } else { + return None; + } + } +} + +/// Returns a handle to a directory for temporary files in the Origin Private File System. +pub(super) async fn get_tmp_dir( + storage: &web_sys::StorageManager, +) -> Option { + let opfs_root = to_future::(storage.get_directory()) + .await + .ok()?; + let mut iter = camino::Utf8Path::new("astrabit.luminol/tmp").iter(); + get_subdir_create(&opfs_root, &mut iter).await +} + /// Generates a random string suitable for use as a unique identifier. pub(super) fn generate_key() -> String { rand::thread_rng()