diff --git a/.github/workflows/package.sh b/.github/workflows/package.sh index 1ef08139..76c005ab 100755 --- a/.github/workflows/package.sh +++ b/.github/workflows/package.sh @@ -55,7 +55,7 @@ EOF # Package one crate at a time and add it to the local registry so that subsequent crates # can pick them up. -for dir in core std repl client terminal sdl st7735s rpi cli; do +for dir in core std repl client cloud terminal sdl st7735s rpi fuse cli; do cd "${dir}" cargo index add --index "${registry}/index" --index-url https://example.com cd - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69950839..e053ddc6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,7 @@ jobs: - uses: mozilla-actions/sccache-action@v0.0.10 - run: brew install sdl2 - run: cargo test --package=endbasic-client -- --include-ignored + - run: cargo test --package=endbasic-cloud -- --include-ignored - run: cargo test --package=endbasic-core -- --include-ignored - run: cargo test --package=endbasic-std -- --include-ignored - run: cargo test --package=endbasic-repl -- --include-ignored @@ -93,6 +94,7 @@ jobs: - run: choco install --allow-empty-checksums unzip - run: ./admin/setup-sdl.ps1 - run: cargo test --package=endbasic-client -- --include-ignored + - run: cargo test --package=endbasic-cloud -- --include-ignored - run: cargo test --package=endbasic-core -- --include-ignored - run: cargo test --package=endbasic-std -- --include-ignored - run: cargo test --package=endbasic-repl -- --include-ignored diff --git a/Cargo.lock b/Cargo.lock index cb45e8fa..0a75bfc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -267,6 +273,7 @@ dependencies = [ "async-channel", "dirs", "endbasic-client", + "endbasic-cloud", "endbasic-core", "endbasic-repl", "endbasic-rpi", @@ -287,18 +294,29 @@ dependencies = [ "async-trait", "base64", "bytes", - "endbasic-core", - "endbasic-std", "rand", "reqwest", "serde", "serde_json", "serde_test", - "time", "tokio", "url", ] +[[package]] +name = "endbasic-cloud" +version = "0.13.99" +dependencies = [ + "async-trait", + "endbasic-client", + "endbasic-core", + "endbasic-std", + "rand", + "serde_test", + "time", + "tokio", +] + [[package]] name = "endbasic-core" version = "0.13.99" @@ -310,6 +328,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "endbasic-fuse" +version = "0.13.99" +dependencies = [ + "anyhow", + "endbasic-client", + "fuser", + "getoptsargs", + "libc", + "tempfile", + "thiserror", + "tokio", +] + [[package]] name = "endbasic-repl" version = "0.13.99" @@ -398,6 +430,7 @@ dependencies = [ "async-trait", "console_error_panic_hook", "endbasic-client", + "endbasic-cloud", "endbasic-core", "endbasic-repl", "endbasic-std", @@ -525,6 +558,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fuser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e697f6f62c20b6fad1ba0f84ae909f25971cf16e735273524e3977c94604cf8" +dependencies = [ + "libc", + "log", + "memchr", + "page_size", + "pkg-config", + "smallvec", + "zerocopy 0.7.35", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1274,6 +1322,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -1348,7 +1406,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.48", ] [[package]] @@ -2555,13 +2613,34 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + [[package]] name = "zerocopy" version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.8.48", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 74e466bf..4a3e7e4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,10 @@ resolver = "3" members = [ "cli", "client", + "cloud", "core", "core", + "fuse", "repl", "rpi", "sdl", diff --git a/NEWS.md b/NEWS.md index 6e911ee0..40223a2e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,6 +14,9 @@ for the time being.** **STILL UNDER DEVELOPMENT; NOT RELEASED YET** +* Added a new Linux-only `endbasic-fuse` binary to mount the EndBASIC + cloud service as a FUSE filesystem. + * Changed the sdl and web graphics backends to use our own font rendering engine (the one implemented for the ST7735s LCD). diff --git a/RELEASE.md b/RELEASE.md index 0dc657c3..0e8e50d9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -37,7 +37,7 @@ 1. Push the new crates out. This is the last step because it's not reversible: ``` - for c in core std repl client terminal sdl st7735s rpi cli; do + for c in core std repl client cloud terminal sdl st7735s rpi fuse cli; do ( cd $c && cargo publish ) || break done ``` diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 60e0e272..6a647c73 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -30,6 +30,10 @@ getoptsargs = "0.2.1" version = "0.13.99" # ENDBASIC-VERSION path = "../client" +[dependencies.endbasic-cloud] +version = "0.13.99" # ENDBASIC-VERSION +path = "../cloud" + [dependencies.endbasic-core] version = "0.13.99" # ENDBASIC-VERSION path = "../core" diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 1d457f13..8a6ef0db 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -72,7 +72,7 @@ pub async fn run_repl_loop( setup_storage(&mut storage.borrow_mut(), local_drive_spec)?; let service = Rc::from(RefCell::from(CloudService::new(service_url)?)); - endbasic_client::add_all( + endbasic_cloud::add_all( &mut builder, service, console.clone(), @@ -133,7 +133,7 @@ pub async fn run_interactive( setup_storage(&mut storage.borrow_mut(), local_drive_spec)?; let service = Rc::from(RefCell::from(CloudService::new(service_url)?)); - endbasic_client::add_all( + endbasic_cloud::add_all( &mut builder, service, console.clone(), diff --git a/client/Cargo.toml b/client/Cargo.toml index eab8e908..df961b8c 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -5,7 +5,7 @@ license = "AGPL-3.0-or-later" authors = ["Julio Merino "] categories = ["development-tools", "parser-implementations"] keywords = ["basic", "interpreter", "learning", "programming"] -description = "The EndBASIC programming language - cloud service client" +description = "The EndBASIC programming language - cloud service API client" homepage = "https://www.endbasic.dev/" repository = "https://github.com/endbasic/endbasic" readme = "README.md" @@ -14,23 +14,17 @@ edition = "2024" [lints] workspace = true +[features] +testutils = [] + [dependencies] async-trait = "0.1" base64 = "0.21" bytes = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -time = { version = "0.3", features = ["std"] } url = "2.2" -[dependencies.endbasic-core] -version = "0.13.99" # ENDBASIC-VERSION -path = "../core" - -[dependencies.endbasic-std] -version = "0.13.99" # ENDBASIC-VERSION -path = "../std" - [dependencies.reqwest] version = "0.11" default-features = false diff --git a/client/README.md b/client/README.md index 455bb22a..ee16888d 100644 --- a/client/README.md +++ b/client/README.md @@ -1,4 +1,4 @@ -# The EndBASIC programming language - cloud service client +# The EndBASIC programming language - cloud service API client [![Crates.io](https://img.shields.io/crates/v/endbasic-client.svg)](https://crates.io/crates/endbasic-client/) [![Docs.rs](https://docs.rs/endbasic-client/badge.svg)](https://docs.rs/endbasic-client/) @@ -23,13 +23,6 @@ EndBASIC is free software under the [AGPL v3+](LICENSE). ## What's in this crate? -`endbasic-client` provides a client for the EndBASIC cloud service. This +`endbasic-client` provides a client for the EndBASIC cloud service API. This service offers remote file storage and file sharing capabilities to allow publishing creations to the public or to other users. - -This library extends the interpreter with the following commands: - -* `LOGIN`: Logs into an account and mounts the user's own cloud drive. -* `LOGOUT`: Logs out of an account. -* `SHARE`: Gets or modifies sharing permissions on a file. -* `SIGNUP`: Interactively creates an account. diff --git a/client/src/cloud.rs b/client/src/cloud.rs index 112c421a..6cb38651 100644 --- a/client/src/cloud.rs +++ b/client/src/cloud.rs @@ -19,8 +19,6 @@ use crate::*; use async_trait::async_trait; use base64::prelude::*; use bytes::Buf; -use endbasic_std::console::remove_control_chars; -use endbasic_std::storage::FileAcls; use reqwest::Response; use reqwest::StatusCode; use reqwest::header::HeaderMap; @@ -52,17 +50,12 @@ async fn http_response_to_io_error(response: Response) -> io::Error { match response.text().await { Ok(text) => match serde_json::from_str::(&text) { - Ok(response) => io::Error::new( - kind, - format!("{} (server code: {})", remove_control_chars(response.message), status), - ), + Ok(response) => { + io::Error::new(kind, format!("{} (server code: {})", response.message, status)) + } _ => io::Error::new( kind, - format!( - "HTTP request returned status {} with text '{}'", - status, - remove_control_chars(text) - ), + format!("HTTP request returned status {} with text '{}'", status, text), ), }, Err(e) => io::Error::new( @@ -70,7 +63,7 @@ async fn http_response_to_io_error(response: Response) -> io::Error { format!( "HTTP request returned status {} and failed to get text due to {}", status, - remove_control_chars(e.to_string()) + e.to_string() ), ), } @@ -230,6 +223,23 @@ impl Service for CloudService { self.auth_data.borrow().as_ref().map(|x| x.username.to_owned()) } + async fn list_users(&mut self) -> io::Result> { + let response = self + .client + .get(self.make_url("api/users")) + .headers(self.default_headers()) + .send() + .await + .map_err(reqwest_error_to_io_error)?; + match response.status() { + StatusCode::OK => { + let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?; + Ok(serde_json::from_reader(bytes.reader())?) + } + _ => Err(http_response_to_io_error(response).await), + } + } + async fn get_files(&mut self, username: &str) -> io::Result { let mut builder = self .client @@ -266,7 +276,7 @@ impl Service for CloudService { } } - async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result { + async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result> { let mut headers = self.default_headers(); headers.insert("X-EndBASIC-GetContent", "false".parse().unwrap()); headers.insert("X-EndBASIC-GetReaders", "true".parse().unwrap()); @@ -296,7 +306,7 @@ impl Service for CloudService { let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?; debug_assert!(bytes.is_empty(), "Did not expect server to return content"); - Ok(FileAcls::default().with_readers(readers)) + Ok(readers) } _ => Err(http_response_to_io_error(response).await), } @@ -331,8 +341,8 @@ impl Service for CloudService { &mut self, username: &str, filename: &str, - add: &FileAcls, - remove: &FileAcls, + add_readers: &Vec, + remove_readers: &Vec, ) -> io::Result<()> { let auth_data = self.auth_data.borrow(); @@ -344,10 +354,10 @@ impl Service for CloudService { // Ensure we have at least one header to go through the header-based request handler. .header("X-EndBASIC-PatchContent", "false"); - for reader in add.readers() { + for reader in add_readers { builder = builder.header("X-EndBASIC-AddReader", reader); } - for reader in remove.readers() { + for reader in remove_readers { builder = builder.header("X-EndBASIC-RemoveReader", reader); } @@ -436,6 +446,10 @@ mod testutils { self.service.logged_in_username() } + async fn list_users(&mut self) -> io::Result> { + self.service.list_users().await + } + async fn get_files(&mut self, username: &str) -> io::Result { self.service.get_files(username).await } @@ -444,7 +458,11 @@ mod testutils { self.service.get_file(username, filename).await } - async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result { + async fn get_file_acls( + &mut self, + username: &str, + filename: &str, + ) -> io::Result> { self.service.get_file_acls(username, filename).await } @@ -466,10 +484,10 @@ mod testutils { &mut self, username: &str, filename: &str, - add: &FileAcls, - remove: &FileAcls, + add_readers: &Vec, + remove_readers: &Vec, ) -> io::Result<()> { - self.service.patch_file_acls(username, filename, add, remove).await + self.service.patch_file_acls(username, filename, add_readers, remove_readers).await } async fn delete_file(&mut self, username: &str, filename: &str) -> io::Result<()> { @@ -643,10 +661,10 @@ mod tests { } let disk_quota: DiskSpace = response.disk_quota.unwrap().into(); let disk_free: DiskSpace = response.disk_free.unwrap().into(); - assert!(disk_quota.bytes() > 0); - assert!(disk_quota.files() > 0); - assert!(disk_free.bytes() >= needed_bytes, "Not enough space for test run"); - assert!(disk_free.files() >= needed_files, "Not enough space for test run"); + assert!(disk_quota.bytes > 0); + assert!(disk_quota.files > 0); + assert!(disk_free.bytes >= needed_bytes, "Not enough space for test run"); + assert!(disk_free.files >= needed_files, "Not enough space for test run"); for (filename, _content) in &filenames_and_contents { let err = context.service.get_file(&username, filename).await.unwrap_err(); @@ -778,12 +796,7 @@ mod tests { context.do_login(1).await; context .service - .patch_file_acls( - &username1, - &filename, - &FileAcls::default().with_readers([username2]), - &FileAcls::default(), - ) + .patch_file_acls(&username1, &filename, &vec![username2], &vec![]) .await .unwrap(); @@ -821,12 +834,7 @@ mod tests { context.do_login(1).await; context .service - .patch_file_acls( - &username1, - &filename, - &FileAcls::default().with_readers(["public".to_owned()]), - &FileAcls::default(), - ) + .patch_file_acls(&username1, &filename, &vec!["public".to_owned()], &vec![]) .await .unwrap(); diff --git a/client/src/lib.rs b/client/src/lib.rs index d956e5a4..e58dd6b5 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -16,40 +16,27 @@ //! EndBASIC service client. use async_trait::async_trait; -use endbasic_std::storage::{DiskSpace, FileAcls}; use serde::{Deserialize, Serialize}; use std::io; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; mod cloud; pub use cloud::CloudService; -mod cmds; -pub use cmds::add_all; -mod drive; -pub(crate) use drive::CloudDriveFactory; -#[cfg(test)] -pub(crate) mod testutils; +#[cfg(any(test, feature = "testutils"))] +pub mod testutils; /// Base address of the production REST API. pub const PROD_API_ADDRESS: &str = "https://service.endbasic.dev/"; -/// Wrapper over `DiskSpace` to implement (de)serialization. +/// Representation of some amount of disk space. Can be used to express both quotas and usage. #[derive(Debug, Deserialize)] #[cfg_attr(test, derive(PartialEq, Serialize))] -struct SerdeDiskSpace { - bytes: u64, - files: u64, -} - -impl From for SerdeDiskSpace { - fn from(ds: DiskSpace) -> Self { - SerdeDiskSpace { bytes: ds.bytes, files: ds.files } - } -} +pub struct DiskSpace { + /// Number of bytes used or allowed. + pub bytes: u64, -impl From for DiskSpace { - fn from(sds: SerdeDiskSpace) -> Self { - DiskSpace { bytes: sds.bytes, files: sds.files } - } + /// Number of files used or allowed. + pub files: u64, } /// An opaque access token obtained during authentication and used for all subsequent requests @@ -60,8 +47,8 @@ pub struct AccessToken(String); impl AccessToken { /// Creates a new access token based on the raw `token` string. - #[cfg(test)] - pub(crate) fn new>(token: S) -> Self { + #[cfg(any(test, feature = "testutils"))] + pub fn new>(token: S) -> Self { Self(token.into()) } @@ -82,36 +69,79 @@ pub struct ErrorResponse { #[derive(Deserialize)] #[cfg_attr(test, derive(Debug, Serialize))] pub struct LoginResponse { - pub(crate) access_token: AccessToken, - motd: Vec, + /// Access token returned by the server, required for subsequent operations. + pub access_token: AccessToken, + + /// Message of the day returned by the server. + pub motd: Vec, } /// Representation of a single directory entry as returned by the server. #[derive(Deserialize)] #[cfg_attr(test, derive(Debug, Serialize))] pub struct DirectoryEntry { - filename: String, - mtime: u64, - length: u64, + /// Name of the file in the directory. + pub filename: String, + + /// Last modification timestamp of the file, as UTC seconds since the Epoch. + pub mtime: u64, + + /// Length of the file, in bytes. + pub length: u64, +} + +/// A file entry with a parsed modification time, suitable for use by callers. +pub struct FileEntry { + /// Name of the file. + pub filename: String, + /// Size of the file in bytes. + pub length: u64, + /// Last modification time. + pub mtime: SystemTime, +} + +impl From for FileEntry { + fn from(e: DirectoryEntry) -> Self { + Self { filename: e.filename, length: e.length, mtime: UNIX_EPOCH + Duration::from_secs(e.mtime) } + } } /// Representation of a directory enumeration response. #[derive(Deserialize)] #[cfg_attr(test, derive(Debug, Serialize))] pub struct GetFilesResponse { - files: Vec, - disk_quota: Option, - disk_free: Option, + /// List of entries in the directory. + pub files: Vec, + + /// Total disk quota, if known/applicable. + pub disk_quota: Option, + + /// Available disk quota, if known/applicable. + pub disk_free: Option, +} + +impl GetFilesResponse { + /// Converts the response into a list of file entries with parsed modification times. + pub fn into_files(self) -> Vec { + self.files.into_iter().map(FileEntry::from).collect() + } } /// Representation of a signup request. #[derive(Debug, Default, Eq, PartialEq, Serialize)] #[cfg_attr(test, derive(Deserialize))] pub struct SignupRequest { - username: String, - password: String, - email: String, - promotional_email: bool, + /// Name of the user to create an account for. + pub username: String, + + /// Password for the new account. + pub password: String, + + /// Email address for the new account. + pub email: String, + + /// Whether the new user desires to receive future promotional email or not. + pub promotional_email: bool, } /// Abstract interface to interact with an EndBASIC service server. @@ -135,6 +165,9 @@ pub trait Service { /// Returns the logged in username if there is an active session. fn logged_in_username(&self) -> Option; + /// Sends a request to the server to obtain the list of all known usernames. + async fn list_users(&mut self) -> io::Result>; + /// Sends a request to the server to obtain the list of files owned by `username` with a /// previously-acquired `access_token`. async fn get_files(&mut self, username: &str) -> io::Result; @@ -144,8 +177,8 @@ pub trait Service { async fn get_file(&mut self, username: &str, filename: &str) -> io::Result>; /// Sends a request to the server to obtain the ACLs of `filename` owned by `username` with a - /// previously-acquired `access_token`. - async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result; + /// previously-acquired `access_token`. Returns the list of allowed readers. + async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result>; /// Sends a request to the server to update the contents of `filename` owned by `username` as /// specified in `content` with a previously-acquired `access_token`. @@ -157,13 +190,13 @@ pub trait Service { ) -> io::Result<()>; /// Sends a request to the server to update the ACLs of `filename` owned by `username` as - /// specified in `add` and `remove` with a previously-acquired `access_token`. + /// specified in `add_readers` and `remove_readers` with a previously-acquired `access_token`. async fn patch_file_acls( &mut self, username: &str, filename: &str, - add: &FileAcls, - remove: &FileAcls, + add_readers: &Vec, + remove_readers: &Vec, ) -> io::Result<()>; /// Sends a request to the server to delete `filename` owned by `username` with a diff --git a/client/src/testutils.rs b/client/src/testutils.rs index 62ed8626..bd34b1f8 100644 --- a/client/src/testutils.rs +++ b/client/src/testutils.rs @@ -16,30 +16,26 @@ //! Test utilities for the cloud service. -use crate::cmds::{LoginCommand, LogoutCommand, ShareCommand, SignupCommand}; use crate::{AccessToken, GetFilesResponse, LoginResponse, Service, SignupRequest}; use async_trait::async_trait; -use endbasic_std::storage::{FileAcls, Storage}; -use endbasic_std::testutils::*; -use std::cell::RefCell; use std::collections::VecDeque; use std::io; -use std::rc::Rc; /// Service client implementation that allows specifying expectations on requests and yields the /// responses previously recorded into it. #[derive(Default)] #[allow(clippy::type_complexity)] pub struct MockService { - access_token: Option, + /// Access token captured by the mock service during login. + pub access_token: Option, mock_signup: VecDeque<(SignupRequest, io::Result<()>)>, mock_login: VecDeque<((String, String), io::Result)>, mock_get_files: VecDeque<(String, io::Result)>, mock_get_file: VecDeque<((String, String), io::Result>)>, - mock_get_file_acls: VecDeque<((String, String), io::Result)>, + mock_get_file_acls: VecDeque<((String, String), io::Result>)>, mock_patch_file_content: VecDeque<((String, String, Vec), io::Result<()>)>, - mock_patch_file_acls: VecDeque<((String, String, FileAcls, FileAcls), io::Result<()>)>, + mock_patch_file_acls: VecDeque<((String, String, Vec, Vec), io::Result<()>)>, mock_delete_file: VecDeque<((String, String), io::Result<()>)>, } @@ -47,8 +43,7 @@ impl MockService { /// Performs an explicit authentication for those tests that don't go through the `LOGIN` /// command logic. The access token that's generated is different every time this is called /// within the same `MockService`. - #[cfg(test)] - pub(crate) async fn do_login(&mut self) { + pub async fn do_login(&mut self) { self.access_token = match &self.access_token { Some(previous) => Some(AccessToken::new(format!("{}$", previous.as_str()))), None => Some(AccessToken::new("$")), @@ -57,15 +52,13 @@ impl MockService { /// Records the behavior of an upcoming signup operation with `request` and that returns /// `result`. - #[cfg(test)] - pub(crate) fn add_mock_signup(&mut self, request: SignupRequest, result: io::Result<()>) { + pub fn add_mock_signup(&mut self, request: SignupRequest, result: io::Result<()>) { self.mock_signup.push_back((request, result)); } /// Records the behavior of an upcoming login operation with `username` and `password` /// credentials and that returns `result`. - #[cfg(test)] - pub(crate) fn add_mock_login( + pub fn add_mock_login( &mut self, username: &str, password: &str, @@ -77,20 +70,14 @@ impl MockService { /// Records the behavior of an upcoming "get files" operation for `username` and that returns /// `result`. - #[cfg(test)] - pub(crate) fn add_mock_get_files( - &mut self, - username: &str, - result: io::Result, - ) { + pub fn add_mock_get_files(&mut self, username: &str, result: io::Result) { let exp_request = username.to_owned(); self.mock_get_files.push_back((exp_request, result)); } /// Records the behavior of an upcoming "get file" operation for the `username`/`filename` /// pair that returns `result`. - #[cfg(test)] - pub(crate) fn add_mock_get_file>>( + pub fn add_mock_get_file>>( &mut self, username: &str, filename: &str, @@ -102,12 +89,11 @@ impl MockService { /// Records the behavior of an upcoming "get file ACLs" operation for the `username`/`filename` /// pair that returns `result`. - #[cfg(test)] - pub(crate) fn add_mock_get_file_acls( + pub fn add_mock_get_file_acls( &mut self, username: &str, filename: &str, - result: io::Result, + result: io::Result>, ) { let exp_request = (username.to_owned(), filename.to_owned()); self.mock_get_file_acls.push_back((exp_request, result)); @@ -115,8 +101,7 @@ impl MockService { /// Records the behavior of an upcoming "patch file content" operation for the /// `username`/`filename` pair with `exp_content` and that returns `result`. - #[cfg(test)] - pub(crate) fn add_mock_patch_file_content>>( + pub fn add_mock_patch_file_content>>( &mut self, username: &str, filename: &str, @@ -129,40 +114,32 @@ impl MockService { /// Records the behavior of an upcoming "patch file ACLS" operation for the /// `username`/`filename` pair with `exp_add` and `exp_remove` and that returns `result`. - #[cfg(test)] - pub(crate) fn add_mock_patch_file_acls, V: Into>>( + pub fn add_mock_patch_file_acls, V: Into>>( &mut self, username: &str, filename: &str, - exp_add: V, - exp_remove: V, + exp_add_readers: V, + exp_remove_readers: V, result: io::Result<()>, ) { - let exp_add = FileAcls { - readers: exp_add.into().into_iter().map(|v| v.into()).collect::>(), - }; - let exp_remove = FileAcls { - readers: exp_remove.into().into_iter().map(|v| v.into()).collect::>(), - }; - let exp_request = (username.to_owned(), filename.to_owned(), exp_add, exp_remove); + let exp_add_readers = + exp_add_readers.into().into_iter().map(|v| v.into()).collect::>(); + let exp_remove_readers = + exp_remove_readers.into().into_iter().map(|v| v.into()).collect::>(); + let exp_request = + (username.to_owned(), filename.to_owned(), exp_add_readers, exp_remove_readers); self.mock_patch_file_acls.push_back((exp_request, result)); } /// Records the behavior of an upcoming "delete file" operation for the `username`/`filename` /// pair and that returns `result`. - #[cfg(test)] - pub(crate) fn add_mock_delete_file( - &mut self, - username: &str, - filename: &str, - result: io::Result<()>, - ) { + pub fn add_mock_delete_file(&mut self, username: &str, filename: &str, result: io::Result<()>) { let exp_request = (username.to_owned(), filename.to_owned()); self.mock_delete_file.push_back((exp_request, result)); } /// Ensures that all requests and responses have been consumed. - pub(crate) fn verify_all_used(&mut self) { + pub fn verify_all_used(&mut self) { assert!(self.mock_signup.is_empty(), "Mock requests not fully consumed"); assert!(self.mock_login.is_empty(), "Mock requests not fully consumed"); assert!(self.mock_get_files.is_empty(), "Mock requests not fully consumed"); @@ -208,6 +185,10 @@ impl Service for MockService { self.access_token.as_ref().map(|_| "logged-in-username".to_owned()) } + async fn list_users(&mut self) -> io::Result> { + unimplemented!("MockService does not implement list_users") + } + async fn get_files(&mut self, username: &str) -> io::Result { self.access_token.as_ref().expect("login not called yet"); let mock = self.mock_get_files.pop_front().expect("No mock requests available"); @@ -224,7 +205,7 @@ impl Service for MockService { mock.1 } - async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result { + async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result> { self.access_token.as_ref().expect("login not called yet"); let mock = self.mock_get_file_acls.pop_front().expect("No mock requests available"); @@ -252,16 +233,16 @@ impl Service for MockService { &mut self, username: &str, filename: &str, - add: &FileAcls, - remove: &FileAcls, + add_readers: &Vec, + remove_readers: &Vec, ) -> io::Result<()> { self.access_token.as_ref().expect("login not called yet"); let mock = self.mock_patch_file_acls.pop_front().expect("No mock requests available"); assert_eq!(&mock.0.0, username); assert_eq!(&mock.0.1, filename); - assert_eq!(&mock.0.2, add); - assert_eq!(&mock.0.3, remove); + assert_eq!(&mock.0.2, add_readers); + assert_eq!(&mock.0.3, remove_readers); mock.1 } @@ -274,153 +255,3 @@ impl Service for MockService { mock.1 } } - -/// Wrapper over the generic `Tester` to validate features related to the cloud service. -#[must_use] -pub(crate) struct ClientTester { - tester: Tester, - service: Rc>, -} - -impl Default for ClientTester { - fn default() -> Self { - let tester = Tester::default(); - let console = tester.get_console(); - let storage = tester.get_storage(); - let service = Rc::from(RefCell::from(MockService::default())); - storage - .borrow_mut() - .register_scheme("cloud", Box::from(crate::CloudDriveFactory::new(service.clone()))); - let tester = tester - .add_callable(LoginCommand::new(service.clone(), console.clone(), storage.clone())) - .add_callable(LogoutCommand::new(service.clone(), console.clone(), storage.clone())) - .add_callable(ShareCommand::new( - service.clone(), - console.clone(), - storage, - "https://repl.example.com/", - )) - .add_callable(SignupCommand::new(service.clone(), console)); - ClientTester { tester, service } - } -} - -impl ClientTester { - /// See the wrapped `Tester::add_input_chars` function for details. - pub fn add_input_chars(self, golden_in: &str) -> Self { - ClientTester { tester: self.tester.add_input_chars(golden_in), service: self.service } - } - - /// See the wrapped `Tester::get_console` function for details. - pub fn get_console(&self) -> Rc> { - self.tester.get_console() - } - - /// Gets the mock service client from the tester. - /// - /// This method should generally not be used. Its primary utility is to hook - /// externally-instantiated commands into the testing features. - pub(crate) fn get_service(&self) -> Rc> { - self.service.clone() - } - - /// See the wrapped `Tester::get_storage` function for details. - pub fn get_storage(&self) -> Rc> { - self.tester.get_storage() - } - - /// See the wrapped `Tester::run` function for details. - pub(crate) fn run>(&mut self, script: S) -> ClientChecker<'_> { - let checker = self.tester.run(script); - ClientChecker { checker, service: self.service.clone(), exp_access_token: None } - } -} - -/// Wrapper over the generic `Checker` to validate features related to the cloud service. -#[must_use] -pub(crate) struct ClientChecker<'a> { - checker: Checker<'a>, - service: Rc>, - exp_access_token: Option, -} - -impl<'a> ClientChecker<'a> { - /// Expects the mock service to have logged in with the access `token`. - pub(crate) fn expect_access_token>(self, token: S) -> Self { - Self { - checker: self.checker, - service: self.service, - exp_access_token: Some(AccessToken::new(token.into())), - } - } - - /// See the wrapped `Checker::expect_err` function for details. - pub fn expect_compilation_err>(self, message: S) -> Self { - Self { - checker: self.checker.expect_compilation_err(message), - service: self.service, - exp_access_token: self.exp_access_token, - } - } - - /// See the wrapped `Checker::expect_err` function for details. - pub fn expect_err>(self, message: S) -> Self { - Self { - checker: self.checker.expect_err(message), - service: self.service, - exp_access_token: self.exp_access_token, - } - } - - /// See the wrapped `Checker::expect_file` function for details. - pub fn expect_file, C: Into>(self, name: N, content: C) -> Self { - Self { - checker: self.checker.expect_file(name, content), - service: self.service, - exp_access_token: self.exp_access_token, - } - } - - /// See the wrapped `Checker::expect_output` function for details. - pub fn expect_output>>(self, out: V) -> Self { - Self { - checker: self.checker.expect_output(out), - service: self.service, - exp_access_token: self.exp_access_token, - } - } - - /// See the wrapped `Checker::expect_prints` function for details. - pub fn expect_prints, V: Into>>(self, out: V) -> Self { - Self { - checker: self.checker.expect_prints(out), - service: self.service, - exp_access_token: self.exp_access_token, - } - } - - /// See the wrapped `Checker::take_captured_out` function for details. - #[must_use] - pub fn take_captured_out(&mut self) -> Vec { - self.checker.take_captured_out() - } - - /// Validates all expectations. - pub(crate) fn check(self) { - self.checker.check(); - - let mut service = self.service.borrow_mut(); - assert_eq!(self.exp_access_token, service.access_token); - service.verify_all_used(); - } -} - -/// See the wrapped `check_stmt_compilation_err` function for details. -pub fn client_check_stmt_compilation_err>(exp_error: S, stmt: &str) { - ClientTester::default().run(stmt).expect_compilation_err(exp_error).check(); -} - -/// See the wrapped `check_stmt_err` function for details. -pub fn client_check_stmt_err>(exp_error: S, stmt: &str) { - ClientTester::default().run(stmt).expect_err(exp_error).check(); -} diff --git a/cloud/Cargo.toml b/cloud/Cargo.toml new file mode 100644 index 00000000..f9226ab4 --- /dev/null +++ b/cloud/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "endbasic-cloud" +version = "0.13.99" # ENDBASIC-VERSION +license = "AGPL-3.0-or-later" +authors = ["Julio Merino "] +categories = ["development-tools", "parser-implementations"] +keywords = ["basic", "interpreter", "learning", "programming"] +description = "The EndBASIC programming language - cloud service integration" +homepage = "https://www.endbasic.dev/" +repository = "https://github.com/endbasic/endbasic" +readme = "README.md" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1" +time = { version = "0.3", features = ["std"] } + +[dependencies.endbasic-client] +version = "0.13.99" # ENDBASIC-VERSION +path = "../client" + +[dependencies.endbasic-core] +version = "0.13.99" # ENDBASIC-VERSION +path = "../core" + +[dependencies.endbasic-std] +version = "0.13.99" # ENDBASIC-VERSION +path = "../std" + +[dev-dependencies] +rand = "0.8" +serde_test = "1" +tokio = { version = "1", features = ["full"] } + +[dev-dependencies.endbasic-client] +version = "0.13.99" # ENDBASIC-VERSION +path = "../client" +features = ["testutils"] diff --git a/cloud/LICENSE b/cloud/LICENSE new file mode 100644 index 00000000..be3f7b28 --- /dev/null +++ b/cloud/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/cloud/NOTICE b/cloud/NOTICE new file mode 100644 index 00000000..f30448d0 --- /dev/null +++ b/cloud/NOTICE @@ -0,0 +1,15 @@ +EndBASIC +Copyright 2020-2026 Julio Merino + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 00000000..47770c77 --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,36 @@ +# The EndBASIC programming language - cloud service integration + +[![Crates.io](https://img.shields.io/crates/v/endbasic-client.svg)](https://crates.io/crates/endbasic-client/) +[![Docs.rs](https://docs.rs/endbasic-client/badge.svg)](https://docs.rs/endbasic-client/) + +EndBASIC is an interpreter for a BASIC-like language and is inspired by +Amstrad's Locomotive BASIC 1.1 and Microsoft's QuickBASIC 4.5. Like the former, +EndBASIC intends to provide an interactive environment that seamlessly merges +coding with immediate visual feedback. Like the latter, EndBASIC offers +higher-level programming constructs and strong typing. + +EndBASIC offers a simplified and restricted environment to learn the foundations +of programming and focuses on features that can quickly reward the programmer. +These features include things like a built-in text editor, commands to +render graphics, and commands to interact with the hardware of a Raspberry +Pi. Implementing this kind of features has priority over others such as +performance or a much richer language. + +EndBASIC is written in Rust and runs both on the web and locally on a variety of +operating systems and platforms, including macOS, Windows, and Linux. + +EndBASIC is free software under the [AGPL v3+](LICENSE). + +## What's in this crate? + +`endbasic-cloud` provides integration (a cloud-backed storage drive and related +REPL commands) with the EndBASIC cloud service. This service offers remote +file storage and file sharing capabilities to allow publishing creations to the +public or to other users. + +This library extends the interpreter with the following commands: + +* `LOGIN`: Logs into an account and mounts the user's own cloud drive. +* `LOGOUT`: Logs out of an account. +* `SHARE`: Gets or modifies sharing permissions on a file. +* `SIGNUP`: Interactively creates an account. diff --git a/client/src/cmds.rs b/cloud/src/cmds.rs similarity index 99% rename from client/src/cmds.rs rename to cloud/src/cmds.rs index 3eb12d90..0361d25b 100644 --- a/client/src/cmds.rs +++ b/cloud/src/cmds.rs @@ -18,6 +18,7 @@ use crate::*; use async_trait::async_trait; +use endbasic_client::*; use endbasic_core::{ ArgSep, ArgSepSyntax, CallError, CallResult, Callable, CallableMetadata, CallableMetadataBuilder, ExprType, RepeatedSyntax, RepeatedTypeSyntax, RequiredValueSyntax, diff --git a/client/src/drive.rs b/cloud/src/drive.rs similarity index 91% rename from client/src/drive.rs rename to cloud/src/drive.rs index 3deb3b05..434505e4 100644 --- a/client/src/drive.rs +++ b/cloud/src/drive.rs @@ -15,9 +15,9 @@ //! Cloud-based implementation of an EndBASIC storage drive. -use crate::*; use async_trait::async_trait; -use endbasic_std::storage::{Drive, DriveFactory, DriveFiles, FileAcls, Metadata}; +use endbasic_client::*; +use endbasic_std::storage::{DiskSpace, Drive, DriveFactory, DriveFiles, FileAcls, Metadata}; use std::cell::RefCell; use std::collections::BTreeMap; use std::io; @@ -56,8 +56,8 @@ impl Drive for CloudDrive { } Ok(DriveFiles::new( entries, - response.disk_quota.map(|x| x.into()), - response.disk_free.map(|x| x.into()), + response.disk_quota.map(|x| DiskSpace { bytes: x.bytes, files: x.files }), + response.disk_free.map(|x| DiskSpace { bytes: x.bytes, files: x.files }), )) } @@ -66,7 +66,8 @@ impl Drive for CloudDrive { } async fn get_acls(&self, filename: &str) -> io::Result { - self.service.borrow_mut().get_file_acls(&self.username, filename).await + let readers = self.service.borrow_mut().get_file_acls(&self.username, filename).await?; + Ok(FileAcls::default().with_readers(readers)) } async fn put(&mut self, filename: &str, content: &[u8]) -> io::Result<()> { @@ -82,7 +83,10 @@ impl Drive for CloudDrive { add: &FileAcls, remove: &FileAcls, ) -> io::Result<()> { - self.service.borrow_mut().patch_file_acls(&self.username, filename, add, remove).await + self.service + .borrow_mut() + .patch_file_acls(&self.username, filename, &add.readers, &remove.readers) + .await } } @@ -115,6 +119,7 @@ impl DriveFactory for CloudDriveFactory { mod tests { use super::*; use crate::testutils::*; + use endbasic_client::testutils::*; #[tokio::test] async fn test_clouddrive_delete() { @@ -141,8 +146,8 @@ mod tests { DirectoryEntry { filename: "one".to_owned(), mtime: 9000, length: 15 }, DirectoryEntry { filename: "two".to_owned(), mtime: 8000, length: 17 }, ], - disk_quota: Some(DiskSpace::new(10000, 100).into()), - disk_free: Some(DiskSpace::new(123, 45).into()), + disk_quota: Some(endbasic_client::DiskSpace { bytes: 10000, files: 100 }), + disk_free: Some(endbasic_client::DiskSpace { bytes: 123, files: 45 }), }), ); let result = drive.enumerate().await.unwrap(); @@ -220,7 +225,7 @@ mod tests { service.borrow_mut().do_login().await; let drive = CloudDrive::new(service.clone(), "the-user"); - let response = FileAcls { readers: vec!["r1".to_owned(), "r2".to_owned()] }; + let response = vec!["r1".to_owned(), "r2".to_owned()]; service.borrow_mut().add_mock_get_file_acls("the-user", "the-filename", Ok(response)); let result = drive.get_acls("the-filename").await.unwrap(); assert_eq!(FileAcls::default().with_readers(["r1".to_owned(), "r2".to_owned()]), result); @@ -234,11 +239,7 @@ mod tests { service.borrow_mut().do_login().await; let drive = CloudDrive::new(service.clone(), "the-user"); - service.borrow_mut().add_mock_get_file_acls( - "the-user", - "the-filename", - Ok(FileAcls::default()), - ); + service.borrow_mut().add_mock_get_file_acls("the-user", "the-filename", Ok(vec![])); let result = drive.get_acls("the-filename").await.unwrap(); assert_eq!(FileAcls::default(), result); @@ -335,8 +336,8 @@ mod tests { mtime: 1622556024, length: 15, }], - disk_quota: Some(DiskSpace::new(10000, 100).into()), - disk_free: Some(DiskSpace::new(123, 45).into()), + disk_quota: Some(endbasic_client::DiskSpace { bytes: 10000, files: 100 }), + disk_free: Some(endbasic_client::DiskSpace { bytes: 123, files: 45 }), }), ); t.get_service().borrow_mut().add_mock_get_files( diff --git a/cloud/src/lib.rs b/cloud/src/lib.rs new file mode 100644 index 00000000..91e34ca5 --- /dev/null +++ b/cloud/src/lib.rs @@ -0,0 +1,25 @@ +// EndBASIC +// Copyright 2021 Julio Merino +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy +// of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +//! EndBASIC service client. + +use std::io; + +mod cmds; +pub use cmds::add_all; +mod drive; +pub(crate) use drive::CloudDriveFactory; +#[cfg(test)] +pub(crate) mod testutils; diff --git a/cloud/src/testutils.rs b/cloud/src/testutils.rs new file mode 100644 index 00000000..56836ae8 --- /dev/null +++ b/cloud/src/testutils.rs @@ -0,0 +1,175 @@ +// EndBASIC +// Copyright 2021 Julio Merino +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Test utilities for the cloud service. + +use crate::cmds::{LoginCommand, LogoutCommand, ShareCommand, SignupCommand}; +use endbasic_client::testutils::*; +use endbasic_client::*; +use endbasic_std::storage::Storage; +use endbasic_std::testutils::*; +use std::cell::RefCell; +use std::rc::Rc; + +/// Wrapper over the generic `Tester` to validate features related to the cloud service. +#[must_use] +pub(crate) struct ClientTester { + tester: Tester, + service: Rc>, +} + +impl Default for ClientTester { + fn default() -> Self { + let tester = Tester::default(); + let console = tester.get_console(); + let storage = tester.get_storage(); + let service = Rc::from(RefCell::from(MockService::default())); + storage + .borrow_mut() + .register_scheme("cloud", Box::from(crate::CloudDriveFactory::new(service.clone()))); + let tester = tester + .add_callable(LoginCommand::new(service.clone(), console.clone(), storage.clone())) + .add_callable(LogoutCommand::new(service.clone(), console.clone(), storage.clone())) + .add_callable(ShareCommand::new( + service.clone(), + console.clone(), + storage, + "https://repl.example.com/", + )) + .add_callable(SignupCommand::new(service.clone(), console)); + ClientTester { tester, service } + } +} + +impl ClientTester { + /// See the wrapped `Tester::add_input_chars` function for details. + pub fn add_input_chars(self, golden_in: &str) -> Self { + ClientTester { tester: self.tester.add_input_chars(golden_in), service: self.service } + } + + /// See the wrapped `Tester::get_console` function for details. + pub fn get_console(&self) -> Rc> { + self.tester.get_console() + } + + /// Gets the mock service client from the tester. + /// + /// This method should generally not be used. Its primary utility is to hook + /// externally-instantiated commands into the testing features. + pub(crate) fn get_service(&self) -> Rc> { + self.service.clone() + } + + /// See the wrapped `Tester::get_storage` function for details. + pub fn get_storage(&self) -> Rc> { + self.tester.get_storage() + } + + /// See the wrapped `Tester::run` function for details. + pub(crate) fn run>(&mut self, script: S) -> ClientChecker<'_> { + let checker = self.tester.run(script); + ClientChecker { checker, service: self.service.clone(), exp_access_token: None } + } +} + +/// Wrapper over the generic `Checker` to validate features related to the cloud service. +#[must_use] +pub(crate) struct ClientChecker<'a> { + checker: Checker<'a>, + service: Rc>, + exp_access_token: Option, +} + +impl<'a> ClientChecker<'a> { + /// Expects the mock service to have logged in with the access `token`. + pub(crate) fn expect_access_token>(self, token: S) -> Self { + Self { + checker: self.checker, + service: self.service, + exp_access_token: Some(AccessToken::new(token.into())), + } + } + + /// See the wrapped `Checker::expect_err` function for details. + pub fn expect_compilation_err>(self, message: S) -> Self { + Self { + checker: self.checker.expect_compilation_err(message), + service: self.service, + exp_access_token: self.exp_access_token, + } + } + + /// See the wrapped `Checker::expect_err` function for details. + pub fn expect_err>(self, message: S) -> Self { + Self { + checker: self.checker.expect_err(message), + service: self.service, + exp_access_token: self.exp_access_token, + } + } + + /// See the wrapped `Checker::expect_file` function for details. + pub fn expect_file, C: Into>(self, name: N, content: C) -> Self { + Self { + checker: self.checker.expect_file(name, content), + service: self.service, + exp_access_token: self.exp_access_token, + } + } + + /// See the wrapped `Checker::expect_output` function for details. + pub fn expect_output>>(self, out: V) -> Self { + Self { + checker: self.checker.expect_output(out), + service: self.service, + exp_access_token: self.exp_access_token, + } + } + + /// See the wrapped `Checker::expect_prints` function for details. + pub fn expect_prints, V: Into>>(self, out: V) -> Self { + Self { + checker: self.checker.expect_prints(out), + service: self.service, + exp_access_token: self.exp_access_token, + } + } + + /// See the wrapped `Checker::take_captured_out` function for details. + #[must_use] + pub fn take_captured_out(&mut self) -> Vec { + self.checker.take_captured_out() + } + + /// Validates all expectations. + pub(crate) fn check(self) { + self.checker.check(); + + let mut service = self.service.borrow_mut(); + assert_eq!(self.exp_access_token, service.access_token); + service.verify_all_used(); + } +} + +/// See the wrapped `check_stmt_compilation_err` function for details. +pub fn client_check_stmt_compilation_err>(exp_error: S, stmt: &str) { + ClientTester::default().run(stmt).expect_compilation_err(exp_error).check(); +} + +/// See the wrapped `check_stmt_err` function for details. +pub fn client_check_stmt_err>(exp_error: S, stmt: &str) { + ClientTester::default().run(stmt).expect_err(exp_error).check(); +} diff --git a/fuse/Cargo.toml b/fuse/Cargo.toml new file mode 100644 index 00000000..b751c653 --- /dev/null +++ b/fuse/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "endbasic-fuse" +version = "0.13.99" # ENDBASIC-VERSION +license = "AGPL-3.0-or-later" +authors = ["Julio Merino "] +categories = ["development-tools", "filesystem"] +keywords = ["basic", "cloud", "filesystem", "fuse"] +description = "The EndBASIC programming language - FUSE cloud filesystem" +homepage = "https://www.endbasic.dev/" +repository = "https://github.com/endbasic/endbasic" +readme = "README.md" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0" +getoptsargs = "0.2" +libc = "0.2" +thiserror = "1.0" + +[dependencies.endbasic-client] +version = "0.13.99" # ENDBASIC-VERSION +path = "../client" + +[dependencies.tokio] +version = "1" +features = ["rt"] + +[target.'cfg(target_os = "linux")'.dependencies] +fuser = "0.14" + +[dev-dependencies] +tempfile = "3" diff --git a/fuse/NOTICE b/fuse/NOTICE new file mode 100644 index 00000000..f30448d0 --- /dev/null +++ b/fuse/NOTICE @@ -0,0 +1,15 @@ +EndBASIC +Copyright 2020-2026 Julio Merino + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/fuse/README.md b/fuse/README.md new file mode 100644 index 00000000..396ec8fc --- /dev/null +++ b/fuse/README.md @@ -0,0 +1,36 @@ +# The EndBASIC programming language - FUSE filesystem + +[![Crates.io](https://img.shields.io/crates/v/endbasic-fuse.svg)](https://crates.io/crates/endbasic-fuse/) +[![Docs.rs](https://docs.rs/endbasic-fuse/badge.svg)](https://docs.rs/endbasic-fuse/) + +EndBASIC is an interpreter for a BASIC-like language and is inspired by +Amstrad's Locomotive BASIC 1.1 and Microsoft's QuickBASIC 4.5. Like the former, +EndBASIC intends to provide an interactive environment that seamlessly merges +coding with immediate visual feedback. Like the latter, EndBASIC offers +higher-level programming constructs and strong typing. + +EndBASIC offers a simplified and restricted environment to learn the foundations +of programming and focuses on features that can quickly reward the programmer. +These features include things like a built-in text editor, commands to +render graphics, and commands to interact with the hardware of a Raspberry +Pi. Implementing this kind of features has priority over others such as +performance or a much richer language. + +EndBASIC is written in Rust and runs both on the web and locally on a variety of +operating systems and platforms, including macOS, Windows, and Linux. + +EndBASIC is free software under the [AGPL v3+](LICENSE). + +## What's in this crate? + +`endbasic-fuse` provides a Linux-only FUSE filesystem that exposes the +EndBASIC cloud service as a mounted hierarchy of users and files. + +Run it like this: + +```shell +endbasic-fuse --user my-name --password my-password /mnt/endbasic +``` + +The mounted tree contains one directory per user at the service root. The +logged-in user's directory is writable; all other user directories are read-only. diff --git a/fuse/src/fs.rs b/fuse/src/fs.rs new file mode 100644 index 00000000..3b0103e0 --- /dev/null +++ b/fuse/src/fs.rs @@ -0,0 +1,691 @@ +// EndBASIC +// Copyright 2026 Julio Merino +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! FUSE filesystem implementation for the EndBASIC cloud service. + +use anyhow::Result; +use endbasic_client::{CloudService, FileEntry, Service}; +use fuser::{ + FileAttr, FileType, Filesystem, MountOption, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, + ReplyEmpty, ReplyEntry, ReplyOpen, ReplyWrite, Request, +}; +use libc::{EACCES, EINVAL, EIO, ENOENT, ENOSYS}; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::os::unix::fs::MetadataExt; +use std::time::{Duration, SystemTime}; + +const BLOCK_SIZE: u32 = 4096; +const ROOT_INODE: u64 = 1; +const TTL: Duration = Duration::from_secs(1); + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum NodeKey { + File { user: String, name: String }, + Root, + UserDir(String), +} + +#[derive(Clone, Debug)] +enum Node { + File(FileNode), + Root, + UserDir(UserDirNode), +} + +#[derive(Clone, Debug)] +struct FileNode { + filename: String, + length: u64, + mtime: SystemTime, + user: String, +} + +#[derive(Clone, Debug)] +struct UserDirNode { + username: String, +} + +#[derive(Debug)] +struct OpenFile { + dirty: bool, + inode: u64, + content: Vec, + loaded: bool, +} + +#[derive(Debug)] +pub(crate) struct EndbasicFs { + gid: u32, + mounted_at: SystemTime, + next_file_handle: u64, + next_inode: u64, + nodes: HashMap, + nodes_by_key: HashMap, + open_files: HashMap, + rt: tokio::runtime::Runtime, + service: CloudService, + uid: u32, + writable_user: String, +} + +impl EndbasicFs { + fn new(rt: tokio::runtime::Runtime, service: CloudService, writable_user: String) -> Self { + let metadata = + std::fs::metadata(".").expect("Current directory metadata must be accessible"); + let mut nodes = HashMap::new(); + nodes.insert(ROOT_INODE, Node::Root); + + let mut nodes_by_key = HashMap::new(); + nodes_by_key.insert(NodeKey::Root, ROOT_INODE); + + Self { + gid: metadata.gid(), + mounted_at: SystemTime::now(), + next_file_handle: 1, + next_inode: ROOT_INODE + 1, + nodes, + nodes_by_key, + open_files: HashMap::new(), + rt, + service, + uid: metadata.uid(), + writable_user, + } + } + + fn alloc_handle(&mut self, inode: u64, content: Vec, dirty: bool, loaded: bool) -> u64 { + let fh = self.next_file_handle; + self.next_file_handle += 1; + self.open_files.insert(fh, OpenFile { dirty, inode, content, loaded }); + fh + } + + fn ensure_file_inode(&mut self, user: &str, file: &FileEntry) -> u64 { + let key = NodeKey::File { user: user.to_owned(), name: file.filename.clone() }; + if let Some(inode) = self.nodes_by_key.get(&key) { + self.nodes.insert( + *inode, + Node::File(FileNode { + filename: file.filename.clone(), + length: file.length, + mtime: file.mtime, + user: user.to_owned(), + }), + ); + *inode + } else { + let inode = self.next_inode; + self.next_inode += 1; + self.nodes.insert( + inode, + Node::File(FileNode { + filename: file.filename.clone(), + length: file.length, + mtime: file.mtime, + user: user.to_owned(), + }), + ); + self.nodes_by_key.insert(key, inode); + inode + } + } + + fn ensure_user_inode(&mut self, username: &str) -> u64 { + let key = NodeKey::UserDir(username.to_owned()); + if let Some(inode) = self.nodes_by_key.get(&key) { + *inode + } else { + let inode = self.next_inode; + self.next_inode += 1; + self.nodes.insert(inode, Node::UserDir(UserDirNode { username: username.to_owned() })); + self.nodes_by_key.insert(key, inode); + inode + } + } + + fn file_attr(&self, inode: u64, file: &FileNode) -> FileAttr { + let writable = file.user == self.writable_user; + FileAttr { + atime: file.mtime, + blksize: BLOCK_SIZE, + blocks: file.length.div_ceil(BLOCK_SIZE as u64), + crtime: file.mtime, + ctime: file.mtime, + flags: 0, + gid: self.gid, + ino: inode, + kind: FileType::RegularFile, + mtime: file.mtime, + nlink: 1, + perm: if writable { 0o644 } else { 0o444 }, + rdev: 0, + size: file.length, + uid: self.uid, + } + } + + fn get_attr(&mut self, inode: u64) -> Result { + let node = self.nodes.get(&inode).cloned().ok_or(ENOENT)?; + Ok(match node { + Node::File(file) => self.file_attr(inode, &file), + Node::Root => self.synthetic_dir_attr(inode, false), + Node::UserDir(user) => { + self.synthetic_dir_attr(inode, user.username == self.writable_user) + } + }) + } + + fn is_writable_path(&self, user: &str) -> bool { + user == self.writable_user + } + + fn list_root(&mut self) -> Result, i32> { + let mut users = self.rt.block_on(self.service.list_users()).map_err(errno_from_io_error)?; + if !users.iter().any(|user| user == &self.writable_user) { + users.push(self.writable_user.clone()); + } + users.sort(); + users.retain(|user| is_valid_name(user)); + users.dedup(); + + let mut entries = vec![(ROOT_INODE, FileType::Directory, ".".to_owned())]; + entries.push((ROOT_INODE, FileType::Directory, "..".to_owned())); + for user in users { + let inode = self.ensure_user_inode(&user); + entries.push((inode, FileType::Directory, user)); + } + Ok(entries) + } + + fn list_user_dir( + &mut self, + user: &str, + inode: u64, + ) -> Result, i32> { + let mut entries = vec![(inode, FileType::Directory, ".".to_owned())]; + entries.push((ROOT_INODE, FileType::Directory, "..".to_owned())); + + let mut files: Vec = self.rt.block_on(self.service.get_files(user)) + .map_err(errno_from_io_error)? + .into_files(); + files.sort_by(|lhs, rhs| lhs.filename.cmp(&rhs.filename)); + files.retain(|file| is_valid_name(&file.filename)); + for file in files { + let file_inode = self.ensure_file_inode(user, &file); + entries.push((file_inode, FileType::RegularFile, file.filename)); + } + Ok(entries) + } + + fn lookup_file(&mut self, user: &str, name: &str) -> Result<(u64, FileAttr), i32> { + for file in self.rt.block_on(self.service.get_files(user)) + .map_err(errno_from_io_error)? + .into_files() + { + if file.filename == name { + let inode = self.ensure_file_inode(user, &file); + return Ok((inode, self.file_attr(inode, self.expect_file(inode)?))); + } + } + Err(ENOENT) + } + + fn open_file(&mut self, inode: u64, flags: i32) -> Result { + let file = self.expect_file(inode)?.clone(); + let wants_write = flags & libc::O_ACCMODE != libc::O_RDONLY; + if wants_write && !self.is_writable_path(&file.user) { + return Err(EACCES); + } + + let truncated = flags & libc::O_TRUNC != 0; + let content = if wants_write { + if truncated { + vec![] + } else { + self.rt.block_on(self.service.get_file(&file.user, &file.filename)).map_err(errno_from_io_error)? + } + } else { + vec![] + }; + Ok(self.alloc_handle(inode, content, truncated, wants_write)) + } + + fn flush_handle(&mut self, ino: u64, fh: u64) -> Result<(), i32> { + let file = match self.nodes.get(&ino).cloned() { + Some(Node::File(file)) => file, + _ => return Err(ENOENT), + }; + + let (dirty, content) = match self.open_files.get(&fh) { + Some(open_file) => (open_file.dirty, open_file.content.clone()), + None => return Ok(()), + }; + if !dirty { + return Ok(()); + } + + self.rt.block_on(self.service.patch_file_content(&file.user, &file.filename, content.clone())) + .map_err(errno_from_io_error)?; + self.update_file_metadata(ino, content.len() as u64)?; + + if let Some(open_file) = self.open_files.get_mut(&fh) { + open_file.dirty = false; + } + Ok(()) + } + + fn reply_readdir(&mut self, ino: u64, offset: i64, mut reply: ReplyDirectory) { + let entries = match self.nodes.get(&ino).cloned() { + Some(Node::Root) => self.list_root(), + Some(Node::UserDir(user)) => self.list_user_dir(&user.username, ino), + Some(Node::File(_)) => Err(ENOENT), + None => Err(ENOENT), + }; + + let entries = match entries { + Ok(entries) => entries, + Err(errno) => { + reply.error(errno); + return; + } + }; + + for (index, (entry_ino, kind, name)) in + entries.into_iter().enumerate().skip(offset as usize) + { + if reply.add(entry_ino, (index + 1) as i64, kind, name) { + break; + } + } + reply.ok(); + } + + fn slice_content(content: &[u8], offset: i64, size: u32) -> &[u8] { + let start = offset.max(0) as usize; + if start >= content.len() { + return &[]; + } + let end = content.len().min(start + size as usize); + &content[start..end] + } + + fn synthetic_dir_attr(&self, inode: u64, writable: bool) -> FileAttr { + FileAttr { + atime: self.mounted_at, + blksize: BLOCK_SIZE, + blocks: 1, + crtime: self.mounted_at, + ctime: self.mounted_at, + flags: 0, + gid: self.gid, + ino: inode, + kind: FileType::Directory, + mtime: self.mounted_at, + nlink: 2, + perm: if writable { 0o755 } else { 0o555 }, + rdev: 0, + size: 0, + uid: self.uid, + } + } + + fn update_file_metadata(&mut self, inode: u64, length: u64) -> Result<(), i32> { + let file = self.expect_file_mut(inode)?; + file.length = length; + file.mtime = SystemTime::now(); + Ok(()) + } + + fn expect_file(&self, inode: u64) -> Result<&FileNode, i32> { + match self.nodes.get(&inode) { + Some(Node::File(file)) => Ok(file), + _ => Err(ENOENT), + } + } + + fn expect_file_mut(&mut self, inode: u64) -> Result<&mut FileNode, i32> { + match self.nodes.get_mut(&inode) { + Some(Node::File(file)) => Ok(file), + _ => Err(ENOENT), + } + } +} + +impl Filesystem for EndbasicFs { + fn create( + &mut self, + _req: &Request<'_>, + parent: u64, + name: &OsStr, + mode: u32, + _umask: u32, + flags: i32, + reply: ReplyCreate, + ) { + let Some(name) = name.to_str() else { + reply.error(EINVAL); + return; + }; + if !is_valid_name(name) { + reply.error(EINVAL); + return; + } + + let user = match self.nodes.get(&parent) { + Some(Node::UserDir(user)) => user.username.clone(), + _ => { + reply.error(ENOENT); + return; + } + }; + if !self.is_writable_path(&user) { + reply.error(EACCES); + return; + } + + let file = FileEntry { filename: name.to_owned(), length: 0, mtime: SystemTime::now() }; + let inode = self.ensure_file_inode(&user, &file); + let fh = self.alloc_handle(inode, vec![], true, true); + let attr = self.file_attr(inode, self.expect_file(inode).expect("New node must exist")); + let _ = mode; + reply.created(&TTL, &attr, 0, fh, flags as u32); + } + + fn flush( + &mut self, + _req: &Request<'_>, + ino: u64, + fh: u64, + _lock_owner: u64, + reply: ReplyEmpty, + ) { + match self.flush_handle(ino, fh) { + Ok(()) => reply.ok(), + Err(errno) => reply.error(errno), + } + } + + fn getattr(&mut self, _req: &Request<'_>, ino: u64, reply: ReplyAttr) { + match self.get_attr(ino) { + Ok(attr) => reply.attr(&TTL, &attr), + Err(errno) => reply.error(errno), + } + } + + fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { + let Some(name) = name.to_str() else { + reply.error(EINVAL); + return; + }; + + let result = match self.nodes.get(&parent).cloned() { + Some(Node::Root) => { + if !is_valid_name(name) { + Err(ENOENT) + } else { + match self.rt.block_on(self.service.list_users()).map_err(errno_from_io_error) { + Ok(mut users) => { + if !users.iter().any(|user| user == &self.writable_user) { + users.push(self.writable_user.clone()); + } + if users.iter().any(|user| user == name) { + let inode = self.ensure_user_inode(name); + Ok(self.synthetic_dir_attr(inode, self.is_writable_path(name))) + } else { + Err(ENOENT) + } + } + Err(errno) => Err(errno), + } + } + } + Some(Node::UserDir(user)) => { + self.lookup_file(&user.username, name).map(|(_, attr)| attr) + } + _ => Err(ENOENT), + }; + + match result { + Ok(attr) => reply.entry(&TTL, &attr, 0), + Err(errno) => reply.error(errno), + } + } + + fn open(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) { + match self.open_file(ino, flags) { + Ok(fh) => reply.opened(fh, flags as u32), + Err(errno) => reply.error(errno), + } + } + + fn read( + &mut self, + _req: &Request<'_>, + ino: u64, + fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + let file = match self.nodes.get(&ino).cloned() { + Some(Node::File(file)) => file, + _ => { + reply.error(ENOENT); + return; + } + }; + + let content = if let Some(open_file) = self.open_files.get(&fh) { + debug_assert_eq!(open_file.inode, ino); + if open_file.loaded { + open_file.content.clone() + } else { + match self.rt.block_on(self.service.get_file(&file.user, &file.filename)) { + Ok(content) => content, + Err(e) => { + reply.error(errno_from_io_error(e)); + return; + } + } + } + } else { + match self.rt.block_on(self.service.get_file(&file.user, &file.filename)) { + Ok(content) => content, + Err(e) => { + reply.error(errno_from_io_error(e)); + return; + } + } + }; + reply.data(Self::slice_content(&content, offset, size)); + } + + fn readdir( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + reply: ReplyDirectory, + ) { + self.reply_readdir(ino, offset, reply); + } + + fn release( + &mut self, + _req: &Request<'_>, + ino: u64, + fh: u64, + _flags: i32, + _lock_owner: Option, + flush: bool, + reply: ReplyEmpty, + ) { + if flush && let Err(errno) = self.flush_handle(ino, fh) { + reply.error(errno); + return; + } + self.open_files.remove(&fh); + reply.ok(); + } + + fn setattr( + &mut self, + _req: &Request<'_>, + ino: u64, + _mode: Option, + _uid: Option, + _gid: Option, + size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + reply: ReplyAttr, + ) { + let Some(size) = size else { + match self.get_attr(ino) { + Ok(attr) => reply.attr(&TTL, &attr), + Err(errno) => reply.error(errno), + } + return; + }; + let Some(fh) = fh else { + reply.error(ENOSYS); + return; + }; + let Some(open_file) = self.open_files.get_mut(&fh) else { + reply.error(EIO); + return; + }; + open_file.content.resize(size as usize, 0); + open_file.dirty = true; + open_file.loaded = true; + + if let Err(errno) = self.update_file_metadata(ino, size) { + reply.error(errno); + return; + } + match self.get_attr(ino) { + Ok(attr) => reply.attr(&TTL, &attr), + Err(errno) => reply.error(errno), + } + } + + fn unlink(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) { + let Some(name) = name.to_str() else { + reply.error(EINVAL); + return; + }; + let user = match self.nodes.get(&parent).cloned() { + Some(Node::UserDir(user)) => user.username, + _ => { + reply.error(ENOENT); + return; + } + }; + if !self.is_writable_path(&user) { + reply.error(EACCES); + return; + } + match self.rt.block_on(self.service.delete_file(&user, name)).map_err(errno_from_io_error) { + Ok(()) => { + let key = NodeKey::File { user, name: name.to_owned() }; + if let Some(inode) = self.nodes_by_key.remove(&key) { + self.nodes.remove(&inode); + } + reply.ok(); + } + Err(errno) => reply.error(errno), + } + } + + fn write( + &mut self, + _req: &Request<'_>, + ino: u64, + fh: u64, + offset: i64, + data: &[u8], + _write_flags: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyWrite, + ) { + if offset < 0 { + reply.error(EINVAL); + return; + } + let Some(open_file) = self.open_files.get_mut(&fh) else { + reply.error(EIO); + return; + }; + let start = offset as usize; + if open_file.content.len() < start { + open_file.content.resize(start, 0); + } + let end = start + data.len(); + if open_file.content.len() < end { + open_file.content.resize(end, 0); + } + open_file.content[start..end].copy_from_slice(data); + open_file.dirty = true; + open_file.loaded = true; + let new_len = open_file.content.len() as u64; + if let Err(errno) = self.update_file_metadata(ino, new_len) { + reply.error(errno); + return; + } + reply.written(data.len() as u32); + } +} + +pub(crate) fn mount( + service_url: String, + username: String, + password: String, + mountpoint: &str, +) -> Result<()> { + let rt = tokio::runtime::Builder::new_current_thread().build()?; + let mut service = CloudService::new(&service_url)?; + rt.block_on(service.login(&username, &password))?; + let fs = EndbasicFs::new(rt, service, username); + let options = vec![MountOption::FSName("endbasic".to_owned()), MountOption::DefaultPermissions]; + fuser::mount2(fs, mountpoint, &options)?; + Ok(()) +} + +fn errno_from_io_error(e: std::io::Error) -> i32 { + match e.kind() { + std::io::ErrorKind::AddrNotAvailable => libc::EHOSTUNREACH, + std::io::ErrorKind::AlreadyExists => libc::EEXIST, + std::io::ErrorKind::InvalidInput => EINVAL, + std::io::ErrorKind::NotFound => ENOENT, + std::io::ErrorKind::PermissionDenied => EACCES, + _ => EIO, + } +} + +fn is_valid_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0') +} diff --git a/fuse/src/main.rs b/fuse/src/main.rs new file mode 100644 index 00000000..9b8d15d3 --- /dev/null +++ b/fuse/src/main.rs @@ -0,0 +1,78 @@ +// EndBASIC +// Copyright 2026 Julio Merino +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Command-line interface for the EndBASIC service FUSE filesystem. + +use anyhow::Result; +use endbasic_client::PROD_API_ADDRESS; +use getoptsargs::prelude::*; + +#[cfg(target_os = "linux")] +mod fs; + +/// Errors caused by the user when invoking this binary. +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +struct UsageError { + message: String, +} + +impl UsageError { + /// Creates a new usage error with `message`. + fn new>(message: T) -> Self { + Self { message: message.into() } + } +} + +fn app_build(builder: Builder) -> Builder { + builder + .copyright("Copyright 2026 Julio Merino") + .license(License::AGPL3OrLater) + .homepage("https://www.endbasic.dev/") + .bugs("https://github.com/endbasic/endbasic/issues") + .optopt("", "password", "password to authenticate with", "PASSWORD") + .optopt("", "service-url", "base URL of the cloud service", "URL") + .optopt("", "user", "user name to authenticate with", "USERNAME") + .trailarg("mountpoint", 1, 1, "directory on which to mount the filesystem") +} + +fn app_main(matches: Matches) -> Result { + let mountpoint = match matches.arg_trail() { + [mountpoint] => mountpoint, + _ => return Err(UsageError::new("Missing mountpoint argument").into()), + }; + + let username = + matches.opt_str("user").ok_or_else(|| UsageError::new("Missing required --user option"))?; + let password = matches + .opt_str("password") + .ok_or_else(|| UsageError::new("Missing required --password option"))?; + let service_url = matches.opt_str("service-url").unwrap_or_else(|| PROD_API_ADDRESS.to_owned()); + + #[cfg(target_os = "linux")] + { + fs::mount(service_url, username, password, mountpoint)?; + Ok(0) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (service_url, username, password, mountpoint); + Err(UsageError::new("This binary is only supported on Linux").into()) + } +} + +app!("EndBASIC FUSE", app_build, app_main); diff --git a/fuse/tests/integration_test.rs b/fuse/tests/integration_test.rs new file mode 100644 index 00000000..f2823069 --- /dev/null +++ b/fuse/tests/integration_test.rs @@ -0,0 +1,181 @@ +// EndBASIC +// Copyright 2026 Julio Merino +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Integration tests for the endbasic-fuse binary. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{self, Child}; +use std::thread; +use std::time::{Duration, Instant}; + +const PROD_API_ADDRESS: &str = "https://service.endbasic.dev/"; + +fn self_dir() -> PathBuf { + let self_exe = env::current_exe().expect("Cannot get self's executable path"); + let dir = self_exe.parent().expect("Cannot get self's directory"); + assert!(dir.ends_with("target/debug/deps") || dir.ends_with("target/release/deps")); + dir.to_owned() +} + +fn bin_path>(name: P) -> PathBuf { + let test_dir = self_dir(); + let debug_or_release_dir = test_dir.parent().expect("Failed to get parent directory"); + debug_or_release_dir.join(name).with_extension(env::consts::EXE_EXTENSION) +} + +fn repo_root() -> PathBuf { + let test_dir = self_dir(); + let debug_or_release_dir = test_dir.parent().expect("Failed to get parent directory"); + let target_dir = debug_or_release_dir.parent().expect("Failed to get parent directory"); + let dir = target_dir.parent().expect("Failed to get parent directory"); + assert!(dir.join("Cargo.toml").exists()); + dir.to_owned() +} + +fn read_passwords() -> (String, String) { + let content = fs::read_to_string(repo_root().join("passwords.txt")) + .expect("Failed to read passwords.txt"); + let mut password1 = None; + let mut password2 = None; + for line in content.lines() { + let Some((username, password)) = line.split_once(':') else { + continue; + }; + match username.trim() { + "testuser1" => password1 = Some(password.trim().to_owned()), + "testuser2" => password2 = Some(password.trim().to_owned()), + _ => panic!("Unexpected account in passwords.txt"), + } + } + (password1.expect("Missing testuser1 password"), password2.expect("Missing testuser2 password")) +} + +fn have_fusermount() -> Option { + for program in ["fusermount3", "fusermount"] { + if process::Command::new(program).arg("--version").output().is_ok() { + return Some(program.to_owned()); + } + } + None +} + +fn wait_for_mount(mountpoint: &Path) { + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if fs::read_dir(mountpoint).is_ok_and(|mut entries| entries.next().is_some()) { + return; + } + thread::sleep(Duration::from_millis(100)); + } + panic!("Timed out waiting for mount to become available"); +} + +struct MountGuard { + child: Child, + mountpoint: tempfile::TempDir, + unmount_program: String, +} + +impl Drop for MountGuard { + fn drop(&mut self) { + let _ = process::Command::new(&self.unmount_program) + .arg("-u") + .arg(self.mountpoint.path()) + .status(); + let _ = self.child.wait(); + } +} + +#[cfg(target_os = "linux")] +fn mount_for_user(username: &str, password: &str) -> MountGuard { + let unmount_program = have_fusermount().expect("Missing fusermount support on this host"); + let mountpoint = tempfile::tempdir().unwrap(); + let child = process::Command::new(bin_path("endbasic-fuse")) + .args([ + "--service-url", + PROD_API_ADDRESS, + "--user", + username, + "--password", + password, + mountpoint.path().to_str().unwrap(), + ]) + .spawn() + .expect("Failed to start endbasic-fuse"); + wait_for_mount(mountpoint.path()); + MountGuard { child, mountpoint, unmount_program } +} + +#[test] +fn test_help() { + let output = process::Command::new(bin_path("endbasic-fuse")).arg("--help").output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("--user")); + assert!(stdout.contains("--password")); + assert!(stdout.contains("mountpoint")); +} + +#[cfg(target_os = "linux")] +#[test] +#[ignore = "Requires Linux FUSE support and talks to the public service"] +fn test_mount_and_file_operations() { + if have_fusermount().is_none() { + return; + } + let (password1, _password2) = read_passwords(); + let guard = mount_for_user("testuser1", &password1); + + let root_entries: Vec<_> = fs::read_dir(guard.mountpoint.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + assert!(root_entries.iter().any(|entry| entry == "testuser1")); + + let filename = format!("fuse-test-{}", process::id()); + let path = guard.mountpoint.path().join("testuser1").join(&filename); + let content = format!("hello from {}", filename); + fs::write(&path, &content).unwrap(); + assert_eq!(content, fs::read_to_string(&path).unwrap()); + fs::remove_file(&path).unwrap(); + assert!(!path.exists()); +} + +#[cfg(target_os = "linux")] +#[test] +#[ignore = "Requires Linux FUSE support and talks to the public service"] +fn test_other_users_are_read_only() { + if have_fusermount().is_none() { + return; + } + let (password1, _password2) = read_passwords(); + let guard = mount_for_user("testuser1", &password1); + + let root_entries: Vec<_> = fs::read_dir(guard.mountpoint.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + if !root_entries.iter().any(|entry| entry == "testuser2") { + return; + } + + let path = + guard.mountpoint.path().join("testuser2").join(format!("fuse-test-{}", process::id())); + let err = fs::write(&path, b"denied").unwrap_err(); + assert_eq!(std::io::ErrorKind::PermissionDenied, err.kind()); +} diff --git a/web/Cargo.toml b/web/Cargo.toml index 0f94ab9b..b7163fac 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -38,6 +38,10 @@ wasm-bindgen-futures = "0.4" version = "0.13.99" # ENDBASIC-VERSION path = "../client" +[dependencies.endbasic-cloud] +version = "0.13.99" # ENDBASIC-VERSION +path = "../cloud" + [dependencies.endbasic-core] version = "0.13.99" # ENDBASIC-VERSION path = "../core" diff --git a/web/src/lib.rs b/web/src/lib.rs index 4050f89a..426b8bb0 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -332,7 +332,7 @@ impl WebTerminal { let service = Rc::from(RefCell::from(endbasic_client::CloudService::new(&self.service_url)?)); - endbasic_client::add_all( + endbasic_cloud::add_all( &mut builder, service, console.clone(),