From f8a016cf2b7d534d886cdac8744c4b1dd7be2727 Mon Sep 17 00:00:00 2001 From: Sophie <47993817+sdankel@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:26:58 -0800 Subject: [PATCH 1/6] feat: Add forc-publish plugin --- Cargo.lock | 153 +++++++++--- Cargo.toml | 6 +- forc-plugins/forc-publish/Cargo.toml | 31 +++ forc-plugins/forc-publish/README.md | 14 ++ forc-plugins/forc-publish/src/credentials.rs | 132 ++++++++++ forc-plugins/forc-publish/src/error.rs | 123 +++++++++ .../forc-publish/src/forc_pub_client.rs | 234 ++++++++++++++++++ forc-plugins/forc-publish/src/lib.rs | 4 + forc-plugins/forc-publish/src/main.rs | 52 ++++ forc-plugins/forc-publish/src/tarball.rs | 185 ++++++++++++++ 10 files changed, 892 insertions(+), 42 deletions(-) create mode 100644 forc-plugins/forc-publish/Cargo.toml create mode 100644 forc-plugins/forc-publish/README.md create mode 100644 forc-plugins/forc-publish/src/credentials.rs create mode 100644 forc-plugins/forc-publish/src/error.rs create mode 100644 forc-plugins/forc-publish/src/forc_pub_client.rs create mode 100644 forc-plugins/forc-publish/src/lib.rs create mode 100644 forc-plugins/forc-publish/src/main.rs create mode 100644 forc-plugins/forc-publish/src/tarball.rs diff --git a/Cargo.lock b/Cargo.lock index 4235889d565..bfcda71fc43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,7 +541,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "tracing", - "uuid 1.11.0", + "uuid 1.12.1", ] [[package]] @@ -648,7 +648,7 @@ dependencies = [ "hex", "hmac 0.12.1", "http 0.2.12", - "http 1.1.0", + "http 1.2.0", "once_cell", "percent-encoding", "sha2 0.10.8", @@ -743,7 +743,7 @@ dependencies = [ "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.1.0", + "http 1.2.0", "pin-project-lite", "tokio", "tracing", @@ -761,7 +761,7 @@ dependencies = [ "bytes-utils", "futures-core", "http 0.2.12", - "http 1.1.0", + "http 1.2.0", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1880,6 +1880,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "deadpool" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.9" @@ -2919,6 +2937,30 @@ dependencies = [ "walkdir", ] +[[package]] +name = "forc-publish" +version = "0.66.6" +dependencies = [ + "clap", + "flate2", + "forc-tracing 0.66.6", + "forc-util", + "reqwest", + "semver 1.0.23", + "serde", + "serde_json", + "tar", + "tempfile", + "thiserror 1.0.69", + "tokio", + "toml 0.8.19", + "tracing", + "url", + "uuid 1.12.1", + "walkdir", + "wiremock", +] + [[package]] name = "forc-test" version = "0.66.6" @@ -3097,9 +3139,9 @@ dependencies = [ [[package]] name = "fuel-core-chain-config" -version = "0.41.4" +version = "0.41.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07eb148d6490789534dca63a77e2d8033ecd85b74c0bbd3118f3e0962f4233e" +checksum = "74ab82455c4bde340c15d92621e6e6808ddb7b17ed5ce9287087c3b5c4f77af6" dependencies = [ "anyhow", "bech32", @@ -3117,9 +3159,9 @@ dependencies = [ [[package]] name = "fuel-core-client" -version = "0.41.4" +version = "0.41.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd96a66ac7026c8c60df5cf171ad9eecbf0648e49abfc57d3f7d5df739ced47" +checksum = "419542578bc07a3f7d216f40a391026825d92544e51767a075f3b262300ccdec" dependencies = [ "anyhow", "base64 0.22.1", @@ -3142,9 +3184,9 @@ dependencies = [ [[package]] name = "fuel-core-metrics" -version = "0.41.4" +version = "0.41.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4862e95dd35b0d0dde68ea5d1b3ba3ac74f688a1f5268b145aa73be723a620eb" +checksum = "2118a8362a4e0d029b742c45ff07c92f15f2341ae5bffd0d82f21d4155b1f3a7" dependencies = [ "once_cell", "parking_lot", @@ -3158,9 +3200,9 @@ dependencies = [ [[package]] name = "fuel-core-poa" -version = "0.41.4" +version = "0.41.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b075ba0902dbc2e73cc8e160af1174336df68edc72de16e53a4c54687ff866f" +checksum = "4a082c9f772f5c63516ae71c7314daf4df1b182b3e5584a933e09623f0acef37" dependencies = [ "anyhow", "async-trait", @@ -3177,9 +3219,9 @@ dependencies = [ [[package]] name = "fuel-core-services" -version = "0.41.4" +version = "0.41.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeedffccabe0b1cf36ee799c4eca8ced6acb08512082c4a8106d418089449f41" +checksum = "b4aebd13812a445e81aae283b8be4af09aba03785e79f3ab293cb0ce64c3fab4" dependencies = [ "anyhow", "async-trait", @@ -3193,9 +3235,9 @@ dependencies = [ [[package]] name = "fuel-core-storage" -version = "0.41.4" +version = "0.41.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b2fb00d3888948699b8412e67fa21fb4a5d19e73b220295071a59b2ced1fc5" +checksum = "2b1012207fe73767adfa338b590b807b6f6a91db143c6a7e78984aed003dd073" dependencies = [ "anyhow", "derive_more 0.99.18", @@ -3215,9 +3257,9 @@ dependencies = [ [[package]] name = "fuel-core-types" -version = "0.41.4" +version = "0.41.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40479537a591e091863ee5f939f3a8ec248cabf864fb287c13cc2f09d8059d11" +checksum = "7ef34b30633185bc3ee565f7979ddc4ecb347de8439c9c84af80ffdf5e6c7ce7" dependencies = [ "anyhow", "bs58", @@ -3826,7 +3868,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.1.0", + "http 1.2.0", "indexmap 2.6.0", "slab", "tokio", @@ -4026,9 +4068,9 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", @@ -4053,7 +4095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http 1.2.0", ] [[package]] @@ -4064,7 +4106,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "pin-project-lite", ] @@ -4113,17 +4155,18 @@ dependencies = [ [[package]] name = "hyper" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", "futures-util", "h2 0.4.7", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -4168,15 +4211,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http 1.1.0", - "hyper 1.5.1", + "http 1.2.0", + "hyper 1.6.0", "hyper-util", "rustls 0.23.17", "rustls-pki-types", "tokio", "tokio-rustls 0.26.0", "tower-service", - "webpki-roots 0.26.7", + "webpki-roots 0.26.8", ] [[package]] @@ -4199,7 +4242,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.5.1", + "hyper 1.6.0", "hyper-util", "native-tls", "tokio", @@ -4216,9 +4259,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", - "hyper 1.5.1", + "hyper 1.6.0", "pin-project-lite", "socket2", "tokio", @@ -5492,7 +5535,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.1.3", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.87", @@ -6599,10 +6642,10 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.7", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "http-body-util", - "hyper 1.5.1", + "hyper 1.6.0", "hyper-rustls 0.27.3", "hyper-tls", "hyper-util", @@ -6631,7 +6674,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.26.7", + "webpki-roots 0.26.8", "windows-registry", ] @@ -6773,7 +6816,7 @@ dependencies = [ "rkyv_derive", "seahash", "tinyvec", - "uuid 1.11.0", + "uuid 1.12.1", ] [[package]] @@ -8997,9 +9040,13 @@ dependencies = [ [[package]] name = "uuid" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" +dependencies = [ + "getrandom", + "serde", +] [[package]] name = "valuable" @@ -9216,9 +9263,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "0.26.7" +version = "0.26.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" dependencies = [ "rustls-pki-types", ] @@ -9479,6 +9526,30 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wiremock" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fff469918e7ca034884c7fd8f93fe27bacb7fcb599fd879df6c7b429a29b646" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.2.0", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 9bcc4389947..9df625d9bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "forc-plugins/forc-fmt", "forc-plugins/forc-lsp", "forc-plugins/forc-migrate", + "forc-plugins/forc-publish", "forc-plugins/forc-tx", "forc-test", "forc-tracing", @@ -57,8 +58,9 @@ forc-debug = { path = "forc-plugins/forc-debug/", version = "0.66.6" } forc-doc = { path = "forc-plugins/forc-doc/", version = "0.66.6" } forc-fmt = { path = "forc-plugins/forc-fmt/", version = "0.66.6" } forc-lsp = { path = "forc-plugins/forc-lsp/", version = "0.66.6" } -forc-tx = { path = "forc-plugins/forc-tx/", version = "0.66.6" } forc-migrate = { path = "forc-plugins/forc-migrate/", version = "0.66.6" } +forc-publish = { path = "forc-plugins/forc-publish/", version = "0.66.6" } +forc-tx = { path = "forc-plugins/forc-tx/", version = "0.66.6" } sway-ast = { path = "sway-ast/", version = "0.66.6" } sway-core = { path = "sway-core/", version = "0.66.6" } @@ -140,6 +142,7 @@ etk-ops = { package = "fuel-etk-ops", version = "0.3.1-dev" } extension-trait = "1.0" fd-lock = "4.0" filecheck = "0.5" +flate2 = "1.0.33" fs_extra = "1.2" futures = { version = "0.3", default-features = false } gag = "1.0" @@ -229,6 +232,7 @@ unicode-bidi = "0.3" unicode-xid = "0.2" url = "2.2" urlencoding = "2.1" +uuid = "1.12.1" vec1 = "1.8" vte = "0.13" walkdir = "2.3" diff --git a/forc-plugins/forc-publish/Cargo.toml b/forc-plugins/forc-publish/Cargo.toml new file mode 100644 index 00000000000..16b79cca111 --- /dev/null +++ b/forc-plugins/forc-publish/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "forc-publish" +version.workspace = true +description = "Forc subcommand for uploading a package to the registry." +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +clap = { workspace = true, features = ["derive", "env"] } +flate2.workspace = true +forc-tracing.workspace = true +forc-util.workspace = true +reqwest = { workspace = true, features = ["json"] } +semver = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +tar.workspace = true +tempfile.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +toml = { workspace = true, features = ["parse"] } +tracing.workspace = true +url.workspace = true +uuid = { workspace = true, features = ["v4", "serde"] } +walkdir.workspace = true + +[dev-dependencies] +wiremock = "0.6.2" diff --git a/forc-plugins/forc-publish/README.md b/forc-plugins/forc-publish/README.md new file mode 100644 index 00000000000..9bd7b2e4fc4 --- /dev/null +++ b/forc-plugins/forc-publish/README.md @@ -0,0 +1,14 @@ +# forc-publish + +Forc subcommand for uploading a package to the registry. + +## Authentication + +Requires either the `--token` argument to be passed, or a `~/.forc/credentials.toml` file like this: + +```toml +[registry] +token = "YOUR_TOKEN" +``` + +This credential file can be created automatically by running the CLI without the `--token` argument. \ No newline at end of file diff --git a/forc-plugins/forc-publish/src/credentials.rs b/forc-plugins/forc-publish/src/credentials.rs new file mode 100644 index 00000000000..db83e038d72 --- /dev/null +++ b/forc-plugins/forc-publish/src/credentials.rs @@ -0,0 +1,132 @@ +use crate::error::Result; +use forc_util::user_forc_directory; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{self}; +use std::path::PathBuf; +use toml; + +const CREDENTIALS_FILE: &str = "credentials.toml"; + +#[derive(Serialize, Deserialize)] +struct Registry { + token: String, +} + +#[derive(Serialize, Deserialize)] +struct Credentials { + registry: Registry, +} + +/// Gets the user's auth token. +/// - First checks CLI arguments. +/// - Then checks `~/.forc/credentials.toml` inside the `[registry]` section. +/// - If neither are found, prompts the user and saves it to `credentials.toml`. +pub fn get_auth_token( + opt_token: Option, + credentials_dir: Option, +) -> Result { + if let Some(token) = opt_token { + return Ok(token); + } + + let credentials_path = credentials_dir + .unwrap_or(user_forc_directory()) + .join(CREDENTIALS_FILE); + if let Some(token) = get_auth_token_from_file(&credentials_path)? { + return Ok(token); + } + + let auth_token = + get_auth_token_from_user_input(&credentials_path, io::stdin().lock(), io::stdout())?; + + Ok(auth_token) +} + +// Check if credentials file exists and read from it +fn get_auth_token_from_file(path: &PathBuf) -> Result> { + if path.exists() { + let content = fs::read_to_string(path)?; + if let Ok(credentials) = toml::from_str::(&content) { + return Ok(Some(credentials.registry.token)); + } + } + Ok(None) +} + +// Prompt user for input and save to credentials file +fn get_auth_token_from_user_input( + credentials_path: &PathBuf, + mut reader: R, + mut writer: W, +) -> Result +where + R: io::BufRead, + W: io::Write, +{ + tracing::info!("Paste your auth token found on https://forc.pub/tokens below: "); + writer.flush()?; + let mut auth_token = String::new(); + reader.read_line(&mut auth_token)?; + let auth_token = auth_token.trim().to_string(); + + // Save the token to the credentials file + let credentials = Credentials { + registry: Registry { + token: auth_token.clone(), + }, + }; + let toml_content = toml::to_string(&credentials)?; + if let Some(parent_path) = credentials_path.parent() { + fs::create_dir_all(parent_path)?; + fs::write(credentials_path, toml_content)?; + tracing::info!("Auth token saved to {}", credentials_path.display()); + } + Ok(auth_token) +} + +#[cfg(test)] +mod test { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_get_auth_token_from_cli_arg() { + let token = Some("cli_token".to_string()); + let result = get_auth_token(token, None).unwrap(); + assert_eq!(result, "cli_token"); + } + + #[test] + fn test_get_auth_token_from_file() { + let temp_dir = tempdir().unwrap(); + let cred_path = temp_dir.path().join("credentials.toml"); + + let credentials = r#" + [registry] + token = "file_token" + "#; + fs::write(&cred_path, credentials).unwrap(); + + let result = get_auth_token(None, Some(cred_path)).unwrap(); + assert_eq!(result, "file_token".to_string()); + } + + #[test] + fn test_get_auth_token_from_user_input() { + let temp_dir = tempdir().unwrap(); + let cred_path = temp_dir.path().join("credentials.toml"); + + let reader = io::Cursor::new(b"user_token"); + + let result = + get_auth_token_from_user_input(&cred_path.clone(), reader, io::sink()).unwrap(); + + assert_eq!(result, "user_token"); + + // Ensure the token is saved in the credentials file + let saved_content = fs::read_to_string(&cred_path).unwrap(); + assert!(saved_content.contains("token = \"user_token\"")); + } +} diff --git a/forc-plugins/forc-publish/src/error.rs b/forc-plugins/forc-publish/src/error.rs new file mode 100644 index 00000000000..e98093494ac --- /dev/null +++ b/forc-plugins/forc-publish/src/error.rs @@ -0,0 +1,123 @@ +use reqwest::StatusCode; +use serde::Deserialize; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("I/O error")] + IoError(#[from] std::io::Error), + + #[error("Json error")] + JsonError(#[from] serde_json::Error), + + #[error("HTTP error")] + HttpError(#[from] reqwest::Error), + + #[error("TOML error")] + TomlError(#[from] toml::ser::Error), + + #[error("Failed to get relative path")] + RelativePathError(#[from] std::path::StripPrefixError), + + #[error("{error}")] + ApiResponseError { status: StatusCode, error: String }, + + #[error("Forc.toml not found in the current directory")] + ForcTomlNotFound, +} + +#[derive(Deserialize)] +pub struct ApiErrorResponse { + error: String, +} + +impl Error { + /// Converts a `reqwest::Response` into an `ApiError` + pub async fn from_response(response: reqwest::Response) -> Self { + let status = response.status(); + match response.json::().await { + Ok(parsed_error) => Error::ApiResponseError { + status, + error: parsed_error.error, + }, + Err(_) => Error::ApiResponseError { + status, + error: "Unknown API error".to_string(), + }, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use reqwest::StatusCode; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn test_error_from_response_with_valid_json() { + let mock_server = MockServer::start().await; + + // Simulated JSON API error response + let error_json = json!({ + "error": "Invalid request data" + }); + + Mock::given(method("POST")) + .and(path("/test")) + .respond_with(ResponseTemplate::new(400).set_body_json(&error_json)) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/test", mock_server.uri())) + .send() + .await + .unwrap(); + + let error = Error::from_response(response).await; + + match error { + Error::ApiResponseError { status, error } => { + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(error, "Invalid request data"); + } + _ => panic!("Expected ApiResponseError"), + } + } + + #[tokio::test] + async fn test_error_from_response_with_invalid_json() { + let mock_server = MockServer::start().await; + + // Simulated invalid JSON response (causing deserialization failure) + let invalid_json = "not a json object"; + + Mock::given(method("POST")) + .and(path("/test")) + .respond_with(ResponseTemplate::new(500).set_body_string(invalid_json)) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/test", mock_server.uri())) + .send() + .await + .unwrap(); + + let error = Error::from_response(response).await; + + match error { + Error::ApiResponseError { status, error } => { + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(error, "Unknown API error"); + } + _ => panic!("Expected ApiResponseError"), + } + } +} diff --git a/forc-plugins/forc-publish/src/forc_pub_client.rs b/forc-plugins/forc-publish/src/forc_pub_client.rs new file mode 100644 index 00000000000..cf4374c1cb7 --- /dev/null +++ b/forc-plugins/forc-publish/src/forc_pub_client.rs @@ -0,0 +1,234 @@ +use crate::error::Error; +use crate::error::Result; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; +use uuid::Uuid; + +/// The publish request. +#[derive(Serialize, Debug)] +pub struct PublishRequest { + pub upload_id: Uuid, +} + +/// The publish response. +#[derive(Serialize, Deserialize, Debug)] +pub struct PublishResponse { + pub name: String, + pub version: Version, +} + +/// The response to an upload_project request. +#[derive(Deserialize, Debug)] +pub struct UploadResponse { + pub upload_id: Uuid, +} + +pub struct ForcPubClient { + client: reqwest::Client, + uri: String, +} + +impl ForcPubClient { + pub fn new(uri: String) -> Self { + let client = reqwest::Client::new(); + Self { client, uri } + } + + /// Uploads the given file to the server + pub async fn upload>(&self, file_path: P, forc_version: &str) -> Result { + let url = format!("{}/upload_project?forc_version={}", self.uri, forc_version); + let file_bytes = fs::read(file_path)?; + + let response = self + .client + .post(&url) + .header("Content-Type", "application/gzip") + .body(file_bytes) + .send() + .await?; + + let status = response.status(); + + if status.is_success() { + // Extract `upload_id` from the response if available + let upload_response: UploadResponse = response.json().await?; + Ok(upload_response.upload_id) + } else { + Err(Error::from_response(response).await) + } + } + + /// Publishes the given upload_id to the registry + pub async fn publish(&self, upload_id: Uuid, auth_token: &str) -> Result { + let url = &format!("{}/publish", self.uri); + let publish_request = PublishRequest { upload_id }; + + let response = self + .client + .post(url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", auth_token)) + .json(&publish_request) + .send() + .await?; + + let status = response.status(); + + if status.is_success() { + let publish_response: PublishResponse = response.json().await?; + Ok(publish_response) + } else { + Err(Error::from_response(response).await) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use reqwest::StatusCode; + use serde_json::json; + use std::fs; + use tempfile::NamedTempFile; + use uuid::Uuid; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn test_upload_success() { + let mock_server = MockServer::start().await; + + let upload_id = Uuid::new_v4(); + let success_response = serde_json::json!({ "upload_id": upload_id }); + + Mock::given(method("POST")) + .and(path("/upload_project")) + .and(query_param("forc_version", "0.66.5")) + .respond_with(ResponseTemplate::new(200).set_body_json(&success_response)) + .mount(&mock_server) + .await; + + let client = ForcPubClient::new(mock_server.uri()); + + // Create a temporary gzip file + let temp_file = NamedTempFile::new().unwrap(); + fs::write(temp_file.path(), b"test content").unwrap(); + + let result = client.upload(temp_file.path(), "0.66.5").await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), upload_id); + } + + #[tokio::test] + async fn test_upload_server_error() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/upload_project")) + .respond_with( + ResponseTemplate::new(500) + .set_body_json(serde_json::json!({ "error": "Internal Server Error" })), + ) + .mount(&mock_server) + .await; + + let client = ForcPubClient::new(mock_server.uri()); + + let temp_file = NamedTempFile::new().unwrap(); + fs::write(temp_file.path(), b"test content").unwrap(); + + let result = client.upload(temp_file.path(), "0.66.5").await; + + assert!(result.is_err()); + match result { + Err(Error::ApiResponseError { status, error }) => { + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(error, "Internal Server Error"); + } + _ => panic!("Expected ApiResponseError"), + } + } + + #[tokio::test] + async fn test_publish_success() { + let mock_server = MockServer::start().await; + + let publish_response = json!({ + "name": "test_project", + "version": "1.0.0" + }); + + Mock::given(method("POST")) + .and(path("/publish")) + .respond_with(ResponseTemplate::new(200).set_body_json(&publish_response)) + .mount(&mock_server) + .await; + + let client = ForcPubClient::new(mock_server.uri()); + let upload_id = Uuid::new_v4(); + + let result = client.publish(upload_id, "valid_auth_token").await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.name, "test_project"); + assert_eq!(response.version.to_string(), "1.0.0"); + } + + #[tokio::test] + async fn test_publish_unauthorized() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/publish")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": "Unauthorized" + }))) + .mount(&mock_server) + .await; + + let client = ForcPubClient::new(mock_server.uri()); + let upload_id = Uuid::new_v4(); + + let result = client.publish(upload_id, "invalid_token").await; + + assert!(result.is_err()); + match result { + Err(Error::ApiResponseError { status, error }) => { + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(error, "Unauthorized"); + } + _ => panic!("Expected ApiResponseError"), + } + } + + #[tokio::test] + async fn test_publish_server_error() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/publish")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": "Internal Server Error" + }))) + .mount(&mock_server) + .await; + + let client = ForcPubClient::new(mock_server.uri()); + let upload_id = Uuid::new_v4(); + + let result = client.publish(upload_id, "valid_token").await; + + assert!(result.is_err()); + match result { + Err(Error::ApiResponseError { status, error }) => { + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(error, "Internal Server Error"); + } + _ => panic!("Expected ApiResponseError"), + } + } +} diff --git a/forc-plugins/forc-publish/src/lib.rs b/forc-plugins/forc-publish/src/lib.rs new file mode 100644 index 00000000000..84331e875cd --- /dev/null +++ b/forc-plugins/forc-publish/src/lib.rs @@ -0,0 +1,4 @@ +pub mod credentials; +pub mod error; +pub mod forc_pub_client; +pub mod tarball; diff --git a/forc-plugins/forc-publish/src/main.rs b/forc-plugins/forc-publish/src/main.rs new file mode 100644 index 00000000000..d549721258d --- /dev/null +++ b/forc-plugins/forc-publish/src/main.rs @@ -0,0 +1,52 @@ +use clap::{crate_version, Parser}; +use forc_publish::credentials::get_auth_token; +use forc_publish::error::Result; +use forc_publish::forc_pub_client::ForcPubClient; +use forc_publish::tarball::create_tarball_from_current_dir; +use forc_tracing::{ + init_tracing_subscriber, println_action_green, println_error, TracingSubscriberOptions, +}; +use tempfile::tempdir; + +// For local development, change to: http://localhost:8080 +const FORC_PUB_URL: &str = "https://forc-pub-dev.swayswap.io"; + +#[derive(Parser, Debug)] +#[clap(name = "forc-publish", version)] +/// Forc plugin for uploading packages to the registry. +pub struct Opt { + /// Token to use when uploading + #[clap(long)] + pub token: Option, +} + +#[tokio::main] +async fn main() { + init_tracing_subscriber(TracingSubscriberOptions::default()); + + if let Err(err) = run().await { + println_error(&format!("{err}")); + std::process::exit(1); + } +} + +async fn run() -> Result<()> { + let config = Opt::parse(); + let auth_token = get_auth_token(config.token, None)?; + let forc_version = crate_version!(); + let client = ForcPubClient::new(FORC_PUB_URL.to_string()); + + // Create the compressed tarball + let temp_dir = tempdir()?; + let file_path = create_tarball_from_current_dir(&temp_dir)?; + + // Upload the tarball and publish it + let upload_id = client.upload(file_path, forc_version).await?; + let published = client.publish(upload_id, &auth_token).await?; + + println_action_green( + "Published", + &format!("{} {}", published.name, published.version), + ); + Ok(()) +} diff --git a/forc-plugins/forc-publish/src/tarball.rs b/forc-plugins/forc-publish/src/tarball.rs new file mode 100644 index 00000000000..53202ebdfc1 --- /dev/null +++ b/forc-plugins/forc-publish/src/tarball.rs @@ -0,0 +1,185 @@ +use crate::error::{Error, Result}; +use flate2::write::GzEncoder; +use flate2::Compression; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use tar::Builder; +use tempfile::{tempdir, TempDir}; +use walkdir::WalkDir; + +/// Creates a .tgz tarball from the current directory in a temporary location. +/// Returns the path to the created tarball. +pub fn create_tarball_from_current_dir(temp_tarball_dir: &TempDir) -> Result { + let current_dir = std::env::current_dir()?; + + // Check if Forc.toml exists + let forc_toml_path = current_dir.join("Forc.toml"); + if !forc_toml_path.exists() { + return Err(Error::ForcTomlNotFound); + } + + // Copy project to a temporary directory, excluding `/out/` + let temp_project_dir = tempdir()?; + copy_project_excluding_out(temp_project_dir.path())?; + + // Pack the temp directory into a tarball + let tarball_path = temp_tarball_dir.path().join("sway-project.tgz"); + let tar_gz = File::create(&tarball_path)?; + let enc = GzEncoder::new(tar_gz, Compression::default()); + let mut tar = Builder::new(enc); + tar.append_dir_all(".", &temp_project_dir)?; + tar.finish()?; + + // Return the tarball path + Ok(tarball_path) +} + +/// Copies the current directory (excluding `/out/`) to a temporary directory. +fn copy_project_excluding_out(temp_project_dir: &Path) -> Result<()> { + let current_dir = std::env::current_dir()?; + + for entry in WalkDir::new(¤t_dir) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + let relative_path = path.strip_prefix(¤t_dir)?; + + // Skip the `/out` directory + if relative_path.starts_with("out") { + continue; + } + + let new_path = temp_project_dir.join(relative_path); + + if path.is_dir() { + fs::create_dir_all(&new_path)?; + } else { + fs::copy(path, &new_path)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use flate2::read::GzDecoder; + use std::{env, fs}; + use tar::Archive; + use tempfile::tempdir; + + #[test] + fn test_create_tarball_success() { + // Create a temporary directory + let temp_project_dir = tempdir().unwrap(); + + // Create a fake Forc.toml + let forc_toml_path = temp_project_dir.path().join("Forc.toml"); + fs::write(&forc_toml_path, "[package]\nname = \"test_project\"").unwrap(); + + // Create another temporary directory for storing the tarball + let temp_output_dir = tempdir().unwrap(); + + // Run the function + env::set_current_dir(&temp_project_dir).unwrap(); + let result = create_tarball_from_current_dir(&temp_output_dir); + assert!(result.is_ok()); + + // Check that the tarball file was created + let tarball_path = result.unwrap(); + assert!(tarball_path.exists()); + + // Verify that the tarball contains Forc.toml + let tar_file = fs::File::open(&tarball_path).unwrap(); + let tar = GzDecoder::new(tar_file); + let mut archive = Archive::new(tar); + + let mut contains_forc_toml = false; + for entry in archive.entries().unwrap() { + let entry = entry.unwrap(); + let path = entry.path().unwrap().to_path_buf(); + if path.ends_with("Forc.toml") { + contains_forc_toml = true; + break; + } + } + + assert!(contains_forc_toml, "Tarball should contain Forc.toml"); + } + + #[test] + fn test_create_tarball_fails_without_forc_toml() { + // Create a temporary directory that DOES NOT contain Forc.toml + let temp_project_dir = tempdir().unwrap(); + + // Create another temporary directory for storing the tarball + let temp_output_dir = tempdir().unwrap(); + + // Run the function, expecting an error + env::set_current_dir(&temp_project_dir).unwrap(); + let result = create_tarball_from_current_dir(&temp_output_dir); + + assert!(matches!(result, Err(Error::ForcTomlNotFound))); + } + + #[test] + fn test_create_tarball_excludes_out_dir() { + let temp_project_dir = tempdir().unwrap(); + + // Create necessary files + fs::write( + temp_project_dir.path().join("Forc.toml"), + "[package]\nname = \"test_project\"", + ) + .unwrap(); + fs::create_dir(temp_project_dir.path().join("src/")).unwrap(); + fs::write(temp_project_dir.path().join("src/main.sw"), "fn main() {}").unwrap(); + + // Create an `out/debug/` directory with a dummy file + let out_dir = temp_project_dir.path().join("out/debug/"); + fs::create_dir_all(&out_dir).unwrap(); + fs::write(out_dir.join("compiled.bin"), "binary content").unwrap(); + + // Create temp dir for tarball storage + let temp_output_dir = tempdir().unwrap(); + + // Change working directory to our fake project + std::env::set_current_dir(temp_project_dir.path()).unwrap(); + + // Run the function + let result = create_tarball_from_current_dir(&temp_output_dir); + assert!(result.is_ok()); + + let tarball_path = result.unwrap(); + assert!(tarball_path.exists()); + + // Verify that the tarball does NOT contain `out/` + let tar_file = fs::File::open(&tarball_path).unwrap(); + let tar = GzDecoder::new(tar_file); + let mut archive = Archive::new(tar); + + let mut contains_forc_toml = false; + let mut contains_main_sw = false; + let mut contains_out_dir = false; + for entry in archive.entries().unwrap() { + let entry = entry.unwrap(); + let path = entry.path().unwrap().to_path_buf(); + if path.starts_with("out") { + contains_out_dir = true; + } else if path.ends_with("Forc.toml") { + contains_forc_toml = true; + } else if path.ends_with("src/main.sw") { + contains_main_sw = true; + } + } + + assert!( + !contains_out_dir, + "Tarball should not contain the `out/` directory" + ); + assert!(contains_forc_toml, "Tarball should contain Forc.toml"); + assert!(contains_main_sw, "Tarball should contain main.sw"); + } +} From e40e9dacf5b04a2ba120ee8be3c6809c71e4a732 Mon Sep 17 00:00:00 2001 From: Sophie <47993817+sdankel@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:51:32 -0800 Subject: [PATCH 2/6] fix credential path in test --- forc-plugins/forc-publish/src/credentials.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forc-plugins/forc-publish/src/credentials.rs b/forc-plugins/forc-publish/src/credentials.rs index db83e038d72..b1a202be4f3 100644 --- a/forc-plugins/forc-publish/src/credentials.rs +++ b/forc-plugins/forc-publish/src/credentials.rs @@ -109,7 +109,7 @@ mod test { "#; fs::write(&cred_path, credentials).unwrap(); - let result = get_auth_token(None, Some(cred_path)).unwrap(); + let result = get_auth_token(None, Some(temp_dir.path().into())).unwrap(); assert_eq!(result, "file_token".to_string()); } From 95d675cd9000dc48368af6c127f5e855f8ef7b37 Mon Sep 17 00:00:00 2001 From: Marcos Henrich Date: Sun, 9 Feb 2025 01:50:05 +0000 Subject: [PATCH 3/6] Fixes argument errors not displayed. (#5909) ## Description `type_check_method_application` does the parsing of arguments in 2 passes, but when the `resolved_method_name` failed the argument error would not be displayed. We now store the arg_handlers and append their errors in case `resolve_method_name` fails. Fixes #5660 ## Checklist - [x] I have linked to any relevant issues. - [x] I have commented my code, particularly in hard-to-understand areas. - [ ] I have updated the documentation where relevant (API docs, the reference, and the Sway book). - [ ] If my change requires substantial documentation changes, I have [requested support from the DevRel team](https://github.com/FuelLabs/devrel-requests/issues/new/choose) - [x] I have added tests that prove my fix is effective or that my feature works. - [x] I have added (or requested a maintainer to add) the necessary `Breaking*` or `New Feature` labels where relevant. - [x] I have done my best to ensure that my PR adheres to [the Fuel Labs Code Review Standards](https://github.com/FuelLabs/rfcs/blob/master/text/code-standards/external-contributors.md). - [ ] I have requested a review from the relevant team or maintainers. Co-authored-by: Joshua Batty --- .../typed_expression/method_application.rs | 22 +++++++++---- .../variable_does_not_exist/Forc.lock | 8 +++++ .../variable_does_not_exist/Forc.toml | 9 ++++++ .../variable_does_not_exist/src/main.sw | 23 ++++++++++++++ .../variable_does_not_exist/test.toml | 31 +++++++++++++++++++ 5 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.lock create mode 100644 test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.toml create mode 100644 test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/src/main.sw create mode 100644 test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/test.toml diff --git a/sway-core/src/semantic_analysis/ast_node/expression/typed_expression/method_application.rs b/sway-core/src/semantic_analysis/ast_node/expression/typed_expression/method_application.rs index 99d7bd2f578..c7ae5c92a2b 100644 --- a/sway-core/src/semantic_analysis/ast_node/expression/typed_expression/method_application.rs +++ b/sway-core/src/semantic_analysis/ast_node/expression/typed_expression/method_application.rs @@ -77,25 +77,35 @@ pub(crate) fn type_check_method_application( true } }); - handler.append(arg_handler); + handler.append(arg_handler.clone()); } - args_opt_buf.push_back((arg_opt, needs_second_pass)); + args_opt_buf.push_back((arg_opt, arg_handler, needs_second_pass)); } // resolve the method name to a typed function declaration and type_check - let (original_decl_ref, call_path_typeid) = resolve_method_name( + let method_result = resolve_method_name( handler, ctx.by_ref(), &method_name_binding, args_opt_buf .iter() - .map(|(arg, _has_errors)| match arg { + .map(|(arg, _, _has_errors)| match arg { Some(arg) => arg.return_type, None => type_engine.new_unknown(), }) .collect(), - )?; + ); + + // In case resolve_method_name fails throw argument errors. + let (original_decl_ref, call_path_typeid) = if let Err(e) = method_result { + for (_, arg_handler, _) in args_opt_buf.iter() { + handler.append(arg_handler.clone()); + } + return Err(e); + } else { + method_result.unwrap() + }; let mut fn_ref = monomorphize_method( handler, @@ -120,7 +130,7 @@ pub(crate) fn type_check_method_application( // type check the function arguments (2nd pass) let mut args_buf = VecDeque::new(); for (arg, index, arg_opt) in izip!(arguments.iter(), 0.., args_opt_buf.iter().cloned()) { - if let (Some(arg), false) = arg_opt { + if let (Some(arg), _, false) = arg_opt { args_buf.push_back(arg); } else { // We type check the argument expression again this time throwing out the error. diff --git a/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.lock b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.lock new file mode 100644 index 00000000000..478b462e84b --- /dev/null +++ b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.lock @@ -0,0 +1,8 @@ +[[package]] +name = "core" +source = "path+from-root-1C5801B8398D8ED4" + +[[package]] +name = "variable_does_not_exist" +source = "member" +dependencies = ["core"] diff --git a/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.toml b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.toml new file mode 100644 index 00000000000..31713fc97e7 --- /dev/null +++ b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/Forc.toml @@ -0,0 +1,9 @@ +[project] +name = "variable_does_not_exist" +authors = ["Fuel Labs "] +entry = "main.sw" +license = "Apache-2.0" +implicit-std = false + +[dependencies] +core = { path = "../../../../../../sway-lib-core" } diff --git a/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/src/main.sw b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/src/main.sw new file mode 100644 index 00000000000..73cc041ae7b --- /dev/null +++ b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/src/main.sw @@ -0,0 +1,23 @@ +script; + +struct S {} + +impl S { + fn associated(a: u64, b: u64, c: u64) -> u64 { + a + b + c + } +} + +fn function(a: u64, b: u64, c: u64) -> u64 { + a + b + c +} + +fn main() { + let _ = S::associated(x, y, z); + + + let _ = function(x, y, z); + + + let _ = x + y + z; +} \ No newline at end of file diff --git a/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/test.toml b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/test.toml new file mode 100644 index 00000000000..64c903bf329 --- /dev/null +++ b/test/src/e2e_vm_tests/test_programs/should_fail/variable_does_not_exist/test.toml @@ -0,0 +1,31 @@ +category = "fail" + +# check: $()let _ = S::associated(x, y, z); +# nextln:$()Variable "x" does not exist in this scope. + +# check: $()let _ = S::associated(x, y, z); +# nextln:$()Variable "y" does not exist in this scope. + +# check: $()let _ = S::associated(x, y, z); +# nextln:$()Variable "z" does not exist in this scope. + +# check: $()let _ = function(x, y, z); +# nextln:$()Variable "x" does not exist in this scope. + +# check: $()let _ = function(x, y, z); +# nextln:$()Variable "y" does not exist in this scope. + +# check: $()let _ = function(x, y, z); +# nextln:$()Variable "z" does not exist in this scope. + +# check: $()let _ = x + y + z; +# nextln: $()Variable "x" does not exist in this scope. + +# check: $()let _ = x + y + z; +# nextln: $()Variable "y" does not exist in this scope. + +# check: $()let _ = x + y + z; +# nextln: $()No method "add({unknown}, unknown)" found for type "{unknown}". + +# check: $()let _ = x + y + z; +# nextln: $()Variable "z" does not exist in this scope. From 08572d7a925e0dec690968f2dbd11e03e59cc312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaya=20G=C3=B6kalp?= Date: Sat, 8 Feb 2025 22:58:36 -0800 Subject: [PATCH 4/6] feat: disallow dependency package name collision in forc-pkg (#6888) ## Description closes #6861 `forc` was allowing dependency name being the same (through the use of `package` alias and the declaration itself) as the project name. The following two cases are now invalid and produces an error on the forc-pkg side before going to the compiler. ```TOML [project] authors = ["Fuel Labs "] entry = "main.sw" license = "Apache-2.0" name = "lib_contract" [dependencies] lib_contract = { path = "../lib_contract_abi/", package = "lib_contract_abi" } ``` and ```TOML [project] authors = ["Fuel Labs "] entry = "main.sw" license = "Apache-2.0" name = "lib_contract_abi" [dependencies] lib_contract = { path = "../lib_contract_abi/", package = "lib_contract_abi" } ``` --------- Co-authored-by: Joshua Batty --- forc-pkg/src/manifest/mod.rs | 55 +++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/forc-pkg/src/manifest/mod.rs b/forc-pkg/src/manifest/mod.rs index 8f77091b518..ef1bdee79c2 100644 --- a/forc-pkg/src/manifest/mod.rs +++ b/forc-pkg/src/manifest/mod.rs @@ -326,6 +326,7 @@ impl DependencyDetails { if git.is_none() && (branch.is_some() || tag.is_some() || rev.is_some()) { bail!("Details reserved for git sources used without a git field"); } + Ok(()) } } @@ -638,13 +639,26 @@ impl PackageManifest { /// 1. The project and organization names against a set of reserved/restricted keywords and patterns. /// 2. The validity of the details provided. Makes sure that there are no mismatching detail /// declarations (to prevent mixing details specific to certain types). + /// 3. The dependencies listed does not have an alias ("package" field) that is the same as package name. pub fn validate(&self) -> Result<()> { validate_project_name(&self.project.name)?; if let Some(ref org) = self.project.organization { validate_name(org, "organization name")?; } - for (_, dependency_details) in self.deps_detailed() { + for (dep_name, dependency_details) in self.deps_detailed() { dependency_details.validate()?; + if dependency_details + .package + .as_ref() + .is_some_and(|package_alias| package_alias == &self.project.name) + { + bail!(format!("Dependency \"{dep_name}\" declares an alias (\"package\" field) that is the same as project name")) + } + if dep_name == &self.project.name { + bail!(format!( + "Dependency \"{dep_name}\" collides with project name." + )) + } } Ok(()) } @@ -1666,4 +1680,43 @@ mod tests { assert_eq!(original.workspace.members, deserialized.workspace.members); assert_eq!(original.workspace.metadata, deserialized.workspace.metadata); } + + #[test] + fn test_dependency_alias_project_name_collision() { + let original_toml = r#" + [project] + authors = ["Fuel Labs "] + entry = "main.sw" + license = "Apache-2.0" + name = "lib_contract_abi" + + [dependencies] + lib_contract = { path = "../lib_contract_abi/", package = "lib_contract_abi" } + "#; + + let project = PackageManifest::from_string(original_toml.to_string()); + let err = project.unwrap_err(); + assert_eq!(err.to_string(), format!("Dependency \"lib_contract\" declares an alias (\"package\" field) that is the same as project name")) + } + + #[test] + fn test_dependency_name_project_name_collision() { + let original_toml = r#" + [project] + authors = ["Fuel Labs "] + entry = "main.sw" + license = "Apache-2.0" + name = "lib_contract" + + [dependencies] + lib_contract = { path = "../lib_contract_abi/", package = "lib_contract_abi" } + "#; + + let project = PackageManifest::from_string(original_toml.to_string()); + let err = project.unwrap_err(); + assert_eq!( + err.to_string(), + format!("Dependency \"lib_contract\" collides with project name.") + ) + } } From ab2151d90543583757fac3f61665f2290d66d032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaya=20G=C3=B6kalp?= Date: Mon, 10 Feb 2025 17:35:18 -0800 Subject: [PATCH 5/6] fix: remove invalid unpacking logic for IPFS pinned pkg sources (#6902) ## Description closes #6721. There were couple of issues: 1. Archive unpacking was getting the bytes from `text` field of response to the IPFS gateway which was adding additional stuff that messes with unpacking routine. 2. Folder name was invalid so forc was unable to find the packages in the `ipfs` cache. Both issues are addressed and this unblocks forc to fetch ipfs fetched packages once again. Next up on our package registry push is to enable alternative sources and set the precedence for them. Also added some tests around extracting logic, which inserts the contents under a directory named after `CID` as this is how `forc` is looking for these sources in the first place (related to point 2 above) --- Cargo.lock | 2 + forc-pkg/Cargo.toml | 2 + forc-pkg/src/source/ipfs.rs | 185 ++++++++++++++++++++++++++++++++---- 3 files changed, 170 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f594835a11..d00f5d2943b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2912,6 +2912,7 @@ dependencies = [ "anyhow", "byte-unit", "cid", + "flate2", "forc-tracing 0.66.7", "forc-util", "fuel-abi-types", @@ -2935,6 +2936,7 @@ dependencies = [ "sway-utils", "sysinfo", "tar", + "tempfile", "toml 0.8.19", "tracing", "url", diff --git a/forc-pkg/Cargo.toml b/forc-pkg/Cargo.toml index 6e0e6431fac..fa29d63a68b 100644 --- a/forc-pkg/Cargo.toml +++ b/forc-pkg/Cargo.toml @@ -13,6 +13,7 @@ ansiterm.workspace = true anyhow.workspace = true byte-unit.workspace = true cid.workspace = true +flate2.workspace = true forc-tracing.workspace = true forc-util.workspace = true fuel-abi-types.workspace = true @@ -42,6 +43,7 @@ walkdir.workspace = true [dev-dependencies] regex = "^1.10.2" +tempfile.workspace = true [target.'cfg(not(target_os = "macos"))'.dependencies] sysinfo = "0.29" diff --git a/forc-pkg/src/source/ipfs.rs b/forc-pkg/src/source/ipfs.rs index 4d465ff23f6..adb5148f8f2 100644 --- a/forc-pkg/src/source/ipfs.rs +++ b/forc-pkg/src/source/ipfs.rs @@ -4,6 +4,7 @@ use crate::{ source, }; use anyhow::Result; +use flate2::read::GzDecoder; use forc_tracing::println_action_green; use futures::TryStreamExt; use ipfs_api::IpfsApi; @@ -130,6 +131,18 @@ impl fmt::Display for Pinned { } impl Cid { + fn extract_archive(&self, reader: R, dst: &Path) -> Result<()> { + let dst_dir = dst.join(self.0.to_string()); + std::fs::create_dir_all(&dst_dir)?; + let mut archive = Archive::new(reader); + + for entry in archive.entries()? { + let mut entry = entry?; + entry.unpack_in(&dst_dir)?; + } + + Ok(()) + } /// Using local node, fetches the content described by this cid. async fn fetch_with_client(&self, ipfs_client: &IpfsClient, dst: &Path) -> Result<()> { let cid_path = format!("/ipfs/{}", self.0); @@ -140,8 +153,7 @@ impl Cid { .try_concat() .await?; // After collecting bytes of the archive, we unpack it to the dst. - let mut archive = Archive::new(bytes.as_slice()); - archive.unpack(dst)?; + self.extract_archive(bytes.as_slice(), dst)?; Ok(()) } @@ -150,7 +162,7 @@ impl Cid { let client = reqwest::Client::new(); // We request the content to be served to us in tar format by the public gateway. let fetch_url = format!( - "{}/ipfs/{}?download=true&format=tar&filename={}.tar", + "{}/ipfs/{}?download=true&filename={}.tar.gz", gateway_url, self.0, self.0 ); let req = client.get(&fetch_url); @@ -158,11 +170,10 @@ impl Cid { if !res.status().is_success() { anyhow::bail!("Failed to fetch from {fetch_url:?}"); } - let bytes: Vec<_> = res.text().await?.bytes().collect(); - - // After collecting bytes of the archive, we unpack it to the dst. - let mut archive = Archive::new(bytes.as_slice()); - archive.unpack(dst)?; + let bytes: Vec<_> = res.bytes().await?.into_iter().collect(); + let tar = GzDecoder::new(bytes.as_slice()); + // After collecting and decoding bytes of the archive, we unpack it to the dst. + self.extract_archive(tar, dst)?; Ok(()) } } @@ -225,18 +236,154 @@ fn pkg_cache_dir(cid: &Cid) -> PathBuf { fn ipfs_client() -> IpfsClient { IpfsClient::default() } +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use std::io::Cursor; + use tar::Header; + use tempfile::TempDir; + + fn create_header(path: &str, size: u64) -> Header { + let mut header = Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_size(size); + header.set_mode(0o755); + header.set_cksum(); + header + } + + fn create_test_tar(files: &[(&str, &str)]) -> Vec { + let mut ar = tar::Builder::new(Vec::new()); + + // Add root project directory + let header = create_header("test-project/", 0); + ar.append(&header, &mut std::io::empty()).unwrap(); + + // Add files + for (path, content) in files { + let full_path = format!("test-project/{}", path); + let header = create_header(&full_path, content.len() as u64); + ar.append(&header, content.as_bytes()).unwrap(); + } + + ar.into_inner().unwrap() + } + + fn create_test_cid() -> Cid { + let cid = cid::Cid::from_str("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG").unwrap(); + Cid(cid) + } + + #[test] + fn test_basic_extraction() -> Result<()> { + let temp_dir = TempDir::new()?; + let cid = create_test_cid(); -#[test] -fn test_source_ipfs_pinned_parsing() { - let string = "ipfs+QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; + let tar_content = create_test_tar(&[("test.txt", "hello world")]); + + cid.extract_archive(Cursor::new(tar_content), temp_dir.path())?; + + let extracted_path = temp_dir + .path() + .join(cid.0.to_string()) + .join("test-project") + .join("test.txt"); + + assert!(extracted_path.exists()); + assert_eq!(std::fs::read_to_string(extracted_path)?, "hello world"); + + Ok(()) + } - let expected = Pinned(Cid(cid::Cid::from_str( - "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", - ) - .unwrap())); + #[test] + fn test_nested_files() -> Result<()> { + let temp_dir = TempDir::new()?; + let cid = create_test_cid(); - let parsed = Pinned::from_str(string).unwrap(); - assert_eq!(parsed, expected); - let serialized = expected.to_string(); - assert_eq!(&serialized, string); + let tar_content = + create_test_tar(&[("src/main.sw", "contract {};"), ("README.md", "# Test")]); + + cid.extract_archive(Cursor::new(tar_content), temp_dir.path())?; + + let base = temp_dir.path().join(cid.0.to_string()).join("test-project"); + assert_eq!( + std::fs::read_to_string(base.join("src/main.sw"))?, + "contract {};" + ); + assert_eq!(std::fs::read_to_string(base.join("README.md"))?, "# Test"); + + Ok(()) + } + + #[test] + fn test_invalid_tar() { + let temp_dir = TempDir::new().unwrap(); + let cid = create_test_cid(); + + let result = cid.extract_archive(Cursor::new(b"not a tar file"), temp_dir.path()); + + assert!(result.is_err()); + } + + #[test] + fn test_source_ipfs_pinned_parsing() { + let string = "ipfs+QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; + let expected = Pinned(Cid(cid::Cid::from_str( + "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + ) + .unwrap())); + let parsed = Pinned::from_str(string).unwrap(); + assert_eq!(parsed, expected); + let serialized = expected.to_string(); + assert_eq!(&serialized, string); + } + + #[test] + fn test_path_traversal_prevention() -> Result<()> { + let temp_dir = TempDir::new()?; + let cid = create_test_cid(); + + // Create a known directory structure + let target_dir = temp_dir.path().join("target"); + std::fs::create_dir(&target_dir)?; + + // Create our canary file in a known location + let canary_content = "sensitive content"; + let canary_path = target_dir.join("canary.txt"); + std::fs::write(&canary_path, canary_content)?; + + // Create tar with malicious path targeting our specific canary file + let mut header = tar::Header::new_gnu(); + let malicious_path = b"../../target/canary.txt"; + header.as_gnu_mut().unwrap().name[..malicious_path.len()].copy_from_slice(malicious_path); + header.set_size(17); + header.set_mode(0o644); + header.set_cksum(); + + let mut ar = tar::Builder::new(Vec::new()); + ar.append(&header, b"malicious content".as_slice())?; + + // Add safe file + let mut safe_header = tar::Header::new_gnu(); + safe_header.set_path("safe.txt")?; + safe_header.set_size(12); + safe_header.set_mode(0o644); + safe_header.set_cksum(); + ar.append(&safe_header, b"safe content".as_slice())?; + + // Extract to a subdirectory of temp_dir + let tar_content = ar.into_inner()?; + let extract_dir = temp_dir.path().join("extract"); + std::fs::create_dir(&extract_dir)?; + cid.extract_archive(Cursor::new(tar_content), &extract_dir)?; + + // Verify canary file was not modified + assert_eq!( + std::fs::read_to_string(&canary_path)?, + canary_content, + "Canary file was modified - path traversal protection failed!" + ); + Ok(()) + } } From b8a99b1d3d5256e5d1972d625d9a3f9de3863295 Mon Sep 17 00:00:00 2001 From: Sophie <47993817+sdankel@users.noreply.github.com> Date: Mon, 10 Feb 2025 19:09:49 -0800 Subject: [PATCH 6/6] feedback --- forc-plugins/forc-publish/README.md | 6 ++- forc-plugins/forc-publish/src/credentials.rs | 13 +++---- forc-plugins/forc-publish/src/error.rs | 3 ++ .../forc-publish/src/forc_pub_client.rs | 38 ++++++++++--------- forc-plugins/forc-publish/src/main.rs | 8 +++- forc-plugins/forc-publish/src/tarball.rs | 4 +- 6 files changed, 43 insertions(+), 29 deletions(-) diff --git a/forc-plugins/forc-publish/README.md b/forc-plugins/forc-publish/README.md index 9bd7b2e4fc4..aaa71338ee6 100644 --- a/forc-plugins/forc-publish/README.md +++ b/forc-plugins/forc-publish/README.md @@ -11,4 +11,8 @@ Requires either the `--token` argument to be passed, or a `~/.forc/credentials.t token = "YOUR_TOKEN" ``` -This credential file can be created automatically by running the CLI without the `--token` argument. \ No newline at end of file +This credential file can be created automatically by running the CLI without the `--token` argument. + +## Local development + +For local development, run [forc.pub](https://github.com/FuelLabs/forc.pub), create an API token and then `forc publish --registry-url http://localhost:8080` diff --git a/forc-plugins/forc-publish/src/credentials.rs b/forc-plugins/forc-publish/src/credentials.rs index b1a202be4f3..ec77d548a22 100644 --- a/forc-plugins/forc-publish/src/credentials.rs +++ b/forc-plugins/forc-publish/src/credentials.rs @@ -71,15 +71,14 @@ where let auth_token = auth_token.trim().to_string(); // Save the token to the credentials file - let credentials = Credentials { - registry: Registry { - token: auth_token.clone(), - }, - }; - let toml_content = toml::to_string(&credentials)?; if let Some(parent_path) = credentials_path.parent() { fs::create_dir_all(parent_path)?; - fs::write(credentials_path, toml_content)?; + let credentials = Credentials { + registry: Registry { + token: auth_token.clone(), + }, + }; + fs::write(credentials_path, toml::to_string(&credentials)?)?; tracing::info!("Auth token saved to {}", credentials_path.display()); } Ok(auth_token) diff --git a/forc-plugins/forc-publish/src/error.rs b/forc-plugins/forc-publish/src/error.rs index e98093494ac..da9a31735cb 100644 --- a/forc-plugins/forc-publish/src/error.rs +++ b/forc-plugins/forc-publish/src/error.rs @@ -17,6 +17,9 @@ pub enum Error { #[error("TOML error")] TomlError(#[from] toml::ser::Error), + #[error("URL error")] + UrlError(#[from] url::ParseError), + #[error("Failed to get relative path")] RelativePathError(#[from] std::path::StripPrefixError), diff --git a/forc-plugins/forc-publish/src/forc_pub_client.rs b/forc-plugins/forc-publish/src/forc_pub_client.rs index cf4374c1cb7..aa338ea3e61 100644 --- a/forc-plugins/forc-publish/src/forc_pub_client.rs +++ b/forc-plugins/forc-publish/src/forc_pub_client.rs @@ -4,6 +4,7 @@ use semver::Version; use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; +use url::Url; use uuid::Uuid; /// The publish request. @@ -27,23 +28,25 @@ pub struct UploadResponse { pub struct ForcPubClient { client: reqwest::Client, - uri: String, + uri: Url, } impl ForcPubClient { - pub fn new(uri: String) -> Self { + pub fn new(uri: Url) -> Self { let client = reqwest::Client::new(); Self { client, uri } } /// Uploads the given file to the server pub async fn upload>(&self, file_path: P, forc_version: &str) -> Result { - let url = format!("{}/upload_project?forc_version={}", self.uri, forc_version); + let url = self + .uri + .join(&format!("upload_project?forc_version={}", forc_version))?; let file_bytes = fs::read(file_path)?; let response = self .client - .post(&url) + .post(url) .header("Content-Type", "application/gzip") .body(file_bytes) .send() @@ -62,7 +65,7 @@ impl ForcPubClient { /// Publishes the given upload_id to the registry pub async fn publish(&self, upload_id: Uuid, auth_token: &str) -> Result { - let url = &format!("{}/publish", self.uri); + let url = self.uri.join("publish")?; let publish_request = PublishRequest { upload_id }; let response = self @@ -96,10 +99,16 @@ mod test { use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; - #[tokio::test] - async fn test_upload_success() { + async fn get_mock_client_server() -> (ForcPubClient, MockServer) { let mock_server = MockServer::start().await; + let url = Url::parse(&mock_server.uri()).expect("url"); + let mock_client = ForcPubClient::new(url); + (mock_client, mock_server) + } + #[tokio::test] + async fn test_upload_success() { + let (client, mock_server) = get_mock_client_server().await; let upload_id = Uuid::new_v4(); let success_response = serde_json::json!({ "upload_id": upload_id }); @@ -110,8 +119,6 @@ mod test { .mount(&mock_server) .await; - let client = ForcPubClient::new(mock_server.uri()); - // Create a temporary gzip file let temp_file = NamedTempFile::new().unwrap(); fs::write(temp_file.path(), b"test content").unwrap(); @@ -124,7 +131,7 @@ mod test { #[tokio::test] async fn test_upload_server_error() { - let mock_server = MockServer::start().await; + let (client, mock_server) = get_mock_client_server().await; Mock::given(method("POST")) .and(path("/upload_project")) @@ -135,8 +142,6 @@ mod test { .mount(&mock_server) .await; - let client = ForcPubClient::new(mock_server.uri()); - let temp_file = NamedTempFile::new().unwrap(); fs::write(temp_file.path(), b"test content").unwrap(); @@ -154,7 +159,7 @@ mod test { #[tokio::test] async fn test_publish_success() { - let mock_server = MockServer::start().await; + let (client, mock_server) = get_mock_client_server().await; let publish_response = json!({ "name": "test_project", @@ -167,7 +172,6 @@ mod test { .mount(&mock_server) .await; - let client = ForcPubClient::new(mock_server.uri()); let upload_id = Uuid::new_v4(); let result = client.publish(upload_id, "valid_auth_token").await; @@ -180,7 +184,7 @@ mod test { #[tokio::test] async fn test_publish_unauthorized() { - let mock_server = MockServer::start().await; + let (client, mock_server) = get_mock_client_server().await; Mock::given(method("POST")) .and(path("/publish")) @@ -190,7 +194,6 @@ mod test { .mount(&mock_server) .await; - let client = ForcPubClient::new(mock_server.uri()); let upload_id = Uuid::new_v4(); let result = client.publish(upload_id, "invalid_token").await; @@ -207,7 +210,7 @@ mod test { #[tokio::test] async fn test_publish_server_error() { - let mock_server = MockServer::start().await; + let (client, mock_server) = get_mock_client_server().await; Mock::given(method("POST")) .and(path("/publish")) @@ -217,7 +220,6 @@ mod test { .mount(&mock_server) .await; - let client = ForcPubClient::new(mock_server.uri()); let upload_id = Uuid::new_v4(); let result = client.publish(upload_id, "valid_token").await; diff --git a/forc-plugins/forc-publish/src/main.rs b/forc-plugins/forc-publish/src/main.rs index d549721258d..6bbe73037c8 100644 --- a/forc-plugins/forc-publish/src/main.rs +++ b/forc-plugins/forc-publish/src/main.rs @@ -7,8 +7,8 @@ use forc_tracing::{ init_tracing_subscriber, println_action_green, println_error, TracingSubscriberOptions, }; use tempfile::tempdir; +use url::Url; -// For local development, change to: http://localhost:8080 const FORC_PUB_URL: &str = "https://forc-pub-dev.swayswap.io"; #[derive(Parser, Debug)] @@ -18,6 +18,10 @@ pub struct Opt { /// Token to use when uploading #[clap(long)] pub token: Option, + + /// The registry URL to use + #[clap(long, default_value = FORC_PUB_URL)] + pub registry_url: String, } #[tokio::main] @@ -34,7 +38,7 @@ async fn run() -> Result<()> { let config = Opt::parse(); let auth_token = get_auth_token(config.token, None)?; let forc_version = crate_version!(); - let client = ForcPubClient::new(FORC_PUB_URL.to_string()); + let client = ForcPubClient::new(Url::parse(&config.registry_url)?); // Create the compressed tarball let temp_dir = tempdir()?; diff --git a/forc-plugins/forc-publish/src/tarball.rs b/forc-plugins/forc-publish/src/tarball.rs index 53202ebdfc1..d7faf4823be 100644 --- a/forc-plugins/forc-publish/src/tarball.rs +++ b/forc-plugins/forc-publish/src/tarball.rs @@ -7,6 +7,8 @@ use tar::Builder; use tempfile::{tempdir, TempDir}; use walkdir::WalkDir; +const TARBALL_FILE_NAME: &str = "sway-project.tgz"; + /// Creates a .tgz tarball from the current directory in a temporary location. /// Returns the path to the created tarball. pub fn create_tarball_from_current_dir(temp_tarball_dir: &TempDir) -> Result { @@ -23,7 +25,7 @@ pub fn create_tarball_from_current_dir(temp_tarball_dir: &TempDir) -> Result