From 961bc0a7c57daadb12735d7ab77fd34c9d20d792 Mon Sep 17 00:00:00 2001 From: Nathan Adams Date: Fri, 5 Apr 2024 11:56:09 +0200 Subject: [PATCH] frontend-utils: Add initial bundle infrastructure --- Cargo.lock | 2 + frontend-utils/Cargo.toml | 4 + frontend-utils/src/bundle.rs | 148 +++++++++++++++ frontend-utils/src/bundle/README.md | 64 +++++++ frontend-utils/src/bundle/info.rs | 162 +++++++++++++++++ frontend-utils/src/bundle/source.rs | 59 ++++++ frontend-utils/src/bundle/source/directory.rs | 170 ++++++++++++++++++ frontend-utils/src/lib.rs | 1 + frontend-utils/src/parse.rs | 20 ++- 9 files changed, 622 insertions(+), 8 deletions(-) create mode 100644 frontend-utils/src/bundle.rs create mode 100644 frontend-utils/src/bundle/README.md create mode 100644 frontend-utils/src/bundle/info.rs create mode 100644 frontend-utils/src/bundle/source.rs create mode 100644 frontend-utils/src/bundle/source/directory.rs diff --git a/Cargo.lock b/Cargo.lock index e60bd8da6..0e55a0adb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4285,6 +4285,8 @@ dependencies = [ name = "ruffle_frontend_utils" version = "0.1.0" dependencies = [ + "tempfile", + "thiserror", "toml_edit 0.22.9", "tracing", "url", diff --git a/frontend-utils/Cargo.toml b/frontend-utils/Cargo.toml index abe2fdf7a..406cf609d 100644 --- a/frontend-utils/Cargo.toml +++ b/frontend-utils/Cargo.toml @@ -14,3 +14,7 @@ workspace = true toml_edit = { version = "0.22.9", features = ["parse"] } url = { workspace = true } tracing = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" \ No newline at end of file diff --git a/frontend-utils/src/bundle.rs b/frontend-utils/src/bundle.rs new file mode 100644 index 000000000..ba494f2ee --- /dev/null +++ b/frontend-utils/src/bundle.rs @@ -0,0 +1,148 @@ +use crate::bundle::info::{ + BundleInformation, BundleInformationParseError, BUNDLE_INFORMATION_FILENAME, +}; +use crate::bundle::source::BundleSource; +use std::path::Path; + +pub mod info; +pub mod source; + +#[derive(Debug, thiserror::Error)] +pub enum BundleError { + #[error("Invalid ruffle-bundle.toml")] + InvalidBundleInformation(#[from] BundleInformationParseError), + + #[error("Missing or corrupt ruffle-bundle.toml")] + MissingBundleInformation, + + #[error("Invalid bundle source")] + InvalidSource(#[from] source::BundleSourceError), + + #[error("Bundle does not exist")] + BundleDoesntExist, +} + +pub struct Bundle { + source: BundleSource, + information: BundleInformation, + warnings: Vec, +} + +impl Bundle { + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Err(BundleError::BundleDoesntExist); + } + let source = BundleSource::from_path(path)?; + let info_file = source + .read_file(BUNDLE_INFORMATION_FILENAME) + .map_err(|_| BundleError::MissingBundleInformation)?; + let info_text = + String::from_utf8(info_file).map_err(|_| BundleError::MissingBundleInformation)?; + let information = BundleInformation::parse(&info_text)?; + + Ok(Bundle { + source, + information: information.result.take(), + warnings: information.warnings, + }) + } + + pub fn source(&self) -> &BundleSource { + &self.source + } + + pub fn warnings(&self) -> &[String] { + &self.warnings + } + + pub fn information(&self) -> &BundleInformation { + &self.information + } +} + +#[cfg(test)] +mod tests { + use crate::bundle::info::{ + BundleInformation, BundleInformationParseError, BUNDLE_INFORMATION_FILENAME, + }; + use crate::bundle::{Bundle, BundleError}; + use tempfile::tempdir; + use url::Url; + + #[test] + fn from_path_nonexistent() { + assert!(matches!( + Bundle::from_path("/this/path/likely/doesnt/exist"), + Err(BundleError::BundleDoesntExist) + )) + } + + #[test] + fn from_path_directory_without_bundle() { + let tmp_dir = tempdir().unwrap(); + let result = Bundle::from_path(tmp_dir.path()); + drop(tmp_dir); + assert!(matches!(result, Err(BundleError::MissingBundleInformation))) + } + + #[test] + fn from_path_directory_with_folder_as_info() { + let tmp_dir = tempdir().unwrap(); + let _ = std::fs::create_dir(tmp_dir.path().join(BUNDLE_INFORMATION_FILENAME)); + let result = Bundle::from_path(tmp_dir.path()); + drop(tmp_dir); + assert!(matches!(result, Err(BundleError::MissingBundleInformation))) + } + + #[test] + fn from_path_directory_with_binary_info() { + let tmp_dir = tempdir().unwrap(); + let _ = std::fs::write( + tmp_dir.path().join(BUNDLE_INFORMATION_FILENAME), + [0, 159, 146, 150], + ); + let result = Bundle::from_path(tmp_dir.path()); + drop(tmp_dir); + assert!(matches!(result, Err(BundleError::MissingBundleInformation))) + } + + #[test] + fn from_path_directory_with_bad_toml() { + let tmp_dir = tempdir().unwrap(); + let _ = std::fs::write(tmp_dir.path().join(BUNDLE_INFORMATION_FILENAME), "???"); + let result = Bundle::from_path(tmp_dir.path()); + drop(tmp_dir); + assert!(matches!( + result, + Err(BundleError::InvalidBundleInformation( + BundleInformationParseError::InvalidToml(_) + )) + )) + } + + #[test] + fn from_path_directory_valid() { + let tmp_dir = tempdir().unwrap(); + let _ = std::fs::write( + tmp_dir.path().join(BUNDLE_INFORMATION_FILENAME), + r#" + [bundle] + name = "Cool Game!" + url = "file:///game.swf" + "#, + ); + let result = Bundle::from_path(tmp_dir.path()); + drop(tmp_dir); + let result = result.unwrap(); + assert_eq!( + BundleInformation { + name: "Cool Game!".to_string(), + url: Url::parse("file:///game.swf").unwrap() + }, + result.information + ); + assert_eq!(Vec::::new(), result.warnings); + } +} diff --git a/frontend-utils/src/bundle/README.md b/frontend-utils/src/bundle/README.md new file mode 100644 index 000000000..bc5905d85 --- /dev/null +++ b/frontend-utils/src/bundle/README.md @@ -0,0 +1,64 @@ +# Ruffle Bundle (.ruf) format specification +A Ruffle Bundle is an easy way to package and share Flash games and any assets that are required to make the game work. + +A bundle can be a directory or a renamed zip file, and must contain at minimum a `bundle.toml` file. + + +* [Ruffle Bundle (.ruf) format specification](#ruffle-bundle-ruf-format-specification) + * [Directory structure](#directory-structure) + * [`ruffle-bundle.toml` (Bundle information)](#ruffle-bundletoml-bundle-information) + * [`content/` (Flash content)](#content-flash-content) + * [`ruffle-bundle.toml` file specification](#ruffle-bundletoml-file-specification) + * [`[bundle]`](#bundle) + * [`name` - The name of the bundle](#name---the-name-of-the-bundle) + * [`url` - The url of the Flash content to open](#url---the-url-of-the-flash-content-to-open) + + +## Directory structure + +- `ruffle-bundle.toml` - **required**, the bundle information +- `content/` - a directory containing any swf files, assets they need, etc. + +More files and folders may be added in the future, as this format is expanded upon. + +### `ruffle-bundle.toml` (Bundle information) +This [toml](https://toml.io/) file is required and contains information that Ruffle needs to run this bundle. + +See [the ruffle-bundle.toml file specification](#bundletoml-file-specification) for more details. + +### `content/` (Flash content) +Every file (and subdirectory) within this directory will be accessible to the Flash content, exposed as a **virtual filesystem**. + +To Flash content, this is accessible through `file:///` - for example, the file `/content/game.swf` is `file:///content.swf`. +The file `/content/locale/en.xml` is `file:///locale/en.xml`. + +You'll want to put the `.swf` file in here, along with any extra files it may need. Files outside this directory are **not** accessible to the content. + +## `ruffle-bundle.toml` file specification +The absolute minimum `ruffle-bundle.toml` looks like this: +```toml +[bundle] +name = "Super Mario 63" +url = "file:///game.swf" +``` + +If either `bundle.name` or `bundle.url` is invalid or missing, the bundle is considered to be invalid and will not open. +The same is true if the toml document is malformed or corrupt. + +All other fields are absolutely optional and reasonable defaults will be assumed if they're missing or invalid. + +### `[bundle]` +This section is required to exist, and contains the two required fields for a bundle to work in Ruffle: + +#### `name` - The name of the bundle +This can be anything, and is shown to the user in UI. +Try to keep this a reasonable length, and descriptive about what this bundle actually *is*. + +#### `url` - The url of the Flash content to open +Whilst this **can** be a URL on the internet (for example, `url = "https://ruffle.rs/demo/logo-anim.swf"` is totally valid), +we would recommend that it instead be the path to a file within the `content/` directory inside the bundle. + +This way, an internet connection is not required, and the bundle won't stop working in 5 years when the website changes. + +Remember - the `content/` directory is accessible through `file:///` - so if you have a game at `content/game.swf`, you'll want to use `url = "file:///game.swf"`. + diff --git a/frontend-utils/src/bundle/info.rs b/frontend-utils/src/bundle/info.rs new file mode 100644 index 000000000..483a23288 --- /dev/null +++ b/frontend-utils/src/bundle/info.rs @@ -0,0 +1,162 @@ +use crate::parse::{DocumentHolder, ParseContext, ParseDetails, ReadExt}; +use toml_edit::DocumentMut; +use url::Url; + +pub const BUNDLE_INFORMATION_FILENAME: &str = "ruffle-bundle.toml"; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum BundleInformationParseError { + #[error("File is not valid TOML")] + InvalidToml(#[from] toml_edit::TomlError), + + #[error("Invalid or missing [bundle] section")] + InvalidBundleSection, + + #[error("Invalid or missing bundle.name")] + InvalidName, + + #[error("Invalid or missing bundle.url")] + InvalidUrl, +} + +#[derive(Debug, PartialEq)] +pub struct BundleInformation { + pub name: String, + pub url: Url, +} + +impl BundleInformation { + pub fn parse( + input: &str, + ) -> Result, BundleInformationParseError> { + let document = input.parse::()?; + + let mut cx = ParseContext::default(); + + let result = document + .get_table_like(&mut cx, "bundle", |cx, bundle| { + let Some(name) = bundle.parse_from_str(cx, "name") else { + return Err(BundleInformationParseError::InvalidName); + }; + let Some(url) = bundle.parse_from_str(cx, "url") else { + return Err(BundleInformationParseError::InvalidUrl); + }; + Ok(BundleInformation { name, url }) + }) + .unwrap_or(Err(BundleInformationParseError::InvalidBundleSection))?; + + Ok(ParseDetails { + result: DocumentHolder::new(result, document), + warnings: cx.warnings, + }) + } +} + +#[cfg(test)] +mod test { + use crate::bundle::info::{BundleInformation, BundleInformationParseError}; + use url::Url; + + fn read(input: &str) -> Result<(BundleInformation, Vec), BundleInformationParseError> { + BundleInformation::parse(input).map(|details| (details.result.take(), details.warnings)) + } + + #[test] + fn invalid_toml() { + // [NA] Can't construct TomlError to be able to test this properly + assert!(matches!( + read("???"), + Err(BundleInformationParseError::InvalidToml(_)) + )); + } + + #[test] + fn empty() { + assert_eq!( + read(""), + Err(BundleInformationParseError::InvalidBundleSection) + ) + } + + #[test] + fn missing_name() { + assert_eq!( + read("[bundle]"), + Err(BundleInformationParseError::InvalidName) + ) + } + + #[test] + fn invalid_name() { + assert_eq!( + read( + r#" + [bundle] + name = 1234 + "# + ), + Err(BundleInformationParseError::InvalidName) + ) + } + + #[test] + fn missing_url() { + assert_eq!( + read( + r#" + [bundle] + name = "Cool Game!" + "# + ), + Err(BundleInformationParseError::InvalidUrl) + ) + } + + #[test] + fn invalid_url_type() { + assert_eq!( + read( + r#" + [bundle] + name = "Cool Game!" + url = 1234 + "# + ), + Err(BundleInformationParseError::InvalidUrl) + ) + } + + #[test] + fn invalid_url_value() { + assert_eq!( + read( + r#" + [bundle] + name = "Cool Game!" + url = "invalid" + "# + ), + Err(BundleInformationParseError::InvalidUrl) + ) + } + + #[test] + fn minimally_valid() { + assert_eq!( + read( + r#" + [bundle] + name = "Cool Game!" + url = "file:///game.swf" + "# + ), + Ok(( + BundleInformation { + name: "Cool Game!".to_string(), + url: Url::parse("file:///game.swf").unwrap(), + }, + vec![] + )) + ) + } +} diff --git a/frontend-utils/src/bundle/source.rs b/frontend-utils/src/bundle/source.rs new file mode 100644 index 000000000..c1f25d4b7 --- /dev/null +++ b/frontend-utils/src/bundle/source.rs @@ -0,0 +1,59 @@ +use std::io::{Error, Read}; +use std::path::{Path, PathBuf}; + +pub mod directory; + +trait BundleSourceImpl { + type Read: Read; + + /// Reads any file from the bundle. + fn read_file(&self, path: &str) -> Result; + + /// Reads a file specifically from the content directory of the bundle. + fn read_content(&self, path: &str) -> Result; +} + +pub enum BundleSource { + Directory(PathBuf), +} + +#[derive(Debug, thiserror::Error)] +pub enum BundleSourceError { + #[error("Unknown bundle source")] + UnknownSource, +} + +impl BundleSource { + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + if path.is_dir() { + return Ok(Self::Directory(path.to_owned())); + } + + Err(BundleSourceError::UnknownSource) + } + + /// Reads any file from the bundle. + pub fn read_file(&self, path: &str) -> Result, Error> { + match self { + BundleSource::Directory(directory) => { + let mut file = directory.read_file(path)?; + let mut data = vec![]; + file.read_to_end(&mut data)?; + Ok(data) + } + } + } + + /// Reads a file specifically from the content directory of the bundle. + pub fn read_content(&self, path: &str) -> Result, Error> { + match self { + BundleSource::Directory(directory) => { + let mut file = directory.read_content(path)?; + let mut data = vec![]; + file.read_to_end(&mut data)?; + Ok(data) + } + } + } +} diff --git a/frontend-utils/src/bundle/source/directory.rs b/frontend-utils/src/bundle/source/directory.rs new file mode 100644 index 000000000..fb92c0db2 --- /dev/null +++ b/frontend-utils/src/bundle/source/directory.rs @@ -0,0 +1,170 @@ +use crate::bundle::source::BundleSourceImpl; +use std::fs::File; +use std::io::{Error, ErrorKind}; +use std::path::Path; + +impl BundleSourceImpl for Path { + type Read = File; + + fn read_file(&self, path: &str) -> Result { + let potential_path = self.join(path); + if !potential_path.starts_with(self) { + return Err(Error::from(ErrorKind::NotFound)); + } + File::open(potential_path) + } + + fn read_content(&self, path: &str) -> Result { + let root = self.join("content"); + let potential_path = root.join(path); + if !potential_path.starts_with(root) { + return Err(Error::from(ErrorKind::NotFound)); + } + File::open(potential_path) + } +} + +#[cfg(test)] +mod tests { + use crate::bundle::source::BundleSourceImpl; + use std::io::{ErrorKind, Read, Write}; + use tempfile::{tempdir, NamedTempFile}; + + /* + [NA] Careful with panicking in these tests. + tempdir() relies on Drop to clean up the directory, + and since we're testing a real filesystem... it sucks if we leak. + + Construct the test, perform the test, drop the tmp_dir and *then* assert. + */ + + #[test] + fn read_file_not_found() { + let tmp_dir = tempdir().unwrap(); + let success = matches!( + tmp_dir.path().read_file("some_file"), + Err(e) if e.kind() == ErrorKind::NotFound + ); + drop(tmp_dir); + assert!(success) + } + + #[test] + fn read_file_invalid() { + let tmp_dir = tempdir().unwrap(); + let success = matches!( + tmp_dir.path().read_file("!?\\//"), + Err(e) if e.kind() == ErrorKind::NotFound + ); + drop(tmp_dir); + assert!(success) + } + + #[test] + fn read_file_works() { + let tmp_dir = tempdir().unwrap(); + let _ = std::fs::write(tmp_dir.path().join("some_file.txt"), "Fancy!"); + let result = tmp_dir + .path() + .read_file("some_file.txt") + .map_err(|e| e.to_string()) + .map(|mut f| { + let mut result = String::new(); + let _ = f.read_to_string(&mut result); + result + }); + drop(tmp_dir); + + assert_eq!(result.as_deref(), Ok("Fancy!")) + } + + #[test] + fn read_file_outside_directory() { + let tmp_dir = tempdir().unwrap(); + let mut tmp_file = NamedTempFile::new().unwrap(); + let _ = tmp_file.write(&[1, 2, 3, 4]); + let success = matches!( + tmp_dir + .path() + .read_file(&tmp_file.path().to_string_lossy()), + Err(e) if e.kind() == ErrorKind::NotFound + ); + drop(tmp_file); + drop(tmp_dir); + + assert!(success) + } + + #[test] + fn read_content_not_found() { + let tmp_dir = tempdir().unwrap(); + let success = matches!( + tmp_dir.path().read_content("some_file"), + Err(e) if e.kind() == ErrorKind::NotFound + ); + drop(tmp_dir); + assert!(success) + } + + #[test] + fn read_content_invalid() { + let tmp_dir = tempdir().unwrap(); + let success = matches!( + tmp_dir.path().read_content("!?\\//"), + Err(e) if e.kind() == ErrorKind::NotFound + ); + drop(tmp_dir); + assert!(success) + } + + #[test] + fn read_content_works() { + let tmp_dir = tempdir().unwrap(); + let _ = std::fs::create_dir(tmp_dir.path().join("content")); + let _ = std::fs::write(tmp_dir.path().join("content/some_file.txt"), "Fancy!"); + let result = tmp_dir + .path() + .read_content("some_file.txt") + .map_err(|e| e.to_string()) + .map(|mut f| { + let mut result = String::new(); + let _ = f.read_to_string(&mut result); + result + }); + drop(tmp_dir); + + assert_eq!(result.as_deref(), Ok("Fancy!")) + } + + #[test] + fn read_content_outside_content_directory() { + let tmp_dir = tempdir().unwrap(); + let _ = std::fs::write(tmp_dir.path().join("some_file.txt"), "Fancy!"); + let success = matches!( + tmp_dir + .path() + .read_content("../some_file.txt"), + Err(e) if e.kind() == ErrorKind::NotFound + ); + drop(tmp_dir); + + assert!(success) + } + + #[test] + fn read_content_outside_root_directory() { + let tmp_dir = tempdir().unwrap(); + let mut tmp_file = NamedTempFile::new().unwrap(); + let _ = tmp_file.write(&[1, 2, 3, 4]); + let success = matches!( + tmp_dir + .path() + .read_content(&tmp_file.path().to_string_lossy()), + Err(e) if e.kind() == ErrorKind::NotFound + ); + drop(tmp_file); + drop(tmp_dir); + + assert!(success) + } +} diff --git a/frontend-utils/src/lib.rs b/frontend-utils/src/lib.rs index 3ad509114..d9ebfccd2 100644 --- a/frontend-utils/src/lib.rs +++ b/frontend-utils/src/lib.rs @@ -1,3 +1,4 @@ pub mod bookmarks; +pub mod bundle; pub mod parse; pub mod write; diff --git a/frontend-utils/src/parse.rs b/frontend-utils/src/parse.rs index 3fc22b0e3..8b88622c2 100644 --- a/frontend-utils/src/parse.rs +++ b/frontend-utils/src/parse.rs @@ -125,17 +125,18 @@ impl ParseContext { pub trait ReadExt<'a> { fn get_impl(&'a self, key: &str) -> Option<&'a Item>; - fn get_table_like( + fn get_table_like( &'a self, cx: &mut ParseContext, key: &'static str, - fun: impl FnOnce(&mut ParseContext, &dyn TableLike), - ) { + fun: impl FnOnce(&mut ParseContext, &dyn TableLike) -> R, + ) -> Option { + let mut result = None; if let Some(item) = self.get_impl(key) { cx.push_key(key); if let Some(table) = item.as_table_like() { - fun(cx, table); + result = Some(fun(cx, table)); } else { cx.add_warning(format!( "Invalid {}: expected table but found {}", @@ -146,19 +147,21 @@ pub trait ReadExt<'a> { cx.pop_key(); } + result } - fn get_array_of_tables( + fn get_array_of_tables( &'a self, cx: &mut ParseContext, key: &'static str, - fun: impl FnOnce(&mut ParseContext, &ArrayOfTables), - ) { + fun: impl FnOnce(&mut ParseContext, &ArrayOfTables) -> R, + ) -> Option { + let mut result = None; if let Some(item) = self.get_impl(key) { cx.push_key(key); if let Some(array) = item.as_array_of_tables() { - fun(cx, array); + result = Some(fun(cx, array)); } else { cx.add_warning(format!( "Invalid {}: expected array of tables but found {}", @@ -169,6 +172,7 @@ pub trait ReadExt<'a> { cx.pop_key(); } + result } fn parse_from_str(&'a self, cx: &mut ParseContext, key: &'static str) -> Option {