Skip to content
Merged
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
24 changes: 24 additions & 0 deletions crates/db/src/raw_dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,30 @@ where
Ok(result.rows_affected())
}

/// Proxies: make the cached files of `repository_id` that were kept as
/// immutable (an apt proxy's packages and `by-hash` copies) ordinary cached
/// files again, due for re-validation and without the upstream's
/// validators, so each is fetched from the upstream again on its next
/// download and checked anew. The cached copy is served while the upstream
/// cannot be reached. Returns how many there were.
pub async fn release_immutable<'e, E>(executor: E, repository_id: i64) -> sqlx::Result<u64>
where
E: PgExecutor<'e>,
{
let result = sqlx::query(
r#"
UPDATE raw_file
SET immutable = false, checked_at = NULL,
upstream_etag = NULL, upstream_last_modified = NULL
WHERE repository_id = $1 AND immutable
"#,
)
.bind(repository_id)
.execute(executor)
.await?;
Ok(result.rows_affected())
}

/// Proxies: make the cached file at `path` due for re-validation. Returns
/// whether it was fresh.
pub async fn expire_file_check<'e, E>(
Expand Down
80 changes: 70 additions & 10 deletions crates/formats/apt/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,49 @@ pub fn is_by_hash(rest: &str) -> bool {
&& hash.bytes().all(|b| b.is_ascii_hexdigit())
}

/// The SHA-256 a `by-hash/SHA256/<hex>` path names.
pub fn by_hash_sha256(rest: &str) -> Option<String> {
let hash = rest
.strip_prefix("by-hash/SHA256/")
.or_else(|| rest.rsplit_once("/by-hash/SHA256/").map(|(_, hash)| hash))?;
(hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| hash.to_ascii_lowercase())
/// A hash type of `by-hash` paths, as apt names it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashType {
Md5,
Sha1,
Sha256,
Sha512,
}

impl HashType {
fn named(name: &str) -> Option<Self> {
match name {
"MD5Sum" => Some(HashType::Md5),
"SHA1" => Some(HashType::Sha1),
"SHA256" => Some(HashType::Sha256),
"SHA512" => Some(HashType::Sha512),
_ => None,
}
}

/// The length of its digest in hex digits.
fn hex_len(self) -> usize {
match self {
HashType::Md5 => 32,
HashType::Sha1 => 40,
HashType::Sha256 => 64,
HashType::Sha512 => 128,
}
}
}

/// The hash type and digest (lowercase hex) a `by-hash/<type>/<hex>` path
/// names; `None` when the digest is not one of its type.
pub fn by_hash_digest(rest: &str) -> Option<(HashType, String)> {
let mut segments = rest.rsplit('/');
let (Some(hash), Some(algorithm), Some("by-hash")) =
(segments.next(), segments.next(), segments.next())
else {
return None;
};
let kind = HashType::named(algorithm)?;
(hash.len() == kind.hex_len() && hash.bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| (kind, hash.to_ascii_lowercase()))
}

/// Whether a pool path is where a hosted package of `component` and `name`
Expand Down Expand Up @@ -361,12 +397,36 @@ mod tests {
"main/binary-amd64/by-hash/SHA256/{hash}"
)));
assert_eq!(
by_hash_sha256(&format!("main/binary-amd64/by-hash/SHA256/{hash}")),
Some(hash.clone())
by_hash_digest(&format!("main/binary-amd64/by-hash/SHA256/{hash}")),
Some((HashType::Sha256, hash.clone()))
);
assert!(!is_by_hash("main/binary-amd64/Packages"));
assert!(!is_by_hash("by-hash/SHA256/xyz"));
assert_eq!(by_hash_sha256("main/by-hash/SHA256/abc"), None);
assert_eq!(by_hash_digest("main/by-hash/SHA256/abc"), None);
// Every hash type apt asks for, each with its digest's length.
for (name, kind, len) in [
("MD5Sum", HashType::Md5, 32),
("SHA1", HashType::Sha1, 40),
("SHA512", HashType::Sha512, 128),
] {
let hex = "AB".repeat(len / 2);
assert_eq!(
by_hash_digest(&format!("main/i18n/by-hash/{name}/{hex}")),
Some((kind, hex.to_ascii_lowercase()))
);
assert_eq!(
by_hash_digest(&format!("main/i18n/by-hash/{name}/{}", "a".repeat(len - 2))),
None
);
}
assert_eq!(
by_hash_digest(&format!("by-hash/SHA384/{}", "a".repeat(96))),
None
);
assert_eq!(
by_hash_digest(&format!("by-hash/SHA256/{hash}")).unwrap().0,
HashType::Sha256
);
}

#[test]
Expand Down
99 changes: 92 additions & 7 deletions crates/formats/apt/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
//! verify the upstream's signatures with the upstream's keys.
//!
//! * `dists/<distribution>/…` (the distribution the proxy is for): the
//! indexes, re-validated once the repository's metadata TTL has passed;
//! their `by-hash` copies never change.
//! indexes, re-validated once the repository's metadata TTL has passed.
//! A `by-hash/<type>/<digest>` copy (`MD5Sum`, `SHA1`, `SHA256` or
//! `SHA512`) is checked against the digest its path names, and never
//! changes once it matched.
//! * `pool/…`: packages, which never change at their path. Before a
//! package is cached the host checks it against the size and SHA-256 the
//! upstream's `Packages` index of its component and architecture gives it
Expand All @@ -13,14 +15,20 @@
//! last before the name's prefix: `main` for the Debian security
//! archive's `pool/updates/main/o/openssl/…`, which
//! `dists/<distribution>/main/binary-<arch>/Packages` lists.
//!
//! Only a file so checked is kept as immutable. One the host could not
//! check (no index lists it, the index could not be fetched, Ubuntu's
//! `_all` packages without a `binary-all` index) is cached as an index is,
//! and re-validated once the metadata TTL has passed: a wrong copy is not
//! kept for good.

use std::io::{BufRead, BufReader, Read};

use forge_plugin_api::{
Fetch, FetchResult, Fetched, FileLocation, Freshness, Remote, UpstreamAnswer, UpstreamStep,
};

use crate::layout::{self, Target};
use crate::layout::{self, HashType, Target};

/// The most an index may inflate to while it is searched.
const MAX_INDEX_BYTES: u64 = 1024 * 1024 * 1024;
Expand Down Expand Up @@ -55,11 +63,18 @@ pub fn file(remote: &Remote, path: &str, fetched: &[FetchResult]) -> UpstreamSte
let mut location = FileLocation::at(url);
match layout::parse(path) {
Ok(Target::Dists { rest, .. }) => {
location.immutable = layout::is_by_hash(&rest);
location.sha256 = layout::by_hash_sha256(&rest);
if let Some((kind, digest)) = layout::by_hash_digest(&rest) {
let field = match kind {
HashType::Md5 => &mut location.md5,
HashType::Sha1 => &mut location.sha1,
HashType::Sha256 => &mut location.sha256,
HashType::Sha512 => &mut location.sha512,
};
*field = Some(digest);
location.immutable = true;
}
}
Ok(Target::Pool(pool)) => {
location.immutable = true;
if let (Some(deb), Some(distribution)) =
(layout::deb_name(&pool.file), remote.option("distribution"))
{
Expand All @@ -70,6 +85,8 @@ pub fn file(remote: &Remote, path: &str, fetched: &[FetchResult]) -> UpstreamSte
match listed(remote, &dir, path, fetched) {
Lookup::Fetch(fetch) => return UpstreamStep::Fetch(vec![fetch]),
Lookup::Found { size, sha256 } => {
// Kept for good only once both are checked.
location.immutable = size.is_some() && sha256.is_some();
location.size = size;
location.sha256 = sha256;
}
Expand Down Expand Up @@ -349,6 +366,38 @@ mod tests {
};
assert!(at.immutable);
assert_eq!(at.sha256, Some(hash));
// Every hash type apt asks for is checked against its digest (#760).
for (name, len) in [("MD5Sum", 32), ("SHA1", 40), ("SHA512", 128)] {
let hash = "e".repeat(len);
let UpstreamStep::Done(UpstreamAnswer::FileAt(at)) = file(
&remote(),
&format!("dists/bookworm/main/binary-amd64/by-hash/{name}/{hash}"),
&[],
) else {
panic!()
};
assert!(at.immutable, "{name}");
let given = match name {
"MD5Sum" => &at.md5,
"SHA1" => &at.sha1,
_ => &at.sha512,
};
assert_eq!(given.as_deref(), Some(hash.as_str()), "{name}");
assert_eq!(at.sha256, None);
}
// A copy whose name is no digest of its type is not kept for good.
let UpstreamStep::Done(UpstreamAnswer::FileAt(at)) = file(
&remote(),
&format!(
"dists/bookworm/main/binary-amd64/by-hash/SHA512/{}",
"e".repeat(64)
),
&[],
) else {
panic!()
};
assert!(!at.immutable);
assert!(!at.has_digest());
}

#[test]
Expand Down Expand Up @@ -387,12 +436,48 @@ mod tests {
assert!(at.immutable);
assert_eq!(at.size, Some(42));
assert!(at.sha256.is_some());
// An index that is down leaves the package to apt's own check.
// An index that is down leaves the package to apt's own check, and
// the copy is re-validated, not kept for good (#760).
let down = result(&fetches[0].url, Fetched::Failed("503".into()));
let UpstreamStep::Done(UpstreamAnswer::FileAt(at)) = file(&remote(), path, &[down]) else {
panic!()
};
assert_eq!((at.size, at.sha256), (None, None));
assert!(!at.immutable);
// So does an index that does not list the package, or lists it
// without a usable digest.
let index = result(&fetches[0].url, Fetched::Found(INDEX.as_bytes().to_vec()));
for path in ["pool/main/x/x/x_1_amd64.deb", "pool/main/a/a/a_1_amd64.deb"] {
let UpstreamStep::Done(UpstreamAnswer::FileAt(at)) =
file(&remote(), path, std::slice::from_ref(&index))
else {
panic!()
};
assert!(!at.immutable, "{path}");
}
// No index anywhere (Ubuntu's `binary-all`).
let gone: Vec<_> = ["Packages.gz", "Packages.xz", "Packages"]
.iter()
.map(|name| {
result(
&format!(
"http://deb.example.test/debian/dists/bookworm/main/binary-amd64/{name}"
),
Fetched::NotFound,
)
})
.collect();
let UpstreamStep::Done(UpstreamAnswer::FileAt(at)) = file(&remote(), path, &gone) else {
panic!()
};
assert!(!at.immutable);
// A package the plugin cannot place in an index.
let UpstreamStep::Done(UpstreamAnswer::FileAt(at)) =
file(&remote(), "pool/main/h/hello/README", &[])
else {
panic!()
};
assert!(!at.immutable);
}

#[test]
Expand Down
18 changes: 17 additions & 1 deletion crates/plugin-api/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1453,14 +1453,22 @@ pub struct FileLocation {
/// On an origin a [`Fetch`] may reach.
pub url: String,
/// The file never changes at its path (a package, a content-addressed
/// copy): the host keeps its copy without re-validating it.
/// copy): the host keeps its copy without re-validating it. A plugin
/// sets it only with a digest below that the content must match, so
/// what is kept is the file the upstream's index (or the path) names.
pub immutable: bool,
/// The size the upstream's index gives the file: the host caches no
/// content of another size, and answers 502.
pub size: Option<u64>,
/// The SHA-256 (lowercase hex) the upstream's index gives the file:
/// the host caches no content of another digest, and answers 502.
pub sha256: Option<String>,
/// The SHA-512 (lowercase hex), checked as [`Self::sha256`] is.
pub sha512: Option<String>,
/// The SHA-1 (lowercase hex), checked as [`Self::sha256`] is.
pub sha1: Option<String>,
/// The MD5 (lowercase hex), checked as [`Self::sha256`] is.
pub md5: Option<String>,
}

impl FileLocation {
Expand All @@ -1471,8 +1479,16 @@ impl FileLocation {
immutable: false,
size: None,
sha256: None,
sha512: None,
sha1: None,
md5: None,
}
}

/// Whether the location names a digest the content must match.
pub fn has_digest(&self) -> bool {
self.sha256.is_some() || self.sha512.is_some() || self.sha1.is_some() || self.md5.is_some()
}
}

/// What a repository's indexes are made of ([`FormatPlugin::index`]).
Expand Down
21 changes: 13 additions & 8 deletions crates/server/src/admin/proxy_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@
//! while the upstream is unreachable;
//! * a docker proxy's cached tags are re-validated on their next pull;
//! * a raw proxy's cached files are re-validated on their next download, and
//! so are the upstream's indexes an apt proxy keeps (its packages and
//! `by-hash` copies never change).
//! so are the upstream's files an apt proxy keeps. A whole-repository
//! invalidation also releases the apt proxy's immutable files (packages
//! and `by-hash` copies): each is fetched from the upstream again on its
//! next download, checked anew, and replaced when it is not the file the
//! upstream has (the cached copy is served while the upstream is down).
//!
//! What stays: downloaded packages, blobs and digest-addressed manifests
//! (removing those is retention's job), the versions the upstream listed and
//! their package-delay clock.
//! (removing those is retention's job, or for an apt proxy's files
//! `DELETE /admin/repositories/{name}/files?path=…`), the versions the
//! upstream listed and their package-delay clock.

use axum::extract::{Path, State};
use axum::http::StatusCode;
Expand Down Expand Up @@ -66,7 +70,7 @@ pub struct Invalidated {
pub not_found_cleared: u64,
/// Tags due for re-validation (docker proxies).
pub tags_expired: u64,
/// Cached files due for re-validation (raw proxies, and apt's indexes).
/// Cached files due for re-validation (raw proxies, and apt's files).
#[serde(skip_serializing_if = "Option::is_none")]
pub files_expired: Option<u64>,
/// Stored upstream documents deleted (package formats' proxies: npm
Expand Down Expand Up @@ -188,9 +192,10 @@ async fn forget(
proxy_dao::clear_not_found(&mut tx, repo.id, package).await?;
if package.is_none() {
proxy_dao::expire_upstream_index(&mut tx, repo.id).await?;
// The upstream's files a package format's proxy keeps (apt's
// indexes); the ones that never change stay as they are.
let expired = raw_dao::expire_checks(&mut *tx, repo.id, "").await?;
// The upstream's files a package format's proxy keeps (apt's),
// those kept as immutable too: a wrong one is not kept for good.
let released = raw_dao::release_immutable(&mut *tx, repo.id).await?;
let expired = released + raw_dao::expire_checks(&mut *tx, repo.id, "").await?;
if expired > 0 {
invalidated.files_expired = Some(expired);
}
Expand Down
Loading
Loading