🏡 index : ~doyle/gitlab-cargo-shim.git

author Jordan Doyle <jordan@doyle.la> 2024-02-24 19:58:36.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2024-02-24 19:58:36.0 +00:00:00
commit
b819b9136de428d47082608c0918485ac80bd441 [patch]
tree
baaa17624392da50555f215ab308f6ea2748fc4b
parent
b019baa44a052831150c9c5d04b45a8f015e39d6
download
b819b9136de428d47082608c0918485ac80bd441.tar.gz

Add bust-cache command



Diff

 CHANGELOG.md                           |  1 +-
 src/command/bust_cache.rs              | 45 +++++++++++++++++++-
 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 +-
 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/main.rs                            | 79 +++++++++++++++++++--------------
 src/providers/gitlab.rs                | 83 +++++++++++++++++++++++++++++++++++-
 src/providers/mod.rs                   |  9 ++++-
 12 files changed, 267 insertions(+), 112 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e43ad8..8860798 100644
--- a/CHANGELOG.md
+++ b/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/command/bust_cache.rs b/src/command/bust_cache.rs
new file mode 100644
index 0000000..f000a37
--- /dev/null
+++ b/src/command/bust_cache.rs
@@ -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(())
}
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
--- /dev/null
+++ b/src/command/git_upload_pack/fetch.rs
@@ -0,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<U: UserProvider + PackageProvider + Send + Sync + 'static>(
    handle: &mut Handler<U>,
    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
--- /dev/null
+++ b/src/command/git_upload_pack/ls_refs.rs
@@ -0,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<U: UserProvider + PackageProvider + Send + Sync + 'static>(
    handle: &mut Handler<U>,
    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
--- /dev/null
+++ b/src/command/git_upload_pack/mod.rs
@@ -0,0 +1,2 @@
pub mod fetch;
pub mod ls_refs;
diff --git a/src/command/mod.rs b/src/command/mod.rs
new file mode 100644
index 0000000..ac69f01
--- /dev/null
+++ b/src/command/mod.rs
@@ -0,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
--- a/src/git_command_handlers/fetch.rs
+++ /dev/null
@@ -1,47 +0,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<U: UserProvider + PackageProvider + Send + Sync + 'static>(
    handle: &mut Handler<U>,
    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
--- a/src/git_command_handlers/ls_refs.rs
+++ /dev/null
@@ -1,31 +0,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<U: UserProvider + PackageProvider + Send + Sync + 'static>(
    handle: &mut Handler<U>,
    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
--- a/src/git_command_handlers/mod.rs
+++ /dev/null
@@ -1,2 +0,0 @@
pub mod fetch;
pub mod ls_refs;
diff --git a/src/main.rs b/src/main.rs
index f010a4c..11092fa 100644
--- a/src/main.rs
+++ b/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 @@ 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};
@@ -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,
@@ -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,
@@ -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))
diff --git a/src/providers/gitlab.rs b/src/providers/gitlab.rs
index 897b80e..572f04e 100644
--- a/src/providers/gitlab.rs
+++ b/src/providers/gitlab.rs
@@ -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]
@@ -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,
@@ -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>,
diff --git a/src/providers/mod.rs b/src/providers/mod.rs
index f110d11..388c606 100644
--- a/src/providers/mod.rs
+++ b/src/providers/mod.rs
@@ -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>;
@@ -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>;
}