diff --git a/crates/crates_io_api_types/src/lib.rs b/crates/crates_io_api_types/src/lib.rs index 132e67895c5..e9fadfd75c9 100644 --- a/crates/crates_io_api_types/src/lib.rs +++ b/crates/crates_io_api_types/src/lib.rs @@ -361,6 +361,9 @@ pub struct EncodableCrate { /// Whether the crate name was an exact match. #[schema(deprecated)] pub exact_match: bool, + + /// Whether this crate can only be published via Trusted Publishing. + pub trustpub_only: bool, } impl EncodableCrate { @@ -386,6 +389,7 @@ impl EncodableCrate { homepage, documentation, repository, + trustpub_only, .. } = krate; let versions_link = match versions { @@ -451,6 +455,7 @@ impl EncodableCrate { exact_match, description, repository, + trustpub_only, links: EncodableCrateLinks { version_downloads: format!("/api/v1/crates/{name}/downloads"), versions: versions_link, @@ -1201,6 +1206,7 @@ mod tests { reverse_dependencies: "".to_string(), }, exact_match: false, + trustpub_only: false, }; let json = serde_json::to_string(&crt).unwrap(); assert_some!(json.as_str().find(r#""updated_at":"2017-01-06T14:23:11Z""#)); diff --git a/crates/crates_io_database/src/models/krate.rs b/crates/crates_io_database/src/models/krate.rs index 98a013f8881..83f1b0b781a 100644 --- a/crates/crates_io_database/src/models/krate.rs +++ b/crates/crates_io_database/src/models/krate.rs @@ -37,6 +37,7 @@ pub struct Crate { pub repository: Option, pub max_upload_size: Option, pub max_features: Option, + pub trustpub_only: bool, } /// We literally never want to select `textsearchable_index_col` @@ -52,6 +53,7 @@ type AllColumns = ( crates::repository, crates::max_upload_size, crates::max_features, + crates::trustpub_only, ); pub const ALL_COLUMNS: AllColumns = ( @@ -65,6 +67,7 @@ pub const ALL_COLUMNS: AllColumns = ( crates::repository, crates::max_upload_size, crates::max_features, + crates::trustpub_only, ); pub const MAX_NAME_LENGTH: usize = 64; diff --git a/crates/crates_io_database/src/schema.rs b/crates/crates_io_database/src/schema.rs index 26113eaac4e..9f75a52b579 100644 --- a/crates/crates_io_database/src/schema.rs +++ b/crates/crates_io_database/src/schema.rs @@ -374,6 +374,8 @@ diesel::table! { /// /// (Automatically generated by Diesel.) textsearchable_index_col -> Tsvector, + /// When true, this crate can only be published via Trusted Publishing, not with API tokens + trustpub_only -> Bool, /// The `updated_at` column of the `crates` table. /// /// Its SQL type is `Timestamptz`. diff --git a/crates/crates_io_database_dump/src/dump-db.toml b/crates/crates_io_database_dump/src/dump-db.toml index eb1770f4888..3ffaa3af6c7 100644 --- a/crates/crates_io_database_dump/src/dump-db.toml +++ b/crates/crates_io_database_dump/src/dump-db.toml @@ -92,6 +92,7 @@ textsearchable_index_col = "private" # This Postgres specific and can be derived repository = "public" max_upload_size = "public" max_features = "public" +trustpub_only = "public" [crates_categories] dependencies = ["categories", "crates"] diff --git a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap index 1d801f192d7..078fa330051 100644 --- a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap +++ b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap @@ -6,7 +6,7 @@ BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY; \copy "categories" ("category", "crates_cnt", "created_at", "description", "id", "path", "slug") TO 'data/categories.csv' WITH CSV HEADER \copy "crate_downloads" ("crate_id", "downloads") TO 'data/crate_downloads.csv' WITH CSV HEADER - \copy "crates" ("created_at", "description", "documentation", "homepage", "id", "max_features", "max_upload_size", "name", "readme", "repository", "updated_at") TO 'data/crates.csv' WITH CSV HEADER + \copy "crates" ("created_at", "description", "documentation", "homepage", "id", "max_features", "max_upload_size", "name", "readme", "repository", "trustpub_only", "updated_at") TO 'data/crates.csv' WITH CSV HEADER \copy "keywords" ("crates_cnt", "created_at", "id", "keyword") TO 'data/keywords.csv' WITH CSV HEADER \copy "metadata" ("total_downloads") TO 'data/metadata.csv' WITH CSV HEADER \copy "reserved_crate_names" ("name") TO 'data/reserved_crate_names.csv' WITH CSV HEADER diff --git a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap index 08b529528c7..a89c3232610 100644 --- a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap +++ b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap @@ -50,7 +50,7 @@ BEGIN; \copy "categories" ("category", "crates_cnt", "created_at", "description", "id", "path", "slug") FROM 'data/categories.csv' WITH CSV HEADER \copy "crate_downloads" ("crate_id", "downloads") FROM 'data/crate_downloads.csv' WITH CSV HEADER - \copy "crates" ("created_at", "description", "documentation", "homepage", "id", "max_features", "max_upload_size", "name", "readme", "repository", "updated_at") FROM 'data/crates.csv' WITH CSV HEADER + \copy "crates" ("created_at", "description", "documentation", "homepage", "id", "max_features", "max_upload_size", "name", "readme", "repository", "trustpub_only", "updated_at") FROM 'data/crates.csv' WITH CSV HEADER \copy "keywords" ("crates_cnt", "created_at", "id", "keyword") FROM 'data/keywords.csv' WITH CSV HEADER \copy "metadata" ("total_downloads") FROM 'data/metadata.csv' WITH CSV HEADER \copy "reserved_crate_names" ("name") FROM 'data/reserved_crate_names.csv' WITH CSV HEADER diff --git a/crates/crates_io_test_utils/src/builders/krate.rs b/crates/crates_io_test_utils/src/builders/krate.rs index 8d75cd400d2..a0eab428b3d 100644 --- a/crates/crates_io_test_utils/src/builders/krate.rs +++ b/crates/crates_io_test_utils/src/builders/krate.rs @@ -16,6 +16,7 @@ pub struct CrateBuilder<'a> { krate: NewCrate<'a>, owner_id: i32, recent_downloads: Option, + trustpub_only: bool, updated_at: Option>, versions: Vec, } @@ -34,6 +35,7 @@ impl<'a> CrateBuilder<'a> { }, owner_id, recent_downloads: None, + trustpub_only: false, updated_at: None, versions: Vec::new(), } @@ -113,6 +115,12 @@ impl<'a> CrateBuilder<'a> { self } + /// Sets the crate's `trustpub_only` flag. + pub fn trustpub_only(mut self, trustpub_only: bool) -> Self { + self.trustpub_only = trustpub_only; + self + } + pub async fn build(mut self, connection: &mut AsyncPgConnection) -> anyhow::Result { use diesel::{insert_into, select, update}; @@ -171,6 +179,14 @@ impl<'a> CrateBuilder<'a> { .await?; } + if self.trustpub_only { + krate = update(&krate) + .set(crates::trustpub_only.eq(true)) + .returning(Crate::as_returning()) + .get_result(connection) + .await?; + } + update_default_version(krate.id, connection).await?; Ok(krate) diff --git a/migrations/2025-11-19-091444-0000_add_trustpub_only_to_crates/down.sql b/migrations/2025-11-19-091444-0000_add_trustpub_only_to_crates/down.sql new file mode 100644 index 00000000000..133a60b15ff --- /dev/null +++ b/migrations/2025-11-19-091444-0000_add_trustpub_only_to_crates/down.sql @@ -0,0 +1 @@ +ALTER TABLE crates DROP COLUMN trustpub_only; diff --git a/migrations/2025-11-19-091444-0000_add_trustpub_only_to_crates/up.sql b/migrations/2025-11-19-091444-0000_add_trustpub_only_to_crates/up.sql new file mode 100644 index 00000000000..023bcff453c --- /dev/null +++ b/migrations/2025-11-19-091444-0000_add_trustpub_only_to_crates/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE crates ADD COLUMN trustpub_only BOOLEAN NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN crates.trustpub_only IS 'When true, this crate can only be published via Trusted Publishing, not with API tokens'; diff --git a/src/controllers/krate.rs b/src/controllers/krate.rs index 9e6de400ab5..3d35f514ac5 100644 --- a/src/controllers/krate.rs +++ b/src/controllers/krate.rs @@ -15,6 +15,7 @@ pub mod owners; pub mod publish; pub mod rev_deps; pub mod search; +pub mod update; pub mod versions; #[derive(Deserialize, FromRequestParts, IntoParams)] diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index 56737663671..fe2201c7c7b 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -216,6 +216,16 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PatchResponse { + /// The updated crate metadata. + #[serde(rename = "crate")] + krate: EncodableCrate, +} + +/// Update crate settings. +#[utoipa::path( + patch, + path = "/api/v1/crates/{name}", + params(CratePath), + request_body = PatchRequest, + security( + ("api_token" = []), + ("cookie" = []), + ), + tag = "crates", + responses((status = 200, description = "Successful Response", body = inline(PatchResponse))), +)] +pub async fn update_crate( + app: AppState, + path: CratePath, + req: Parts, + Extension(real_ip): Extension, + Json(body): Json, +) -> AppResult> { + let mut conn = app.db_write().await?; + + // Check that the crate exists + let krate = path.load_crate(&mut conn).await?; + + // Check that the user is authenticated with appropriate permissions + let auth = AuthCheck::default() + .with_endpoint_scope(EndpointScope::TrustedPublishing) + .for_crate(&krate.name) + .check(&req, &mut conn) + .await?; + + if auth + .api_token() + .is_some_and(|token| token.endpoint_scopes.is_none()) + { + return Err(forbidden( + "This endpoint cannot be used with legacy API tokens. Use a scoped API token instead.", + )); + } + + // Update crate settings in a transaction + conn.transaction(|conn| { + update_inner(conn, &app, &krate, auth.user(), &real_ip, body).scope_boxed() + }) + .await +} + +async fn update_inner( + conn: &mut diesel_async::AsyncPgConnection, + app: &AppState, + krate: &Crate, + user: &User, + real_ip: &RealIp, + body: PatchRequest, +) -> AppResult> { + // Query user owners to check permissions and send emails + let user_owners = crate_owners::table + .inner_join(users::table) + .inner_join(emails::table.on(users::id.eq(emails::user_id))) + .filter(crate_owners::crate_id.eq(krate.id)) + .filter(crate_owners::deleted.eq(false)) + .filter(crate_owners::owner_kind.eq(crate::models::OwnerKind::User)) + .select((users::id, users::gh_login, emails::email, emails::verified)) + .load::<(i32, String, String, bool)>(conn) + .await?; + + // Check that the authenticated user is an owner + if !user_owners.iter().any(|(id, _, _, _)| *id == user.id) { + let msg = "only owners have permission to modify crate settings"; + return Err(custom(StatusCode::FORBIDDEN, msg)); + } + + // Update trustpub_only if provided + if let Some(trustpub_only) = body.trustpub_only + && trustpub_only != krate.trustpub_only + { + diesel::update(crates::table) + .filter(crates::id.eq(krate.id)) + .set(crates::trustpub_only.eq(trustpub_only)) + .execute(conn) + .await?; + + // Audit log the setting change + info!( + target: "audit", + action = "trustpub_only_change", + krate.name = %krate.name, + network.client.ip = %**real_ip, + usr.id = user.id, + usr.name = %user.gh_login, + "User {} set trustpub_only={trustpub_only} for crate {}", + user.gh_login, + krate.name + ); + + // Send email notifications to all crate owners + for (_, gh_login, email_address, email_verified) in &user_owners { + if *email_verified { + let email = TrustpubOnlyChangedEmail { + recipient: gh_login, + auth_user: user, + krate, + trustpub_only, + }; + + if let Err(err) = email.send(app, email_address).await { + warn!("Failed to send trustpub_only notification to {email_address}: {err}"); + } + } + } + } + + // Reload the crate to get updated data + let (krate, downloads, recent_downloads, default_version, yanked, num_versions): ( + Crate, + i64, + Option, + Option, + Option, + Option, + ) = Crate::by_name(&krate.name) + .inner_join(crate_downloads::table) + .left_join(recent_crate_downloads::table) + .left_join(default_versions::table) + .left_join(versions::table.on(default_versions::version_id.eq(versions::id))) + .select(( + Crate::as_select(), + crate_downloads::downloads, + recent_crate_downloads::downloads.nullable(), + versions::num.nullable(), + versions::yanked.nullable(), + default_versions::num_versions.nullable(), + )) + .first(conn) + .await + .optional()? + .ok_or_else(|| crate_not_found(&krate.name))?; + + let encodable_crate = EncodableCrate::from( + krate, + default_version.as_deref(), + num_versions.unwrap_or_default(), + yanked, + None, + None, + None, + None, + false, + downloads, + recent_downloads, + ); + + Ok(Json(PatchResponse { + krate: encodable_crate, + })) +} + +#[derive(Serialize)] +struct TrustpubOnlyChangedEmail<'a> { + /// The GitHub login of the email recipient. + recipient: &'a str, + /// The user who changed the setting. + auth_user: &'a User, + /// The crate for which the setting was changed. + krate: &'a Crate, + /// The new value of the trustpub_only flag. + trustpub_only: bool, +} + +impl TrustpubOnlyChangedEmail<'_> { + async fn send(&self, state: &AppState, email_address: &str) -> anyhow::Result<()> { + let email = EmailMessage::from_template("trustpub_only_changed", self); + let email = email.context("Failed to render email template")?; + + state + .emails + .send(email_address, email) + .await + .context("Failed to send email") + } +} diff --git a/src/controllers/trustpub/emails.rs b/src/controllers/trustpub/emails.rs index 9f5c4374465..38b80087e53 100644 --- a/src/controllers/trustpub/emails.rs +++ b/src/controllers/trustpub/emails.rs @@ -79,6 +79,7 @@ mod tests { repository: None, max_upload_size: None, max_features: None, + trustpub_only: false, } } diff --git a/src/email/templates/trustpub_only_changed/body.txt.j2 b/src/email/templates/trustpub_only_changed/body.txt.j2 new file mode 100644 index 00000000000..44e489d105a --- /dev/null +++ b/src/email/templates/trustpub_only_changed/body.txt.j2 @@ -0,0 +1,25 @@ +{% extends "base.txt.j2" %} + +{% block content %} +Hello {{ recipient }}! + +{% if recipient == auth_user.gh_login -%} +You changed the publishing method restriction for your crate "{{ krate.name }}". +{%- else -%} +crates.io user {{ auth_user.gh_login }} changed the publishing method restriction for a crate that you manage ("{{ krate.name }}"). +{%- endif %} + +{% if trustpub_only -%} +This crate can now ONLY be published via Trusted Publishing. Publishing with API tokens has been disabled. + +This means that only trusted publishers (like GitHub Actions or GitLab CI workflows) that you have configured will be able to publish new versions of this crate. API tokens will no longer work for publishing. +{%- else -%} +This crate can now be published via both Trusted Publishing and API tokens. + +This means that both trusted publishers (like GitHub Actions or GitLab CI workflows) and users with API tokens will be able to publish new versions of this crate. +{%- endif %} + +If you did not make this change and you think it was made maliciously, you can revert the setting from the "Settings" tab on the crate's page. + +If you are unable to revert the change and need to do so, you can email help@crates.io for assistance. +{% endblock %} diff --git a/src/email/templates/trustpub_only_changed/subject.txt.j2 b/src/email/templates/trustpub_only_changed/subject.txt.j2 new file mode 100644 index 00000000000..5c7187da99c --- /dev/null +++ b/src/email/templates/trustpub_only_changed/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Publishing method restriction changed for {{ krate.name }} diff --git a/src/router.rs b/src/router.rs index 73cddeab45b..449f0d74259 100644 --- a/src/router.rs +++ b/src/router.rs @@ -31,6 +31,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { // Routes used by the frontend .routes(routes!( krate::metadata::find_crate, + krate::update::update_crate, krate::delete::delete_crate )) .routes(routes!( diff --git a/src/tests/krate/publish/auth.rs b/src/tests/krate/publish/auth.rs index 740c1590f29..5da8276375b 100644 --- a/src/tests/krate/publish/auth.rs +++ b/src/tests/krate/publish/auth.rs @@ -1,6 +1,6 @@ use crate::builders::{CrateBuilder, PublishBuilder}; use crate::util::{MockTokenUser, RequestHelper, TestApp}; -use crates_io::schema::api_tokens; +use crates_io::schema::{api_tokens, crates}; use diesel::ExpressionMethods; use diesel_async::RunQueryDsl; use googletest::prelude::*; @@ -78,3 +78,31 @@ async fn new_krate_with_bearer_token() { rss/updates.xml "); } + +#[tokio::test(flavor = "multi_thread")] +async fn publish_with_token_rejected_when_trustpub_only() { + let (app, _, user, token) = TestApp::full().with_token().await; + let mut conn = app.db_conn().await; + + // Create a crate + CrateBuilder::new("foo_trustpub_only", user.as_model().id) + .expect_build(&mut conn) + .await; + + // Set trustpub_only to true + diesel::update(crates::table) + .filter(crates::name.eq("foo_trustpub_only")) + .set(crates::trustpub_only.eq(true)) + .execute(&mut conn) + .await + .unwrap(); + + // Try to publish with API token - should be rejected + let crate_to_publish = PublishBuilder::new("foo_trustpub_only", "1.0.0"); + let response = token.publish_crate(crate_to_publish).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"New versions of this crate can only be published using Trusted Publishing (see https://crates.io/docs/trusted-publishing)."}]}"#); + + assert_that!(app.stored_files().await, is_empty()); + assert_that!(app.emails().await, is_empty()); +} diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__auth__new_krate_with_bearer_token-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__auth__new_krate_with_bearer_token-2.snap index 88cc12fe00c..c9ecba99892 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__auth__new_krate_with_bearer_token-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__auth__new_krate_with_bearer_token-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate-2.snap index 28cb4db0450..d49e3abbb84 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice-2.snap index 501d39ee175..2d4ac8d32ec 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice_alt-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice_alt-2.snap index 06a5033f589..86310a1443c 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice_alt-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_twice_alt-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_weird_version-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_weird_version-2.snap index 72ade9bb92e..4ac962b4dd9 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_weird_version-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_weird_version-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_with_token-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_with_token-2.snap index 28cb4db0450..d49e3abbb84 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_with_token-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__basics__new_krate_with_token-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_1.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_1.snap index 41b3c2f3247..fcdf38c64b8 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_1.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_1.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_2.snap index c4b64395e8e..44ba9b58393 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_3.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_3.snap index 41b3c2f3247..fcdf38c64b8 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_3.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__build_metadata__version_with_build_metadata@build_metadata_3.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__categories__good_categories-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__categories__good_categories-2.snap index 26be0235fbd..a6c75cc9155 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__categories__good_categories-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__categories__good_categories-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__dependencies__dep_limit-4.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__dependencies__dep_limit-4.snap index 469fe526ce4..fb761155819 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__dependencies__dep_limit-4.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__dependencies__dep_limit-4.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__edition__edition_is_saved-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__edition__edition_is_saved-2.snap index 7a884ebe94e..e49caf40320 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__edition__edition_is_saved-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__edition__edition_is_saved-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__keywords__good_keywords-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__keywords__good_keywords-2.snap index 88d74b8c9d9..33ee243f3eb 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__keywords__good_keywords-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__keywords__good_keywords-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__links__crate_with_links_field.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__links__crate_with_links_field.snap index 07e0cfe632c..c56f5430cf3 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__links__crate_with_links_field.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__links__crate_with_links_field.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__boolean_readme-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__boolean_readme-2.snap index c0787b7c239..42a2e6c15af 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__boolean_readme-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__boolean_readme-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__lib_and_bin_crate-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__lib_and_bin_crate-2.snap index c0787b7c239..42a2e6c15af 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__lib_and_bin_crate-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__manifest__lib_and_bin_crate-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__max_size__tarball_between_default_axum_limit_and_max_upload_size-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__max_size__tarball_between_default_axum_limit_and_max_upload_size-2.snap index c2acc59eeb3..85327b9f9ae 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__max_size__tarball_between_default_axum_limit_and_max_upload_size-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__max_size__tarball_between_default_axum_limit_and_max_upload_size-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_empty_readme-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_empty_readme-2.snap index 1b92337edd3..73dad84c62f 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_empty_readme-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_empty_readme-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme-2.snap index 1b92337edd3..73dad84c62f 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme_and_plus_version-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme_and_plus_version-2.snap index 85f7ef58068..e93f8621437 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme_and_plus_version-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__readme__new_krate_with_readme_and_plus_version-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__full_flow-7.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__full_flow-7.snap index 319deb2ad54..367b9c4420d 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__full_flow-7.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__full_flow-7.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path-2.snap index 319deb2ad54..367b9c4420d 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path_with_fancy_auth_header-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path_with_fancy_auth_header-2.snap index 319deb2ad54..367b9c4420d 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path_with_fancy_auth_header-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_github__happy_path_with_fancy_auth_header-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__full_flow-7.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__full_flow-7.snap index cf382896d0c..3b43123ce16 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__full_flow-7.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__full_flow-7.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path-2.snap index cf382896d0c..3b43123ce16 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path_with_fancy_auth_header-2.snap b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path_with_fancy_auth_header-2.snap index cf382896d0c..3b43123ce16 100644 --- a/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path_with_fancy_auth_header-2.snap +++ b/src/tests/krate/publish/snapshots/integration__krate__publish__trustpub_gitlab__happy_path_with_fancy_auth_header-2.snap @@ -30,6 +30,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/krate/publish/trustpub_github.rs b/src/tests/krate/publish/trustpub_github.rs index 24e8dfa4f3f..50b4020c079 100644 --- a/src/tests/krate/publish/trustpub_github.rs +++ b/src/tests/krate/publish/trustpub_github.rs @@ -1,6 +1,7 @@ use crate::builders::{CrateBuilder, PublishBuilder}; use crate::util::{MockTokenUser, RequestHelper, TestApp}; use chrono::{TimeDelta, Utc}; +use crates_io::schema::crates; use crates_io_database::models::trustpub::NewToken; use crates_io_github::{GitHubUser, MockGitHubClient}; use crates_io_trustpub::access_token::AccessToken; @@ -9,7 +10,8 @@ use crates_io_trustpub::github::test_helpers::FullGitHubClaims; use crates_io_trustpub::keystore::MockOidcKeyStore; use crates_io_trustpub::test_keys::encode_for_testing; use diesel::QueryResult; -use diesel_async::AsyncPgConnection; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; use insta::{assert_json_snapshot, assert_snapshot}; use mockall::predicate::*; use p256::ecdsa::signature::digest::Output; @@ -325,3 +327,30 @@ async fn test_token_for_wrong_crate() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_trustpub_works_when_trustpub_only_enabled() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + // Set trustpub_only to true + diesel::update(crates::table) + .filter(crates::name.eq(&krate.name)) + .set(crates::trustpub_only.eq(true)) + .execute(&mut conn) + .await?; + + let token = new_token(&mut conn, krate.id).await?; + let oidc_token_client = MockTokenUser::with_auth_header(token, app.clone()); + + // Publishing with trusted publishing should work + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_snapshot!(response.status(), @"200 OK"); + + Ok(()) +} diff --git a/src/tests/krate/publish/trustpub_gitlab.rs b/src/tests/krate/publish/trustpub_gitlab.rs index eff8d982085..27c80478ba6 100644 --- a/src/tests/krate/publish/trustpub_gitlab.rs +++ b/src/tests/krate/publish/trustpub_gitlab.rs @@ -1,6 +1,7 @@ use crate::builders::{CrateBuilder, PublishBuilder}; use crate::util::{MockTokenUser, RequestHelper, TestApp}; use chrono::{TimeDelta, Utc}; +use crates_io::schema::crates; use crates_io_database::models::trustpub::NewToken; use crates_io_trustpub::access_token::AccessToken; use crates_io_trustpub::gitlab::GITLAB_ISSUER_URL; @@ -8,7 +9,8 @@ use crates_io_trustpub::gitlab::test_helpers::FullGitLabClaims; use crates_io_trustpub::keystore::MockOidcKeyStore; use crates_io_trustpub::test_keys::encode_for_testing; use diesel::QueryResult; -use diesel_async::AsyncPgConnection; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; use insta::{assert_json_snapshot, assert_snapshot}; use p256::ecdsa::signature::digest::Output; use secrecy::ExposeSecret; @@ -306,3 +308,30 @@ async fn test_token_for_wrong_crate() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_trustpub_works_when_trustpub_only_enabled() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + // Set trustpub_only to true + diesel::update(crates::table) + .filter(crates::name.eq(&krate.name)) + .set(crates::trustpub_only.eq(true)) + .execute(&mut conn) + .await?; + + let token = new_token(&mut conn, krate.id).await?; + let oidc_token_client = MockTokenUser::with_auth_header(token, app.clone()); + + // Publishing with trusted publishing should work + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_snapshot!(response.status(), @"200 OK"); + + Ok(()) +} diff --git a/src/tests/routes/crates/mod.rs b/src/tests/routes/crates/mod.rs index bd1e1aaa9f1..5d06c5de97c 100644 --- a/src/tests/routes/crates/mod.rs +++ b/src/tests/routes/crates/mod.rs @@ -7,4 +7,5 @@ mod new; pub mod owners; mod read; mod reverse_dependencies; +mod update; pub mod versions; diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__read__include_default_version-2.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__read__include_default_version-2.snap index 85a9950f33f..b2dcf2edbd4 100644 --- a/src/tests/routes/crates/snapshots/integration__routes__crates__read__include_default_version-2.snap +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__read__include_default_version-2.snap @@ -31,6 +31,7 @@ expression: response.json() "num_versions": 3, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__read__new_name-2.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__read__new_name-2.snap index 991a1909f2c..039673bf580 100644 --- a/src/tests/routes/crates/snapshots/integration__routes__crates__read__new_name-2.snap +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__read__new_name-2.snap @@ -31,6 +31,7 @@ expression: response.json() "num_versions": 1, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__read__show-2.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__read__show-2.snap index 0d8f97403d5..e57bcb96ca1 100644 --- a/src/tests/routes/crates/snapshots/integration__routes__crates__read__show-2.snap +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__read__show-2.snap @@ -33,6 +33,7 @@ expression: response.json() "num_versions": 3, "recent_downloads": 10, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": [ 3, diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_all_yanked-2.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_all_yanked-2.snap index b18dbcdc4b9..3efa201fdef 100644 --- a/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_all_yanked-2.snap +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_all_yanked-2.snap @@ -33,6 +33,7 @@ expression: response.json() "num_versions": 2, "recent_downloads": 10, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": [ 2, diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_minimal-2.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_minimal-2.snap index d1480ec96ef..9eba2e9ef0e 100644 --- a/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_minimal-2.snap +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__read__show_minimal-2.snap @@ -31,6 +31,7 @@ expression: response.json() "num_versions": 3, "recent_downloads": null, "repository": null, + "trustpub_only": false, "updated_at": "[datetime]", "versions": null, "yanked": false diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-10.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-10.snap new file mode 100644 index 00000000000..7310d8cd458 --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-10.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/routes/crates/update.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Publishing method restriction changed for foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +You changed the publishing method restriction for your crate "foo". + +This crate can now be published via both Trusted Publishing and API tokens. + +This means that both trusted publishers (like GitHub Actions or GitLab CI workflows) and users with API tokens will be able to publish new versions of this crate. + +If you did not make this change and you think it was made maliciously, you can revert the setting from the "Settings" tab on the crate's page. + +If you are unable to revert the change and need to do so, you can email help@crates.io for assistance. + +-- +The crates.io Team diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-3.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-3.snap new file mode 100644 index 00000000000..4171996d570 --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-3.snap @@ -0,0 +1,38 @@ +--- +source: src/tests/routes/crates/update.rs +expression: json +--- +{ + "crate": { + "badges": [], + "categories": null, + "created_at": "[datetime]", + "default_version": "0.99.0", + "description": null, + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": null, + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": "/api/v1/crates/foo/versions" + }, + "max_stable_version": null, + "max_version": "0.0.0", + "name": "foo", + "newest_version": "0.0.0", + "num_versions": 1, + "recent_downloads": null, + "repository": null, + "trustpub_only": true, + "updated_at": "[datetime]", + "versions": null, + "yanked": false + } +} diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-6.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-6.snap new file mode 100644 index 00000000000..8aaf1c25c3b --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-6.snap @@ -0,0 +1,38 @@ +--- +source: src/tests/routes/crates/update.rs +expression: json +--- +{ + "crate": { + "badges": [], + "categories": null, + "created_at": "[datetime]", + "default_version": "0.99.0", + "description": null, + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": null, + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": "/api/v1/crates/foo/versions" + }, + "max_stable_version": null, + "max_version": "0.0.0", + "name": "foo", + "newest_version": "0.0.0", + "num_versions": 1, + "recent_downloads": null, + "repository": null, + "trustpub_only": false, + "updated_at": "[datetime]", + "versions": null, + "yanked": false + } +} diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-9.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-9.snap new file mode 100644 index 00000000000..753aaab2edb --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__disable_trustpub_only-9.snap @@ -0,0 +1,84 @@ +--- +source: src/tests/routes/crates/update.rs +expression: json +--- +{ + "categories": [], + "crate": { + "badges": [], + "categories": [], + "created_at": "[datetime]", + "default_version": "0.99.0", + "description": null, + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": [], + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": null + }, + "max_stable_version": "0.99.0", + "max_version": "0.99.0", + "name": "foo", + "newest_version": "0.99.0", + "num_versions": 1, + "recent_downloads": null, + "repository": null, + "trustpub_only": false, + "updated_at": "[datetime]", + "versions": [ + 1 + ], + "yanked": false + }, + "keywords": [], + "versions": [ + { + "audit_actions": [], + "bin_names": null, + "checksum": " ", + "crate": "foo", + "crate_size": 0, + "created_at": "[datetime]", + "description": null, + "dl_path": "/api/v1/crates/foo/0.99.0/download", + "documentation": null, + "downloads": 0, + "edition": null, + "features": {}, + "has_lib": null, + "homepage": null, + "id": 1, + "lib_links": null, + "license": null, + "linecounts": null, + "links": { + "authors": "/api/v1/crates/foo/0.99.0/authors", + "dependencies": "/api/v1/crates/foo/0.99.0/dependencies", + "version_downloads": "/api/v1/crates/foo/0.99.0/downloads" + }, + "num": "0.99.0", + "published_by": { + "avatar": null, + "id": 1, + "login": "foo", + "name": null, + "url": "https://github.com/foo" + }, + "readme_path": "/api/v1/crates/foo/0.99.0/readme", + "repository": null, + "rust_version": null, + "trustpub_data": null, + "updated_at": "[datetime]", + "yank_message": null, + "yanked": false + } + ] +} diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-10.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-10.snap new file mode 100644 index 00000000000..4cf0ce8fd35 --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-10.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/routes/crates/update.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Publishing method restriction changed for foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +You changed the publishing method restriction for your crate "foo". + +This crate can now ONLY be published via Trusted Publishing. Publishing with API tokens has been disabled. + +This means that only trusted publishers (like GitHub Actions or GitLab CI workflows) that you have configured will be able to publish new versions of this crate. API tokens will no longer work for publishing. + +If you did not make this change and you think it was made maliciously, you can revert the setting from the "Settings" tab on the crate's page. + +If you are unable to revert the change and need to do so, you can email help@crates.io for assistance. + +-- +The crates.io Team diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-3.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-3.snap new file mode 100644 index 00000000000..8aaf1c25c3b --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-3.snap @@ -0,0 +1,38 @@ +--- +source: src/tests/routes/crates/update.rs +expression: json +--- +{ + "crate": { + "badges": [], + "categories": null, + "created_at": "[datetime]", + "default_version": "0.99.0", + "description": null, + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": null, + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": "/api/v1/crates/foo/versions" + }, + "max_stable_version": null, + "max_version": "0.0.0", + "name": "foo", + "newest_version": "0.0.0", + "num_versions": 1, + "recent_downloads": null, + "repository": null, + "trustpub_only": false, + "updated_at": "[datetime]", + "versions": null, + "yanked": false + } +} diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-6.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-6.snap new file mode 100644 index 00000000000..4171996d570 --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-6.snap @@ -0,0 +1,38 @@ +--- +source: src/tests/routes/crates/update.rs +expression: json +--- +{ + "crate": { + "badges": [], + "categories": null, + "created_at": "[datetime]", + "default_version": "0.99.0", + "description": null, + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": null, + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": "/api/v1/crates/foo/versions" + }, + "max_stable_version": null, + "max_version": "0.0.0", + "name": "foo", + "newest_version": "0.0.0", + "num_versions": 1, + "recent_downloads": null, + "repository": null, + "trustpub_only": true, + "updated_at": "[datetime]", + "versions": null, + "yanked": false + } +} diff --git a/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-9.snap b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-9.snap new file mode 100644 index 00000000000..7aed958afbd --- /dev/null +++ b/src/tests/routes/crates/snapshots/integration__routes__crates__update__enable_trustpub_only-9.snap @@ -0,0 +1,84 @@ +--- +source: src/tests/routes/crates/update.rs +expression: json +--- +{ + "categories": [], + "crate": { + "badges": [], + "categories": [], + "created_at": "[datetime]", + "default_version": "0.99.0", + "description": null, + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": [], + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": null + }, + "max_stable_version": "0.99.0", + "max_version": "0.99.0", + "name": "foo", + "newest_version": "0.99.0", + "num_versions": 1, + "recent_downloads": null, + "repository": null, + "trustpub_only": true, + "updated_at": "[datetime]", + "versions": [ + 1 + ], + "yanked": false + }, + "keywords": [], + "versions": [ + { + "audit_actions": [], + "bin_names": null, + "checksum": " ", + "crate": "foo", + "crate_size": 0, + "created_at": "[datetime]", + "description": null, + "dl_path": "/api/v1/crates/foo/0.99.0/download", + "documentation": null, + "downloads": 0, + "edition": null, + "features": {}, + "has_lib": null, + "homepage": null, + "id": 1, + "lib_links": null, + "license": null, + "linecounts": null, + "links": { + "authors": "/api/v1/crates/foo/0.99.0/authors", + "dependencies": "/api/v1/crates/foo/0.99.0/dependencies", + "version_downloads": "/api/v1/crates/foo/0.99.0/downloads" + }, + "num": "0.99.0", + "published_by": { + "avatar": null, + "id": 1, + "login": "foo", + "name": null, + "url": "https://github.com/foo" + }, + "readme_path": "/api/v1/crates/foo/0.99.0/readme", + "repository": null, + "rust_version": null, + "trustpub_data": null, + "updated_at": "[datetime]", + "yank_message": null, + "yanked": false + } + ] +} diff --git a/src/tests/routes/crates/update.rs b/src/tests/routes/crates/update.rs new file mode 100644 index 00000000000..64e3a5f9d29 --- /dev/null +++ b/src/tests/routes/crates/update.rs @@ -0,0 +1,296 @@ +use crate::builders::CrateBuilder; +use crate::util::{RequestHelper, TestApp}; +use crates_io::models::token::{CrateScope, EndpointScope}; +use insta::{assert_json_snapshot, assert_snapshot}; + +#[tokio::test(flavor = "multi_thread")] +async fn test_enable_trustpub_only() { + let (app, _, user) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create a crate + let owner_id = user.as_model().id; + CrateBuilder::new("foo", owner_id) + .expect_build(&mut conn) + .await; + + let url = "/api/v1/crates/foo"; + + // Try to set trustpub_only to false when it's already false (no change) + let body = serde_json::json!({ "trustpub_only": false }); + let response = user.patch::<()>(url, body.to_string()).await; + assert_snapshot!(response.status(), @"200 OK"); + let json = response.json(); + assert_json_snapshot!(json["crate"]["trustpub_only"], @"false"); + assert_json_snapshot!(json, { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + }); + + // Now enable trustpub_only + let body = serde_json::json!({ "trustpub_only": true }); + let response = user.patch::<()>(url, body.to_string()).await; + assert_snapshot!(response.status(), @"200 OK"); + let json = response.json(); + assert_json_snapshot!(json["crate"]["trustpub_only"], @"true"); + assert_json_snapshot!(json, { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + }); + + // Verify the flag was set + let response = user.get::<()>(url).await; + assert_snapshot!(response.status(), @"200 OK"); + let json = response.json(); + assert_json_snapshot!(json["crate"]["trustpub_only"], @"true"); + assert_json_snapshot!(json, { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + ".versions[].created_at" => "[datetime]", + ".versions[].updated_at" => "[datetime]", + }); + + assert_snapshot!(app.emails_snapshot().await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_disable_trustpub_only() { + let (app, _, user) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create a crate with trustpub_only enabled + let owner_id = user.as_model().id; + CrateBuilder::new("foo", owner_id) + .trustpub_only(true) + .expect_build(&mut conn) + .await; + + let url = "/api/v1/crates/foo"; + + // Try to set trustpub_only to true when it's already true (no change) + let body = serde_json::json!({ "trustpub_only": true }); + let response = user.patch::<()>(url, body.to_string()).await; + assert_snapshot!(response.status(), @"200 OK"); + let json = response.json(); + assert_json_snapshot!(json["crate"]["trustpub_only"], @"true"); + assert_json_snapshot!(json, { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + }); + + // Now disable trustpub_only + let body = serde_json::json!({ "trustpub_only": false }); + let response = user.patch::<()>(url, body.to_string()).await; + assert_snapshot!(response.status(), @"200 OK"); + let json = response.json(); + assert_json_snapshot!(json["crate"]["trustpub_only"], @"false"); + assert_json_snapshot!(json, { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + }); + + // Verify the flag was unset + let response = user.get::<()>(url).await; + assert_snapshot!(response.status(), @"200 OK"); + let json = response.json(); + assert_json_snapshot!(json["crate"]["trustpub_only"], @"false"); + assert_json_snapshot!(json, { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + ".versions[].created_at" => "[datetime]", + ".versions[].updated_at" => "[datetime]", + }); + + assert_snapshot!(app.emails_snapshot().await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_update_trustpub_only_requires_authentication() { + let (app, anon, user) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create a crate + let owner_id = user.as_model().id; + CrateBuilder::new("foo", owner_id) + .expect_build(&mut conn) + .await; + + // Try to update as an unauthenticated user + let url = "/api/v1/crates/foo"; + let body = serde_json::json!({ "trustpub_only": true }); + let response = anon.patch::<()>(url, body.to_string()).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + + assert_eq!(app.emails().await.len(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_update_trustpub_only_requires_ownership() { + let (app, _, user) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create a crate with one user + let owner_id = user.as_model().id; + CrateBuilder::new("foo", owner_id) + .expect_build(&mut conn) + .await; + + // Create a different user + let another_user = app.db_new_user("another").await; + + // Try to update with a different user + let url = "/api/v1/crates/foo"; + let body = serde_json::json!({ "trustpub_only": true }); + let response = another_user.patch::<()>(url, body.to_string()).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + + assert_eq!(app.emails().await.len(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_update_nonexistent_crate() { + let (app, _, user) = TestApp::full().with_user().await; + + let url = "/api/v1/crates/nonexistent"; + let body = serde_json::json!({ "trustpub_only": true }); + let response = user.patch::<()>(url, body.to_string()).await; + assert_snapshot!(response.status(), @"404 Not Found"); + + assert_eq!(app.emails().await.len(), 0); +} + +mod auth { + use super::*; + + const CRATE_NAME: &str = "foo"; + + async fn prepare() -> (TestApp, crate::util::MockCookieUser) { + let (app, _, user) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create a crate + let owner_id = user.as_model().id; + CrateBuilder::new(CRATE_NAME, owner_id) + .expect_build(&mut conn) + .await; + + (app, user) + } + + #[tokio::test(flavor = "multi_thread")] + async fn token_user_with_legacy_token() { + let (app, user) = prepare().await; + let token = user.db_new_token("test-token").await; + + let url = format!("/api/v1/crates/{}", CRATE_NAME); + let body = serde_json::json!({ "trustpub_only": true }); + let response = token.patch::<()>(&url, body.to_string()).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"This endpoint cannot be used with legacy API tokens. Use a scoped API token instead."}]}"#); + + assert_eq!(app.emails().await.len(), 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn token_user_with_correct_endpoint_scope() { + let (app, user) = prepare().await; + let token = user + .db_new_scoped_token( + "test-token", + None, + Some(vec![EndpointScope::TrustedPublishing]), + None, + ) + .await; + + let url = format!("/api/v1/crates/{}", CRATE_NAME); + let body = serde_json::json!({ "trustpub_only": true }); + let response = token.patch::<()>(&url, body.to_string()).await; + assert_snapshot!(response.status(), @"200 OK"); + + assert!(!app.emails().await.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn token_user_with_incorrect_endpoint_scope() { + let (app, user) = prepare().await; + let token = user + .db_new_scoped_token( + "test-token", + None, + Some(vec![EndpointScope::PublishUpdate]), + None, + ) + .await; + + let url = format!("/api/v1/crates/{}", CRATE_NAME); + let body = serde_json::json!({ "trustpub_only": true }); + let response = token.patch::<()>(&url, body.to_string()).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#); + + assert_eq!(app.emails().await.len(), 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn token_user_with_only_crate_scope() { + let (app, user) = prepare().await; + let token = user + .db_new_scoped_token( + "test-token", + Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]), + None, + None, + ) + .await; + + let url = format!("/api/v1/crates/{}", CRATE_NAME); + let body = serde_json::json!({ "trustpub_only": true }); + let response = token.patch::<()>(&url, body.to_string()).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"This endpoint cannot be used with legacy API tokens. Use a scoped API token instead."}]}"#); + + assert_eq!(app.emails().await.len(), 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn token_user_with_incorrect_crate_scope() { + let (app, user) = prepare().await; + let token = user + .db_new_scoped_token( + "test-token", + Some(vec![CrateScope::try_from("bar").unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + None, + ) + .await; + + let url = format!("/api/v1/crates/{}", CRATE_NAME); + let body = serde_json::json!({ "trustpub_only": true }); + let response = token.patch::<()>(&url, body.to_string()).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#); + + assert_eq!(app.emails().await.len(), 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn token_user_with_both_scopes() { + let (app, user) = prepare().await; + let token = user + .db_new_scoped_token( + "test-token", + Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + None, + ) + .await; + + let url = format!("/api/v1/crates/{}", CRATE_NAME); + let body = serde_json::json!({ "trustpub_only": true }); + let response = token.patch::<()>(&url, body.to_string()).await; + assert_snapshot!(response.status(), @"200 OK"); + + assert!(!app.emails().await.is_empty()); + } +} diff --git a/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap b/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap index 44f0c3e181e..ed4e602eeb7 100644 --- a/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap +++ b/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap @@ -359,6 +359,10 @@ expression: response.json() "null" ] }, + "trustpub_only": { + "description": "Whether this crate can only be published via Trusted Publishing.", + "type": "boolean" + }, "updated_at": { "description": "The date and time this crate was last updated.", "example": "2019-12-13T13:46:41Z", @@ -394,7 +398,8 @@ expression: response.json() "max_version", "newest_version", "links", - "exact_match" + "exact_match", + "trustpub_only" ], "type": "object" }, @@ -907,6 +912,18 @@ expression: response.json() ], "type": "object" }, + "PatchRequest": { + "properties": { + "trustpub_only": { + "description": "Whether this crate can only be published via Trusted Publishing.", + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "PublishWarnings": { "properties": { "invalid_badges": { @@ -2250,6 +2267,63 @@ expression: response.json() "tags": [ "crates" ] + }, + "patch": { + "operationId": "update_crate", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "crate": { + "$ref": "#/components/schemas/Crate", + "description": "The updated crate metadata." + } + }, + "required": [ + "crate" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Update crate settings.", + "tags": [ + "crates" + ] } }, "/api/v1/crates/{name}/downloads": {