1use serde::{Deserialize, Serialize};
5
6use super::invalid;
7use crate::{
8 error::Result,
9 object::{MarkerName, ThreadName},
10};
11
12pub const MAX_IMPORT_REFS: usize = api::import_authority::MAX_IMPORT_SOURCE_REFS;
18
19#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum GitObjectId {
24 Sha1([u8; 20]),
25 Sha256([u8; 32]),
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum GitRefObjectType {
33 Commit,
34 Tag,
35 Tree,
36 Blob,
37 Unknown,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum GitRefTarget {
43 Direct {
44 oid: GitObjectId,
45 object_type: GitRefObjectType,
46 },
47 Symbolic(Vec<u8>),
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct ImportRefIdentity {
55 pub raw_name: Vec<u8>,
56 pub raw_target: GitRefTarget,
57 pub peeled_commit: Option<GitObjectId>,
58}
59
60impl ImportRefIdentity {
61 pub fn direct_oid(&self) -> Option<&GitObjectId> {
62 match &self.raw_target {
63 GitRefTarget::Direct { oid, .. } => Some(oid),
64 GitRefTarget::Symbolic(_) => None,
65 }
66 }
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum GitObjectFormat {
72 Sha1,
73 Sha256,
74}
75
76impl GitObjectFormat {
77 pub(crate) fn sley(self) -> sley_core::ObjectFormat {
78 match self {
79 Self::Sha1 => sley_core::ObjectFormat::Sha1,
80 Self::Sha256 => sley_core::ObjectFormat::Sha256,
81 }
82 }
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ImportRefDisposition {
88 Branch,
89 CommitTag,
90 DefaultHead,
91 RequiredNotes,
92 Unsupported { reason: ImportSkipReason },
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum ImportSkipReason {
99 NonUtf8RefName,
100 InvalidNativeName,
101 RemoteTracking,
102 Replace,
103 Pull,
104 OtherNotes,
105 NonCommitTag,
106 SymbolicRef,
107 OtherRef,
108 DanglingUnsupported,
109}
110
111impl ImportSkipReason {
112 pub fn description(self) -> &'static str {
113 match self {
114 Self::NonUtf8RefName => "ref name is not valid UTF-8",
115 Self::InvalidNativeName => "invalid Git ref name",
116 Self::RemoteTracking => "remote-tracking ref",
117 Self::Replace => "replace ref",
118 Self::Pull => "pull ref",
119 Self::OtherNotes => "other notes ref",
120 Self::NonCommitTag => "tag does not point to a commit",
121 Self::SymbolicRef => "symbolic ref",
122 Self::OtherRef => "unsupported ref namespace",
123 Self::DanglingUnsupported => "dangling unsupported ref",
124 }
125 }
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum ImportRefFailure {
130 MissingRequiredTarget,
131 InvalidDefaultHead,
132 UnknownRequiredTarget,
133}
134
135pub fn classify_git_import_ref(
137 reference: &ImportRefIdentity,
138) -> std::result::Result<ImportRefDisposition, ImportRefFailure> {
139 let name = reference.raw_name.as_slice();
140 let Ok(utf8_name) = std::str::from_utf8(name) else {
141 return Ok(ImportRefDisposition::Unsupported {
142 reason: ImportSkipReason::NonUtf8RefName,
143 });
144 };
145 if name == b"HEAD" {
146 return match &reference.raw_target {
147 GitRefTarget::Symbolic(target)
148 if target.starts_with(b"refs/heads/") && target.len() > b"refs/heads/".len() =>
149 {
150 Ok(ImportRefDisposition::DefaultHead)
151 }
152 _ => Err(ImportRefFailure::InvalidDefaultHead),
153 };
154 }
155 if name.starts_with(b"refs/pull/") {
156 return Ok(ImportRefDisposition::Unsupported {
157 reason: ImportSkipReason::Pull,
158 });
159 }
160 if matches!(&reference.raw_target, GitRefTarget::Symbolic(_)) {
161 return Ok(ImportRefDisposition::Unsupported {
162 reason: ImportSkipReason::SymbolicRef,
163 });
164 }
165 if name == b"refs/notes/heddle" {
166 return match &reference.raw_target {
167 GitRefTarget::Direct {
168 object_type: GitRefObjectType::Commit,
169 ..
170 } => Ok(ImportRefDisposition::RequiredNotes),
171 _ => Err(ImportRefFailure::MissingRequiredTarget),
172 };
173 }
174 let kind = match &reference.raw_target {
175 GitRefTarget::Direct { object_type, .. } => object_type,
176 GitRefTarget::Symbolic(_) => return Err(ImportRefFailure::UnknownRequiredTarget),
177 };
178 if name.starts_with(b"refs/heads/") && name.len() > b"refs/heads/".len() {
179 if ThreadName::from_git_branch(&utf8_name["refs/heads/".len()..]).is_err() {
180 return Ok(ImportRefDisposition::Unsupported {
181 reason: ImportSkipReason::InvalidNativeName,
182 });
183 }
184 return match (
185 kind,
186 reference.peeled_commit.as_ref(),
187 reference.direct_oid(),
188 ) {
189 (GitRefObjectType::Commit, Some(peeled), Some(raw)) if peeled == raw => {
190 Ok(ImportRefDisposition::Branch)
191 }
192 (GitRefObjectType::Unknown, _, _) => Err(ImportRefFailure::MissingRequiredTarget),
193 _ => Err(ImportRefFailure::UnknownRequiredTarget),
194 };
195 }
196 if name.starts_with(b"refs/tags/") && name.len() > b"refs/tags/".len() {
197 if MarkerName::from_git_tag(&utf8_name["refs/tags/".len()..]).is_err() {
198 return Ok(ImportRefDisposition::Unsupported {
199 reason: ImportSkipReason::InvalidNativeName,
200 });
201 }
202 return match kind {
203 GitRefObjectType::Commit | GitRefObjectType::Tag
204 if reference.peeled_commit.is_some() =>
205 {
206 Ok(ImportRefDisposition::CommitTag)
207 }
208 GitRefObjectType::Tag | GitRefObjectType::Tree | GitRefObjectType::Blob => {
209 Ok(ImportRefDisposition::Unsupported {
210 reason: ImportSkipReason::NonCommitTag,
211 })
212 }
213 GitRefObjectType::Unknown => Err(ImportRefFailure::MissingRequiredTarget),
214 _ => Err(ImportRefFailure::UnknownRequiredTarget),
215 };
216 }
217 let reason = if *kind == GitRefObjectType::Unknown {
218 ImportSkipReason::DanglingUnsupported
219 } else if name.starts_with(b"refs/remotes/") {
220 ImportSkipReason::RemoteTracking
221 } else if name.starts_with(b"refs/replace/") {
222 ImportSkipReason::Replace
223 } else if name.starts_with(b"refs/notes/") {
224 ImportSkipReason::OtherNotes
225 } else {
226 ImportSkipReason::OtherRef
227 };
228 Ok(ImportRefDisposition::Unsupported { reason })
229}
230
231#[derive(Clone, Debug, PartialEq, Eq)]
232pub struct SkippedImportRef {
233 pub raw_name: Vec<u8>,
234 pub reason: ImportSkipReason,
235}
236
237impl SkippedImportRef {
238 pub fn display_name(&self) -> String {
241 std::str::from_utf8(&self.raw_name)
242 .map_or_else(|_| self.raw_name.escape_ascii().to_string(), str::to_owned)
243 }
244}
245
246#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct ClassifiedImportRefs {
250 pub default_branch: Vec<u8>,
251 pub dispositions: Vec<ImportRefDisposition>,
252 pub skipped_refs: Vec<SkippedImportRef>,
253 pub native_ref_count: u32,
254 pub partial: bool,
255}
256
257pub fn classify_frozen_import_refs(refs: &[ImportRefIdentity]) -> Result<ClassifiedImportRefs> {
258 let mut native_ref_count = 0usize;
259 let mut dispositions = Vec::with_capacity(refs.len());
260 let mut skipped_refs = Vec::new();
261 let mut default_branch = None;
262 let mut last_name: Option<&[u8]> = None;
263 let mut partial = false;
264 for reference in refs {
265 if reference.raw_name.is_empty()
266 || last_name.is_some_and(|last| last >= reference.raw_name.as_slice())
267 {
268 return Err(invalid(
269 "Git import refs are not strictly sorted by raw name",
270 ));
271 }
272 last_name = Some(&reference.raw_name);
273 let disposition = classify_git_import_ref(reference)
274 .map_err(|error| invalid(format!("Git import ref cannot be represented: {error:?}")))?;
275 if disposition == ImportRefDisposition::DefaultHead {
276 let GitRefTarget::Symbolic(target) = &reference.raw_target else {
277 return Err(invalid("default HEAD is not symbolic"));
278 };
279 default_branch = Some(target.clone());
280 }
281 if matches!(
282 disposition,
283 ImportRefDisposition::Branch | ImportRefDisposition::CommitTag
284 ) {
285 native_ref_count += 1;
286 if native_ref_count > MAX_IMPORT_REFS {
287 return Err(invalid(format!(
288 "Git import has more than {MAX_IMPORT_REFS} native refs"
289 )));
290 }
291 }
292 if let ImportRefDisposition::Unsupported { reason } = disposition
294 && reason != ImportSkipReason::Pull
295 {
296 skipped_refs.push(SkippedImportRef {
297 raw_name: reference.raw_name.clone(),
298 reason,
299 });
300 partial = true;
301 }
302 dispositions.push(disposition);
303 }
304 let default_branch =
305 default_branch.ok_or_else(|| invalid("Git import has no symbolic HEAD"))?;
306 let default_index = refs
307 .binary_search_by(|reference| reference.raw_name.cmp(&default_branch))
308 .map_err(|_| invalid("Git import HEAD points to an absent branch"))?;
309 if dispositions.get(default_index) != Some(&ImportRefDisposition::Branch) {
310 return Err(invalid(
311 "Git import HEAD does not point to a supported branch",
312 ));
313 }
314 Ok(ClassifiedImportRefs {
315 default_branch,
316 dispositions,
317 skipped_refs,
318 native_ref_count: native_ref_count as u32,
319 partial,
320 })
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 #[test]
327 fn unrepresentable_ref_kinds_have_explicit_partial_outcomes() {
328 let target = GitObjectId::Sha1([1; 20]);
329 for (name, object_type, reason) in [
330 (
331 b"refs/remotes/origin/main".as_slice(),
332 GitRefObjectType::Commit,
333 ImportSkipReason::RemoteTracking,
334 ),
335 (
336 b"refs/replace/abc".as_slice(),
337 GitRefObjectType::Commit,
338 ImportSkipReason::Replace,
339 ),
340 (
341 b"refs/pull/12/head".as_slice(),
342 GitRefObjectType::Commit,
343 ImportSkipReason::Pull,
344 ),
345 (
346 b"refs/notes/other".as_slice(),
347 GitRefObjectType::Commit,
348 ImportSkipReason::OtherNotes,
349 ),
350 (
351 b"refs/tags/blob".as_slice(),
352 GitRefObjectType::Blob,
353 ImportSkipReason::NonCommitTag,
354 ),
355 (
356 b"refs/tags/tree".as_slice(),
357 GitRefObjectType::Tree,
358 ImportSkipReason::NonCommitTag,
359 ),
360 (
361 b"refs/custom/x".as_slice(),
362 GitRefObjectType::Commit,
363 ImportSkipReason::OtherRef,
364 ),
365 (
366 b"refs/custom/dangling".as_slice(),
367 GitRefObjectType::Unknown,
368 ImportSkipReason::DanglingUnsupported,
369 ),
370 ] {
371 let reference = ImportRefIdentity {
372 raw_name: name.to_vec(),
373 raw_target: GitRefTarget::Direct {
374 oid: target.clone(),
375 object_type,
376 },
377 peeled_commit: None,
378 };
379 assert_eq!(
380 classify_git_import_ref(&reference),
381 Ok(ImportRefDisposition::Unsupported { reason }),
382 "{}",
383 String::from_utf8_lossy(name),
384 );
385 }
386 let symbolic = ImportRefIdentity {
387 raw_name: b"refs/remotes/origin/HEAD".to_vec(),
388 raw_target: GitRefTarget::Symbolic(b"refs/remotes/origin/main".to_vec()),
389 peeled_commit: None,
390 };
391 assert_eq!(
392 classify_git_import_ref(&symbolic),
393 Ok(ImportRefDisposition::Unsupported {
394 reason: ImportSkipReason::SymbolicRef,
395 })
396 );
397 for name in [b"refs/heads/main".as_slice(), b"refs/tags/v1".as_slice()] {
398 let dangling = ImportRefIdentity {
399 raw_name: name.to_vec(),
400 raw_target: GitRefTarget::Direct {
401 oid: target.clone(),
402 object_type: GitRefObjectType::Unknown,
403 },
404 peeled_commit: None,
405 };
406 assert_eq!(
407 classify_git_import_ref(&dangling),
408 Err(ImportRefFailure::MissingRequiredTarget)
409 );
410 }
411 let detached_head = ImportRefIdentity {
412 raw_name: b"HEAD".to_vec(),
413 raw_target: GitRefTarget::Direct {
414 oid: target,
415 object_type: GitRefObjectType::Commit,
416 },
417 peeled_commit: None,
418 };
419 assert_eq!(
420 classify_git_import_ref(&detached_head),
421 Err(ImportRefFailure::InvalidDefaultHead)
422 );
423 }
424
425 #[test]
426 fn github_provider_refs_do_not_make_import_partial() {
427 let commit = GitObjectId::Sha1([4; 20]);
428 let mut refs = vec![ImportRefIdentity {
429 raw_name: b"HEAD".to_vec(),
430 raw_target: GitRefTarget::Symbolic(b"refs/heads/main".to_vec()),
431 peeled_commit: None,
432 }];
433 for name in [
434 b"refs/heads/main".as_slice(),
435 b"refs/pull/12/head".as_slice(),
436 b"refs/pull/12/merge".as_slice(),
437 b"refs/tags/v1".as_slice(),
438 ] {
439 refs.push(ImportRefIdentity {
440 raw_name: name.to_vec(),
441 raw_target: GitRefTarget::Direct {
442 oid: commit.clone(),
443 object_type: GitRefObjectType::Commit,
444 },
445 peeled_commit: Some(commit.clone()),
446 });
447 }
448 let classified = classify_frozen_import_refs(&refs).expect("GitHub-shaped refs");
449 assert!(!classified.partial);
450 assert!(classified.skipped_refs.is_empty());
451 assert_eq!(classified.native_ref_count, 2);
452 assert_eq!(classified.default_branch, b"refs/heads/main");
453 }
454
455 #[test]
456 fn complete_ref_classification_keeps_unsupported_refs_and_requires_head() {
457 let commit = GitObjectId::Sha1([4; 20]);
458 let head = ImportRefIdentity {
459 raw_name: b"HEAD".to_vec(),
460 raw_target: GitRefTarget::Symbolic(b"refs/heads/main".to_vec()),
461 peeled_commit: Some(commit.clone()),
462 };
463 let branch = ImportRefIdentity {
464 raw_name: b"refs/heads/main".to_vec(),
465 raw_target: GitRefTarget::Direct {
466 oid: commit.clone(),
467 object_type: GitRefObjectType::Commit,
468 },
469 peeled_commit: Some(commit.clone()),
470 };
471 let remote = ImportRefIdentity {
472 raw_name: b"refs/remotes/origin/main".to_vec(),
473 raw_target: GitRefTarget::Direct {
474 oid: commit,
475 object_type: GitRefObjectType::Commit,
476 },
477 peeled_commit: None,
478 };
479 let refs = vec![head, branch, remote];
480 let result = classify_frozen_import_refs(&refs).expect("complete snapshot");
481 assert_eq!(result.default_branch, b"refs/heads/main");
482 assert_eq!(result.native_ref_count, 1);
483 assert!(result.partial);
484 assert_eq!(
485 result.skipped_refs,
486 vec![SkippedImportRef {
487 raw_name: b"refs/remotes/origin/main".to_vec(),
488 reason: ImportSkipReason::RemoteTracking,
489 }]
490 );
491 assert_eq!(result.dispositions.len(), refs.len());
492 assert!(classify_frozen_import_refs(&refs[1..]).is_err());
493 let mut moved = refs;
494 moved[0].raw_target = GitRefTarget::Symbolic(b"refs/heads/absent".to_vec());
495 assert!(classify_frozen_import_refs(&moved).is_err());
496 }
497
498 fn frozen_refs_with_native(native: usize) -> Vec<ImportRefIdentity> {
500 let commit = GitObjectId::Sha1([5; 20]);
501 let direct = |raw_name: String| ImportRefIdentity {
502 raw_name: raw_name.into_bytes(),
503 raw_target: GitRefTarget::Direct {
504 oid: commit.clone(),
505 object_type: GitRefObjectType::Commit,
506 },
507 peeled_commit: Some(commit.clone()),
508 };
509 let mut refs = vec![ImportRefIdentity {
510 raw_name: b"HEAD".to_vec(),
511 raw_target: GitRefTarget::Symbolic(b"refs/heads/b-00000".to_vec()),
512 peeled_commit: None,
513 }];
514 refs.extend(
515 (0..native)
516 .step_by(2)
517 .map(|i| direct(format!("refs/heads/b-{i:05}"))),
518 );
519 refs.extend(
520 (1..native)
521 .step_by(2)
522 .map(|i| direct(format!("refs/tags/t-{i:05}"))),
523 );
524 refs
525 }
526
527 #[test]
528 fn native_ref_bound_admits_4096_and_refuses_4097() {
529 assert_eq!(
531 MAX_IMPORT_REFS,
532 api::import_authority::MAX_IMPORT_SOURCE_REFS
533 );
534 assert_eq!(MAX_IMPORT_REFS, 4096);
535 for native in [600, MAX_IMPORT_REFS] {
536 let classified = classify_frozen_import_refs(&frozen_refs_with_native(native))
537 .unwrap_or_else(|error| panic!("{native} native refs: {error}"));
538 assert_eq!(classified.native_ref_count as usize, native);
539 assert!(!classified.partial);
540 }
541 let error = classify_frozen_import_refs(&frozen_refs_with_native(MAX_IMPORT_REFS + 1))
542 .expect_err("one native ref past the bound");
543 assert!(
544 error
545 .to_string()
546 .contains("Git import has more than 4096 native refs"),
547 "{error}"
548 );
549 }
550
551 #[test]
552 fn reserved_branch_is_admitted_for_escaped_native_emission() {
553 let oid = GitObjectId::Sha1([8; 20]);
554 let reference = ImportRefIdentity {
555 raw_name: b"refs/heads/heddle/reserved".to_vec(),
556 raw_target: GitRefTarget::Direct {
557 oid: oid.clone(),
558 object_type: GitRefObjectType::Commit,
559 },
560 peeled_commit: Some(oid),
561 };
562 assert!(matches!(
563 classify_git_import_ref(&reference),
564 Ok(ImportRefDisposition::Branch)
565 ));
566 }
567}