Skip to content

Implement PUT /api/v1/trusted_publishing/github_configs API endpoint #11113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ crates_io_pagerduty = { path = "crates/crates_io_pagerduty" }
crates_io_session = { path = "crates/crates_io_session" }
crates_io_tarball = { path = "crates/crates_io_tarball" }
crates_io_team_repo = { path = "crates/crates_io_team_repo" }
crates_io_trustpub = { path = "crates/crates_io_trustpub" }
crates_io_worker = { path = "crates/crates_io_worker" }
csv = "=1.3.1"
chrono = { version = "=0.4.41", default-features = false, features = ["serde"] }
Expand Down
1 change: 1 addition & 0 deletions crates/crates_io_database/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ pub mod krate;
mod owner;
pub mod team;
pub mod token;
pub mod trustpub;
pub mod user;
pub mod version;
37 changes: 37 additions & 0 deletions crates/crates_io_database/src/models/trustpub/github_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use crate::schema::trustpub_configs_github;
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use diesel_async::{AsyncPgConnection, RunQueryDsl};

#[derive(Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))]
pub struct GitHubConfig {
pub id: i32,
pub created_at: DateTime<Utc>,
pub crate_id: i32,
pub repository_owner: String,
pub repository_owner_id: i32,
pub repository_name: String,
pub workflow_filename: String,
pub environment: Option<String>,
}

#[derive(Debug, Insertable)]
#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))]
pub struct NewGitHubConfig<'a> {
pub crate_id: i32,
pub repository_owner: &'a str,
pub repository_owner_id: i32,
pub repository_name: &'a str,
pub workflow_filename: &'a str,
pub environment: Option<&'a str>,
}

impl NewGitHubConfig<'_> {
pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult<GitHubConfig> {
self.insert_into(trustpub_configs_github::table)
.returning(GitHubConfig::as_returning())
.get_result(conn)
.await
}
}
3 changes: 3 additions & 0 deletions crates/crates_io_database/src/models/trustpub/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mod github_config;

pub use self::github_config::{GitHubConfig, NewGitHubConfig};
10 changes: 10 additions & 0 deletions crates/crates_io_github/examples/test_github_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ enum Request {
#[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)]
access_token: SecretString,
},
GetUser {
name: String,
#[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)]
access_token: SecretString,
},
OrgByName {
org_name: String,
#[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)]
Expand Down Expand Up @@ -58,6 +63,11 @@ async fn main() -> Result<()> {
let response = github_client.current_user(&access_token).await?;
println!("{response:#?}");
}
Request::GetUser { name, access_token } => {
let access_token = AccessToken::new(access_token.expose_secret().into());
let response = github_client.get_user(&name, &access_token).await?;
println!("{response:#?}");
}
Request::OrgByName {
org_name,
access_token,
Expand Down
6 changes: 6 additions & 0 deletions crates/crates_io_github/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Result<T> = std::result::Result<T, GitHubError>;
#[async_trait]
pub trait GitHubClient: Send + Sync {
async fn current_user(&self, auth: &AccessToken) -> Result<GitHubUser>;
async fn get_user(&self, name: &str, auth: &AccessToken) -> Result<GitHubUser>;
async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result<GitHubOrganization>;
async fn team_by_name(
&self,
Expand Down Expand Up @@ -102,6 +103,11 @@ impl GitHubClient for RealGitHubClient {
self.request("/user", auth).await
}

async fn get_user(&self, name: &str, auth: &AccessToken) -> Result<GitHubUser> {
let url = format!("/users/{name}");
self.request(&url, auth).await
}

async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result<GitHubOrganization> {
let url = format!("/orgs/{org_name}");
self.request(&url, auth).await
Expand Down
16 changes: 16 additions & 0 deletions crates/crates_io_trustpub/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "crates_io_trustpub"
version = "0.0.0"
license = "MIT OR Apache-2.0"
edition = "2024"

[lints]
workspace = true

[dependencies]
regex = "=1.11.1"
thiserror = "=2.0.12"

[dev-dependencies]
claims = "=0.8.0"
insta = "=1.43.1"
3 changes: 3 additions & 0 deletions crates/crates_io_trustpub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# crates_io_trustpub

This crate contains code related to the "[Trusted Publishing](https://github.com/rust-lang/rfcs/pull/3691)" feature of crates.io.
1 change: 1 addition & 0 deletions crates/crates_io_trustpub/src/github/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod validation;
154 changes: 154 additions & 0 deletions crates/crates_io_trustpub/src/github/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use std::sync::LazyLock;

const MAX_FIELD_LENGTH: usize = 255;

#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("GitHub repository owner name may not be empty")]
OwnerEmpty,
#[error("GitHub repository owner name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
OwnerTooLong,
#[error("Invalid GitHub repository owner name")]
OwnerInvalid,

#[error("GitHub repository name may not be empty")]
RepoEmpty,
#[error("GitHub repository name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
RepoTooLong,
#[error("Invalid GitHub repository name")]
RepoInvalid,

#[error("Workflow filename may not be empty")]
WorkflowFilenameEmpty,
#[error("Workflow filename is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
WorkflowFilenameTooLong,
#[error("Workflow filename must end with `.yml` or `.yaml`")]
WorkflowFilenameMissingSuffix,
#[error("Workflow filename must be a filename only, without directories")]
WorkflowFilenameContainsSlash,

#[error("Environment name may not be empty (use `null` to omit)")]
EnvironmentEmptyString,
#[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")]
EnvironmentTooLong,
#[error("Environment name may not start with whitespace")]
EnvironmentStartsWithWhitespace,
#[error("Environment name may not end with whitespace")]
EnvironmentEndsWithWhitespace,
#[error(r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#)]
EnvironmentInvalidChars,
}

pub fn validate_owner(owner: &str) -> Result<(), ValidationError> {
static RE_VALID_GITHUB_OWNER: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$").unwrap());

if owner.is_empty() {
Err(ValidationError::OwnerEmpty)
} else if owner.len() > MAX_FIELD_LENGTH {
Err(ValidationError::OwnerTooLong)
} else if !RE_VALID_GITHUB_OWNER.is_match(owner) {
Err(ValidationError::OwnerInvalid)
Comment on lines +43 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to cover the following cases:

  1. Ends with -
  2. Length exceeds 39

} else {
Ok(())
}
}

pub fn validate_repo(repo: &str) -> Result<(), ValidationError> {
static RE_VALID_GITHUB_REPO: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9-_.]+$").unwrap());

if repo.is_empty() {
Err(ValidationError::RepoEmpty)
} else if repo.len() > MAX_FIELD_LENGTH {
Err(ValidationError::RepoTooLong)
} else if !RE_VALID_GITHUB_REPO.is_match(repo) {
Err(ValidationError::RepoInvalid)
Comment on lines +58 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to cover the following cases:

  1. Reserved names . and ..
  2. Length exceeds 100

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the currently proposed regex might be sufficient, as the regex crate doesn't support lookaround features. If we'd like to harden this part, although there might be some workarounds for lookaround, I think it would be best implemented with simple string-related checks for maintenance reasons.

} else {
Ok(())
}
}

pub fn validate_workflow_filename(filename: &str) -> Result<(), ValidationError> {
if filename.is_empty() {
Err(ValidationError::WorkflowFilenameEmpty)
} else if filename.len() > MAX_FIELD_LENGTH {
Err(ValidationError::WorkflowFilenameTooLong)
} else if !filename.ends_with(".yml") && !filename.ends_with(".yaml") {
Err(ValidationError::WorkflowFilenameMissingSuffix)
} else if filename.contains('/') {
Err(ValidationError::WorkflowFilenameContainsSlash)
} else {
Ok(())
}
}

pub fn validate_environment(env: &str) -> Result<(), ValidationError> {
static RE_INVALID_ENVIRONMENT_CHARS: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"[\x00-\x1F\x7F'"`,;\\]"#).unwrap());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm betting you had fun figuring out what "non-printable character" meant in this context (from the GitHub error).


if env.is_empty() {
Err(ValidationError::EnvironmentEmptyString)
} else if env.len() > MAX_FIELD_LENGTH {
Err(ValidationError::EnvironmentTooLong)
} else if env.starts_with(" ") {
Err(ValidationError::EnvironmentStartsWithWhitespace)
} else if env.ends_with(" ") {
Err(ValidationError::EnvironmentEndsWithWhitespace)
} else if RE_INVALID_ENVIRONMENT_CHARS.is_match(env) {
Err(ValidationError::EnvironmentInvalidChars)
} else {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use claims::assert_err;
use insta::assert_snapshot;

#[test]
fn test_validate_owner() {
assert_snapshot!(assert_err!(validate_owner("")), @"GitHub repository owner name may not be empty");
assert_snapshot!(assert_err!(validate_owner(&"x".repeat(256))), @"GitHub repository owner name is too long (maximum is 255 characters)");
assert_snapshot!(assert_err!(validate_owner("invalid_characters@")), @"Invalid GitHub repository owner name");
}

#[test]
fn test_validate_repo() {
assert_snapshot!(assert_err!(validate_repo("")), @"GitHub repository name may not be empty");
assert_snapshot!(assert_err!(validate_repo(&"x".repeat(256))), @"GitHub repository name is too long (maximum is 255 characters)");
assert_snapshot!(assert_err!(validate_repo("$invalid#characters")), @"Invalid GitHub repository name");
}

#[test]
fn test_validate_workflow_filename() {
assert_snapshot!(assert_err!(validate_workflow_filename("")), @"Workflow filename may not be empty");
assert_snapshot!(assert_err!(validate_workflow_filename(&"x".repeat(256))), @"Workflow filename is too long (maximum is 255 characters)");
assert_snapshot!(assert_err!(validate_workflow_filename("missing_suffix")), @"Workflow filename must end with `.yml` or `.yaml`");
assert_snapshot!(assert_err!(validate_workflow_filename("/slash")), @"Workflow filename must end with `.yml` or `.yaml`");
assert_snapshot!(assert_err!(validate_workflow_filename("/many/slashes")), @"Workflow filename must end with `.yml` or `.yaml`");
assert_snapshot!(assert_err!(validate_workflow_filename("/slash.yml")), @"Workflow filename must be a filename only, without directories");
}

#[test]
fn test_validate_environment() {
assert_snapshot!(assert_err!(validate_environment("")), @"Environment name may not be empty (use `null` to omit)");
assert_snapshot!(assert_err!(validate_environment(&"x".repeat(256))), @"Environment name is too long (maximum is 255 characters)");
assert_snapshot!(assert_err!(validate_environment(" foo")), @"Environment name may not start with whitespace");
assert_snapshot!(assert_err!(validate_environment("foo ")), @"Environment name may not end with whitespace");
assert_snapshot!(assert_err!(validate_environment("'")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\"")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("`")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment(",")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment(";")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\\")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\x00")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\x1f")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\x7f")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\t")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\r")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
assert_snapshot!(assert_err!(validate_environment("\n")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#);
}
}
3 changes: 3 additions & 0 deletions crates/crates_io_trustpub/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#![doc = include_str!("../README.md")]

pub mod github;
1 change: 1 addition & 0 deletions src/controllers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ pub mod site_metadata;
pub mod summary;
pub mod team;
pub mod token;
pub mod trustpub;
pub mod user;
pub mod version;
Loading