1use super::{HookSet, Pipeline};
3use crate::ServerError;
4use crate::scanner_retrieval::{RetrievalResponse, service::missing};
5use crate::store::{MultipartBlobStore, NamespaceStore};
6
7impl<B: MultipartBlobStore, N: NamespaceStore, H: HookSet> Pipeline<B, N, H> {
8 #[cfg(feature = "remote-hooks")]
9 pub(super) fn retrieval_assignment(
10 op: &crate::op::Operation,
11 advance: Option<&super::advance::AdvanceWrite<'_>>,
12 repository: &str,
13 snapshot: &super::Snapshot,
14 set: &crate::indexed::inspection::InspectionSet,
15 ) -> Result<crate::scanner_retrieval::Assignment, ServerError> {
16 use super::{codec, internal, keys, meta_error};
17 let auth = op
18 .auth
19 .as_ref()
20 .ok_or_else(|| internal("retrieval requires authenticated write"))?;
21 let ref_name = match &op.kind {
22 crate::op::OpKind::UpdateRef(update) => &update.name,
23 crate::op::OpKind::AdvanceRefs { head, .. } => &head.name,
24 _ => return Err(internal("retrieval requires ref write")),
25 };
26 if advance.is_none() && !set.raw_packs().is_empty() {
27 return Err(internal("retrieval additions require tickets"));
28 }
29 let mut packs: Vec<crate::scanner_retrieval::PackGrant> = Vec::new();
30 for id in advance.map_or(&[][..], |advance| advance.ids) {
31 let raw = snapshot
32 .get(&keys::ticket(id))
33 .ok_or_else(|| internal("retrieval ticket missing"))?;
34 let ticket = codec::decode_ticket(raw).map_err(meta_error)?;
35 if !set.raw_packs().contains(&ticket.pack_id) {
36 continue;
37 }
38 if let Some(pack) = packs.iter_mut().find(|pack| pack.id == ticket.pack_id) {
39 if pack.length != ticket.bytes {
40 return Err(internal("retrieval length mismatch"));
41 }
42 pack.tickets.push(*id);
43 } else {
44 packs.push(crate::scanner_retrieval::PackGrant {
45 id: ticket.pack_id,
46 length: ticket.bytes,
47 tickets: vec![*id],
48 });
49 }
50 }
51 Ok(crate::scanner_retrieval::Assignment {
52 namespace: op.repo.namespace.as_str().to_owned(),
53 repo_name: op.repo.name.as_str().to_owned(),
54 repository: repository.to_owned(),
57 ref_name: ref_name.to_owned(),
58 signer: auth.signer,
59 packs,
60 })
61 }
62
63 #[must_use]
65 pub fn scanner_retrieval_enabled(&self) -> bool {
66 self.cfg.scanner_retrieval.is_some()
67 }
68
69 #[cfg_attr(not(feature = "remote-hooks"), allow(clippy::unused_async))]
77 pub async fn retrieve_scanner_pack(
78 &self,
79 body: &[u8],
80 headers: &mkit_core::write_auth::Headers,
81 ) -> Result<RetrievalResponse, ServerError> {
82 #[cfg(feature = "remote-hooks")]
83 let result = self
84 .retrieve_inner(body, headers)
85 .await
86 .map_err(|_| missing());
87 #[cfg(not(feature = "remote-hooks"))]
88 let result: Result<RetrievalResponse, ServerError> = {
89 let _ = (body, headers);
90 Err(missing())
91 };
92 self.metrics.incr(
93 "mkit_server_scanner_retrieval_calls",
94 &[("result", if result.is_ok() { "ok" } else { "not_found" })],
95 1,
96 );
97 if let Ok(response) = &result {
98 self.metrics.incr(
99 "mkit_server_scanner_retrieval_bytes",
100 &[],
101 response.bytes.len() as u64,
102 );
103 }
104 result
105 }
106
107 #[cfg(feature = "remote-hooks")]
108 async fn retrieve_inner(
109 &self,
110 body: &[u8],
111 headers: &mkit_core::write_auth::Headers,
112 ) -> Result<RetrievalResponse, ServerError> {
113 use crate::indexed::budget::{Budgeted, SliceBudget};
114 use crate::scanner_retrieval::{MAX_CALLS, MAX_RESPONSE_BYTES};
115 use crate::store::{BlobKey, BlobStore, ByteRange};
116 let (claims, request, repo) =
117 request_authority(&self.cfg, self.clock.as_ref(), body, headers)?;
118 let pack_id = mkit_core::hash::from_hex(&request.pack_id).map_err(|_| missing())?;
119 if mkit_core::hash::to_hex(&pack_id) != request.pack_id {
120 return Err(missing());
121 }
122 let pack = claims
123 .assignment
124 .packs
125 .iter()
126 .find(|pack| pack.id == pack_id)
127 .ok_or_else(missing)?;
128 let (start, end, partial) = match (request.start, request.end_inclusive) {
129 (None, None) => (0, pack.length.checked_sub(1).ok_or_else(missing)?, false),
130 (Some(start), Some(end)) => (start, end, true),
131 _ => return Err(missing()),
132 };
133 let length = end
134 .checked_sub(start)
135 .and_then(|n| n.checked_add(1))
136 .ok_or_else(missing)?;
137 if end >= pack.length || length > MAX_RESPONSE_BYTES as u64 {
138 return Err(missing());
139 }
140 let budget = SliceBudget::new(MAX_CALLS);
141 let meta = Budgeted::new(&self.meta, &budget);
142 let blobs = Budgeted::new(&self.blobs, &budget);
143 let partition = self.shards.ref_shard(&repo, &claims.assignment.ref_name);
144 check_tickets(
145 &meta,
146 &partition,
147 &repo,
148 &claims.assignment,
149 pack,
150 self.clock.as_ref(),
151 )
152 .await?;
153 crate::takedown::denial::require_pack_clear_for_scanner(
154 &meta,
155 self.shards.as_ref(),
156 &repo,
157 &pack.id,
158 )
159 .await?;
160 let now = u64::try_from(self.clock.now_ms()).map_err(|_| missing())?;
161 if now >= claims.expires_at_ms {
162 return Err(missing());
163 }
164 let raw = blobs
165 .get(
166 &BlobKey::pack(pack.id),
167 Some(ByteRange {
168 start,
169 end_inclusive: end,
170 }),
171 )
172 .await
173 .map_err(|_| missing())?
174 .ok_or_else(missing)?;
175 let bytes = collect_segment(raw, length).await?;
176 let now = u64::try_from(self.clock.now_ms()).map_err(|_| missing())?;
177 if now >= claims.expires_at_ms {
178 return Err(missing());
179 }
180 check_tickets(
181 &meta,
182 &partition,
183 &repo,
184 &claims.assignment,
185 pack,
186 self.clock.as_ref(),
187 )
188 .await?;
189 let now = u64::try_from(self.clock.now_ms()).map_err(|_| missing())?;
190 if now >= claims.expires_at_ms {
191 return Err(missing());
192 }
193 Ok(RetrievalResponse {
194 bytes,
195 start,
196 total: pack.length,
197 partial,
198 })
199 }
200}
201
202#[cfg(feature = "remote-hooks")]
203#[derive(serde::Deserialize)]
204#[serde(deny_unknown_fields)]
205struct Request {
206 capability: String,
207 pack_id: String,
208 start: Option<u64>,
209 end_inclusive: Option<u64>,
210}
211
212#[cfg(feature = "remote-hooks")]
213fn request_authority(
214 cfg: &super::PipelineConfig,
215 clock: &dyn crate::Clock,
216 body: &[u8],
217 headers: &mkit_core::write_auth::Headers,
218) -> Result<(crate::scanner_retrieval::Claims, Request, crate::RepoId), ServerError> {
219 use crate::scanner_retrieval::{MAX_REQUEST_BYTES, PATH};
220 if body.len() > MAX_REQUEST_BYTES {
221 return Err(missing());
222 }
223 let config = cfg.scanner_retrieval.as_ref().ok_or_else(missing)?;
224 let crate::pipeline::AuthMode::AuthV2(auth) = &cfg.auth else {
225 return Err(missing());
226 };
227 let now = u64::try_from(clock.now_ms()).map_err(|_| missing())?;
228 let request: Request = serde_json::from_slice(body).map_err(|_| missing())?;
229 let claims = config.verify(&request.capability, auth.audience(), now)?;
230 let signed = crate::auth_v2::verify_unary_for(
231 auth,
232 &claims.assignment.repository,
233 PATH,
234 body,
235 clock.now_ms(),
236 headers,
237 )?;
238 if !config.accepts(&signed.signer) {
239 return Err(missing());
240 }
241 let repo = cfg
242 .addressing
243 .resolve(Some(&claims.assignment.repository), true)?
244 .repo;
245 if repo.namespace.as_str() != claims.assignment.namespace
246 || repo.name.as_str() != claims.assignment.repo_name
247 {
248 return Err(missing());
249 }
250 Ok((claims, request, repo))
251}
252
253#[cfg(feature = "remote-hooks")]
254async fn check_tickets<N: NamespaceStore>(
255 store: &N,
256 partition: &crate::Partition,
257 repo: &crate::RepoId,
258 assignment: &crate::scanner_retrieval::Assignment,
259 pack: &crate::scanner_retrieval::PackGrant,
260 clock: &dyn crate::Clock,
261) -> Result<(), ServerError> {
262 use crate::store::{codec, keys, tickets};
263 let keys: Vec<_> = pack.tickets.iter().map(keys::ticket).collect();
264 let rows = store
265 .get_many(partition, &keys)
266 .await
267 .map_err(|_| missing())?;
268 if rows.len() != keys.len() {
269 return Err(missing());
270 }
271 let now = u64::try_from(clock.now_ms()).map_err(|_| missing())?;
272 for (id, raw) in pack.tickets.iter().zip(rows) {
273 let t = codec::decode_ticket(&raw.ok_or_else(missing)?).map_err(|_| missing())?;
274 if tickets::ticket_id(&t.reservation_id) != *id
275 || t.repo != repo.name
276 || t.ref_name != assignment.ref_name
277 || t.signer != assignment.signer
278 || t.pack_id != pack.id
279 || t.bytes != pack.length
280 || now >= t.expires_at_ms
281 {
282 return Err(missing());
283 }
284 }
285 Ok(())
286}
287
288#[cfg(feature = "remote-hooks")]
289async fn collect_segment(raw: crate::BlobBody, length: u64) -> Result<bytes::Bytes, ServerError> {
290 use crate::BlobBody;
291 use futures::StreamExt as _;
292 let length = usize::try_from(length).map_err(|_| missing())?;
293 if length > crate::scanner_retrieval::MAX_RESPONSE_BYTES {
294 return Err(missing());
295 }
296 let BlobBody::Stream { len, mut stream } = raw else {
297 return match raw {
298 BlobBody::Bytes(bytes) if bytes.len() == length => Ok(bytes),
299 _ => Err(missing()),
300 };
301 };
302 if len != length as u64 {
303 return Err(missing());
304 }
305 let mut bytes = bytes::BytesMut::with_capacity(length);
306 while let Some(chunk) = stream.next().await {
307 let chunk = chunk.map_err(|_| missing())?;
308 if chunk.len() > length - bytes.len() {
309 return Err(missing());
310 }
311 bytes.extend_from_slice(&chunk);
312 }
313 if bytes.len() != length {
314 return Err(missing());
315 }
316 Ok(bytes.freeze())
317}