From b819b9136de428d47082608c0918485ac80bd441 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Sat, 24 Feb 2024 19:58:36 +0000 Subject: [PATCH] Add bust-cache command --- CHANGELOG.md | 1 + src/main.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------- src/command/bust_cache.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/command/mod.rs | 2 ++ src/git_command_handlers/fetch.rs | 47 ----------------------------------------------- src/git_command_handlers/ls_refs.rs | 31 ------------------------------- src/git_command_handlers/mod.rs | 2 -- src/providers/gitlab.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/providers/mod.rs | 9 +++++++++ src/command/git_upload_pack/fetch.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/command/git_upload_pack/ls_refs.rs | 31 +++++++++++++++++++++++++++++++ src/command/git_upload_pack/mod.rs | 2 ++ 12 files changed, 267 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e43ad8..8860798 100644 --- a/CHANGELOG.md +++ a/CHANGELOG.md @@ -9,6 +9,7 @@ - Added crate eligibility cache. - Introduce configurable cache backend with a RocksDB implementation (set `cache.type = "rocksdb"` and `cache.path = "cache"` to use it), defaults to `cache.type = "in-memory"`. - Support crate yanking by creating a `yanked` file on the release. +- Add `bust-cache` command, invoked via `ssh [registry] -- bust-cache [project] [crate-name] [crate-version]` to remove eligibility cache (ie. after a crate has been yanked) # v0.1.4 diff --git a/src/main.rs b/src/main.rs index f010a4c..11092fa 100644 --- a/src/main.rs +++ a/src/main.rs @@ -6,8 +6,8 @@ )] pub mod cache; +pub mod command; pub mod config; -pub mod git_command_handlers; pub mod metadata; pub mod providers; pub mod util; @@ -19,7 +19,7 @@ providers::{gitlab::Gitlab, PackageProvider, Release, User, UserProvider}, util::get_crate_folder, }; -use anyhow::anyhow; +use anyhow::{anyhow, bail}; use bytes::{BufMut, Bytes, BytesMut}; use clap::Parser; use futures::{stream::FuturesOrdered, Future, StreamExt}; @@ -485,7 +485,7 @@ match frame.command.as_ref() { b"command=ls-refs" => { - git_command_handlers::ls_refs::handle( + command::git_upload_pack::ls_refs::handle( &mut self, &mut session, channel, @@ -494,7 +494,7 @@ )?; } b"command=fetch" => { - git_command_handlers::fetch::handle( + command::git_upload_pack::fetch::handle( &mut self, &mut session, channel, @@ -583,40 +583,55 @@ let mut args = args.into_iter().flat_map(Vec::into_iter); - // check the executable requested to be ran is the `git-upload-pack` we - // expect. we're not actually going to execute this, but we'll pretend - // to be it instead in `data`. - if args.next().as_deref() != Some("git-upload-pack") { - anyhow::bail!("not git-upload-pack"); - } + match args.next().as_deref() { + Some("git-upload-pack") => { + // check the executable requested to be ran is the `git-upload-pack` we + // expect. we're not actually going to execute this, but we'll pretend + // to be it instead in `data`. + if args.next().as_deref() != Some("git-upload-pack") { + anyhow::bail!("not git-upload-pack"); + } - // parse the requested project from the given path (the argument - // given to `git-upload-pack`) - let arg = args.next(); - if let Some(project) = arg.as_deref() - .filter(|v| *v != "/") - .map(|project| project.trim_start_matches('/').trim_end_matches('/')) - .filter(|project| project.contains('/')) - { - self.project = Some(Arc::from(project.to_string())); - } else { - session.extended_data(channel, 1, CryptoVec::from_slice(indoc::indoc! {b" + // parse the requested project from the given path (the argument + // given to `git-upload-pack`) + let arg = args.next(); + if let Some(project) = arg.as_deref() + .filter(|v| *v != "/") + .map(|project| project.trim_start_matches('/').trim_end_matches('/')) + .filter(|project| project.contains('/')) + { + self.project = Some(Arc::from(project.to_string())); + } else { + session.extended_data(channel, 1, CryptoVec::from_slice(indoc::indoc! {b" \r\nNo project was given in the path part of the SSH URI. A GitLab group and project should be defined in your .cargo/config.toml as follows: [registries] my-project = {{ index = \"ssh://domain.to.registry.com/my-group/my-project\" }}\r\n "})); - session.close(channel); - } + session.close(channel); + } - // preamble, sending our capabilities and what have you - self.write(PktLine::Data(b"version 2\n"))?; - self.write(PktLine::Data(AGENT.as_bytes()))?; - self.write(PktLine::Data(b"ls-refs=unborn\n"))?; - self.write(PktLine::Data(b"fetch=shallow wait-for-done\n"))?; - self.write(PktLine::Data(b"server-option\n"))?; - self.write(PktLine::Data(b"object-info\n"))?; - self.write(PktLine::Flush)?; - self.flush(&mut session, channel); + // preamble, sending our capabilities and what have you + self.write(PktLine::Data(b"version 2\n"))?; + self.write(PktLine::Data(AGENT.as_bytes()))?; + self.write(PktLine::Data(b"ls-refs=unborn\n"))?; + self.write(PktLine::Data(b"fetch=shallow wait-for-done\n"))?; + self.write(PktLine::Data(b"server-option\n"))?; + self.write(PktLine::Data(b"object-info\n"))?; + self.write(PktLine::Flush)?; + self.flush(&mut session, channel); + } + Some("bust-cache") => { + if let Err(e) = command::bust_cache::handle(&mut self, &mut session, channel, args).await { + session.data( + channel, + CryptoVec::from(e.to_string()), + ); + session.exit_status_request(channel, 1); + session.close(channel); + } + } + _ => bail!("invalid command"), + } Ok((self, session)) }).instrument(span)) diff --git a/src/command/bust_cache.rs b/src/command/bust_cache.rs new file mode 100644 index 0000000..f000a37 100644 --- /dev/null +++ a/src/command/bust_cache.rs @@ -1,0 +1,45 @@ +use crate::{ + providers::{PackageProvider, UserProvider}, + Handler, +}; +use anyhow::{bail, Context}; +use thrussh::server::Session; +use thrussh::{ChannelId, CryptoVec}; +use tracing::instrument; + +#[instrument(skip_all, err)] +pub async fn handle( + handle: &mut Handler, + session: &mut Session, + channel: ChannelId, + mut params: impl Iterator, +) -> Result<(), anyhow::Error> { + let (Some(project), Some(crate_name), Some(version)) = + (params.next(), params.next(), params.next()) + else { + bail!("usage: bust-cache [gitlab project] [crate name] [version]"); + }; + + if !handle + .gitlab + .is_project_maintainer(handle.user()?, &project) + .await + .context("Failed to check project maintainer status")? + { + bail!("This command can only be ran by project maintainers"); + } + + handle + .gitlab + .bust_cache(&project, &crate_name, &version) + .await?; + + session.data( + channel, + CryptoVec::from_slice("Successfully bust cache for release.".as_bytes()), + ); + session.exit_status_request(channel, 0); + session.close(channel); + + Ok(()) +} diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 0000000..ac69f01 100644 --- /dev/null +++ a/src/command/mod.rs @@ -1,0 +1,2 @@ +pub mod bust_cache; +pub mod git_upload_pack; diff --git a/src/git_command_handlers/fetch.rs b/src/git_command_handlers/fetch.rs deleted file mode 100644 index fe72273..0000000 100644 --- a/src/git_command_handlers/fetch.rs +++ /dev/null @@ -1,47 +1,0 @@ -use bytes::Bytes; -use packfile::{ - low_level::{PackFile, PackFileEntry}, - PktLine, -}; -use thrussh::{server::Session, ChannelId}; -use tracing::instrument; - -use crate::{Handler, PackageProvider, UserProvider}; - -#[instrument(skip(handle, session, channel, metadata, packfile_entries), err)] -pub fn handle( - handle: &mut Handler, - session: &mut Session, - channel: ChannelId, - metadata: &[Bytes], - packfile_entries: &[PackFileEntry], -) -> Result<(), anyhow::Error> { - // the client sending us `done` in the metadata means they know there's no negotiation - // required for which commits we need to send, they just want us to send whatever we - // have. - let done = metadata.iter().any(|v| v.as_ref() == b"done"); - - // the client thinks we can negotiate some commits with them, but we don't want to so - // we'll just say we've got nothing in common and continue on as we were. - if !done { - handle.write(PktLine::Data(b"acknowledgments\n"))?; - handle.write(PktLine::Data(b"ready\n"))?; - handle.write(PktLine::Delimiter)?; - } - - // magic header - handle.write(PktLine::Data(b"packfile\n"))?; - - // send the complete packfile - let packfile = PackFile::new(packfile_entries); - handle.write(PktLine::SidebandData(packfile))?; - handle.write(PktLine::Flush)?; - handle.flush(session, channel); - - // tell the client we exited successfully and close the channel - session.exit_status_request(channel, 0); - session.eof(channel); - session.close(channel); - - Ok(()) -} diff --git a/src/git_command_handlers/ls_refs.rs b/src/git_command_handlers/ls_refs.rs deleted file mode 100644 index 81a68ae..0000000 100644 --- a/src/git_command_handlers/ls_refs.rs +++ /dev/null @@ -1,31 +1,0 @@ -//! [ls-refs][lsr] is sent from the client when they want to see what refs we have -//! on the server, we're generating our commits on the fly though so we'll just tell -//! them we have a master branch with whatever the generated commit hash is. -//! -//! [lsr]: https://git-scm.com/docs/protocol-v2/2.19.0#_ls_refs - -use bytes::Bytes; -use packfile::{low_level::HashOutput, PktLine}; -use thrussh::{server::Session, ChannelId}; -use tracing::instrument; - -use crate::{Handler, PackageProvider, UserProvider}; - -#[instrument(skip(handle, session, channel, _metadata, commit_hash), err)] -pub fn handle( - handle: &mut Handler, - session: &mut Session, - channel: ChannelId, - _metadata: &[Bytes], - commit_hash: &HashOutput, -) -> Result<(), anyhow::Error> { - let commit_hash = hex::encode(commit_hash); - - handle.write(PktLine::Data( - format!("{commit_hash} HEAD symref-target:refs/heads/master").as_bytes(), - ))?; - handle.write(PktLine::Flush)?; - handle.flush(session, channel); - - Ok(()) -} diff --git a/src/git_command_handlers/mod.rs b/src/git_command_handlers/mod.rs deleted file mode 100644 index 8406cdb..0000000 100644 --- a/src/git_command_handlers/mod.rs +++ /dev/null @@ -1,2 +1,0 @@ -pub mod fetch; -pub mod ls_refs; diff --git a/src/providers/gitlab.rs b/src/providers/gitlab.rs index 897b80e..572f04e 100644 --- a/src/providers/gitlab.rs +++ a/src/providers/gitlab.rs @@ -240,6 +240,28 @@ Ok(impersonation_token.token) } + + /// Checks if the user is a maintainer of the given project. + #[instrument(skip(self), err)] + async fn is_project_maintainer(&self, do_as: &User, project: &str) -> anyhow::Result { + let uri = self.base_url.join(&format!( + "projects/{}", + utf8_percent_encode(project, NON_ALPHANUMERIC), + ))?; + + let result: GitlabProject = handle_error( + self.client + .get(uri) + .user_or_admin_token(do_as, &self.admin_token) + .send_retry_429() + .await?, + ) + .await? + .json() + .await?; + + Ok(result.permissions.access_level() >= GitlabProjectAccess::MAINTAINER_ACCESS_LEVEL) + } } #[async_trait] @@ -318,6 +340,23 @@ .filter_map(|v| async move { v.map(Result::ok).transpose() }) .try_collect() .await + } + + /// Removes the given release from the cache. + async fn bust_cache( + &self, + project: &str, + crate_name: &str, + crate_version: &str, + ) -> anyhow::Result<()> { + self.cache + .remove::>>(EligibilityCacheKey::new( + project, + crate_name, + crate_version, + )) + .await?; + Ok(()) } #[instrument(skip(self), err)] @@ -367,7 +406,51 @@ let msg = json.message.or(json.error).unwrap_or(text); anyhow::bail!("{url}: {status}: {msg}") + } +} + +/// The result of a `/project/[project]` call to GitLab. +#[derive(Debug, Deserialize)] +pub struct GitlabProject { + /// The user's permissions to the current project. + pub permissions: GitlabProjectPermissions, +} + +/// The user's permissions to a project. +#[derive(Debug, Deserialize)] +pub struct GitlabProjectPermissions { + /// The access granted to this project via direct project permissions. + #[serde(default)] + pub project_access: GitlabProjectAccess, + /// The access granted to this project via group permissions. + #[serde(default)] + pub group_access: GitlabProjectAccess, +} + +impl GitlabProjectPermissions { + /// Grabs the highest access the user has to the project via either direct permissions or + /// group permissions. + #[must_use] + pub fn access_level(&self) -> u8 { + std::cmp::max( + self.project_access.access_level, + self.group_access.access_level, + ) } +} + +/// The user's access level to a project. +#[derive(Debug, Deserialize, Default)] +pub struct GitlabProjectAccess { + /// See + access_level: u8, +} + +impl GitlabProjectAccess { + /// Any users with access above this level are considered maintainers. + /// + /// See: + pub const MAINTAINER_ACCESS_LEVEL: u8 = 40; } #[derive(Default, Deserialize)] diff --git a/src/providers/mod.rs b/src/providers/mod.rs index f110d11..388c606 100644 --- a/src/providers/mod.rs +++ a/src/providers/mod.rs @@ -15,6 +15,8 @@ username_password: &str, ) -> anyhow::Result>; + async fn is_project_maintainer(&self, do_as: &User, project: &str) -> anyhow::Result; + async fn find_user_by_ssh_key(&self, fingerprint: &str) -> anyhow::Result>; async fn fetch_token_for_user(&self, user: &User) -> anyhow::Result; @@ -39,6 +41,13 @@ version: &str, do_as: &Arc, ) -> anyhow::Result; + + async fn bust_cache( + &self, + project: &str, + crate_name: &str, + crate_version: &str, + ) -> anyhow::Result<()>; fn cargo_dl_uri(&self, project: &str, token: &str) -> anyhow::Result; } diff --git a/src/command/git_upload_pack/fetch.rs b/src/command/git_upload_pack/fetch.rs new file mode 100644 index 0000000..fe72273 100644 --- /dev/null +++ a/src/command/git_upload_pack/fetch.rs @@ -1,0 +1,47 @@ +use bytes::Bytes; +use packfile::{ + low_level::{PackFile, PackFileEntry}, + PktLine, +}; +use thrussh::{server::Session, ChannelId}; +use tracing::instrument; + +use crate::{Handler, PackageProvider, UserProvider}; + +#[instrument(skip(handle, session, channel, metadata, packfile_entries), err)] +pub fn handle( + handle: &mut Handler, + session: &mut Session, + channel: ChannelId, + metadata: &[Bytes], + packfile_entries: &[PackFileEntry], +) -> Result<(), anyhow::Error> { + // the client sending us `done` in the metadata means they know there's no negotiation + // required for which commits we need to send, they just want us to send whatever we + // have. + let done = metadata.iter().any(|v| v.as_ref() == b"done"); + + // the client thinks we can negotiate some commits with them, but we don't want to so + // we'll just say we've got nothing in common and continue on as we were. + if !done { + handle.write(PktLine::Data(b"acknowledgments\n"))?; + handle.write(PktLine::Data(b"ready\n"))?; + handle.write(PktLine::Delimiter)?; + } + + // magic header + handle.write(PktLine::Data(b"packfile\n"))?; + + // send the complete packfile + let packfile = PackFile::new(packfile_entries); + handle.write(PktLine::SidebandData(packfile))?; + handle.write(PktLine::Flush)?; + handle.flush(session, channel); + + // tell the client we exited successfully and close the channel + session.exit_status_request(channel, 0); + session.eof(channel); + session.close(channel); + + Ok(()) +} diff --git a/src/command/git_upload_pack/ls_refs.rs b/src/command/git_upload_pack/ls_refs.rs new file mode 100644 index 0000000..81a68ae 100644 --- /dev/null +++ a/src/command/git_upload_pack/ls_refs.rs @@ -1,0 +1,31 @@ +//! [ls-refs][lsr] is sent from the client when they want to see what refs we have +//! on the server, we're generating our commits on the fly though so we'll just tell +//! them we have a master branch with whatever the generated commit hash is. +//! +//! [lsr]: https://git-scm.com/docs/protocol-v2/2.19.0#_ls_refs + +use bytes::Bytes; +use packfile::{low_level::HashOutput, PktLine}; +use thrussh::{server::Session, ChannelId}; +use tracing::instrument; + +use crate::{Handler, PackageProvider, UserProvider}; + +#[instrument(skip(handle, session, channel, _metadata, commit_hash), err)] +pub fn handle( + handle: &mut Handler, + session: &mut Session, + channel: ChannelId, + _metadata: &[Bytes], + commit_hash: &HashOutput, +) -> Result<(), anyhow::Error> { + let commit_hash = hex::encode(commit_hash); + + handle.write(PktLine::Data( + format!("{commit_hash} HEAD symref-target:refs/heads/master").as_bytes(), + ))?; + handle.write(PktLine::Flush)?; + handle.flush(session, channel); + + Ok(()) +} diff --git a/src/command/git_upload_pack/mod.rs b/src/command/git_upload_pack/mod.rs new file mode 100644 index 0000000..8406cdb 100644 --- /dev/null +++ a/src/command/git_upload_pack/mod.rs @@ -1,0 +1,2 @@ +pub mod fetch; +pub mod ls_refs; -- rgit 0.1.3