frontend-utils: Add initial bundle infrastructure

This commit is contained in:
Nathan Adams 2024-04-05 11:56:09 +02:00
parent 906837c6a1
commit 961bc0a7c5
9 changed files with 622 additions and 8 deletions

2
Cargo.lock generated
View File

@ -4285,6 +4285,8 @@ dependencies = [
name = "ruffle_frontend_utils"
version = "0.1.0"
dependencies = [
"tempfile",
"thiserror",
"toml_edit 0.22.9",
"tracing",
"url",

View File

@ -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"

View File

@ -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<String>,
}
impl Bundle {
pub fn from_path(path: impl AsRef<Path>) -> Result<Bundle, BundleError> {
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::<String>::new(), result.warnings);
}
}

View File

@ -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.
<!-- TOC -->
* [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)
<!-- TOC -->
## 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"`.

View File

@ -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<ParseDetails<BundleInformation>, BundleInformationParseError> {
let document = input.parse::<DocumentMut>()?;
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<String>), 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![]
))
)
}
}

View File

@ -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<Self::Read, Error>;
/// Reads a file specifically from the content directory of the bundle.
fn read_content(&self, path: &str) -> Result<Self::Read, Error>;
}
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<Path>) -> Result<Self, BundleSourceError> {
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<Vec<u8>, 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<Vec<u8>, 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)
}
}
}
}

View File

@ -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<Self::Read, Error> {
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<Self::Read, Error> {
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)
}
}

View File

@ -1,3 +1,4 @@
pub mod bookmarks;
pub mod bundle;
pub mod parse;
pub mod write;

View File

@ -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<R>(
&'a self,
cx: &mut ParseContext,
key: &'static str,
fun: impl FnOnce(&mut ParseContext, &dyn TableLike),
) {
fun: impl FnOnce(&mut ParseContext, &dyn TableLike) -> R,
) -> Option<R> {
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<R>(
&'a self,
cx: &mut ParseContext,
key: &'static str,
fun: impl FnOnce(&mut ParseContext, &ArrayOfTables),
) {
fun: impl FnOnce(&mut ParseContext, &ArrayOfTables) -> R,
) -> Option<R> {
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<T: FromStr>(&'a self, cx: &mut ParseContext, key: &'static str) -> Option<T> {