👀

git pushの裏側で何が起こっているのか。~javascriptでgitをリバースエンジニアリングで実装する(4)~

2020/12/21に公開

概要

前回までの記事はこちら!!

  1. javascriptでgitをリバースエンジニアリングで実装する
  2. javascriptでgitをリバースエンジニアリングで実装する(2) commit編
  3. javascriptでgitをリバースエンジニアリングで実装する(3) ~commit編 後編~

この記事では、git pushの際にどのようなやりとりを、git-cliを使ってみていきます。git-cliで通信を走らせてみることで、どのようなプロトコルの元にリモートサーバーとやりとりをしているのかをみていきます。

利用規約に反していないと思いますが、万が一反していた場合非公開にしますのでコメントくださると幸いです。

git pushに関しては、次の記事で実際に自作していきます。

pushについて探る

documentを読んでみる

Updates remote refs using local refs, while sending objects necessary to complete the given refs.

You can make interesting things happen to a repository every time you push into it, by setting up hooks there. See documentation for git-receive-pack[1].

When the command line does not specify where to push with the <repository> argument, branch.*.remote configuration for the current branch is consulted to determine where to push. If the configuration is missing, it defaults to origin.

When the command line does not specify what to push with <refspec>... arguments or --all, --mirror, --tags options, the command finds the default <refspec> by consulting remote.*.push configuration, and if it is not found, honors push.default configuration to decide what to push (See git-config[1] for the meaning of push.default).

When neither the command-line nor the configuration specify what to push, the default behavior is used, which corresponds to the simple value for push.default: the current branch is pushed to the corresponding upstream branch, but as a safety measure, the push is aborted if the upstream branch does not have the same name as the local one.

引用 https://git-scm.com/docs/git-receive-pack

重要なのは最初の一文です。

Updates remote refs using local refs, while sending objects necessary to complete the given refs.

前回までの記事でやったrefsを更新すること、それからobjectsの送信がpushコマンドの役割として書かれていますね。

refsについては、refs自体はコミットオブジェクトのsha1が格納されていること、現在の最新の作業は.git/HEADファイルに記載されているrefsを見れば良いことくらいを踏まえていれば良いと思います。

参考
https://git-scm.com/book/ja/v2/Gitの内側-Gitの参照

ここからはgithubの操作が適宜入りますが、適宜自分のリポジトリで読み替えてください。

ここから実際に作業をしていきますが、本記事の仕様については下記のdocumentに乗っ取っていますので、公式のものが見たい方は下記のリンクを参照してください。

参考
https://www.git-scm.com/book/en/v2/Git-Internals-Transfer-Protocols
https://github.com/git/git/blob/master/Documentation/technical/http-protocol.txt
https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt

pushの挙動を探る。

pushの通信の様子をみてみる。

git pushは具体的に何をやっているのでしょうか。

githubを使って、gitがpushの際にリモートのサーバーにどのようにして保存しているのかを見ていきましょう。

ここでは、新しくリポジトリを作ってみて実際のpush時の挙動を検証していきます。

push-testというレポジトリを作りました。

今回は通信の監視のためhttpsで通信を行いたいので、httpsの方でcloneします。ghqを使っていますが、普通にcloneするのと変わりません。この記事とは関係ありませんがghqはおすすめです。

$ ghq get https://github.com/hirokihello/push-test.git
     clone https://github.com/hirokihello/push-test.git -> /Users/inoue_h/ghq/github.com/hirokihello/push-test
       git clone --recursive https://github.com/hirokihello/push-test.git /Users/inoue_h/ghq/github.com/hirokihello/push-test
Cloning into '/Users/inoue_h/ghq/github.com/hirokihello/push-test'...
warning: You appear to have cloned an empty repository.
$ cd ~/ghq/github.com/hirokihello/push-test 

さてここで適当なファイルを追加します。

$ echo "console.log('hoge')" > hoge.js
$ git add hoge.js
$ git commit -m 'first commit: add hoge'
[master (root-commit) 521fc09] first commit: add hoge
 1 file changed, 1 insertion(+)
 create mode 100644 hoge.js

また今回はどのような通信が行われているのかをみるため、charles を使って検証します。

適宜好きなproxy toolを使ってhttps通信を復号してください。

pushしてみます。

$ git push
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 237 bytes | 118.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To https://github.com/hirokihello/push-test.git
 * [new branch]      master -> master

どのような通信が走ったでしょうか。

三つの通信が走りました。

1回目の通信

初めの通信は/hirokihello/push-test.git/info/refs?service=git-receive-packに対してgetしています。しかし、githubは認証を必要とするので、弾かれています。

2回目の通信

2回目では上記の通信に対してbearerで認証情報を送っています。この認証を送る部分に関しては多分githubの実装なので気にしなくて良いです。

response

text

001f# service=git-receive-pack
0000009b0000000000000000000000000000000000000000 capabilities^{}report-status delete-refs side-band-64k quiet atomic ofs-delta agent=git/github-gcd21660c8f10
0000

hex

00000000  30 30 31 66 23 20 73 65 72 76 69 63 65 3d 67 69   001f# service=gi
00000010  74 2d 72 65 63 65 69 76 65 2d 70 61 63 6b 0a 30   t-receive-pack 0
00000020  30 30 30 30 30 39 62 30 30 30 30 30 30 30 30 30   000009b000000000
00000030  30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30   0000000000000000
00000040  30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 20   000000000000000 
00000050  63 61 70 61 62 69 6c 69 74 69 65 73 5e 7b 7d 00   capabilities^{} 
00000060  72 65 70 6f 72 74 2d 73 74 61 74 75 73 20 64 65   report-status de
00000070  6c 65 74 65 2d 72 65 66 73 20 73 69 64 65 2d 62   lete-refs side-b
00000080  61 6e 64 2d 36 34 6b 20 71 75 69 65 74 20 61 74   and-64k quiet at
00000090  6f 6d 69 63 20 6f 66 73 2d 64 65 6c 74 61 20 61   omic ofs-delta a
000000a0  67 65 6e 74 3d 67 69 74 2f 67 69 74 68 75 62 2d   gent=git/github-
000000b0  67 63 64 32 31 36 36 30 63 38 66 31 30 0a 30 30   gcd21660c8f10 00
000000c0  30 30                                             00 
3回目の通信

/hirokihello/push-test.git/git-receive-packに対してpostを行なっています。

request body

00a70000000000000000000000000000000000000000 f3d3808deea3388f30cf5d4451f265737fe70028 refs/heads/master report-status side-band-64k agent=git/2.21.1.(Apple.Git-122.3)0000PACKxœŒK
à ÷žâíÅOÔg)¥WyÕg”Fc¹}½Ag9033äE'cCL„Y{M-{”Æ;T%å^|"è3KPêèïZxÛ:Ü+µvðÁê¹6ªÛ5ööå¤2a±á"ƒ”â´­ÎÉÅ"×±Oø=n@)Aé+‹/ß
;	£xœ340031QÈÈOOÕË*f¸~øqÜٖĺyÛíjä·¼÷	×Päö
Æ´xœKÎÏ+ÎÏIÕËÉO×PÏÈOOU×äNá°ùCŒ|·½©ïå}62Z„ãòïqÆ
00000000  30 30 61 37 30 30 30 30 30 30 30 30 30 30 30 30   00a7000000000000
00000010  30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30   0000000000000000
00000020  30 30 30 30 30 30 30 30 30 30 30 30 20 66 33 64   000000000000 f3d
00000030  33 38 30 38 64 65 65 61 33 33 38 38 66 33 30 63   3808deea3388f30c
00000040  66 35 64 34 34 35 31 66 32 36 35 37 33 37 66 65   f5d4451f265737fe
00000050  37 30 30 32 38 20 72 65 66 73 2f 68 65 61 64 73   70028 refs/heads
00000060  2f 6d 61 73 74 65 72 00 20 72 65 70 6f 72 74 2d   /master  report-
00000070  73 74 61 74 75 73 20 73 69 64 65 2d 62 61 6e 64   status side-band
00000080  2d 36 34 6b 20 61 67 65 6e 74 3d 67 69 74 2f 32   -64k agent=git/2
00000090  2e 32 31 2e 31 2e 28 41 70 70 6c 65 2e 47 69 74   .21.1.(Apple.Git
000000a0  2d 31 32 32 2e 33 29 30 30 30 30 50 41 43 4b 00   -122.3)0000PACK 
000000b0  00 00 02 00 00 00 03 9d 0b 78 9c 9d 8c 4b 0a c3            x   K  
000000c0  20 14 00 f7 9e e2 ed 0b c5 4f d4 67 29 a5 57 79            O g) Wy
000000d0  d5 67 94 46 04 63 17 b9 7d 03 bd 41 67 39 30 33    g F c  }  Ag903
000000e0  07 33 e4 45 27 63 43 4c 84 59 7b 4d 11 2d 7b 94    3 E'cCL Y{M -{ 
000000f0  c6 3b 54 1a 25 1a e5 5e 7c 22 e8 33 4b 1f 50 ea    ;T %  ^|" 3K P 
00000100  e8 ef 5a 78 db 3a dc 2b b5 76 f0 c1 ea b9 36 aa     Zx : + v    6 
00000110  db 35 f6 f6 00 e5 a4 32 61 b1 0e e1 22 83 94 e2    5     2a   "   
00000120  b4 ad ce c9 7f c5 22 d7 b1 4f f8 3d 6e 40 29 41        "  O =n@)A
00000130  e9 2b 8b 2f df 0d 3b 09 a3 02 78 9c 33 34 30 30    + /  ;   x 3400
00000140  33 31 51 c8 c8 4f 4f d5 cb 2a 66 b8 7e f8 71 dc   31Q  OO  *f ~ q 
00000150  d9 96 c4 ba 79 db ed 18 6a e4 b7 bc f7 09 d7 50       y   j      P
00000160  04 00 e4 f6 0d c6 b4 01 78 9c 4b ce cf 2b ce cf           x K  +  
00000170  49 d5 cb c9 4f d7 50 cf c8 4f 4f 55 d7 e4 02 00   I   O P  OOU    
00000180  4e e1 06 b0 f9 43 8c 7c b7 bd a9 ef e5 7d 36 32   N    C |     }62
00000190  5a 84 e3 f2 ef 1a 71 c6                           Z     q    

response

0013000eunpack ok
001e0019ok refs/heads/master
000900000000
00000000  30 30 31 33 01 30 30 30 65 75 6e 70 61 63 6b 20   0013 000eunpack 
00000010  6f 6b 0a 30 30 31 65 01 30 30 31 39 6f 6b 20 72   ok 001e 0019ok r
00000020  65 66 73 2f 68 65 61 64 73 2f 6d 61 73 74 65 72   efs/heads/master
00000030  0a 30 30 30 39 01 30 30 30 30 30 30 30 30          0009 00000000  

解説

上記の三つの通信の中身を具体例についてみてみました。

ここまでみてきた部分を踏まえてpushを実装する際に考えることをまとめてみましょう。

クライアントサイドでは

1. get /info/refs?service=git-receive-pack
2. post /git-receive-pack

の二つを順番に行うだけです。

今回3つ通信が走ったのは、githubが要求するbasic認証の認証情報をheaderに付与しなかったためであり最初の通信と2度目の通信はほとんど同じです。なので無視します。

具体的に解説していきます。

get /info/refs?service=git-receive-pack

このエンドポイントを叩くと、下記のレスポンスが返ってきます。

重要な点は3つあります。

  1. 二行目以降の各行の最初の4bytesは、その行のサイズを表す
  2. 二行目には、それぞれのブランチのrefの示すコミットオブジェクトが格納されている。(ここではrepositoryをinitしたばかりでコミット、refsがないのでわかりづらいでずが...)
  3. 最後の行は0000で示される。

(今回は初回のcommitのためか、2行目の最初の4bytesが0000になり次の4bytesに009bというその行の残りの大きさが入っているが、理由は不明。おそらくrepositoryをinitした初期だからだと思われる。わかる方指摘してくださいますと幸いです。)

text

001f# service=git-receive-pack
0000009b0000000000000000000000000000000000000000 capabilities^{}report-status delete-refs side-band-64k quiet atomic ofs-delta agent=git/github-gcd21660c8f10
0000

hex

00000000  30 30 31 66 23 20 73 65 72 76 69 63 65 3d 67 69   001f# service=gi
00000010  74 2d 72 65 63 65 69 76 65 2d 70 61 63 6b 0a 30   t-receive-pack 0
00000020  30 30 30 30 30 39 62 30 30 30 30 30 30 30 30 30   000009b000000000
00000030  30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30   0000000000000000
00000040  30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 20   000000000000000 
00000050  63 61 70 61 62 69 6c 69 74 69 65 73 5e 7b 7d 00   capabilities^{} 
00000060  72 65 70 6f 72 74 2d 73 74 61 74 75 73 20 64 65   report-status de
00000070  6c 65 74 65 2d 72 65 66 73 20 73 69 64 65 2d 62   lete-refs side-b
00000080  61 6e 64 2d 36 34 6b 20 71 75 69 65 74 20 61 74   and-64k quiet at
00000090  6f 6d 69 63 20 6f 66 73 2d 64 65 6c 74 61 20 61   omic ofs-delta a
000000a0  67 65 6e 74 3d 67 69 74 2f 67 69 74 68 75 62 2d   gent=git/github-
000000b0  67 63 64 32 31 36 36 30 63 38 66 31 30 0a 30 30   gcd21660c8f10 00
000000c0  30 30                                             00 

ここでやっていることは、自分が最終的にpushしたいgit serverの現在の状態がどのようになっているのかを取得しています。2行目のサイズのすぐ後に続くのが、report-statusなどはオプションです。詳細はgitのプロトコルを確認してください。

post /git-receive-pack

さて本丸の部分です。ここでやっていることは、gitのリモートサーバーに、どのcommitまで進めるかを伝え関連するファイルを送信するということをここでは行なっています。

request body

00a70000000000000000000000000000000000000000 f3d3808deea3388f30cf5d4451f265737fe70028 refs/heads/master report-status side-band-64k agent=git/2.21.1.(Apple.Git-122.3)0000PACKxœŒK
à ÷žâíÅOÔg)¥WyÕg”Fc¹}½Ag9033äE'cCL„Y{M-{”Æ;T%å^|"è3KPêèïZxÛ:Ü+µvðÁê¹6ªÛ5ööå¤2a±á"ƒ”â´­ÎÉÅ"×±Oø=n@)Aé+‹/ß
;	£xœ340031QÈÈOOÕË*f¸~øqÜٖĺyÛíjä·¼÷	×Päö
Æ´xœKÎÏ+ÎÏIÕËÉO×PÏÈOOU×äNá°ùCŒ|·½©ïå}62Z„ãòïqÆ
00000000  30 30 61 37 30 30 30 30 30 30 30 30 30 30 30 30   00a7000000000000
00000010  30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30   0000000000000000
00000020  30 30 30 30 30 30 30 30 30 30 30 30 20 66 33 64   000000000000 f3d
00000030  33 38 30 38 64 65 65 61 33 33 38 38 66 33 30 63   3808deea3388f30c
00000040  66 35 64 34 34 35 31 66 32 36 35 37 33 37 66 65   f5d4451f265737fe
00000050  37 30 30 32 38 20 72 65 66 73 2f 68 65 61 64 73   70028 refs/heads
00000060  2f 6d 61 73 74 65 72 00 20 72 65 70 6f 72 74 2d   /master  report-
00000070  73 74 61 74 75 73 20 73 69 64 65 2d 62 61 6e 64   status side-band
00000080  2d 36 34 6b 20 61 67 65 6e 74 3d 67 69 74 2f 32   -64k agent=git/2
00000090  2e 32 31 2e 31 2e 28 41 70 70 6c 65 2e 47 69 74   .21.1.(Apple.Git
000000a0  2d 31 32 32 2e 33 29 30 30 30 30 50 41 43 4b 00   -122.3)0000PACK 
000000b0  00 00 02 00 00 00 03 9d 0b 78 9c 9d 8c 4b 0a c3            x   K  
000000c0  20 14 00 f7 9e e2 ed 0b c5 4f d4 67 29 a5 57 79            O g) Wy
000000d0  d5 67 94 46 04 63 17 b9 7d 03 bd 41 67 39 30 33    g F c  }  Ag903
000000e0  07 33 e4 45 27 63 43 4c 84 59 7b 4d 11 2d 7b 94    3 E'cCL Y{M -{ 
000000f0  c6 3b 54 1a 25 1a e5 5e 7c 22 e8 33 4b 1f 50 ea    ;T %  ^|" 3K P 
00000100  e8 ef 5a 78 db 3a dc 2b b5 76 f0 c1 ea b9 36 aa     Zx : + v    6 
00000110  db 35 f6 f6 00 e5 a4 32 61 b1 0e e1 22 83 94 e2    5     2a   "   
00000120  b4 ad ce c9 7f c5 22 d7 b1 4f f8 3d 6e 40 29 41        "  O =n@)A
00000130  e9 2b 8b 2f df 0d 3b 09 a3 02 78 9c 33 34 30 30    + /  ;   x 3400
00000140  33 31 51 c8 c8 4f 4f d5 cb 2a 66 b8 7e f8 71 dc   31Q  OO  *f ~ q 
00000150  d9 96 c4 ba 79 db ed 18 6a e4 b7 bc f7 09 d7 50       y   j      P
00000160  04 00 e4 f6 0d c6 b4 01 78 9c 4b ce cf 2b ce cf           x K  +  
00000170  49 d5 cb c9 4f d7 50 cf c8 4f 4f 55 d7 e4 02 00   I   O P  OOU    
00000180  4e e1 06 b0 f9 43 8c 7c b7 bd a9 ef e5 7d 36 32   N    C |     }62
00000190  5a 84 e3 f2 ef 1a 71 c6                           Z     q    

response

0013000eunpack ok
001e0019ok refs/heads/master
000900000000
00000000  30 30 31 33 01 30 30 30 65 75 6e 70 61 63 6b 20   0013 000eunpack 
00000010  6f 6b 0a 30 30 31 65 01 30 30 31 39 6f 6b 20 72   ok 001e 0019ok r
00000020  65 66 73 2f 68 65 61 64 73 2f 6d 61 73 74 65 72   efs/heads/master
00000030  0a 30 30 30 39 01 30 30 30 30 30 30 30 30          0009 00000000  

まずはリクエストbodyをみていきましょう。

00a70000000000000000000000000000000000000000 f3d3808deea3388f30cf5d4451f265737fe70028 refs/heads/master report-status side-band-64k agent=git/2.21.1.(Apple.Git-122.3)0000PACKxœŒK
à ÷žâíÅOÔg)¥WyÕg”Fc¹}½Ag9033äE'cCL„Y{M-{”Æ;T%å^|"è3KPêèïZxÛ:Ü+µvðÁê¹6ªÛ5ööå¤2a±á"ƒ”â´­ÎÉÅ"×±Oø=n@)Aé+‹/ß
;	£xœ340031QÈÈOOÕË*f¸~øqÜٖĺyÛíjä·¼÷	×Päö
Æ´xœKÎÏ+ÎÏIÕËÉO×PÏÈOOU×äNá°ùCŒ|·½©ïå}62Z„ãòïqÆ

最初の4桁は、その行のサイズを示すhexとなっています。

その次に来る、0000000000000000000000000000000000000000 f3d3808deea3388f30cf5d4451f265737fe70028はなんでしょうか。
これはリモートの現在のcommit object + ' ' + 修正後のpushしたいcommit objectという形式で、リモートサーバーのrefsをどこからどこまで進めるかを示しています。

f3d3808deea3388f30cf5d4451f265737fe70028を手元でcat-fileしてみます。

$ git cat-file -p f3d3808deea3388f30cf5d4451f265737fe70028
tree f42d359cda8f272ac85e780376812808316beeee
author hirokihello <iammyeye1@gmail.com> 1601394568 +0900
committer hirokihello <iammyeye1@gmail.com> 1601394568 +0900

first commit: add hoge

commitオブジェクトを示すことがわかりました。

次に来るrefs/heads/masterは、remoteのgit serverのrefs/heads/masterを書き換えるように示しています。

その次にはoptionsが続きます。report-statusは更新時に生じたことのリスト要求、side-band-64kは送信ファイルのサイズ、agent=git/2.21.1.(Apple.Git-122.3)はバージョン番号ですね。ここら辺の細かい説明は先ほどあげた公式のdocを読んでください。

ここから0パディングで4byteパディングされた後に、PACKの文字列とバイナリファイルが入っていますね。

このPACKから始まるものはpackfileと呼ばれており、ここにリモートのサーバーの更新に必要となる情報が全て入っています。

packfileについては後述します。

レスポンスを一旦見てみます。

0013000eunpack ok
001e0019ok refs/heads/master
000900000000
00000000  30 30 31 33 01 30 30 30 65 75 6e 70 61 63 6b 20   0013 000e,unpack 
00000010  6f 6b 0a 30 30 31 65 01 30 30 31 39 6f 6b 20 72   ok 001e 0019ok r
00000020  65 66 73 2f 68 65 61 64 73 2f 6d 61 73 74 65 72   efs/heads/master
00000030  0a 30 30 30 39 01 30 30 30 30 30 30 30 30          0009 00000000  

シンプルですね。送ったのと同じようですが若干異なり、最初の4byteにその行全体のバイト数、スペースを空けて次の4bytesに、4bytes+そこからのその行のバイト数が続きます。

それでは実際にpackfileの形式をみて、実装をしていきいましょう。

packfile

ここからはpackfileについての簡単な説明をします。packfileはgitにおいて、通信料を減らすために通信のやりとりで用いられている仕様です。

簡単な説明と書いてありますが、公式ドキュメント以外にあまり情報が落ちていません。

まず全てはここに書いてあります。詳しく知りたい人はここを読んでください。

https://github.com/git/git/blob/master/Documentation/technical/pack-format.txt

まず全体の構造ですが、packfileの中身は三つに分かれます。

  1. header
  2. object entries
  3. sha1

sha1については今までやった通りの1と2をくっつけたものをsha1でhash計算したものをぶち込むだけです。

ここからは、headerとobject entriesについて解説していきます。

header

headerは三つの部分にn分かれます。

  1. signature("PACK"の4文字)
  2. version(2)
  3. object num

それぞれが4bytesで合計12 byteです。indexと似ているので特に問題はないと思います。
先ほどpackfileとわかったのも、このsignatureがあったからです。

object entries

object entriesは大きく分けて3つの構造が積み重なっています。

  1. object type(3 bits)
  2. size(4 + n bits)
  3. 圧縮されたデータの部分

データの部分は、zlibで圧縮されたデータとなります。

しかし、このobject type, size二つ合わせたobject headerの格納方法が少し厄介なので詳しく見ていきます。

まず前提としてobject headerは8bytesごとに見ていくことなります。これはめちゃくちゃ重要です。

下記は引用ですが、packfileの構造についてわかりやすいので引用します。


引用http://shafiul.github.io/gitbook/7_the_packfile.html

各エントリーのobject headerは、最初の1byteとそこからのn bytesで異なっています。

最初の1byte = 8bitsは下記のような構造になっています。

  1. 1bitのMSB(most significant bit)
  2. 3bitsのobject type
  3. 4bitsのサイズ

最初の1bit(MSB)は次にheader objectがくる場合は1になり、それ以外では0になります。

MSBの次にくる3bitsのobject typeですが、今回覚えておいて欲しいのは、

1: commit object
2: tree object
3: blob object

の三つです。これは、010ならtree objectが格納されていることを表します。

最初の1byteに続くn bytesは下記のような構造になっています。

  1. 1bitのMSB(most significant bit)
  2. 7bitsのサイズ

これが、MSBが0になるbyteまで続きます。なので、初めてMSBが0になった部分までがheaderです。

ここで注意しなければならないのは、サイズの計算方法です。sizeはビッグエンディアンで格納されており、通常のリトルエンディアンとは異なるので、自分で計算する時にそこに注意しなければいけません。

先ほどと同じ引用先からの画像ですが、これを見てください。object headerの構造についてです。


引用(http://shafiul.github.io/gitbook/7_the_packfile.html)

これをみながら、この後実際にparseしてみましょう。

この後に来るのは、zlibで圧縮されたbody部分です。

packfile

parseしてみる

さてここからは先ほどまでみた通りに、packfileを実際にparseしていきます。

先ほど実際に送ったhexを使ってparseしていきます。

00000000  30 30 61 37 30 30 30 30 30 30 30 30 30 30 30 30   00a7000000000000
00000010  30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30   0000000000000000
00000020  30 30 30 30 30 30 30 30 30 30 30 30 20 66 33 64   000000000000 f3d
00000030  33 38 30 38 64 65 65 61 33 33 38 38 66 33 30 63   3808deea3388f30c
00000040  66 35 64 34 34 35 31 66 32 36 35 37 33 37 66 65   f5d4451f265737fe
00000050  37 30 30 32 38 20 72 65 66 73 2f 68 65 61 64 73   70028 refs/heads
00000060  2f 6d 61 73 74 65 72 00 20 72 65 70 6f 72 74 2d   /master  report-
00000070  73 74 61 74 75 73 20 73 69 64 65 2d 62 61 6e 64   status side-band
00000080  2d 36 34 6b 20 61 67 65 6e 74 3d 67 69 74 2f 32   -64k agent=git/2
00000090  2e 32 31 2e 31 2e 28 41 70 70 6c 65 2e 47 69 74   .21.1.(Apple.Git
000000a0  2d 31 32 32 2e 33 29 30 30 30 30 50 41 43 4b 00   -122.3)0000PACK 
000000b0  00 00 02 00 00 00 03 9d 0b 78 9c 9d 8c 4b 0a c3            x   K  
000000c0  20 14 00 f7 9e e2 ed 0b c5 4f d4 67 29 a5 57 79            O g) Wy
000000d0  d5 67 94 46 04 63 17 b9 7d 03 bd 41 67 39 30 33    g F c  }  Ag903
000000e0  07 33 e4 45 27 63 43 4c 84 59 7b 4d 11 2d 7b 94    3 E'cCL Y{M -{ 
000000f0  c6 3b 54 1a 25 1a e5 5e 7c 22 e8 33 4b 1f 50 ea    ;T %  ^|" 3K P 
00000100  e8 ef 5a 78 db 3a dc 2b b5 76 f0 c1 ea b9 36 aa     Zx : + v    6 
00000110  db 35 f6 f6 00 e5 a4 32 61 b1 0e e1 22 83 94 e2    5     2a   "   
00000120  b4 ad ce c9 7f c5 22 d7 b1 4f f8 3d 6e 40 29 41        "  O =n@)A
00000130  e9 2b 8b 2f df 0d 3b 09 a3 02 78 9c 33 34 30 30    + /  ;   x 3400
00000140  33 31 51 c8 c8 4f 4f d5 cb 2a 66 b8 7e f8 71 dc   31Q  OO  *f ~ q 
00000150  d9 96 c4 ba 79 db ed 18 6a e4 b7 bc f7 09 d7 50       y   j      P
00000160  04 00 e4 f6 0d c6 b4 01 78 9c 4b ce cf 2b ce cf           x K  +  
00000170  49 d5 cb c9 4f d7 50 cf c8 4f 4f 55 d7 e4 02 00   I   O P  OOU    
00000180  4e e1 06 b0 f9 43 8c 7c b7 bd a9 ef e5 7d 36 32   N    C |     }62
00000190  5a 84 e3 f2 ef 1a 71 c6                           Z     q    

まずpackfileの始まりですが、上記のhexの中央あたりの50 41 43 4bが文字列の"PACK"を示しますので、そこからのhexを文字列にして取得します。

ここからはjavascriptで実際にコードを書いて出力結果をみてみます。

まずはheaderの部分をparseしてみます。headerは12bytesで、3つの4bytesのセクションがありましたね。

packfile.js

async function packfile() {
  const packFile = Buffer.from( "5041434b00000002000000039d0b789c9d8c4b0ac3201400f79ee2ed0bc54fd46729a55779d5679446046317b97d03bd41673930330733e4452763434c84597b4d112d7b94c63b541a251ae55e7c22e8334b1f50eae8ef5a78db3adc2bb576f0c1eab936aadb35f6f600e5a43261b10ee1228394e2b4adcec97fc522d7b14ff83d6e402941e92b8b2fdf0d3b09a302789c33343030333151c8c84f4fd5cb2a66b87ef871dcd996c4ba79dbed186ae4b7bcf709d7500400e4f60dc6b401789c4bcecf2bcecf49d5cbc94fd750cfc84f4f55d7e402004ee106b0f9438c7cb7bda9efe57d36325a84e3f2ef1a71c6",
    "hex"
  )

  console.log(packFile.slice(0, 4).toString()) // PACKの文字列
  console.log(parseInt(packFile.slice(4, 8).toString("hex"), "hex")) // バージョン番号(=2)
  console.log(parseInt(packFile.slice(8, 12).toString("hex"), "hex"))
}

packfile()
$ node packfile.js
PACK
2
3

parseできました。PACKの文字列、バージョン番号、ファイル数がそれぞれ取れました。

今回の場合は、ファイル数は3つですね。

ここからはobject entriesの実装をしてみます。実行結果はこんな感じです。

const fs = require('fs').promises;
const zlib = require('zlib');
const util = require('util');
const inflate = util.promisify(zlib.inflate);
const deflate = util.promisify(zlib.deflate);

const ObjectTypeStr = {
  1: 'commit',
  2: 'tree',
  3: 'blob',
};

async function packfile() {
  const packFile = Buffer.from(
    "5041434b00000002000000039d0b789c9d8c4b0ac3201400f79ee2ed0bc54fd46729a55779d5679446046317b97d03bd41673930330733e4452763434c84597b4d112d7b94c63b541a251ae55e7c22e8334b1f50eae8ef5a78db3adc2bb576f0c1eab936aadb35f6f600e5a43261b10ee1228394e2b4adcec97fc522d7b14ff83d6e402941e92b8b2fdf0d3b09a302789c33343030333151c8c84f4fd5cb2a66b87ef871dcd996c4ba79dbed186ae4b7bcf709d7500400e4f60dc6b401789c4bcecf2bcecf49d5cbc94fd750cfc84f4f55d7e402004ee106b0f9438c7cb7bda9efe57d36325a84e3f2ef1a71c6",
    "hex"
  )

  console.log(packFile.slice(0, 4).toString()) // PACKの文字列
  console.log(parseInt(packFile.slice(4, 8).toString("hex"), "hex")) // バージョン番号(=2)
  console.log(parseInt(packFile.slice(8, 12).toString("hex"), "hex"))

  const objectNum = parseInt(packFile.slice(8, 12).toString("hex"), "hex")

  let packObjects = packFile.slice(12)
  const entries = []
  let pointer = 0;

  for (let ii=0; ii < objectNum; ii++) {
    const objectType = ObjectTypeStr[((packObjects[pointer] & 0b01110000) >> 4)]
    let idx = 0;
    let fileSizeBit = 4;
    let size = packObjects[pointer] & 0b00001111 // 最初の8bitの下位4bitはsize
    // 0の次を入れたいので、最初は必ず処理が始まり次にいくようにしている。
    while (packObjects[pointer + idx] & 0b10000000) {
      idx++
      const s = packObjects[pointer + idx] & 0b01111111
      size += (s << fileSizeBit)
      fileSizeBit += 7
    }

    // console.log({size})

    const object = await inflate(packObjects.slice(pointer + idx + 1)).catch(err => console.log(err))
    const deflateObject = await deflate(object.toString())

    entries.push({
      type: objectType,
      content: packObjects.slice(pointer + idx + 1, pointer + idx + 1 + deflateObject.length)
    })

    pointer = pointer + idx + 1 + deflateObject.length
  }

  entries.map(entry => {
    inflate(entry.content, (err, res)=>{
      console.log({type: entry.type})
      if(!err) console.log(res.toString())
    })
  })
}

packfile()

具体的に解説します。

まずはこの部分です。この部分では、このpackfileに含まれている、ファイル数を取得しています。packfileの9~12byteに含まれているので、この情報を取得します。また、13bytes目からobject entriesは始まるので、その部分を変数化、またどこまでみたのかをpointerとして保持しておきます。

  const objectNum = parseInt(packFile.slice(8, 12).toString("hex"), "hex")

  const packObjects = packFile.slice(12)
  const entries = []
  let pointer = 0;

ここからが中核です。

まず最初の3bitを取得して、bit演算した後にobject typeを変数化しています。そのあとは、MSBが1かどうかの判定をして、MSBが1の場合は次の8bitの下位7bitを使って変数sizeに代入していきます。big endianであるので、loopのたびに4 + 7 * (n-1)bit分をシフトしたものを使って最終サイズを計算していきます。

しかし今回はこのサイズを使いません。理由として、ここで格納されるサイズはinflate、つまり復号化したものが格納されているため直接使えません。

つまり、deflateされた本体・中身の部分のサイズがいくつかわからないのです。

ここでzlibの特徴を使います。zlibは解凍したもののうち、不要だった末尾のbytes/bitsを無視してくれます。つまり圧縮後のファイルのうち不要なbufferをいくつつけても、正しく解凍できるということです。

この特徴を使って圧縮後のサイズを算出します。まずheaderを取り除いて、全てのbufferを使って解凍します。そうしたら最初に来るdeflateされたもののみが解凍されます。

次に、この解凍したものを再度圧縮します。こうすることで、正しくオリジナルのファイルを圧縮したbufferが得られます。

この正しくオリジナルのファイルを圧縮したbufferのサイズの分だけ、entries変数に格納してpointerを進めて次のループに行きます。

  for (let ii=0; ii < objectNum; ii++) {
    const objectType = ObjectTypeStr[((packObjects[pointer] & 0b01110000) >> 4)]
    let idx = 0;
    let fileSizeBit = 4;
    let size = packObjects[pointer] & 0b00001111 // 最初の8bitの下位4bitはsize
    // 0の次を入れたいので、最初は必ず処理が始まり次にいくようにしている。
    while (packObjects[pointer + idx] & 0b10000000) {
      idx++
      const s = packObjects[pointer + idx] & 0b01111111
      size += (s << fileSizeBit)
      fileSizeBit += 7
    }

    // console.log({size})

    const object = await inflate(packObjects.slice(pointer + idx + 1)).catch(err => console.log(err))
    const deflateObject = await deflate(object.toString())

    entries.push({
      type: objectType,
      content: packObjects.slice(pointer + idx + 1, pointer + idx + 1 + deflateObject.length)
    })

    pointer = pointer + idx + 1 + deflateObject.length
  }

  entries.map(entry => {
    inflate(entry.content, (err, res)=>{
      console.log({type: entry.type})
      if(!err) console.log(res.toString())
    })
  })

実行してみると、無事に取れていることがわかります。treefileが文字化けしているのは、sha1 hashがファイルに含まれているからですね。

$ node packfile.js
PACK
2
3
{ type: 'commit' }
tree f42d359cda8f272ac85e780376812808316beeee
author hirokihello <iammyeye1@gmail.com> 1601394568 +0900
committer hirokihello <iammyeye1@gmail.com> 1601394568 +0900

first commit: add hoge

{ type: 'tree' }
100644 hoge.js���^̈́a~��>|��LW(!
{ type: 'blob' }
console.log('hoge')

これでpackfileがどのようになるのか、一通り見ることができました。
これの逆を行うことで実装できそうです。

実装してみる

やる気が出ないのでテストで忙しいため実装は次回にします!!

Discussion