Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_ore/src/gitexport.rs

35.2 KiB, 80 runs

created by r1870400018:20377, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! An emitter for git's fast-import stream, the format `git fast-import` reads.
2//!
3//! This is [`crate::fastexport`] in the other direction, and it exists for the
4//! same reason: the byte stream described by `git-fast-import(1)` is the one
5//! interface git maintains for foreign producers of a repository, so writing it
6//! keeps packfiles, the reference backend and the object hash somebody else's
7//! problem. A history held in this crate's vocabulary becomes a git repository
8//! by being spelled as this stream and handed to git.
9//!
10//! # A pure emitter
11//!
12//! Bytes out, and nothing else. Nothing here spawns a process, opens a file or
13//! reads a clock: running `git fast-import` over what this produces is the
14//! caller's job. That keeps the module portable to `wasm32-unknown-unknown`
15//! along with the rest of the crate, and it keeps it testable against a stream
16//! read back by the parser next door.
17//!
18//! # One vocabulary, both directions
19//!
20//! [`FileMode`], [`Person`], [`When`], [`TzOffset`], [`ObjRef`] and [`BlobRef`]
21//! are the parser's types, used here rather than declared again, so a stream
22//! parsed and re-emitted is expressible without a translation layer.
23//!
24//! # Refuse rather than write something wrong
25//!
26//! The parser refuses a line it cannot place rather than reading past it, and
27//! this keeps the same posture on the way out. Git will take a stream that puts
28//! a file and a directory at one path and will silently drop the file; it will
29//! abort part way through on a path holding `.git`, leaving a half-built
30//! repository behind. Both are checked here, before a byte is emitted, and named
31//! in the error.
32//!
33//! # Example
34//!
35//! ```
36//! use oxedyne_fe2o3_core::prelude::*;
37//! use oxedyne_fe2o3_ore::fastexport::{BlobRef, FileMode, Person, TzOffset, When};
38//! use oxedyne_fe2o3_ore::gitexport::{Change, Commit, Stream};
39//!
40//! # fn main() -> Outcome<()> {
41//! let who = Person {
42//! name: b"Ada Lovelace".to_vec(),
43//! email: b"ada@example.org".to_vec(),
44//! when: When { secs: 0, tz: TzOffset::new(0) },
45//! };
46//! let mut stream = Stream::new();
47//! res!(stream.commit(&Commit {
48//! refname: fmt!("refs/heads/main"),
49//! mark: Some(1),
50//! author: None,
51//! committer: who,
52//! message: b"first".to_vec(),
53//! from: None,
54//! merges: Vec::new(),
55//! changes: vec![Change::Modify {
56//! mode: FileMode::Normal,
57//! data: BlobRef::Inline(b"hello\n".to_vec()),
58//! path: b"greeting.txt".to_vec(),
59//! }],
60//! }));
61//! stream.done();
62//! assert!(stream.bytes().starts_with(b"commit refs/heads/main\n"));
63//! # Ok(())
64//! # }
65//! ```
66//!
67//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
68//! Anthropic Claude
69
70use crate::fastexport::{
71 show,
72 BlobRef,
73 FileMode,
74 ObjRef,
75 Person,
76};
77
78use oxedyne_fe2o3_core::prelude::*;
79
80use std::collections::{
81 BTreeMap,
82 BTreeSet,
83};
84
85
86// Bytes a ref name may not contain, beyond the shapes `check_refname` refuses
87// outright.
88const REF_FORBIDDEN: &[u8] = b" ~^:?*[\\";
89
90// Component length
91// Bytes, not characters, and the difference is the whole point: a component of
92// 200 accented letters passes a character count and is 400 bytes in the
93// directory entry. Git states no limit of its own -- this one is the
94// filesystem's, and 255 is what every filesystem git runs on allows a file name.
95const REF_COMPONENT_BYTES: usize = 255;
96
97
98// ---------------------------------------------------------------------------
99// What a commit says about the tree.
100// ---------------------------------------------------------------------------
101
102/// One change a commit makes to the tree.
103///
104/// The parser's `FileChange` carries the copy, the rename and the note as well,
105/// which git infers rather than records; a tree is fully described by what each
106/// path holds, so those are not spelled here.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub enum Change {
109 Modify { // `M`, sets the content and mode of a path
110 mode: FileMode,
111 data: BlobRef,
112 path: Vec<u8>,
113 },
114 // `D`. Removing a path that names a directory removes everything under it,
115 // which is git's own rule.
116 Delete {
117 path: Vec<u8>,
118 },
119 DeleteAll, // `deleteall`, emptying the tree before the rest apply
120}
121
122
123/// A `blob` command and its payload.
124///
125/// Content may be given a mark here and referred to by that mark from any
126/// number of later commits, which is what a stream does when one blob appears in
127/// many trees. Content named once is simpler given inline, as
128/// [`BlobRef::Inline`], whose bytes follow the file command as a `data` payload.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct Blob {
131 pub mark: Option<u64>,
132 pub data: Vec<u8>,
133}
134
135
136/// A `commit` command, complete with its file changes.
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct Commit {
139 pub refname: String,
140 pub mark: Option<u64>,
141 pub author: Option<Person>, // absent where it is the committer
142 pub committer: Person,
143 pub message: Vec<u8>,
144 pub from: Option<ObjRef>, // first parent, absent for a root commit
145 pub merges: Vec<ObjRef>, // the rest, in order
146 pub changes: Vec<Change>, // in the order they are applied
147}
148
149
150/// One entry of a tree: what a path holds, and what it is.
151#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct Entry {
153 pub mode: FileMode,
154 pub data: Vec<u8>,
155}
156
157
158/// A whole tree, by path.
159pub type Tree = BTreeMap<Vec<u8>, Entry>;
160
161
162// ---------------------------------------------------------------------------
163// The emitter.
164// ---------------------------------------------------------------------------
165
166/// A fast-import stream under construction.
167#[derive(Clone, Debug, Default)]
168pub struct Stream {
169 out: Vec<u8>,
170 ended: bool, // after `done`, nothing more may be written
171}
172
173impl Stream {
174
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn bytes(&self) -> &[u8] {
180 &self.out
181 }
182
183 /// Leaves the stream empty and unfinished.
184 pub fn take(&mut self) -> Vec<u8> {
185 self.ended = false;
186 std::mem::take(&mut self.out)
187 }
188
189 pub fn len(&self) -> usize {
190 self.out.len()
191 }
192
193 pub fn is_empty(&self) -> bool {
194 self.out.is_empty()
195 }
196
197 pub fn blob(&mut self, blob: &Blob)
198 -> Outcome<()>
199 {
200 res!(self.open("blob"));
201 self.out.extend_from_slice(b"blob\n");
202 if let Some(mark) = blob.mark {
203 res!(self.mark(mark));
204 }
205 self.data(&blob.data);
206 Ok(())
207 }
208
209 /// Writes a `commit` command and every change it carries.
210 pub fn commit(&mut self, commit: &Commit)
211 -> Outcome<()>
212 {
213 res!(self.open("commit"));
214 res!(check_refname(&commit.refname));
215 for change in &commit.changes {
216 res!(check_change(change));
217 }
218 self.out.extend_from_slice(b"commit ");
219 self.out.extend_from_slice(commit.refname.as_bytes());
220 self.out.push(b'\n');
221 if let Some(mark) = commit.mark {
222 res!(self.mark(mark));
223 }
224 if let Some(author) = &commit.author {
225 res!(self.identity("author", author));
226 }
227 res!(self.identity("committer", &commit.committer));
228 self.data(&commit.message);
229 if let Some(from) = &commit.from {
230 self.out.extend_from_slice(b"from ");
231 self.out.extend_from_slice(fmt!("{}", from).as_bytes());
232 self.out.push(b'\n');
233 }
234 for merge in &commit.merges {
235 self.out.extend_from_slice(b"merge ");
236 self.out.extend_from_slice(fmt!("{}", merge).as_bytes());
237 self.out.push(b'\n');
238 }
239 for change in &commit.changes {
240 self.change(change);
241 }
242 self.out.push(b'\n');
243 Ok(())
244 }
245
246 /// Writes a `reset` command, which points a reference somewhere.
247 ///
248 /// This is also how a lightweight tag is made: a reset of `refs/tags/<name>`
249 /// mints no object of its own, which is exactly what a lightweight tag is.
250 pub fn reset(&mut self, refname: &str, from: Option<&ObjRef>)
251 -> Outcome<()>
252 {
253 res!(self.open("reset"));
254 res!(check_refname(refname));
255 self.out.extend_from_slice(b"reset ");
256 self.out.extend_from_slice(refname.as_bytes());
257 self.out.push(b'\n');
258 if let Some(at) = from {
259 self.out.extend_from_slice(b"from ");
260 self.out.extend_from_slice(fmt!("{}", at).as_bytes());
261 self.out.push(b'\n');
262 }
263 self.out.push(b'\n');
264 Ok(())
265 }
266
267 /// Writes a `checkpoint` command, asking git to make its work durable.
268 pub fn checkpoint(&mut self)
269 -> Outcome<()>
270 {
271 res!(self.open("checkpoint"));
272 self.out.extend_from_slice(b"checkpoint\n\n");
273 Ok(())
274 }
275
276 /// Writes a `progress` command, whose text is for the operator.
277 pub fn progress(&mut self, text: &[u8])
278 -> Outcome<()>
279 {
280 res!(self.open("progress"));
281 if text.contains(&b'\n') {
282 return Err(err!(
283 "Progress text \"{}\" holds a line feed, which would end the command \
284 early.", show(text);
285 Invalid, Input));
286 }
287 self.out.extend_from_slice(b"progress ");
288 self.out.extend_from_slice(text);
289 self.out.extend_from_slice(b"\n\n");
290 Ok(())
291 }
292
293 /// Writes the `done` command, which says the stream ended where it meant to.
294 ///
295 /// Run git with `--done` and a stream cut short is refused rather than
296 /// half-applied, which is the difference between a mirror that failed and a
297 /// mirror that is quietly wrong.
298 pub fn done(&mut self) {
299 if !self.ended {
300 self.out.extend_from_slice(b"done\n");
301 self.ended = true;
302 }
303 }
304
305 /// Refuses a command written after `done`.
306 fn open(&self, what: &str)
307 -> Outcome<()>
308 {
309 if self.ended {
310 return Err(err!(
311 "A {} command was written after the stream said done.", what;
312 Invalid, Input, Order));
313 }
314 Ok(())
315 }
316
317 fn mark(&mut self, mark: u64)
318 -> Outcome<()>
319 {
320 if mark == 0 {
321 return Err(err!(
322 "A mark is numbered from one, and zero is how the stream spells no \
323 mark at all.";
324 Invalid, Input, Range));
325 }
326 self.out.extend_from_slice(fmt!("mark :{}\n", mark).as_bytes());
327 Ok(())
328 }
329
330 /// Writes an `author`, `committer` or `tagger` line.
331 fn identity(&mut self, what: &str, who: &Person)
332 -> Outcome<()>
333 {
334 res!(check_identity(who));
335 self.out.extend_from_slice(what.as_bytes());
336 self.out.push(b' ');
337 self.out.extend_from_slice(&who.name);
338 self.out.extend_from_slice(b" <");
339 self.out.extend_from_slice(&who.email);
340 self.out.extend_from_slice(b"> ");
341 self.out.extend_from_slice(fmt!("{}", who.when).as_bytes());
342 self.out.push(b'\n');
343 Ok(())
344 }
345
346 /// Writes a `data` command and its payload, with the trailing line feed the
347 /// format permits and git's own exporter writes.
348 fn data(&mut self, payload: &[u8]) {
349 self.out.extend_from_slice(fmt!("data {}\n", payload.len()).as_bytes());
350 self.out.extend_from_slice(payload);
351 self.out.push(b'\n');
352 }
353
354 /// Writes one file change, its path already checked.
355 fn change(&mut self, change: &Change) {
356 match change {
357 Change::Modify { mode, data, path } => {
358 self.out.extend_from_slice(b"M ");
359 self.out.extend_from_slice(mode.as_str().as_bytes());
360 self.out.push(b' ');
361 match data {
362 BlobRef::Mark(n) => self.out.extend_from_slice(fmt!(":{}", n).as_bytes()),
363 BlobRef::Name(name) => self.out.extend_from_slice(name.as_bytes()),
364 BlobRef::Inline(_) => self.out.extend_from_slice(b"inline"),
365 }
366 self.out.push(b' ');
367 self.out.extend_from_slice(&quote_path(path));
368 self.out.push(b'\n');
369 if let BlobRef::Inline(bytes) = data {
370 self.data(bytes);
371 }
372 },
373 Change::Delete { path } => {
374 self.out.extend_from_slice(b"D ");
375 self.out.extend_from_slice(&quote_path(path));
376 self.out.push(b'\n');
377 },
378 Change::DeleteAll => self.out.extend_from_slice(b"deleteall\n"),
379 }
380 }
381}
382
383
384// ---------------------------------------------------------------------------
385// From one tree to the next.
386// ---------------------------------------------------------------------------
387
388/// A commit's changes are applied over its first parent's tree, so what a commit
389/// has to say is the difference and not the whole. Removals come first, so that a
390/// path which was a file and is now a directory, or the reverse, is emptied
391/// before it is filled.
392///
393/// The whole of `next` is checked as a tree before anything is produced: git
394/// takes a stream naming both `a` and `a/b` and drops `a` without a word, which
395/// is the one failure a mirror must never have.
396pub fn changes(prev: &Tree, next: &Tree)
397 -> Outcome<Vec<Change>>
398{
399 res!(check_tree(next.keys().map(|p| p.as_slice())));
400 let mut out = Vec::new();
401 for path in prev.keys() {
402 if !next.contains_key(path) {
403 out.push(Change::Delete { path: path.clone() });
404 }
405 }
406 for (path, entry) in next {
407 if prev.get(path) == Some(entry) {
408 continue;
409 }
410 out.push(Change::Modify {
411 mode: entry.mode,
412 data: BlobRef::Inline(entry.data.clone()),
413 path: path.clone(),
414 });
415 }
416 for change in &out {
417 res!(check_change(change));
418 }
419 Ok(out)
420}
421
422
423// ---------------------------------------------------------------------------
424// What git will hold.
425// ---------------------------------------------------------------------------
426
427fn check_change(change: &Change)
428 -> Outcome<()>
429{
430 match change {
431 Change::Modify { mode, path, .. } => {
432 res!(check_path(path));
433 match mode {
434 FileMode::Normal | FileMode::Executable | FileMode::Symlink => Ok(()),
435 other => Err(err!(
436 "The path {:?} is given the mode {}, which names something that is \
437 not a file with bytes and so has no content to write.",
438 show(path), other;
439 Invalid, Input, NoImpl)),
440 }
441 },
442 Change::Delete { path } => check_path(path),
443 Change::DeleteAll => Ok(()),
444 }
445}
446
447/// Git's own refusals are fatal and part way through: a path holding `.git`
448/// aborts the import with a crash report and whatever was already applied left
449/// behind. Refusing here instead costs nothing and leaves the repository
450/// untouched.
451pub fn check_path(path: &[u8])
452 -> Outcome<()>
453{
454 if path.is_empty() {
455 return Err(err!(
456 "A file change names the empty path.";
457 Invalid, Input, Missing));
458 }
459 if path.contains(&0) {
460 return Err(err!(
461 "The path \"{}\" holds a NUL byte, which no git tree can name.", show(path);
462 Invalid, Input));
463 }
464 if path[0] == b'/' {
465 return Err(err!(
466 "The path \"{}\" is absolute, and a git tree names everything relative to \
467 the repository root.", show(path);
468 Invalid, Input));
469 }
470 if path[path.len() - 1] == b'/' {
471 return Err(err!(
472 "The path \"{}\" ends in a separator, so it names a directory rather than \
473 a file.", show(path);
474 Invalid, Input));
475 }
476 for part in path.split(|b| *b == b'/') {
477 if part.is_empty() {
478 return Err(err!(
479 "The path \"{}\" holds an empty component.", show(path);
480 Invalid, Input));
481 }
482 if part == b"." || part == b".." {
483 return Err(err!(
484 "The path \"{}\" holds the component \"{}\", which names a directory \
485 relative to another rather than a file.", show(path), show(part);
486 Invalid, Input));
487 }
488 if part.eq_ignore_ascii_case(b".git") {
489 return Err(err!(
490 "The path \"{}\" holds the component \"{}\", and a git repository \
491 keeps its own workings there.", show(path), show(part);
492 Invalid, Input, Conflict));
493 }
494 }
495 Ok(())
496}
497
498/// Git names a path either a file or a directory and never both. Handed both it
499/// keeps the directory and drops the file, saying nothing, so a caller that
500/// might hold such a state asks here first.
501pub fn check_tree<'a, I>(paths: I)
502 -> Outcome<()>
503where
504 I: IntoIterator<Item = &'a [u8]>,
505{
506 let mut files: BTreeSet<&[u8]> = BTreeSet::new();
507 let mut dirs: BTreeMap<Vec<u8>, &[u8]> = BTreeMap::new();
508 for path in paths {
509 res!(check_path(path));
510 files.insert(path);
511 let mut at = 0;
512 while let Some(i) = path[at..].iter().position(|b| *b == b'/') {
513 at += i;
514 dirs.entry(path[..at].to_vec()).or_insert(path);
515 at += 1;
516 }
517 }
518 for path in &files {
519 if let Some(under) = dirs.get(*path) {
520 return Err(err!(
521 "The path \"{}\" is a file and is also the directory holding \"{}\". \
522 Git names a path one or the other, and given both it keeps the \
523 directory and drops the file without a word.",
524 show(path), show(under);
525 Invalid, Input, Conflict));
526 }
527 }
528 Ok(())
529}
530
531fn check_identity(who: &Person)
532 -> Outcome<()>
533{
534 for (what, bytes) in [("name", &who.name), ("email", &who.email)] {
535 for b in bytes.iter() {
536 match *b {
537 0 | b'\n' | b'<' | b'>' => return Err(err!(
538 "The {} \"{}\" holds the byte \"{}\", which an identity line uses \
539 to say where its parts begin and end.",
540 what, show(bytes), show(&[*b]);
541 Invalid, Input)),
542 _ => (),
543 }
544 }
545 }
546 if who.email.is_empty() {
547 return Err(err!(
548 "An identity line gives no email, and the angle brackets that hold one are \
549 not optional.";
550 Invalid, Input, Missing));
551 }
552 Ok(())
553}
554
555/// The rules are `git-check-ref-format(1)`'s, less the ones that concern a
556/// reference spelled on a command line rather than in a stream, and one more
557/// that is not git's at all: how long a component may be.
558///
559/// That last one is the filesystem's rule and git states none, which is why a
560/// name can satisfy `git-check-ref-format(1)` and still be a reference git
561/// cannot write: a loose reference is a file named after the component, and a
562/// file name is 255 bytes. It matters here rather than at the point of failure
563/// because `git fast-import` refuses the **whole stream** over one reference it
564/// could not create, having already applied everything else, and reports it on
565/// stderr after the caller's command has returned successfully. A caller
566/// deriving a name from something a person wrote -- a commit subject, a title --
567/// cuts it to length before offering it, and this is what says so.
568pub fn check_refname(name: &str)
569 -> Outcome<()>
570{
571 let bytes = name.as_bytes();
572 if bytes.is_empty() {
573 return Err(err!(
574 "A command names the empty reference.";
575 Invalid, Input, Missing));
576 }
577 if bytes[bytes.len() - 1] == b'/' || bytes[0] == b'/' {
578 return Err(err!(
579 "The reference {:?} begins or ends with a separator.", name;
580 Invalid, Input));
581 }
582 if name.ends_with(".lock") || name.ends_with('.') {
583 return Err(err!(
584 "The reference {:?} ends in {:?}, which git reserves.",
585 name, if name.ends_with(".lock") { ".lock" } else { "." };
586 Invalid, Input, Conflict));
587 }
588 if name.contains("..") || name.contains("@{") {
589 return Err(err!(
590 "The reference {:?} holds a sequence git reads as a revision expression \
591 rather than as a name.", name;
592 Invalid, Input));
593 }
594 for b in bytes.iter() {
595 if *b < 0x20 || *b == 0x7f || REF_FORBIDDEN.contains(b) {
596 return Err(err!(
597 "The reference {:?} holds the byte \"{}\", which git does not allow in \
598 a name.", name, show(&[*b]);
599 Invalid, Input));
600 }
601 }
602 for part in name.split('/') {
603 if part.is_empty() {
604 return Err(err!(
605 "The reference {:?} holds an empty component.", name;
606 Invalid, Input));
607 }
608 if part.starts_with('.') {
609 return Err(err!(
610 "The reference {:?} holds the component {:?}, and a component may not \
611 begin with a full stop.", name, part;
612 Invalid, Input));
613 }
614 if part.len() > REF_COMPONENT_BYTES {
615 return Err(err!(
616 "The reference {:?} holds a component of {} bytes. Git keeps a loose \
617 reference in a file whose name is that component, and {} bytes is what \
618 a file name may be, so git would accept this name by its own rules and \
619 then fail to create it -- refusing the whole stream it arrived in. Cut \
620 the name to length before offering it.",
621 name, part.len(), REF_COMPONENT_BYTES;
622 Invalid, Input, Excessive));
623 }
624 }
625 Ok(())
626}
627
628/// Returns a name git will accept as a reference component, derived from `name`.
629///
630/// Every byte git refuses becomes a hyphen and a leading full stop is replaced,
631/// so the result is a function of the input and of nothing else. It is not
632/// reversible and it is not injective: a caller that needs two different names
633/// to stay different appends something that distinguishes them, since only the
634/// caller knows what that is.
635pub fn sanitise_refname(name: &str) -> String {
636 let mut out = String::new();
637 for ch in name.chars() {
638 let ok = !ch.is_control()
639 && ch != '\u{7f}'
640 && !REF_FORBIDDEN.contains(&(ch as u32 as u8).min(0x7f))
641 && !"~^:?*[\\ ".contains(ch)
642 && ch != '/';
643 if ok {
644 out.push(ch);
645 } else {
646 out.push('-');
647 }
648 }
649 while out.contains("..") {
650 out = out.replace("..", ".-.");
651 }
652 while out.contains("@{") {
653 out = out.replace("@{", "@-{");
654 }
655 if out.starts_with('.') {
656 out.replace_range(0..1, "-");
657 }
658 if out.ends_with('.') {
659 let n = out.len();
660 out.replace_range(n - 1..n, "-");
661 }
662 if out.ends_with(".lock") {
663 let n = out.len();
664 out.replace_range(n - 5..n, "-lock");
665 }
666 if out.is_empty() {
667 out.push('-');
668 }
669 out
670}
671
672/// Returns a path as the stream spells it, quoted where it has to be.
673///
674/// An unquoted path runs to the next space, so a path holding one is quoted; so
675/// is a path holding a line feed, a quotation mark, a backslash, or any byte
676/// outside printable ASCII, since those are what the C-style escapes exist for.
677/// The escapes are the ones [`crate::fastexport`] reads back.
678pub fn quote_path(path: &[u8]) -> Vec<u8> {
679 let plain = path.iter().all(|b| {
680 (0x21..=0x7e).contains(b) && *b != b'"' && *b != b'\\'
681 });
682 if plain {
683 return path.to_vec();
684 }
685 let mut out = vec![b'"'];
686 for b in path {
687 match *b {
688 0x07 => out.extend_from_slice(b"\\a"),
689 0x08 => out.extend_from_slice(b"\\b"),
690 0x0c => out.extend_from_slice(b"\\f"),
691 b'\n' => out.extend_from_slice(b"\\n"),
692 b'\r' => out.extend_from_slice(b"\\r"),
693 b'\t' => out.extend_from_slice(b"\\t"),
694 0x0b => out.extend_from_slice(b"\\v"),
695 b'"' => out.extend_from_slice(b"\\\""),
696 b'\\' => out.extend_from_slice(b"\\\\"),
697 0x20..=0x7e => out.push(*b),
698 other => out.extend_from_slice(fmt!("\\{:03o}", other).as_bytes()),
699 }
700 }
701 out.push(b'"');
702 out
703}
704
705
706#[cfg(test)]
707mod test {
708 use super::*;
709 use crate::fastexport::{
710 Event,
711 FileChange,
712 Parser,
713 TzOffset,
714 When,
715 };
716
717 fn ada() -> Person {
718 Person {
719 name: b"Ada Lovelace".to_vec(),
720 email: b"ada@example.org".to_vec(),
721 when: When { secs: 1_700_000_000, tz: TzOffset::new(600) },
722 }
723 }
724
725 fn one_file(path: &[u8], data: &[u8]) -> Commit {
726 Commit {
727 refname: fmt!("refs/heads/main"),
728 mark: Some(1),
729 author: None,
730 committer: ada(),
731 message: b"only".to_vec(),
732 from: None,
733 merges: Vec::new(),
734 changes: vec![Change::Modify {
735 mode: FileMode::Normal,
736 data: BlobRef::Inline(data.to_vec()),
737 path: path.to_vec(),
738 }],
739 }
740 }
741
742 /// Reads a stream back through the parser.
743 ///
744 /// The `done` the emitter ends with is checked for and then dropped, so a
745 /// caller counts the commands it wrote rather than the commands plus one.
746 fn reparse(bytes: &[u8])
747 -> Outcome<Vec<Event>>
748 {
749 let mut parser = Parser::new();
750 parser.feed(bytes);
751 parser.end();
752 let mut out = Vec::new();
753 while let Some(event) = res!(parser.next_event()) {
754 out.push(event);
755 }
756 match out.pop() {
757 Some(Event::Done) => (),
758 other => return Err(err!(
759 "The stream did not end with done, but with {:?}.", other; Test)),
760 }
761 Ok(out)
762 }
763
764 /// What is emitted is what the parser next door reads.
765 #[test]
766 fn a_commit_reparses_to_itself() -> Outcome<()> {
767 let mut stream = Stream::new();
768 res!(stream.commit(&one_file(b"greeting.txt", b"hello\n")));
769 stream.done();
770 let events = res!(reparse(stream.bytes()));
771 assert_eq!(events.len(), 1, "one commit, one event: {:?}", events);
772 let commit = match &events[0] {
773 Event::Commit(c) => c,
774 other => return Err(err!("Expected a commit, got {:?}.", other; Test)),
775 };
776 assert_eq!(commit.refname, "refs/heads/main");
777 assert_eq!(commit.mark, Some(1));
778 assert_eq!(commit.committer, ada());
779 assert_eq!(commit.message, b"only");
780 assert_eq!(commit.changes, vec![FileChange::Modify {
781 mode: FileMode::Normal,
782 data: BlobRef::Inline(b"hello\n".to_vec()),
783 path: b"greeting.txt".to_vec(),
784 }]);
785 Ok(())
786 }
787
788 /// A payload holding what looks like a command is length-prefixed, so the
789 /// parser reads it as content and not as a command.
790 #[test]
791 fn a_payload_that_looks_like_a_command_is_content() -> Outcome<()> {
792 let payload = b"commit refs/heads/other\ndata 3\nxxx\n\x00\xff\n";
793 let mut stream = Stream::new();
794 res!(stream.commit(&one_file(b"f", payload)));
795 stream.done();
796 let events = res!(reparse(stream.bytes()));
797 assert_eq!(events.len(), 1, "one command was written and one was read");
798 match &events[0] {
799 Event::Commit(c) => assert_eq!(c.changes, vec![FileChange::Modify {
800 mode: FileMode::Normal,
801 data: BlobRef::Inline(payload.to_vec()),
802 path: b"f".to_vec(),
803 }]),
804 other => return Err(err!("Expected a commit, got {:?}.", other; Test)),
805 }
806 Ok(())
807 }
808
809 #[test]
810 fn odd_paths_round_trip() -> Outcome<()> {
811 let paths: Vec<Vec<u8>> = vec![
812 b"plain.txt".to_vec(),
813 b"a space.txt".to_vec(),
814 b"quote\"mark".to_vec(),
815 b"back\\slash".to_vec(),
816 b"line\nfeed".to_vec(),
817 b"tab\there".to_vec(),
818 vec![0xff, 0xfe, b'.', b't'],
819 "caf\u{e9}.txt".as_bytes().to_vec(),
820 ];
821 for path in &paths {
822 let mut stream = Stream::new();
823 res!(stream.commit(&one_file(path, b"x")));
824 stream.done();
825 let events = res!(reparse(stream.bytes()));
826 let commit = match &events[0] {
827 Event::Commit(c) => c,
828 other => return Err(err!("Expected a commit, got {:?}.", other; Test)),
829 };
830 match &commit.changes[0] {
831 FileChange::Modify { path: got, .. } => assert_eq!(
832 got, path, "the path {:?} came back as {:?}", show(path), show(got)),
833 other => return Err(err!("Expected a modify, got {:?}.", other; Test)),
834 }
835 }
836 Ok(())
837 }
838
839 /// A symbolic link is a mode and a blob whose bytes are the target, which is
840 /// git's own model and needs no invention.
841 #[test]
842 fn a_symlink_is_a_mode_and_a_target() -> Outcome<()> {
843 let mut stream = Stream::new();
844 res!(stream.commit(&Commit {
845 changes: vec![Change::Modify {
846 mode: FileMode::Symlink,
847 data: BlobRef::Inline(b"elsewhere/target".to_vec()),
848 path: b"link".to_vec(),
849 }],
850 ..one_file(b"unused", b"")
851 }));
852 stream.done();
853 let text = String::from_utf8_lossy(stream.bytes()).into_owned();
854 assert!(text.contains("M 120000 inline link\n"), "the mode is spelled: {}", text);
855 assert!(text.contains("elsewhere/target"), "the target is the content: {}", text);
856 Ok(())
857 }
858
859 /// A file and the directory above it at one path is refused, because git
860 /// takes it and drops the file without a word.
861 #[test]
862 fn a_file_that_is_also_a_directory_is_refused() -> Outcome<()> {
863 let mut next = Tree::new();
864 next.insert(b"a".to_vec(), Entry { mode: FileMode::Normal, data: b"one".to_vec() });
865 next.insert(b"a/b".to_vec(), Entry { mode: FileMode::Normal, data: b"two".to_vec() });
866 let e = match changes(&Tree::new(), &next) {
867 Ok(_) => return Err(err!("The clash between a and a/b was not refused."; Test)),
868 Err(e) => fmt!("{}", e.plain()),
869 };
870 assert!(e.contains("\"a\""), "the file is named: {}", e);
871 assert!(e.contains("a/b"), "and so is what is under it: {}", e);
872 Ok(())
873 }
874
875 #[test]
876 fn a_clash_is_found_across_intervening_paths() -> Outcome<()> {
877 let mut next = Tree::new();
878 for path in [&b"a"[..], b"a!x", b"a-y", b"a/b/c"] {
879 next.insert(path.to_vec(), Entry {
880 mode: FileMode::Normal, data: b"x".to_vec(),
881 });
882 }
883 assert!(changes(&Tree::new(), &next).is_err(),
884 "a and a/b/c clash however many paths sort between them");
885 Ok(())
886 }
887
888 /// A path git aborts on is refused before a byte is emitted.
889 #[test]
890 fn paths_git_will_not_hold_are_refused() -> Outcome<()> {
891 for path in [
892 &b""[..], b"/absolute", b"trailing/", b"double//slash", b"here/./there",
893 b"up/../out", b".git/config", b"sub/.GIT/x", b"nul\0byte",
894 ] {
895 let mut stream = Stream::new();
896 assert!(stream.commit(&one_file(path, b"x")).is_err(),
897 "the path {:?} is refused", show(path));
898 assert!(stream.is_empty(),
899 "and nothing is emitted for it: {:?}", show(stream.bytes()));
900 }
901 Ok(())
902 }
903
904 #[test]
905 fn a_mode_with_no_content_is_refused() -> Outcome<()> {
906 for mode in [FileMode::Gitlink, FileMode::Subdirectory] {
907 let mut stream = Stream::new();
908 let outcome = stream.commit(&Commit {
909 changes: vec![Change::Modify {
910 mode,
911 data: BlobRef::Inline(Vec::new()),
912 path: b"thing".to_vec(),
913 }],
914 ..one_file(b"unused", b"")
915 });
916 assert!(outcome.is_err(), "the mode {} is refused", mode);
917 }
918 Ok(())
919 }
920
921 #[test]
922 fn an_identity_that_would_not_parse_is_refused() -> Outcome<()> {
923 for (name, email) in [
924 (&b"Ada <the first>"[..], &b"ada@example.org"[..]),
925 (b"Ada", b"ada@example.org>x"),
926 (b"Ada\nLovelace", b"ada@example.org"),
927 (b"Ada", b""),
928 ] {
929 let mut stream = Stream::new();
930 let outcome = stream.commit(&Commit {
931 committer: Person {
932 name: name.to_vec(),
933 email: email.to_vec(),
934 when: When { secs: 0, tz: TzOffset::new(0) },
935 },
936 ..one_file(b"f", b"x")
937 });
938 assert!(outcome.is_err(),
939 "the identity {:?} <{:?}> is refused", show(name), show(email));
940 }
941 Ok(())
942 }
943
944 /// Only the difference between two trees is written, and a removal is
945 /// written before the path it frees is filled again.
946 #[test]
947 fn only_the_difference_is_written() -> Outcome<()> {
948 let mut prev = Tree::new();
949 prev.insert(b"kept".to_vec(), Entry {
950 mode: FileMode::Normal, data: b"same".to_vec() });
951 prev.insert(b"gone".to_vec(), Entry {
952 mode: FileMode::Normal, data: b"away".to_vec() });
953 prev.insert(b"a".to_vec(), Entry {
954 mode: FileMode::Normal, data: b"file".to_vec() });
955 let mut next = Tree::new();
956 next.insert(b"kept".to_vec(), Entry {
957 mode: FileMode::Normal, data: b"same".to_vec() });
958 next.insert(b"a/b".to_vec(), Entry {
959 mode: FileMode::Normal, data: b"now a directory".to_vec() });
960 let got = res!(changes(&prev, &next));
961 assert_eq!(got, vec![
962 Change::Delete { path: b"a".to_vec() },
963 Change::Delete { path: b"gone".to_vec() },
964 Change::Modify {
965 mode: FileMode::Normal,
966 data: BlobRef::Inline(b"now a directory".to_vec()),
967 path: b"a/b".to_vec(),
968 },
969 ], "the unchanged path is not restated and the removals come first");
970 Ok(())
971 }
972
973 #[test]
974 fn a_mode_alone_is_a_change() -> Outcome<()> {
975 let mut prev = Tree::new();
976 prev.insert(b"s.sh".to_vec(), Entry {
977 mode: FileMode::Normal, data: b"#!/bin/sh\n".to_vec() });
978 let mut next = Tree::new();
979 next.insert(b"s.sh".to_vec(), Entry {
980 mode: FileMode::Executable, data: b"#!/bin/sh\n".to_vec() });
981 let got = res!(changes(&prev, &next));
982 assert_eq!(got.len(), 1, "the file is restated: {:?}", got);
983 match &got[0] {
984 Change::Modify { mode, .. } => assert_eq!(*mode, FileMode::Executable),
985 other => return Err(err!("Expected a modify, got {:?}.", other; Test)),
986 }
987 Ok(())
988 }
989
990 #[test]
991 fn reference_names_are_checked() -> Outcome<()> {
992 for name in [
993 "", "refs/heads/", "/refs/heads/main", "refs/heads/a..b",
994 "refs/heads/main.lock", "refs/heads/.hidden", "refs/heads/a b",
995 "refs/heads/a~1", "refs/heads/a@{0}", "refs/heads//main",
996 ] {
997 assert!(check_refname(name).is_err(), "the reference {:?} is refused", name);
998 }
999 for name in ["refs/heads/main", "refs/tags/v1.0", "refs/tags/a.b-c_d"] {
1000 res!(check_refname(name));
1001 }
1002 Ok(())
1003 }
1004
1005 /// A component longer than a file name may be is refused, and counted in
1006 /// bytes.
1007 ///
1008 /// The two-byte character is the whole of the second half: 200 of them are
1009 /// 200 characters and 400 bytes, so a rule counting characters passes a name
1010 /// the filesystem will not hold. Every name here is otherwise legal, which is
1011 /// what makes the length the only thing being asked about.
1012 #[test]
1013 fn a_reference_component_is_bounded_in_bytes() -> Outcome<()> {
1014 let longest = "a".repeat(255);
1015 res!(check_refname(&fmt!("refs/tags/{}", longest)));
1016 assert!(check_refname(&fmt!("refs/tags/{}a", longest)).is_err(),
1017 "one byte over the limit is refused");
1018 // Every component is asked, not only the last.
1019 assert!(check_refname(&fmt!("refs/{}a/main", longest)).is_err(),
1020 "a long component anywhere in the name is refused");
1021 // And the whole name may exceed the limit while no component does, since
1022 // the limit is on the file name and not on the path.
1023 res!(check_refname(&fmt!("refs/{}/{}", longest, longest)));
1024 let accented = "\u{e9}".repeat(200);
1025 assert_eq!(accented.chars().count(), 200, "two hundred characters");
1026 assert_eq!(accented.len(), 400, "and four hundred bytes");
1027 assert!(check_refname(&fmt!("refs/tags/{}", accented)).is_err(),
1028 "the count is of bytes, which is what the directory entry holds");
1029 Ok(())
1030 }
1031
1032 #[test]
1033 fn names_are_made_usable() -> Outcome<()> {
1034 for name in [
1035 "plain", "with a space", "a~b^c:d?e*f[g\\h", ".leading", "trailing.",
1036 "a..b", "a@{0}", "", "\u{7f}control", "sub/path", "ends.lock",
1037 ] {
1038 let made = sanitise_refname(name);
1039 res!(check_refname(&fmt!("refs/tags/{}", made)));
1040 }
1041 assert_eq!(sanitise_refname("with a space"), "with-a-space");
1042 assert_eq!(sanitise_refname("sub/path"), "sub-path");
1043 Ok(())
1044 }
1045
1046 #[test]
1047 fn nothing_follows_done() -> Outcome<()> {
1048 let mut stream = Stream::new();
1049 res!(stream.commit(&one_file(b"f", b"x")));
1050 stream.done();
1051 assert!(stream.commit(&one_file(b"g", b"y")).is_err(),
1052 "a command after done is refused");
1053 stream.done();
1054 let text = String::from_utf8_lossy(stream.bytes()).into_owned();
1055 assert_eq!(text.matches("done\n").count(), 1, "done is written once: {}", text);
1056 Ok(())
1057 }
1058
1059 /// A lightweight tag is a reset and mints no object.
1060 #[test]
1061 fn a_lightweight_tag_is_a_reset() -> Outcome<()> {
1062 let mut stream = Stream::new();
1063 res!(stream.commit(&one_file(b"f", b"x")));
1064 res!(stream.reset("refs/tags/v1", Some(&ObjRef::Mark(1))));
1065 stream.done();
1066 let events = res!(reparse(stream.bytes()));
1067 assert_eq!(events.len(), 2, "a commit and a reset: {:?}", events);
1068 match &events[1] {
1069 Event::Reset { refname, from } => {
1070 assert_eq!(refname, "refs/tags/v1");
1071 assert_eq!(*from, Some(ObjRef::Mark(1)));
1072 },
1073 other => return Err(err!("Expected a reset, got {:?}.", other; Test)),
1074 }
1075 Ok(())
1076 }
1077
1078 /// A merge is spelled with the first parent apart from the rest, which is
1079 /// the only place the stream records parent order.
1080 #[test]
1081 fn a_merge_names_its_parents_in_order() -> Outcome<()> {
1082 let mut stream = Stream::new();
1083 res!(stream.commit(&Commit {
1084 mark: Some(3),
1085 from: Some(ObjRef::Mark(1)),
1086 merges: vec![ObjRef::Mark(2)],
1087 ..one_file(b"f", b"joined")
1088 }));
1089 stream.done();
1090 let events = res!(reparse(stream.bytes()));
1091 match &events[0] {
1092 Event::Commit(c) => {
1093 assert_eq!(c.from, Some(ObjRef::Mark(1)));
1094 assert_eq!(c.merges, vec![ObjRef::Mark(2)]);
1095 },
1096 other => return Err(err!("Expected a commit, got {:?}.", other; Test)),
1097 }
1098 Ok(())
1099 }
1100
1101 /// A blob given a mark is referred to by it, so one payload serves many
1102 /// commits.
1103 #[test]
1104 fn a_marked_blob_is_referred_to() -> Outcome<()> {
1105 let mut stream = Stream::new();
1106 res!(stream.blob(&Blob { mark: Some(9), data: b"shared\n".to_vec() }));
1107 res!(stream.commit(&Commit {
1108 changes: vec![Change::Modify {
1109 mode: FileMode::Normal,
1110 data: BlobRef::Mark(9),
1111 path: b"f".to_vec(),
1112 }],
1113 ..one_file(b"unused", b"")
1114 }));
1115 stream.done();
1116 let events = res!(reparse(stream.bytes()));
1117 assert_eq!(events.len(), 2, "a blob and a commit: {:?}", events);
1118 match &events[0] {
1119 Event::Blob(b) => {
1120 assert_eq!(b.mark, Some(9));
1121 assert_eq!(b.data, b"shared\n");
1122 },
1123 other => return Err(err!("Expected a blob, got {:?}.", other; Test)),
1124 }
1125 Ok(())
1126 }
1127
1128 /// A mark is numbered from one, zero being how the stream spells none.
1129 #[test]
1130 fn mark_zero_is_refused() -> Outcome<()> {
1131 let mut stream = Stream::new();
1132 assert!(stream.blob(&Blob { mark: Some(0), data: Vec::new() }).is_err(),
1133 "a blob marked zero is refused");
1134 Ok(())
1135 }
1136
1137 #[test]
1138 fn an_empty_tree_is_a_commit_with_no_changes() -> Outcome<()> {
1139 let got = res!(changes(&Tree::new(), &Tree::new()));
1140 assert!(got.is_empty(), "nothing to say: {:?}", got);
1141 let mut stream = Stream::new();
1142 res!(stream.commit(&Commit { changes: got, ..one_file(b"unused", b"") }));
1143 stream.done();
1144 let events = res!(reparse(stream.bytes()));
1145 match &events[0] {
1146 Event::Commit(c) => assert!(c.changes.is_empty()),
1147 other => return Err(err!("Expected a commit, got {:?}.", other; Test)),
1148 }
1149 Ok(())
1150 }
1151}