Skip to content

Commit

Permalink
Add bust-cache command
Browse files Browse the repository at this point in the history
  • Loading branch information
w4 committed Feb 24, 2024
1 parent b019baa commit b819b91
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 32 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions src/command/bust_cache.rs
Original file line number Diff line number Diff line change
@@ -0,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<U: UserProvider + PackageProvider + Send + Sync + 'static>(
handle: &mut Handler<U>,
session: &mut Session,
channel: ChannelId,
mut params: impl Iterator<Item = String>,
) -> 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(())
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 2 additions & 0 deletions src/command/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod bust_cache;
pub mod git_upload_pack;
79 changes: 47 additions & 32 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +19,7 @@ use crate::{
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};
Expand Down Expand Up @@ -485,7 +485,7 @@ impl<U: UserProvider + PackageProvider + Send + Sync + 'static> thrussh::server:

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,
Expand All @@ -494,7 +494,7 @@ impl<U: UserProvider + PackageProvider + Send + Sync + 'static> thrussh::server:
)?;
}
b"command=fetch" => {
git_command_handlers::fetch::handle(
command::git_upload_pack::fetch::handle(
&mut self,
&mut session,
channel,
Expand Down Expand Up @@ -583,40 +583,55 @@ impl<U: UserProvider + PackageProvider + Send + Sync + 'static> thrussh::server:

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))
Expand Down
83 changes: 83 additions & 0 deletions src/providers/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,28 @@ impl super::UserProvider for Gitlab {

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<bool> {
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]
Expand Down Expand Up @@ -320,6 +342,23 @@ impl super::PackageProvider for Gitlab {
.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::<Option<Release<'static>>>(EligibilityCacheKey::new(
project,
crate_name,
crate_version,
))
.await?;
Ok(())
}

#[instrument(skip(self), err)]
async fn fetch_metadata_for_release(
&self,
Expand Down Expand Up @@ -370,6 +409,50 @@ pub async fn handle_error(resp: reqwest::Response) -> Result<reqwest::Response,
}
}

/// 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 <https://docs.gitlab.com/ee/api/access_requests.html#valid-access-levels>
access_level: u8,
}

impl GitlabProjectAccess {
/// Any users with access above this level are considered maintainers.
///
/// See: <https://docs.gitlab.com/ee/api/access_requests.html#valid-access-levels>
pub const MAINTAINER_ACCESS_LEVEL: u8 = 40;
}

#[derive(Default, Deserialize)]
pub struct GitlabErrorResponse {
message: Option<String>,
Expand Down
9 changes: 9 additions & 0 deletions src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub trait UserProvider {
username_password: &str,
) -> anyhow::Result<Option<User>>;

async fn is_project_maintainer(&self, do_as: &User, project: &str) -> anyhow::Result<bool>;

async fn find_user_by_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<User>>;

async fn fetch_token_for_user(&self, user: &User) -> anyhow::Result<String>;
Expand All @@ -40,6 +42,13 @@ pub trait PackageProvider {
do_as: &Arc<User>,
) -> anyhow::Result<cargo_metadata::Metadata>;

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<String>;
}

Expand Down

0 comments on commit b819b91

Please sign in to comment.