Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6dabebc
disambiguate username
moskirathe Jul 12, 2026
f3ea0da
owner add tests
moskirathe Jul 12, 2026
8be3e00
remove owner disambiguation
moskirathe Jul 12, 2026
d6a9e22
remove owner tests
moskirathe Jul 12, 2026
ce2762c
update all relevant uses of gh_login to username
moskirathe Jul 12, 2026
5928cda
snapshot fix
moskirathe Jul 12, 2026
5870c0c
DRY disambiguate implementation
moskirathe Jul 12, 2026
ede90c5
fix formatting, minor updates
moskirathe Jul 12, 2026
e5a6ffe
fix formatting
moskirathe Jul 12, 2026
d4f4876
disambiguate using oauth_github.login rather than users.gh_login
moskirathe Jul 12, 2026
ff2f94a
split out refactoring changes
moskirathe Jul 13, 2026
b1a679f
minor updates to split out refactoring from behavioural changes
moskirathe Jul 13, 2026
4d9231b
update comments; parse login before owner check; update parse login f…
moskirathe Jul 19, 2026
584bc46
owner remove error conversion
moskirathe Jul 19, 2026
7b4abe0
index migrations for username and gh login fields
moskirathe Jul 20, 2026
2050824
update error message
moskirathe Jul 20, 2026
3dd13a2
minor updates
moskirathe Jul 20, 2026
6eb1a81
test updates
moskirathe Jul 20, 2026
feff924
tests
moskirathe Jul 20, 2026
ce90cd2
rebase and resolve merge conflicts
moskirathe Jul 20, 2026
b9d8348
minor formatting updates
moskirathe Jul 20, 2026
2afe497
regenerate schema, fix migration to run concurrently
moskirathe Jul 20, 2026
eb38559
fix migration
moskirathe Jul 21, 2026
3a924cb
fix migrations
moskirathe Jul 21, 2026
a827d1a
remove unecessary oauth_github lookup since its included in left join
moskirathe Jul 23, 2026
5eed9fa
minor comment update
moskirathe Jul 25, 2026
644e9e0
canonicalization fixes and tests, resolve ambigous login first before…
moskirathe Aug 4, 2026
b09e6a3
minor update
moskirathe Aug 4, 2026
33d0069
fix lint
moskirathe Aug 4, 2026
d42af5a
minor update
moskirathe Aug 5, 2026
c1091af
minor update
moskirathe Aug 5, 2026
35131d3
remove unused migration added in a separate pr
moskirathe Aug 8, 2026
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
49 changes: 45 additions & 4 deletions crates/crates_io_database/src/models/krate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ impl Crate {
Ok(users.chain(teams).collect())
}

pub async fn owner_remove(
/// Remove owner given a cratesio username.
pub async fn owner_remove_with_username(
&self,
mut conn: &AsyncPgConnection,
login: &str,
Expand All @@ -225,7 +226,7 @@ impl Crate {
CASE WHEN crate_owners.owner_kind = 1 THEN
teams.login
ELSE
users.gh_login
users.username
END AS login
FROM crate_owners
LEFT JOIN teams
Expand All @@ -243,7 +244,47 @@ impl Crate {
WHERE crate_owners.crate_id = crate_owners_with_login.crate_id
AND crate_owners.owner_id = crate_owners_with_login.owner_id
AND crate_owners.owner_kind = crate_owners_with_login.owner_kind
AND lower(crate_owners_with_login.login) = lower($2);"#,
AND canon_username(crate_owners_with_login.login) = canon_username($2);"#,
);

let num_updated_rows = query
.bind::<Integer, _>(self.id)
.bind::<Text, _>(login)
.execute(&mut conn)
.await?;

if num_updated_rows == 0 {
return Err(OwnerRemoveError::not_found(login));
}

Ok(())
}

/// Remove owner given a github username.
pub async fn owner_remove_with_gh_login(
&self,
mut conn: &AsyncPgConnection,
login: &str,
) -> Result<(), OwnerRemoveError> {
let query = diesel::sql_query(
r#"WITH crate_owners_with_gh_login AS (
SELECT
crate_owners.*,
login
FROM crate_owners
JOIN oauth_github
ON crate_owners.owner_id = oauth_github.user_id
AND crate_owners.owner_kind = 0
WHERE crate_owners.crate_id = $1
AND crate_owners.deleted = false
)
UPDATE crate_owners
SET deleted = true
FROM crate_owners_with_gh_login
WHERE crate_owners.crate_id = crate_owners_with_gh_login.crate_id
AND crate_owners.owner_id = crate_owners_with_gh_login.owner_id
AND crate_owners.owner_kind = crate_owners_with_gh_login.owner_kind
AND canon_username(crate_owners_with_gh_login.login) = canon_username($2);"#,
);

let num_updated_rows = query
Expand All @@ -265,7 +306,7 @@ impl Crate {
pub enum NewOwnerInvite {
/// The invitee was a [`User`], and they must accept the invite through the
/// UI or via the provided invite token.
User(User, SecretString),
User(User, SecretString, String),

/// The invitee was a [`Team`], and they were immediately added as an owner.
Team(Team),
Expand Down
3 changes: 2 additions & 1 deletion crates/crates_io_database/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ pub use self::owner::{CrateOwner, Owner, OwnerKind};
pub use self::team::{NewTeam, Team};
pub use self::token::ApiToken;
pub use self::trustpub::TrustpubData;
pub use self::user::{NewOauthGithub, NewUser, OauthGithub, PublicUser, User};
pub use self::user::{NewOauthGithub, OauthGithub};
pub use self::user::{NewUser, PublicUser, User};
pub use self::version::{NewVersion, TopVersions, Version};

pub mod helpers;
Expand Down
11 changes: 9 additions & 2 deletions crates/crates_io_database/src/models/owner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,20 @@ impl Owner {
}
}

pub fn login(&self) -> &str {
pub fn username(&self) -> &str {
match self {
Owner::User(user) => &user.gh_login,
Owner::User(user) => &user.username,
Owner::Team(team) => &team.login,
}
}

pub fn gh_login(&self) -> Option<&str> {
match self {
Owner::User(user) => user.gh_username.as_deref(),
Owner::Team(team) => Some(&team.login),
}
}

pub fn id(&self) -> i32 {
match self {
Owner::User(user) => user.id,
Expand Down
77 changes: 74 additions & 3 deletions crates/crates_io_database/src/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use diesel::upsert::excluded;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use serde::Serialize;

use crate::fns::lower;
use crate::fns::canon_username;
use crate::models::{Crate, CrateOwner, Email, OwnerKind};
use crate::schema::{crate_owners, emails, oauth_github, users};

Expand Down Expand Up @@ -49,6 +49,10 @@ pub struct User {
pub name: Option<String>,
pub gh_id: i32,
pub gh_login: String,
// This is the same as gh_login, but reads from oauth_github instead.
// Can rename to `gh_login` or something more appropriate when gh_login is removed from this struct.
#[diesel(select_expression = oauth_github::login.nullable())]
pub gh_username: Option<String>,

@moskirathe moskirathe Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is the same as gh_login but reading from oauth_github instead of the users table. I considered a change to stop reading from users.gh_login and start reading from oauth_github.login throughout the codebase instead (similar to #14258), but I think that would be a much bigger change that would warrant being tackled in a separate pr

View changes since the review

#[diesel(select_expression = oauth_github::avatar.nullable())]
pub gh_avatar: Option<String>,
#[diesel(select_expression = oauth_github::encrypted_token.nullable())]
Expand All @@ -70,9 +74,12 @@ impl User {
.await
}

pub async fn find_by_login(mut conn: &AsyncPgConnection, login: &str) -> QueryResult<User> {
pub async fn find_by_username(
mut conn: &AsyncPgConnection,
username: &str,
) -> QueryResult<User> {
User::query()
.filter(lower(users::gh_login).eq(login.to_lowercase()))
.filter(canon_username(users::username).eq(canon_username(username)))
.filter(users::gh_id.ne(-1))
.order(users::gh_id.desc())
.first(&mut conn)
Expand Down Expand Up @@ -188,6 +195,20 @@ pub struct OauthGithub {
pub user_id: i32,
}

impl OauthGithub {
pub async fn find_by_login(
mut conn: &AsyncPgConnection,
login: &str,
) -> QueryResult<OauthGithub> {
oauth_github::table
.filter(canon_username(oauth_github::login).eq(canon_username(login)))
.filter(oauth_github::account_id.ne(-1))
.order(oauth_github::account_id.desc())
.first(&mut conn)
.await
}
}

/// Represents a new crates.io user to GitHub user OAuth link to be inserted into the
/// `oauth_github` table.
#[derive(Insertable, Debug, Builder)]
Expand Down Expand Up @@ -217,3 +238,53 @@ impl NewOauthGithub<'_> {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crates_io_test_db::TestDatabase;

async fn insert_user(
conn: &AsyncPgConnection,
username: &str,
gh_login: &str,
gh_id: i32,
) -> QueryResult<i32> {
let user_id = NewUser::builder()
.gh_id(gh_id)
.gh_login(gh_login)
.username(username)
.build()
.insert(conn)
.await?;

NewOauthGithub::builder()
.account_id(gh_id as i64)
.encrypted_token(&[])
.login(gh_login)
.user_id(user_id)
.build()
.insert(conn)
.await?;

Ok(user_id)
}

#[tokio::test]
async fn test_find_by_login_returns_highest_account_id_account() {
let test_db = TestDatabase::new();
let conn = test_db.async_connect().await;

insert_user(&conn, "alice", "alice", 100).await.unwrap();
let user_id = insert_user(&conn, "alice", "Alice", 200).await.unwrap();

// case-insensitive checks
for login in ["alice", "Alice", "ALICE"] {
let user = OauthGithub::find_by_login(&conn, login).await.unwrap();

assert_eq!(user.account_id, 200);
assert_eq!(user.user_id, user_id);
assert_eq!(user.login, "Alice");
}
}
}
36 changes: 18 additions & 18 deletions crates/crates_io_database/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,24 @@ diesel::table! {
}
}

diesel::table! {
/// Representation of the `recent_crate_downloads` view.
///
/// This data represents the downloads in the last 90 days.
/// This view does not contain realtime data.
/// It is refreshed by the `update-downloads` script.
recent_crate_downloads (crate_id) {
/// The `crate_id` column of the `recent_crate_downloads` view.
///
/// Its SQL type is `Integer`.
crate_id -> Integer,
/// The `downloads` column of the `recent_crate_downloads` table.
///
/// Its SQL type is `BigInt`.
downloads -> BigInt,
}
}

diesel::table! {
use diesel::sql_types::*;
use diesel_full_text_search::Tsvector;
Expand All @@ -791,24 +809,6 @@ diesel::table! {
}
}

diesel::table! {
/// Representation of the `recent_crate_downloads` view.
///
/// This data represents the downloads in the last 90 days.
/// This view does not contain realtime data.
/// It is refreshed by the `update-downloads` script.
recent_crate_downloads (crate_id) {
/// The `crate_id` column of the `recent_crate_downloads` view.
///
/// Its SQL type is `Integer`.
crate_id -> Integer,
/// The `downloads` column of the `recent_crate_downloads` table.
///
/// Its SQL type is `BigInt`.
downloads -> BigInt,
}
}

diesel::table! {
use diesel::sql_types::*;
use diesel_full_text_search::Tsvector;
Expand Down
21 changes: 18 additions & 3 deletions crates/crates_io_test_utils/src/builders/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ static ENCRYPTED_TOKEN: LazyLock<Vec<u8>> = LazyLock::new(|| {
pub struct UserBuilder<'a> {
username: &'a str,
display_name: Option<&'a str>,
gh_login: &'a str,
}

impl<'a> UserBuilder<'a> {
Expand All @@ -32,11 +33,16 @@ impl<'a> UserBuilder<'a> {
Self {
username: "octocat",
display_name: None,
gh_login: "octocat",
}
}

pub fn with_username(self, username: &'a str) -> Self {
Self { username, ..self }
Self {
username,
gh_login: username,
..self
}
}

pub fn with_display_name(self, display_name: &'a str) -> Self {
Expand All @@ -46,11 +52,16 @@ impl<'a> UserBuilder<'a> {
}
}

pub fn with_gh_login(self, gh_login: &'a str) -> Self {
Self { gh_login, ..self }
}

pub fn build(self) -> User {
User {
id: 1,
gh_login: self.username.into(),
name: self.display_name.map(ToString::to_string),
gh_login: self.gh_login.into(),
gh_username: Some(self.gh_login.into()),
gh_id: 123,
gh_avatar: None,
gh_encrypted_token: None,
Expand All @@ -66,7 +77,7 @@ impl<'a> UserBuilder<'a> {
pub fn new_user(self) -> NewUser<'a> {
NewUser::builder()
.gh_id(next_gh_id())
.gh_login(self.username)
.gh_login(self.gh_login)
.username(self.username)
.maybe_name(self.display_name)
.build()
Expand Down Expand Up @@ -106,6 +117,10 @@ impl<'a> OauthGithubBuilder<'a> {
}
}

pub fn with_login(self, login: &'a str) -> Self {
Self { login, ..self }
}

pub async fn insert(self, mut conn: &AsyncPgConnection) {
diesel::insert_into(oauth_github::table)
.values((
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS index_oauth_github_login;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
run_in_transaction = false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS index_oauth_github_login ON oauth_github (lower(login));
14 changes: 12 additions & 2 deletions packages/crates-io-api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2673,9 +2673,14 @@ export interface operations {
*
* For users, use just the username (e.g., `"octocat"`).
* For GitHub teams, use the format `github:org:team` (e.g., `"github:rust-lang:owners"`).
*
* To disambiguate between crates.io and GitHub usernames, use
* the `cratesio:username` or `github:username` prefix.
* @example [
* "octocat",
* "github:rust-lang:owners"
* "github:rust-lang:owners",
* "cratesio:some_user",
* "github:other_user"
* ]
*/
owners: string[];
Expand Down Expand Up @@ -2720,9 +2725,14 @@ export interface operations {
*
* For users, use just the username (e.g., `"octocat"`).
* For GitHub teams, use the format `github:org:team` (e.g., `"github:rust-lang:owners"`).
*
* To disambiguate between crates.io and GitHub usernames, use
* the `cratesio:username` or `github:username` prefix.
* @example [
* "octocat",
* "github:rust-lang:owners"
* "github:rust-lang:owners",
* "cratesio:some_user",
* "github:other_user"
* ]
*/
owners: string[];
Expand Down
4 changes: 2 additions & 2 deletions src/bin/crates-io/admin/delete_crate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub struct Opts {
#[arg(short, long)]
yes: bool,

/// Your GitHub username.
/// Your crates.io username.
#[arg(long)]
deleted_by: String,

Expand Down Expand Up @@ -59,7 +59,7 @@ pub async fn run(opts: Opts) -> anyhow::Result<()> {
.await
.context("Failed to look up crate name from the database")?;

let deleted_by = User::find_by_login(&conn, &opts.deleted_by)
let deleted_by = User::find_by_username(&conn, &opts.deleted_by)
.await
.context("Failed to look up `--deleted-by` user from the database")?;

Expand Down
Loading