Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
135 changes: 135 additions & 0 deletions proxy-lib/src/http/firewall/rule/nuget/min_package_age/catalog_list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use rama::{
error::{BoxError, ErrorContext},
http::{Body, Response, Uri, body::util::BodyExt},
telemetry::tracing,
};

use crate::{
http::firewall::rule::nuget::NugetRemoteReleasedPackageList,
package::{
name_formatter::LowerCasePackageName,
version::{PackageVersion, PragmaticSemver},
},
utils::time::SystemTimestampMilliseconds,
};

const BASE_PATH: &str = "/v3/registration5-gz-semver2";

pub struct CatalogList {}

impl CatalogList {
pub fn match_uri<'u>(&self, uri: &'u Uri) -> Option<&'u str> {
uri.path()
.strip_prefix(BASE_PATH)?
.trim_start_matches('/')
.split_once('/')
.map(|(package_name, _)| package_name)
}

pub async fn remove_new_packages(
&self,
resp: Response,
_: &str,
remote_released_packages_list: &NugetRemoteReleasedPackageList,
cutoff_secs: SystemTimestampMilliseconds,
) -> Result<Response, BoxError> {
let (parts, body) = resp.into_parts();

let bytes = body
.collect()
.await
.context("collect nuget index response body")?
.to_bytes();

let mut json: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(err) => {
tracing::debug!(
"nuget index response body is not valid JSON, passing through: {err}"
);
return Ok(Response::from_parts(parts, Body::from(bytes)));
}
};

Self::handle_items_collection(&mut json, remote_released_packages_list, cutoff_secs);

let new_bytes =
serde_json::to_vec(&json).context("serialize modified nuget index response")?;

Ok(Response::from_parts(parts, Body::from(new_bytes)))
}

fn handle_items_collection(
json: &mut serde_json::Value,
remote_released_packages_list: &NugetRemoteReleasedPackageList,
cutoff_secs: SystemTimestampMilliseconds,
) {
let Some(serde_json::Value::Array(items)) = json.get_mut("items") else {
return;
};

items.retain_mut(|item| {
Comment thread
SanderDeclerck marked this conversation as resolved.
if Self::has_type(item, "Package") {
return !Self::should_remove_package(
item,
remote_released_packages_list,
cutoff_secs,
);
}

// Recursively loop over child items arrays
Self::handle_items_collection(item, remote_released_packages_list, cutoff_secs);
Comment thread
SanderDeclerck marked this conversation as resolved.
Outdated
true
});

let new_count = items.len();
if let Some(count) = json.get_mut("count")
&& count.as_u64() != Some(new_count as u64)
{
*count = serde_json::Value::Number(new_count.into());
}
}

fn has_type(json: &serde_json::Value, type_name: &str) -> bool {
match json.get("@type") {
Some(serde_json::Value::String(s)) => s.eq_ignore_ascii_case(type_name),
Some(serde_json::Value::Array(arr)) => arr.iter().any(|v| {
v.as_str()
.is_some_and(|s| s.eq_ignore_ascii_case(type_name))
}),
_ => false,
}
}

fn should_remove_package(
package_json: &serde_json::Value,
remote_released_packages_list: &NugetRemoteReleasedPackageList,
cutoff_secs: SystemTimestampMilliseconds,
) -> bool {
let serde_json::Value::Object(package) = package_json else {
return false;
};
let Some(serde_json::Value::Object(catalog_entry)) = package.get("catalogEntry") else {
return false;
};
let Some(serde_json::Value::String(package_name)) = catalog_entry.get("id") else {
return false;
};
let Some(serde_json::Value::String(package_version)) = catalog_entry.get("version") else {
return false;
};

let version = match PragmaticSemver::parse(package_version) {
Ok(version_semver) => PackageVersion::Semver(version_semver),
Err(_) => PackageVersion::Unknown(package_version.into()),
};

let normalized_package_name = LowerCasePackageName::from(package_name);

remote_released_packages_list.is_recently_released(
&normalized_package_name,
Some(&version),
cutoff_secs,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use rama::{
error::{BoxError, ErrorContext},
http::{Body, Response, Uri, body::util::BodyExt},
telemetry::tracing,
};

use crate::{
http::firewall::rule::nuget::NugetRemoteReleasedPackageList,
package::{
name_formatter::LowerCasePackageName,
version::{PackageVersion, PragmaticSemver},
},
utils::time::SystemTimestampMilliseconds,
};

const BASE_PATH: &str = "/v3-flatcontainer";

pub struct FlatVersionList {}

impl FlatVersionList {
pub fn match_uri<'a>(&self, uri: &'a Uri) -> Option<&'a str> {
let (package_name, index_json) = uri
.path()
.strip_prefix(BASE_PATH)?
.trim_start_matches('/')
.split_once('/')?;

if index_json.eq_ignore_ascii_case("index.json") {
Some(package_name)
} else {
None
}
}

pub async fn remove_new_packages(
&self,
resp: Response,
package_name: &str,
released_package_list: &NugetRemoteReleasedPackageList,
cutoff_secs: SystemTimestampMilliseconds,
) -> Result<Response, BoxError> {
let (parts, body) = resp.into_parts();

let bytes = body
.collect()
.await
.context("collect nuget index response body")?
.to_bytes();

let mut json: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(err) => {
tracing::debug!(
"nuget index response body is not valid JSON, passing through: {err}"
);
return Ok(Response::from_parts(parts, Body::from(bytes)));
}
};

if let Some(versions) = json.get_mut("versions").and_then(|v| v.as_array_mut()) {
versions.retain(|v| {
Comment thread
SanderDeclerck marked this conversation as resolved.
let Some(version_str) = v.as_str() else {
return true;
};
let Ok(version) = PragmaticSemver::parse(version_str) else {
return true;
};

let normalized_package_name = LowerCasePackageName::from(package_name);

if released_package_list.is_recently_released(
&normalized_package_name,
Some(&PackageVersion::Semver(version)),
cutoff_secs,
) {
tracing::info!("Version {version_str} was recently released.");
false
} else {
tracing::info!("Version {version_str} was not recently released.");
true
}
});
}

let new_bytes =
serde_json::to_vec(&json).context("serialize modified nuget index response")?;

Ok(Response::from_parts(parts, Body::from(new_bytes)))
}
}
90 changes: 90 additions & 0 deletions proxy-lib/src/http/firewall/rule/nuget/min_package_age/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
TODO list:
- add notifier
- check last modified response header
*/

use rama::{
error::BoxError,
http::{
Response, Uri,
headers::{ContentType, HeaderMapExt as _},
},
};

use crate::{
http::{
KnownContentType,
firewall::rule::nuget::{
NugetRemoteReleasedPackageList,
min_package_age::{catalog_list::CatalogList, flat_version_list::FlatVersionList},
},
},
utils::time::SystemTimestampMilliseconds,
};

mod catalog_list;
mod flat_version_list;
#[cfg(test)]
mod tests;

pub(in crate::http::firewall) struct MinPackageAgeNuget {
remote_released_packages_list: NugetRemoteReleasedPackageList,
flat_version_list: FlatVersionList,
catalog_list: CatalogList,
}

impl MinPackageAgeNuget {
pub fn new(
remote_released_packages_list: NugetRemoteReleasedPackageList,
) -> MinPackageAgeNuget {
Self {
remote_released_packages_list,
flat_version_list: FlatVersionList {},
catalog_list: CatalogList {},
}
}

pub async fn remove_new_packages(
&self,
resp: Response,
req_uri: &Uri,
cut_off_secs: SystemTimestampMilliseconds,
) -> Result<Response, BoxError> {
if resp
.headers()
.typed_get::<ContentType>()
.clone()
.and_then(KnownContentType::detect_from_content_type_header)
!= Some(KnownContentType::Json)
{
return Ok(resp);
}

if let Some(package_name) = self.flat_version_list.match_uri(req_uri) {
return self
.flat_version_list
.remove_new_packages(
resp,
package_name,
&self.remote_released_packages_list,
cut_off_secs,
)
.await;
}

if let Some(package_name) = self.catalog_list.match_uri(req_uri) {
return self
.catalog_list
.remove_new_packages(
resp,
package_name,
&self.remote_released_packages_list,
cut_off_secs,
)
.await;
}

Ok(resp)
}
}
65 changes: 65 additions & 0 deletions proxy-lib/src/http/firewall/rule/nuget/min_package_age/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use rama::http::Uri;

use super::catalog_list::CatalogList;
use super::flat_version_list::FlatVersionList;

// FlatVersionList matches GET /v3-flatcontainer/{package}/index.json (step 4 in nuget restore)

#[test]
fn test_flat_version_list_match_uri_returns_package_name() {
let uri = Uri::from_static(
"https://api.nuget.org/v3-flatcontainer/microsoft.extensions.logging/index.json",
);
assert_eq!(
FlatVersionList {}.match_uri(&uri),
Some("microsoft.extensions.logging")
);
}

#[test]
fn test_flat_version_list_match_uri_no_match_for_package_download() {
let uri = Uri::from_static(
"https://api.nuget.org/v3-flatcontainer/microsoft.extensions.logging/9.0.1/microsoft.extensions.logging.9.0.1.nupkg",
);
assert_eq!(FlatVersionList {}.match_uri(&uri), None);
}

#[test]
fn test_flat_version_list_match_uri_no_match_for_wrong_base_path() {
let uri = Uri::from_static(
"https://api.nuget.org/v3/registration5-gz-semver2/microsoft.extensions.logging/index.json",
);
assert_eq!(FlatVersionList {}.match_uri(&uri), None);
}

// CatalogList matches GET /v3/registration5-gz-semver2/{package}/... (steps 2 and 3 in nuget restore)

#[test]
fn test_catalog_list_match_uri_returns_package_name_for_index() {
let uri = Uri::from_static(
"https://api.nuget.org/v3/registration5-gz-semver2/microsoft.extensions.logging/index.json",
);
assert_eq!(
CatalogList {}.match_uri(&uri),
Some("microsoft.extensions.logging")
);
}

#[test]
fn test_catalog_list_match_uri_returns_package_name_for_page_request() {
let uri = Uri::from_static(
"https://api.nuget.org/v3/registration5-gz-semver2/microsoft.extensions.logging/page/9.0.1/11.0.0-preview.3.26207.106.json",
);
assert_eq!(
CatalogList {}.match_uri(&uri),
Some("microsoft.extensions.logging")
);
}

#[test]
fn test_catalog_list_match_uri_no_match_for_wrong_base_path() {
let uri = Uri::from_static(
"https://api.nuget.org/v3-flatcontainer/microsoft.extensions.logging/index.json",
);
assert_eq!(CatalogList {}.match_uri(&uri), None);
}
Loading
Loading