Licence CC 0

p2p internals #4 - Disséquons git

Découvrons qu'est-ce qui se trame derrière un git clone

Continuons cette série d’articles sur les systèmes distribués en parlant d’un outil que beaucoup utilisent chaque jour: git.

Git est un outil de versionnage. Contrairement à certains systèmes tels que subversion, git ne dépend pas d’un serveur. Chaque personne possède une copie plus ou moins partielle du projet qui peut être transmise d’une personne à une autre directement. Alors, même si aujourd’hui une grande partie des gens utilisent des systèmes comme Gitea, GitLab, GitHub, etc. il est aussi tout à fait possible de faire sans (en cas de panne de l’hébergeur par exemple).

Dans cet article, je ne veux pas expliquer comment utiliser git. Des milliers d’articles à ce propos existent déjà (exemple : https://try.github.io/), et si tu cherches à savoir comment utiliser git, cet article n’est pas pour toi. Mais je vais essayer d’expliquer ce qu’il se passe lorsque quelqu’un tape git clone https://github.com/zestedesavoir/zmarkdown/ dans son terminal d’un point de vue réseau.

Pour se faire, nous allons réaliser un petit programme (en Rust, mais ce n’est pas vraiment important) qui va cloner un dépot sur un protocole inventé pour les besoins de cet article (qu’on appellera WOLF1).


  1. git transmet des pack-files. On peut donc parler de wolf-pack

Un transport git c'est quoi ?

Les différentes couches du transport

Pour échanger des données entre un serveur et un client, git utilise des packfiles transférées via ce qu’on appelle le pack-protocol ou le http-protocol. Ces deux protocoles ont des logiques relativement similaires, mais dans la suite de ce billet, on regardera seulement le pack-protocol. La définition du http-protocol étant laissé à la curiosité du lecteur (mais sa documentation est encore incomplète).

Ces deux protocoles sont composés de deux services :

  1. upload-pack (côté serveur, fetch-pack côté client) qui permet de transférer des données du serveur vers le client. On se concentrera sur ce service dans la suite du billet.
  2. received-pack (côté serveur, send-pack côté client) pour envoyer des données d’un client à un serveur.

Finalement, ces protocoles sont utilisés par dessus différents transports pour transmettre les paquets. De base, il est possible d’utiliser 4 types de transports : ssh:// (SSH), file:// (un pipe), git:// (un simple serveur TCP, généralement combiné avec SSH pour l’authentification) pour le pack-protocol et http:// pour le http-protocol.

Les protocoles HTTP sont de 2 types. Les Dumb qui sont de simples serveurs web proposant des fichiers, et les smart répondant à des services git.

Pour résumer, voici ce que j’appelle un transport git :

upload-pack OU receive-pack

pack-protocol + pack-format

http-protocol + pack-format

Protocole de communication tel que ssh://, file://, git://.

http:// ou https://.

Et voici ce que nous allons réaliser:

upload-pack

pack-protocol + pack-format

wolf://

Finalement les protocoles git définissent deux grandes notions, le format pkt-line et le format pack.

Le format pkt-line

Une pkt-line est une chaîne binaire. Les 4 premiers octets de cette chaîne sont utilisés pour stocker la taille totale de la ligne au format hexadécimal. Ainsi, une pkt-line débutant par 00230023 fera donc 3535 octets de long.

Le format a quelques autres spécificités comme :

  • Si la chaîne est non binaire (ne contient pas de \0) elle devrait finir par un LF (\n) qui doit être inclu dans la taille.
  • Il est interdit d’envoyer un paquet vide (00040004). Ce paquet vide est appellé flush-pkt est s’écrit 00000000.
  • La taille maximale d’un pkt-line est de 65520 au total (65516 de données).

Exemple, pour envoyer "foobar\n" on envoie "000bfoobar\n".

Le format pack

Une fois la partie négociation réalisée. Il est alors nécessaire de transmettre les objets. Ces objets sont transférés via le format pack qui peut faire l’objet d’un article complet. Mais comme de nombreuses bibliothèques (comme libgit2 que nous allons utiliser) fournissent déjà toute l’abstraction nécessaire pour générer des données dans ce format, je ne vais pas m’attarder dessus.

Cependant, si vous souhaitez comprendre exactement ce que contient un packfile, je vous recommande la lecture de cette page : https://git-scm.com/docs/pack-format

Pour résumer, voici généralement à quoi ressemble le contenu de ce format :

En-tête

données

checksum

exemple

signature (PACK)

version (2)

nombre d’objets

type

contenu

trailer

Les données qui peuvent être transférées sont généralement de 4 types possibles (commit, tree, tag, blob) et peuvent être compressées dans un format appellé Deltified representation.

La commande inutile donc indispensable : upload-pack

git est une commande qui possède une très longue liste de sous-programmes. Certains ne vous serviront (heureusement) jamais mais elles possèdent tout de même un petit intérêt ; comme par exemple git http-backend permettant de rouler rapidement un serveur git accessible en HTTP (et donc de jouer avec le http-protocol que je mentionnais). Cependant, certaines de ces commandes n’existent que pour être invoquées par d’autres (mais jamais par l’utilisateur). C’est par exemple le cas de la commande que je vous propose de découvrir, git upload-pack qui va nous permettre de jouer avec le pack-protocol.

git upload-pack est une commande invoquée par git fetch-pack utilisée par git fetch. En d’autres termes, git upload-pack permet de faire un git fetch ou git clone de la manière la plus manuelle possible.

Ainsi, voici à quoi ressemble l’utilisation de git upload-pack sur le repo de ZMarkdown:

# https://github.com/zestedesavoir/zmarkdown a été cloné pour simplifier la trace.
# via `git clone git@github.com:zestedesavoir/zmarkdown.git`
# 7e31d5dcc19fa412df5674676f5fb7092d035275 est le commit pointé par master au moment où j'écris.

$ echo "0032want 7e31d5dcc19fa412df5674676f5fb7092d035275\n00000009done\n" | git upload-pack zmarkdown > dump_git_upload_pack

Pour résumer ce que fait cette commande, elle demande de récupérer tout l’arbre de commits situé en dessous du commit 7e31d5dcc19fa412df5674676f5fb7092d0352757e31d5dcc19fa412df5674676f5fb7092d035275 et d’écrire tout le contenu entrant dans le fichier dump_git_upload_pack.

Ce fichier servira de base pour la seconde partie mais n’est pas important à regarder. Par contre si un jour vous avez besoin de debugguer ce qu’un serveur git vous envoie, je peux conseiller de le lire avec xxd dump_git_upload_pack | less ou less dump_git_upload_pack pour les premières lignes.

Réalisons notre propre serveur git

Maintenant que nous possèdons les bases de comment fonctionne git, il est temps de mettre ces connaissances en pratique et de creuser un peu en réalisant un petit serveur git qui permettra de répondre à un git clone ou git fetch sur un protocole custom.

Le code complet sera disponible dans la partie suivante.

Découverte des références

Ainsi, comme nous l’avons vu, la première chose que réalisera notre client est d’envoyer la commande git-upload-pack. Le format tel que précisé par la documentation est le suivant :

pkt_len (4 octets)

git-upload-pack

space

pathname

\0

host-parameter

\0

\0

extra-parameters

\0

Par exemple, notre client va envoyer

003dgit-upload-pack zmarkdown.git\0host=localhost\0\0version=1\0

À noter que la partie extra-parameters est optionelle et ne supporte que version=1 pour le moment. Donc ceci est valide :

0032git-upload-pack zmarkdown.git\0host=localhost\0

Puis le travail de notre serveur va commencer en envoyant ses références. Ainsi, voici à quoi va commencer à ressembler notre code :

impl Server {
    // La boucle principale de notre server, il lit juste ce que le client envoie
    // (qui sera un vecteur d'octets (Vec<u8>)
    pub fn read(&mut self) {
        loop {
            let buf = self.channel.lock().unwrap().recv().unwrap();
            self.recv(buf);
        }
    }

    fn recv(&mut self, buf: Vec<u8>) {
        let mut buf = Some(buf);
        let mut need_more_parsing = true;
        while need_more_parsing {
            need_more_parsing = self.parse(buf.take());
        }
    }

    // Fonction qui servira à traiter les données entrantes
    fn parse(&mut self, mut buf: Option<Vec<u8>>) -> bool {
        // Le client pouvant envoyer plusieurs pkt-line, self.buf sert de cache.
        if buf.is_some() {
            self.buf.append(&mut buf.unwrap());
        }
        // Les 4 premiers octets définissent une taille (>4, 0000 étant un FLUSH)
        let pkt_len = str::from_utf8(&self.buf[0..4]).unwrap();
        let pkt_len = max(4 as usize, i64::from_str_radix(pkt_len, 16).unwrap() as usize);
        let pkt : Vec<u8> = self.buf.drain(0..pkt_len).collect();
        let pkt = str::from_utf8(&pkt[0..pkt_len]).unwrap();
        println!("Received pkt: {}", pkt);

        if pkt.find(UPLOAD_PACK_CMD) == Some(4) {
            // Cf: https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L166
            // Notre git-upload-pack est détecté. Pour simplifier on ignore les *extra-parameters*
            println!("Upload pack command detected");
            // Envoyons nos références
            self.send_references_capabilities();
        }
        // Si d'autres pkt-line doivent être traitées, le cache ne sera pas vide
        self.buf.len() != 0
    }
}

Maintenant, concentrons nous sur self.send_references_capabilities();. Voici ce que nous dit la référence :

  • Tout d’abord le serveur doit répondre par sa version si version est passée dans les extra-parameters (pas le cas ici, mais le paquet attendu est 000eversion1000eversion 1),
  • Puis la liste de toutes les références connues et des hashs pointés,
  • La liste des références est ordonnée,
  • Si HEAD est présent, alors HEAD doit être la première référence affichée,
  • La première référence est suivie par \0 puis les capacités supportées par le serveur.
  • La liste est terminée par un paquet FLUSH.

Ce qu’on peut donc traduire par :

fn send_references_capabilities(&self) {
    let current_head = self.repository.refname_to_id("HEAD").unwrap();
    let mut capabilities = format!("{} HEAD\0side-band side-band-64k shallow no-progress include-tag", current_head);
    capabilities = format!("{:04x}{}\n", capabilities.len() + 5 /* taille + \n */, capabilities);

    for name in self.repository.referencess().unwrap().names() {
        let reference : &str = name.unwrap();
        let oid = self.repository.refname_to_id(reference).unwrap();
        capabilities += &*format!("{:04x}{} {}\n", 6 /* taille + espace + \n */ + 40 /* oid */ + reference.len(), oid, reference);
    }

    print!("Send: {}", capabilities);
    self.channel.lock().unwrap().send(capabilities.as_bytes().to_vec()).unwrap();
    println!("Send: {}", FLUSH_PKT);
    self.channel.lock().unwrap().send(FLUSH_PKT.as_bytes().to_vec()).unwrap();
}

Le serveur peut proposer énormément d’options que je ne vais pas détailler ici. Mais par exemple, si le client demande à l’étape suivante side-band et side-band-64k, le serveur va devoir envoyer les données sous format multiplexé, de maximum 999999 octets pour side-band, 6551965519 pour side-band-64k + 11 octet de contrôle (je reviendrais dessus). no-progress fait en sorte que le serveur n’envoie pas la progression. Le reste des propriétés sont décrites ici.

Finalement, voici à quoi ressemble notre début de communication :

C: 0028git-upload-pack /zdshost=localhost\0host=localhost\0
S: 006ac29eb686c3638633bff5b852ee1ed76211882ba1 HEAD\0side-band side-band-64k shallow no-progress include-tag
S: 003f7300c6f39366e2c0bcaf99efc05cc45e8511a384 refs/heads/master
S: 0046c29eb686c3638633bff5b852ee1ed76211882ba1 refs/remotes/origin/HEAD
S: 0048c29eb686c3638633bff5b852ee1ed76211882ba1 refs/remotes/origin/master
S: 0000

Maintenant que le client sait ce que le serveur peut proposer, il lui est possible de demander ce qu’il souhaite. C’est parti pour la partie négociation !

Négociation du Packfile

Si le client ne souhaite rien (par exemple si il ne cherche qu’à réaliser un ls-remote) il peut terminer directement la connexion via un FLUSH, mais généralement le client va rentrer dans une phase de négociation. Le client va alors annoncer les commits qu’il souhaite, possède, et les options supportées.

Le client doit au moins envoyer une ligne want, et les commits référencés doivent être ceux proposés par le serveur à la phase précédente. Si le client possède des objets dont il possède une shallow copy il doit le dire. Il annonce aussi la profondeur maximale de l’historique souhaitée. Ensuite, et dans le but d’obtenir le packfile minimum le client envoie la liste des (au maximum 3232) commits qu’il possède.

Puis le serveur termine en envoyant le ACK pour les objets qu’il possède. Pour se faire, trois modes existent.

  • Le multi-ack où :
    • Il répond `ACK obj-id continue' pour les commits communs.
    • ACK pour tous les commits une fois qu’un commit commun est trouvé
    • NAK pour attendre une nouvelle réponse du client (done ou de nouveaux have).
  • Le multi-ack-detailed :
    • ACK obj-id ready et ACK obj-id common peuvent être envoyés
  • sinon :
    • ACK obj-id sur le premier objet commun puis plus rien tant que le client n’envoie pas done.
    • NAK si il reçoit un paquet FLUSH est qu’aucun paquet commun n’a été trouvé.

Le client décide alors si les informations données par le serveur sont suffisantes et peut alors soit redemander des informations en complétant sa liste de have (jusqu’à 256256 have (sinon le serveur enverra juste tous ces objets)) et envoie un done lorsqu’il a terminé.

Le serveur termine cette phase en envoyant soit un dernier NAK (si pas de commit commun) ou un ACK obj-id final.

Ce qui nous donne par exemple :

Pour un clone :

C: 004dwant c42412dfee248e8acbd21df6038991dff6fd1b2a side-band-64k include-tag\n
C: 0000
C: 0009done\n
S: 0008NAK\n

Ou pour un fetch :

C: 004dwant c42412dfee248e8acbd21df6038991dff6fd1b2a side-band-64k include-tag\n
C: 0000
C: 0032have eafc4105f398a014c97e0db4b171d787aeeef0d7\n
C: 0000
S: 003aACK c42412dfee248e8acbd21df6038991dff6fd1b2a continue\n
S: 0008NAK\n
C: 0009done\n
S: 0031ACK c42412dfee248e8acbd21df6038991dff6fd1b2a\n

Envoi des données

Finalement, la dernière étape est d’envoyer les objets dans un packfile. libgit2 possède l’objet PackBuilder qui va être utile pour préparer les données. De notre côté, il suffit juste d’ajouter les commits attendus. On va donc ajouter tous les commits se situant entre le commit souhaité (via le want) jusqu’au commit que le client possède forcément (si des merge commits sont trouvés, il faut parcourir les différentes branches mergées jusqu’à la racine commune).

Une subtilité existe tout de même pour l’envoi des données. Le serveur que nous réalisons offre la capacité side-band-64k. Ce qui signifie que notre pack-file sera découpé en morceaux de 64k. Le paquet sera donc du format décrit un peu plus haut : 4 octets pour la taille, 1 pour l’octet de contrôle (0x10x1 ici car pour les données. 0x20x2 est utilisé pour envoyer la progression 0x30x3 pour les erreurs), 6551565515 octets de données.

Et le tout est terminé par un paquet FLUSH.

Ce qui nous donne :

fn send_pack_data(&self) {
        println!("Send: [PACKFILE]");
        // Note: Ici, on ajoute tous les commits tant qu'on à pas
        // trouvé un commit annoncé par le client et qu'on ne sois
        // pas sûr d'avoir parcouru toutes les branches (ou jusqu'au
        // commit initial).
        let mut pb = self.repository.packbuilder().unwrap();
        let fetched = Oid::from_str(&*self.wanted).unwrap();
        let mut revwalk = self.repository.revwalk().unwrap();
        let _ = revwalk.push(fetched);
        let _ = revwalk.set_sorting(Sort::TOPOLOGICAL);

        let mut parents : Vec<String> = Vec::new();
        let mut have = false;

        while let Some(oid) = revwalk.next() {
            let oid = oid.unwrap();
            let oid_str = oid.to_string();
            have |= self.have.iter().find(|&o| *o == oid_str).is_some();
            if let Some(pos) = parents.iter().position(|p| *p == oid_str) {
                parents.remove(pos);
            }
            if have && parents.is_empty() {
                // Commit initial
                break;
            }
            let _ = pb.insert_commit(oid);
            let commit = self.repository.find_commit(oid).unwrap();
            let mut commit_parents = commit.parents();
            // On doit être sûr de parcourir tous les chemins
            while let Some(p) = commit_parents.next() {
                parents.push(p.id().to_string());
            }
        }

        // Note: Packbuilder possède des fonctions pour écrire des chunks et pas tout le packfile d'un coup.
        let mut data = Buf::new();
        let _ = pb.write_buf(&mut data);

        let len = data.len();
        let data : Vec<u8> = data.to_vec();
        let mut sent = 0;
        while sent < len {
            // cf https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L166
            // Si side-band-64k est spécifié, on doit envoyer jusqu'à 65519 octets + 1 de contrôle par pkt-line.
            let pkt_size = min(65515, len - sent);
            // Le paquet: Size (4 octets), Control byte (0x01 pour les données), pack data.
            let pkt = format!("{:04x}", pkt_size + 5 /* taille + control */);
            self.channel.lock().unwrap().send(pkt.as_bytes().to_vec()).unwrap();
            self.channel.lock().unwrap().send(b"\x01".to_vec()).unwrap();
            self.channel.lock().unwrap().send(data[sent..(sent+pkt_size)].to_vec()).unwrap();
            sent += pkt_size;
        }

        println!("Send: {}", FLUSH_PKT);
        // Et on fini par un FLUSH
        self.channel.lock().unwrap().send(FLUSH_PKT.as_bytes().to_vec()).unwrap();
    }

Et voilà, le client a récupéré les données souhaitées, il peut maintenant fermer la connexion !

Testons notre serveur

Notre serveur minimal est dorénavant opérationel. On peut le tester avec un vrai répertoire à cloner.

Le petit programme de test que je propose se décompose en 3 parties. La première partie décrit le transport pour libgit2. Cette bibliothèque est très pratique et pas mal utilisée pour réaliser différents programmes utilisant git. Elle possède un wrapper Rust que je vais utiliser ici : git2-rs. Le problème est que la documentation est incomplète, surtout pour la partie parlant des smart-transports, donc je vous invite à trouver des exemples implémentant ce que vous souhaitez si un jour vous en avez besoin. En tout cas, voici mon fichier src/wolftransport.rs qui définit un smart-transport utilisant le protocole WOLF. Ce protocole ajoute devant tous les paquets envoyés un header de 4 bytes: WOLFWOLF.

src/wolftransport.rs :

// https://docs.rs/git2/0.13.17/git2/transport/fn.register.html

use bichannel::{ SendError, RecvError };
use git2::Error;
use git2::transport::SmartSubtransportStream;
use git2::transport::{Service, SmartSubtransport, Transport};
use std::io;
use std::io::prelude::*;
use std::sync::{Arc, Mutex};

static HOST_TAG: &str = "host=";

// Note : Ce transport est purement fictif et ne fait que ajouter un header
// de 4 octets: "WOLF"
pub struct WolfChannel
{
    pub channel: bichannel::Channel<Vec<u8>, Vec<u8>>,
}

impl WolfChannel
{
    pub fn recv(&self) -> Result<Vec<u8>, RecvError> {
        let res = self.channel.recv();
        if !res.is_ok() {
            return res;
        }
        let mut res = res.unwrap();
        res.drain(0..4);
        Ok(res)
    }

    pub fn send(&self, data: Vec<u8>) -> Result<(), SendError<Vec<u8>>> {
        let mut to_send = "WOLF".as_bytes().to_vec();
        to_send.extend(data);
        self.channel.send(to_send)
    }
}

pub type Channel = Arc<Mutex<WolfChannel>>;

// Maintenant, écrivons notre smart transport pour git2-rs répondant au scheme wolf://
struct WolfTransport {
    channel: Channel,
}

struct WolfSubTransport {
    action: Service,
    channel: Channel,
    url: String,
    sent_request: bool
}

pub unsafe fn register(channel: Channel) {
    git2::transport::register("wolf", move |remote| factory(remote, channel.clone())).unwrap();
}

fn factory(remote: &git2::Remote<'_>, channel: Channel) -> Result<Transport, Error> {
    Transport::smart(
        remote,
        false, // rpc = false, signifie que notre channel est connecté durant tout le long de la transaction
        WolfTransport {
            channel
        },
    )
}

impl SmartSubtransport for WolfTransport {
    /**
     * Génère un nouveau transport à utiliser. Comme rpc = false, on ne répond que à upload-pack-ls & receive-pack-ls
     */
    fn action(
        &self,
        url: &str,
        action: Service,
    ) -> Result<Box<dyn SmartSubtransportStream>, Error> {
        Ok(Box::new(WolfSubTransport {
            action,
            channel: self.channel.clone(),
            url: String::from(url),
            sent_request: false,
        }))
    }

    fn close(&self) -> Result<(), Error> {
        Ok(())
    }
}

impl WolfTransport {
    fn generate_request(cmd: &str, url: &str) -> Vec<u8> {
        // url format = wolf://host/repo
        // Note: Cette requête n'est envoyé que quand le client démarre sa partie afin de notifier le serveur
        let sep = url.rfind('/').unwrap();
        let host = url.get(7..sep).unwrap();
        let repo = url.get(sep..).unwrap();

        let null_char = '\0';
        let total = 4                                   /* 4 bytes for the len len */
                    + cmd.len()                         /* followed by the command */
                    + 1                                 /* space */
                    + repo.len()                        /* repo to clone */
                    + 1                                 /* \0 */
                    + HOST_TAG.len() + host.len()       /* host=server */
                    + 1                                 /* \0 */;
        let request = format!("{:04x}{} {}{}{}{}{}", total, cmd, repo, null_char, HOST_TAG, host, null_char);
        request.as_bytes().to_vec()
    }
}

impl Read for WolfSubTransport {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        // Envoie de la requête pour le serveur
        if !self.sent_request {
            let cmd = match self.action {
                Service::UploadPackLs => "git-upload-pack",
                Service::UploadPack => "git-upload-pack",
                Service::ReceivePackLs => "git-receive-pack",
                Service::ReceivePack => "git-receive-pack",
            };
            let cmd = WolfTransport::generate_request(cmd, &*self.url);
            let _ = self.channel.lock().unwrap().send(cmd);
            self.sent_request = true;
        }
        // Lis la réponse du serveur
        let mut recv = self.channel.lock().unwrap().recv().unwrap_or(Vec::new());
        let mut iter = recv.drain(..);
        let mut idx = 0;
        while let Some(v) = iter.next() {
            buf[idx] = v;
            idx += 1;
        }
        Ok(idx)
    }
}

impl Write for WolfSubTransport {
    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
        let _ = self.channel.lock().unwrap().send(data.to_vec());
        Ok(data.len())
    }
    fn flush(&mut self) -> io::Result<()> {
        // Non utilisé dans notre cas
        Ok(())
    }
}

Puis vient notre server src/server.rs :

use crate::wolftransport::Channel;
use git2::{ Buf, Oid, Repository, Sort };
use std::cmp::{ max, min };
use std::i64;
use std::str;

static FLUSH_PKT: &str = "0000";
static NAK_PKT: &str = "0008NAK\n";
static DONE_PKT: &str = "0009done\n";
static WANT_CMD: &str = "want";
static HAVE_CMD: &str = "have";
static UPLOAD_PACK_CMD: &str = "git-upload-pack";

/**
 * Représente un serveur git fonctionnant sur notre serveur personnalisé servant un répertoire
 */
pub struct Server {
    repository: Repository,
    channel: Channel,
    wanted: String,
    common: String,
    have: Vec<String>,
    buf: Vec<u8>,
    stop: bool,
}

impl Server {
    pub fn new(channel: Channel, path: &str) -> Self {
        let repository = Repository::open(path).unwrap();
        Self {
            repository,
            channel,
            wanted: String::new(),
            common: String::new(),
            have: Vec::new(),
            buf: Vec::new(),
            stop: false,
        }
    }

    pub fn run(&mut self) {
        // Stop est mis à true quand le clone est terminé.
        while !self.stop {
            let buf = self.channel.lock().unwrap().recv().unwrap();
            self.recv(buf);
        }
    }

    fn recv(&mut self, buf: Vec<u8>) {
        let mut buf = Some(buf);
        let mut need_more_parsing = true;
        // Comme le client peut envoyer plusieurs pkt-line, on traite
        // jusqu'à ce qu'on soit prêt à recevoir de nouveaux ordres.
        while need_more_parsing {
            need_more_parsing = self.parse(buf.take());
        }
    }

    fn parse(&mut self, buf: Option<Vec<u8>>) -> bool {
        // Parse la taille du pjt-line
        // Référence : https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt#L51
        // Les 4 premiers octets stockent la taille, sauf pour 0000 qui est le paquet FLUSH
        if buf.is_some() {
            self.buf.append(&mut buf.unwrap());
        }
        let pkt_len = str::from_utf8(&self.buf[0..4]).unwrap();
        let pkt_len = max(4 as usize, i64::from_str_radix(pkt_len, 16).unwrap() as usize);
        let pkt : Vec<u8> = self.buf.drain(0..pkt_len).collect();
        let pkt = str::from_utf8(&pkt[0..pkt_len]).unwrap();
        println!("RECV: {}", pkt);

        if pkt.find(UPLOAD_PACK_CMD) == Some(4) {
            // Cf: https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L166
            // Envoi des références
            println!("Upload pack command detected");
            // Note: la commande peut aussi contenir des paramètres comme version=1.
            // Pour cet article, on ignore ce cas.
            self.send_references_capabilities();
        } else if pkt.find(WANT_CMD) == Some(4) {
            // Référence :
            // https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L229
            // NOTE: Un client peut envoyer plusieurs want. À noter aussi que le premier want est suivi des
            // options que le client souhaite. Pour simplifier, ces cas sont ignorés ici.
            self.wanted = String::from(pkt.get(9..49).unwrap()); // on prend juste le commit id
            println!("Detected wanted commit: {}", self.wanted);
        } else if pkt.find(HAVE_CMD) == Some(4) {
            // Référence :
            // https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L390
            // NOTE: possible d'améliorer cette partie pour le multi-ack
            let have_commit = String::from(pkt.get(9..49).unwrap()); // on prend juste le commit id
            if self.common.is_empty() {
                if self.repository.find_commit(Oid::from_str(&*have_commit).unwrap()).is_ok() {
                    self.common = have_commit.clone();
                    println!("Set common commit to: {}", self.common);
                }
            }
            self.have.push(have_commit);
        } else if pkt == DONE_PKT {
            // Référence :
            // https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L390
            // NOTE: multi-ack ignoré ici. Si pas de base commune, on envoie seulement NAK.
            println!("Peer negotiation is done. Answering to want order");
            let send_data = match self.common.is_empty() {
                true => self.nak(),
                false => self.ack_first(),
            };
            if send_data {
                self.send_pack_data();
            }
            self.stop = true;
        } else if pkt == FLUSH_PKT {
            if !self.have.is_empty() {
                // Référence :
                // https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L390
                self.ack_common();
                self.nak();
            }
        } else {
            println!("Unwanted packet received: {}", pkt);
        }
        self.buf.len() != 0
    }

    fn send_references_capabilities(&self) {
        let current_head = self.repository.refname_to_id("HEAD").unwrap();
        let mut capabilities = format!("{} HEAD\0side-band side-band-64k shallow no-progress include-tag", current_head);
        capabilities = format!("{:04x}{}\n", capabilities.len() + 5 /* taille + \n */, capabilities);

        for name in self.repository.referencess().unwrap().names() {
            let reference : &str = name.unwrap();
            let oid = self.repository.refname_to_id(reference).unwrap();
            capabilities += &*format!("{:04x}{} {}\n", 6 /* taille + espace + \n */ + 40 /* oid */ + reference.len(), oid, reference);
        }

        print!("Send: {}", capabilities);
        self.channel.lock().unwrap().send(capabilities.as_bytes().to_vec()).unwrap();
        println!("Send: {}", FLUSH_PKT);
        self.channel.lock().unwrap().send(FLUSH_PKT.as_bytes().to_vec()).unwrap();
    }

    fn nak(&self) -> bool {
        print!("Send: {}", NAK_PKT);
        self.channel.lock().unwrap().send(NAK_PKT.as_bytes().to_vec()).is_ok()
    }

    fn ack_common(&self) -> bool {
        let length = 18 /* taille + ACK + espace * 2 + continue + \n */ + self.common.len();
        let msg = format!("{:04x}ACK {} continue\n", length, self.common);
        print!("Send: {}", msg);
        self.channel.lock().unwrap().send(msg.as_bytes().to_vec()).is_ok()
    }

    fn ack_first(&self) -> bool {
        let length = 9 /* taille + ACK + espace + \n */ + self.common.len();
        let msg = format!("{:04x}ACK {}\n", length, self.common);
        print!("Send: {}", msg);
        self.channel.lock().unwrap().send(msg.as_bytes().to_vec()).is_ok()
    }

    fn send_pack_data(&self) {
        println!("Send: [PACKFILE]");
        // Note: Ici, on ajoute tous les commits tant qu'on à pas
        // trouvé un commit annoncé par le client et qu'on ne sois
        // pas sûr d'avoir parcouru toutes les branches (ou jusqu'au
        // commit initial).
        let mut pb = self.repository.packbuilder().unwrap();
        let fetched = Oid::from_str(&*self.wanted).unwrap();
        let mut revwalk = self.repository.revwalk().unwrap();
        let _ = revwalk.push(fetched);
        let _ = revwalk.set_sorting(Sort::TOPOLOGICAL);

        let mut parents : Vec<String> = Vec::new();
        let mut have = false;

        while let Some(oid) = revwalk.next() {
            let oid = oid.unwrap();
            let oid_str = oid.to_string();
            have |= self.have.iter().find(|&o| *o == oid_str).is_some();
            if let Some(pos) = parents.iter().position(|p| *p == oid_str) {
                parents.remove(pos);
            }
            if have && parents.is_empty() {
                // Commit initial
                break;
            }
            let _ = pb.insert_commit(oid);
            let commit = self.repository.find_commit(oid).unwrap();
            let mut commit_parents = commit.parents();
            // On doit être sûr de parcourir tous les chemins
            while let Some(p) = commit_parents.next() {
                parents.push(p.id().to_string());
            }
        }

        // Note: Packbuilder possède des fonctions pour écrire des chunks et pas tout le packfile d'un coup.
        let mut data = Buf::new();
        let _ = pb.write_buf(&mut data);

        let len = data.len();
        let data : Vec<u8> = data.to_vec();
        let mut sent = 0;
        while sent < len {
            // cf https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L166
            // Si side-band-64k est spécifié, on doit envoyer jusqu'à 65519 octets + 1 de contrôle par pkt-line.
            let pkt_size = min(65515, len - sent);
            // Le paquet: Size (4 octets), Control byte (0x01 pour les données), pack data.
            let pkt = format!("{:04x}", pkt_size + 5 /* taille + control */);
            self.channel.lock().unwrap().send(pkt.as_bytes().to_vec()).unwrap();
            self.channel.lock().unwrap().send(b"\x01".to_vec()).unwrap();
            self.channel.lock().unwrap().send(data[sent..(sent+pkt_size)].to_vec()).unwrap();
            sent += pkt_size;
        }

        println!("Send: {}", FLUSH_PKT);
        // Et on fini par un FLUSH
        self.channel.lock().unwrap().send(FLUSH_PKT.as_bytes().to_vec()).unwrap();
    }
}

Puis notre fichier src/main.rs pour lancer le tout :

mod wolftransport;
mod server;

use bichannel::channel;
use wolftransport::WolfChannel;
use git2::build::RepoBuilder;
use server::Server;
use std::env;
use std::sync::{Arc, Mutex};
use std::thread;
use std::path::Path;

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() != 3 {
        println!("Usage: ./p2p-internal-git <src_dir> <dest_dir>");
        return;
    }
    
    let src_dir = args[1].clone();
    let dest_dir = Path::new(&args[2]);

    if dest_dir.is_dir() {
        println!("Can't clone into an existing directory");
        return;
    }

    // On utilise ici bichannel::channel() à des fins de démos
    // mais on peut le remplacer par le transport que l'on souhaite
    // par exemple un transport TLS.
    let (server_channel, transport_channel) = channel();
    let transport_channel = Arc::new(Mutex::new(WolfChannel {
        channel: transport_channel
    }));
    let server_channel = Arc::new(Mutex::new(WolfChannel {
        channel: server_channel
    }));
    unsafe {
        wolftransport::register(transport_channel);
    }

    let server = thread::spawn(move || {
        println!("Starting server for {}", src_dir);
        let mut server = Server::new(server_channel, &*src_dir);
        server.run();
    });

    // Note: "wolf://" est pour utiliser notre transport. localhost/zds n'est pas utilisé
    // parce que notre serveur ne gère qu'un seul répertoire.
    RepoBuilder::new().clone("wolf://localhost/zds", dest_dir).unwrap();
    println!("Cloned into {:?}!", dest_dir);

    server.join().expect("The server panicked");
}

et notre Cargo.toml :

[package]
name = "p2p-internals-git"
version = "0.1.0"
authors = ["AmarOk"]
edition = "2018"

[dependencies]
bichannel = "0.0.4"
git2 = { git = "https://github.com/AmarOk1412/git2-rs" }

Et maintenant lançons le programme qui copie un petit répertoire git d’un dossier à un autre :

#Note, comme le serveur ne supporte pas le multi-ack, on ne copiera que la branche master (sans les tags)
 amarok@tars3  ~/Projects/p2p-internals-git   main  git clone -b master --single-branch --no-tags https://github.com/zestedesavoir/zmarkdown.git
Cloning into 'zmarkdown'...
remote: Enumerating objects: 1207, done.
remote: Counting objects: 100% (1207/1207), done.
remote: Compressing objects: 100% (206/206), done.
remote: Total 16609 (delta 1006), reused 1192 (delta 1001), pack-reused 15402
Receiving objects: 100% (16609/16609), 10.85 MiB | 2.25 MiB/s, done.
Resolving deltas: 100% (11966/11966), done.
# On copie via notre petit outil
 amarok@tars3  ~/Projects/p2p-internals-git   main  ./target/debug/p2p-internals-git zmarkdown zmarkdown-copy                                   
Starting server for zmarkdown
RECV: 0028git-upload-pack /zdshost=localhost
Upload pack command detected
Send: 006ac42412dfee248e8acbd21df6038991dff6fd1b2a HEADside-band side-band-64k shallow no-progress include-tag
Send: 003fc42412dfee248e8acbd21df6038991dff6fd1b2a refs/heads/master
Send: 0046c42412dfee248e8acbd21df6038991dff6fd1b2a refs/remotes/origin/HEAD
Send: 0048c42412dfee248e8acbd21df6038991dff6fd1b2a refs/remotes/origin/master
Send: 0000
RECV: 004dwant c42412dfee248e8acbd21df6038991dff6fd1b2a side-band-64k include-tag 

Detected wanted commit: c42412dfee248e8acbd21df6038991dff6fd1b2a
RECV: 0000
RECV: 0009done

Peer negotiation is done. Answering to want order
Send: 0008NAK
Send: [PACKFILE]
Send: 0000
Cloned into "zmarkdown-copy"!
# Et plus qu'à valider!
 amarok@tars3  ~/Projects/p2p-internals-git   main  git -C zmarkdown rev-parse HEAD
c42412dfee248e8acbd21df6038991dff6fd1b2a
 amarok@tars3  ~/Projects/p2p-internals-git   main  git -C zmarkdown-copy rev-parse HEAD 
c42412dfee248e8acbd21df6038991dff6fd1b2a

Et voilà, on a réalisé un petit serveur git qui clone un répertoire. Bien entendu ce serveur est loin d’être complet et beaucoup de choses manquent, mais j’espère vous avoir éclairé sur comment git communique entre son client et son serveur.


Références

Merci de m’avoir lu jusqu’ici. Voici une liste de références qui m’ont servi durant l’écriture de cet article ou pour des projets personnels :

Aucun commentaire

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte