Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions proxy-lib/src/http/firewall/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ impl Firewall {
layered_client.clone(),
data.clone(),
remote_endpoint_config.clone(),
notifier.clone(),
)
.await
.context("create block rule: nuget")?
Expand Down
202 changes: 202 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,202 @@
use rama::{
error::{BoxError, ErrorContext},
http::{Body, Response, Uri, body::util::BodyExt},
telemetry::tracing,
utils::str::arcstr::ArcStr,
};

use crate::{
http::{
firewall::{
events::{Artifact, MinPackageAgeEvent},
notifier::EventNotifier,
rule::nuget::{NUGET_PRODUCT_KEY, NugetRemoteReleasedPackageList},
},
headers::make_response_uncacheable,
},
package::{
name_formatter::LowerCasePackageName,
version::{PackageVersion, PragmaticSemver},
},
utils::time::SystemTimestampMilliseconds,
};

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

pub struct CatalogList {
pub notifier: Option<EventNotifier>,
}

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

pub async fn remove_new_packages(
&self,
resp: Response,
package_name: ArcStr,
remote_released_packages_list: &NugetRemoteReleasedPackageList,
cutoff_secs: SystemTimestampMilliseconds,
) -> Result<Response, BoxError> {
let (mut 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)));
}
};

let mut removed_versions: Vec<String> = vec![];

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

if removed_versions.is_empty() {
return Ok(Response::from_parts(parts, Body::from(bytes)));
} else {
if let Some(notifier) = &self.notifier {
let event = MinPackageAgeEvent {
ts_ms: SystemTimestampMilliseconds::now(),
artifact: Artifact {
product: NUGET_PRODUCT_KEY,
identifier: package_name.clone(),
display_name: Some(package_name),
version: None,
},
suppressed_versions: removed_versions
.iter()
.filter_map(|v| v.parse().ok())
.collect(),
};
notifier.notify_min_package_age(event).await;
}
}

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

make_response_uncacheable(&mut parts.headers);

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

fn handle_items_collection(
json: &mut serde_json::Value,
removed_versions: &mut Vec<String>,
remote_released_packages_list: &NugetRemoteReleasedPackageList,
cutoff_secs: SystemTimestampMilliseconds,
depth: usize,
) {
const MAX_DEPTH: usize = 5;
if depth > MAX_DEPTH {
return;
}
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 match Self::should_remove_package(
item,
remote_released_packages_list,
cutoff_secs,
) {
Some(version) => {
removed_versions.push(version.to_string());
false
}
None => true,
};
}

// Each collection can, depending on its type, contain a sub-items collection
// Loop over these as well to recursively find all Package entries
Self::handle_items_collection(
item,
removed_versions,
remote_released_packages_list,
cutoff_secs,
depth + 1,
);
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,
) -> Option<PackageVersion> {
let serde_json::Value::Object(package) = package_json else {
return None;
};
let Some(serde_json::Value::Object(catalog_entry)) = package.get("catalogEntry") else {
return None;
};
let Some(serde_json::Value::String(package_name)) = catalog_entry.get("id") else {
return None;
};
let Some(serde_json::Value::String(package_version)) = catalog_entry.get("version") else {
return None;
};

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);

if remote_released_packages_list.is_recently_released(
&normalized_package_name,
Some(&version),
cutoff_secs,
) {
tracing::info!(
"{package_name}@{package_version} was removed from the nuget meta response because it was recently released."
);
Some(version)
} else {
None
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
use rama::{
error::{BoxError, ErrorContext},
http::{Body, Response, Uri, body::util::BodyExt},
telemetry::tracing,
utils::str::arcstr::ArcStr,
};

use crate::{
http::{
firewall::{
events::{Artifact, MinPackageAgeEvent},
notifier::EventNotifier,
rule::nuget::{NUGET_PRODUCT_KEY, NugetRemoteReleasedPackageList},
},
headers::make_response_uncacheable,
},
package::{
name_formatter::LowerCasePackageName,
version::{PackageVersion, PragmaticSemver},
},
utils::time::SystemTimestampMilliseconds,
};

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

pub struct FlatVersionList {
pub notifier: Option<EventNotifier>,
}

impl FlatVersionList {
pub fn match_uri(&self, uri: &Uri) -> Option<ArcStr> {
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.into())
} else {
None
}
}

pub async fn remove_new_packages(
&self,
resp: Response,
package_name: ArcStr,
released_package_list: &NugetRemoteReleasedPackageList,
cutoff_secs: SystemTimestampMilliseconds,
) -> Result<Response, BoxError> {
let (mut 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)));
}
};

let mut removed_versions: Vec<String> = vec![];

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

if released_package_list.is_recently_released(
&LowerCasePackageName::from(package_name.clone()),
Some(&PackageVersion::Semver(version)),
cutoff_secs,
) {
removed_versions.push(version_str.to_string());
tracing::info!("{package_name}@{version_str} was removed from the nuget meta response because it was recently released.");
false
} else {
true
}
});
}

if removed_versions.is_empty() {
return Ok(Response::from_parts(parts, Body::from(bytes)));
} else {
if let Some(notifier) = &self.notifier {
let event = MinPackageAgeEvent {
ts_ms: SystemTimestampMilliseconds::now(),
artifact: Artifact {
product: NUGET_PRODUCT_KEY,
identifier: package_name.clone(),
display_name: Some(package_name),
version: None,
},
suppressed_versions: removed_versions
.iter()
.filter_map(|v| v.parse().ok())
.collect(),
};
notifier.notify_min_package_age(event).await;
}
}

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

make_response_uncacheable(&mut parts.headers);

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