Signed-off-by: p4u <p4u@dabax.net>feature_chain_module
@ -0,0 +1,128 @@ |
|||
## Census HTTP service |
|||
|
|||
Reference implementation of a voting census service running on the Vocdoni platform |
|||
|
|||
## Compile |
|||
|
|||
In a GO ready environment: |
|||
|
|||
``` |
|||
go get -u github.com/vocdoni/dvote-census/... |
|||
go build -o censusHttpService github.com/vocdoni/dvote-census/cmd/censushttp |
|||
``` |
|||
|
|||
## Usage |
|||
|
|||
`./censusHttpService <port> <censusId>[:pubKey] [<censusId>[:pubKey] ...]` |
|||
|
|||
Example |
|||
|
|||
``` |
|||
./censusHttpService 1500 Got_Favorite |
|||
2019/02/12 10:20:16 Starting process HTTP service on port 1500 for namespace GoT_Favorite |
|||
2019/02/12 10:20:16 Starting server in http mode |
|||
``` |
|||
|
|||
## API |
|||
|
|||
A HTTP jSON endpoint is available with the following possible fields: `censusId`, `claimData`, `rootHash` and `proofData`. |
|||
|
|||
If `pubKey` has been configured for a specific `censusId`, then two more methods are available (`timeStamp` and `signature`) to provide authentication. |
|||
|
|||
The next table shows the available methods and its relation with the fields. |
|||
|
|||
| method | censusId | claimData | rootHash | proofData | protected? | description | |
|||
|------------|-----------|-----------|----------|-----------|------------|------------| |
|||
| `addCLaim` | mandatory | mandatory | none | none | yes | adds a new claim to the merkle tree | |
|||
| `getRoot` | mandatory | none | none | none | no | get the current merkletree root hash |
|||
| `genProof` | mandatory | mandatory | optional | none | no | generate the merkle proof for a given claim |
|||
| `checkProof` | mandatory | mandatory | optional | mandatory | no | check a claim and its merkle proof |
|||
| `getIdx` | mandatory | mandatory | optional | none | no | get the merkletree data index of a given claim |
|||
| `dump` | mandatory | none | optional | none | yes | list the contents of the census for a given hash |
|||
|
|||
|
|||
## Signature |
|||
|
|||
The signature provides authentication by signing a concatenation of the following strings (even if empty) without spaces: `censusId rootHash claimData timeStamp`. |
|||
|
|||
The `timeStamp` when received on the server side must not differ more than 10 seconds from the current UNIX time. |
|||
|
|||
## Examples |
|||
|
|||
#### add claims |
|||
|
|||
Add two new claims, one for `Jon Snow` and another for `Tyrion`. |
|||
``` |
|||
curl -d '{"censusID":"GoT_Favorite","claimData":"Jon Snow"}' http://localhost:1500/addClaim |
|||
|
|||
{"error":false,"response":""} |
|||
``` |
|||
|
|||
``` |
|||
curl -d '{"censusID":"GoT_Favorite","claimData":"Tyrion"}' http://localhost:1500/addClaim |
|||
|
|||
{"error":false,"response":""} |
|||
``` |
|||
|
|||
In case signature is enabled: |
|||
|
|||
``` |
|||
curl -d '{ |
|||
"censusID":"GoT_Favorite", |
|||
"claimData":"Jon Snow", |
|||
"timeStamp":"1547814675", |
|||
"signature":"a117c4ce12b29090884112ffe57e664f007e7ef142a1679996e2d34fd2b852fe76966e47932f1e9d3a54610d0f361383afe2d9aab096e15d136c236abb0a0d0e" }' http://localhost:1500/addClaim |
|||
|
|||
{"error":false,"response":""} |
|||
``` |
|||
|
|||
|
|||
#### generate proof |
|||
|
|||
Generate a merkle proof for the claim `Jon Snow` |
|||
|
|||
``` |
|||
curl -d '{"censusID":"GoT_Favorite","claimData":"Jon Snow"}' http://localhost:1500/genProof |
|||
|
|||
{"error":false,"response":"0x000200000000000000000000000000000000000000000000000000000000000212f8134039730791388a9bd0460f9fbd0757327212a64b3a2b0f0841ce561ee3"} |
|||
``` |
|||
|
|||
If `rootHash` is specified, the proof will be calculated for the given root hash. |
|||
|
|||
#### get root |
|||
|
|||
The previous merkle proof is valid only for the current root hash. Let's get it |
|||
|
|||
``` |
|||
curl -d '{"censusID":"GoT_Favorite"}' http://localhost:1500/getRoot |
|||
|
|||
{"error":false,"response":"0x2f0ddde5cb995eae23dc3b75a5c0333f1cc89b73f3a00b0fe71996fb90fef04b"} |
|||
``` |
|||
|
|||
|
|||
#### check proof |
|||
|
|||
Now let's check if the proof is valid |
|||
|
|||
``` |
|||
curl -d '{ |
|||
"censusID":"GoT_Favorite","claimData":"Jon Snow", |
|||
"rootHash":"0x2f0ddde5cb995eae23dc3b75a5c0333f1cc89b73f3a00b0fe71996fb90fef04b", |
|||
"proofData":"0x000200000000000000000000000000000000000000000000000000000000000212f8134039730791388a9bd0460f9fbd0757327212a64b3a2b0f0841ce561ee3"}' http://localhost:1500/checkProof |
|||
|
|||
{"error":false,"response":"valid"} |
|||
``` |
|||
|
|||
If `rootHash` is not specified, the current root hash is used. |
|||
|
|||
#### dump |
|||
|
|||
Dump contents of a specific censusId (values) |
|||
|
|||
``` |
|||
curl -d '{"censusID":"GoT_Favorite"}' http://localhost:1500/dump |
|||
|
|||
{"error":false,"response":"[\"Tyrion\",\"Jon Snow\"]"} |
|||
``` |
|||
|
|||
If `rootHash` is specified, dump will return the values for the merkle tree with the given root hash. |
@ -0,0 +1,37 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"log" |
|||
"os" |
|||
"strconv" |
|||
"strings" |
|||
|
|||
censusmanager "github.com/vocdoni/dvote-census/service" |
|||
) |
|||
|
|||
func main() { |
|||
if len(os.Args) < 2 { |
|||
log.Fatal("Usage: " + os.Args[0] + |
|||
" <port> <namespace>[:pubKey] [<namespace>[:pubKey]]...") |
|||
os.Exit(2) |
|||
} |
|||
port, err := strconv.Atoi(os.Args[1]) |
|||
if err != nil { |
|||
log.Fatal(err) |
|||
os.Exit(2) |
|||
} |
|||
for i := 2; i < len(os.Args); i++ { |
|||
s := strings.Split(os.Args[i], ":") |
|||
ns := s[0] |
|||
pubK := "" |
|||
if len(s) > 1 { |
|||
pubK = s[1] |
|||
log.Printf("Public Key authentication enabled on namespace %s\n", ns) |
|||
} |
|||
censusmanager.AddNamespace(ns, pubK) |
|||
log.Printf("Starting process HTTP service on port %d for namespace %s\n", |
|||
port, ns) |
|||
} |
|||
censusmanager.Listen(port, "http") |
|||
|
|||
} |
@ -0,0 +1,309 @@ |
|||
package censusmanager |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"log" |
|||
"net/http" |
|||
"strconv" |
|||
"time" |
|||
|
|||
tree "github.com/vocdoni/dvote-census/tree" |
|||
signature "github.com/vocdoni/dvote-relay/crypto/signature" |
|||
) |
|||
|
|||
const hashSize = 32 |
|||
const authTimeWindow = 10 // Time window (seconds) in which TimeStamp will be accepted if auth enabled
|
|||
var MkTrees map[string]*tree.Tree // MerkleTree dvote-census library
|
|||
var Signatures map[string]string |
|||
var Signature signature.SignKeys // Signature dvote-relay library
|
|||
|
|||
type Claim struct { |
|||
CensusID string `json:"censusId"` // References to MerkleTree namespace
|
|||
RootHash string `json:"rootHash"` // References to MerkleTree rootHash
|
|||
ClaimData string `json:"claimData"` // Data to add to the MerkleTree
|
|||
ProofData string `json:"proofData"` // MerkleProof to check
|
|||
TimeStamp string `json:"timeStamp"` // Unix TimeStamp in seconds
|
|||
Signature string `json:"signature"` // Signature as Hexadecimal String
|
|||
} |
|||
|
|||
type Result struct { |
|||
Error bool `json:"error"` |
|||
Response string `json:"response"` |
|||
} |
|||
|
|||
func AddNamespace(name, pubKey string) { |
|||
if len(MkTrees) == 0 { |
|||
MkTrees = make(map[string]*tree.Tree) |
|||
} |
|||
if len(Signatures) == 0 { |
|||
Signatures = make(map[string]string) |
|||
} |
|||
|
|||
mkTree := tree.Tree{} |
|||
mkTree.Init(name) |
|||
MkTrees[name] = &mkTree |
|||
Signatures[name] = pubKey |
|||
} |
|||
|
|||
func reply(resp *Result, w http.ResponseWriter) { |
|||
err := json.NewEncoder(w).Encode(resp) |
|||
if err != nil { |
|||
http.Error(w, err.Error(), 500) |
|||
} else { |
|||
w.Header().Set("content-type", "application/json") |
|||
} |
|||
} |
|||
|
|||
func checkRequest(w http.ResponseWriter, req *http.Request) bool { |
|||
if req.Body == nil { |
|||
http.Error(w, "Please send a request body", 400) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
|
|||
func checkAuth(timestamp, signature, pubKey, message string) bool { |
|||
if len(pubKey) < 1 { |
|||
return true |
|||
} |
|||
currentTime := int64(time.Now().Unix()) |
|||
timeStampRemote, err := strconv.ParseInt(timestamp, 10, 32) |
|||
if err != nil { |
|||
log.Printf("Cannot parse timestamp data %s\n", err) |
|||
return false |
|||
} |
|||
if timeStampRemote < currentTime+authTimeWindow && |
|||
timeStampRemote > currentTime-authTimeWindow { |
|||
v, err := Signature.Verify(message, signature, pubKey) |
|||
if err != nil { |
|||
log.Printf("Verification error: %s\n", err) |
|||
} |
|||
return v |
|||
} |
|||
return false |
|||
} |
|||
|
|||
func claimHandler(w http.ResponseWriter, req *http.Request, op string) { |
|||
var c Claim |
|||
var resp Result |
|||
|
|||
if ok := checkRequest(w, req); !ok { |
|||
return |
|||
} |
|||
// Decode JSON
|
|||
err := json.NewDecoder(req.Body).Decode(&c) |
|||
if err != nil { |
|||
http.Error(w, err.Error(), 400) |
|||
return |
|||
} |
|||
|
|||
// Process data
|
|||
log.Printf("censusId:{%s} rootHash:{%s} claimData:{%s} proofData:{%s} timeStamp:{%s} signature:{%s}\n", |
|||
c.CensusID, c.RootHash, c.ClaimData, c.ProofData, c.TimeStamp, c.Signature) |
|||
authString := fmt.Sprintf("%s%s%s%s", c.CensusID, c.RootHash, c.ClaimData, c.TimeStamp) |
|||
resp.Error = false |
|||
resp.Response = "" |
|||
censusFound := false |
|||
if len(c.CensusID) > 0 { |
|||
_, censusFound = MkTrees[c.CensusID] |
|||
} |
|||
if !censusFound { |
|||
resp.Error = true |
|||
resp.Response = "censusId not valid or not found" |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
|
|||
if op == "add" { |
|||
if auth := checkAuth(c.TimeStamp, c.Signature, Signatures[c.CensusID], authString); auth { |
|||
err = MkTrees[c.CensusID].AddClaim([]byte(c.ClaimData)) |
|||
} else { |
|||
resp.Error = true |
|||
resp.Response = "invalid authentication" |
|||
} |
|||
} |
|||
|
|||
if op == "gen" { |
|||
var t *tree.Tree |
|||
var err error |
|||
if len(c.RootHash) > 1 { //if rootHash specified
|
|||
t, err = MkTrees[c.CensusID].Snapshot(c.RootHash) |
|||
if err != nil { |
|||
log.Printf("Snapshot error: %s", err.Error()) |
|||
resp.Error = true |
|||
resp.Response = "invalid root hash" |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
} else { //if rootHash not specified use current tree
|
|||
t = MkTrees[c.CensusID] |
|||
} |
|||
resp.Response, err = t.GenProof([]byte(c.ClaimData)) |
|||
if err != nil { |
|||
resp.Error = true |
|||
resp.Response = err.Error() |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
} |
|||
|
|||
if op == "root" { |
|||
resp.Response = MkTrees[c.CensusID].GetRoot() |
|||
} |
|||
|
|||
if op == "idx" { |
|||
|
|||
} |
|||
|
|||
if op == "dump" { |
|||
var t *tree.Tree |
|||
if auth := checkAuth(c.TimeStamp, c.Signature, Signatures[c.CensusID], authString); !auth { |
|||
resp.Error = true |
|||
resp.Response = "invalid authentication" |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
|
|||
if len(c.RootHash) > 1 { //if rootHash specified
|
|||
t, err = MkTrees[c.CensusID].Snapshot(c.RootHash) |
|||
if err != nil { |
|||
log.Printf("Snapshot error: %s", err.Error()) |
|||
resp.Error = true |
|||
resp.Response = "invalid root hash" |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
} else { //if rootHash not specified use current merkletree
|
|||
t = MkTrees[c.CensusID] |
|||
} |
|||
|
|||
//dump the claim data and return it
|
|||
values, err := t.Dump() |
|||
if err != nil { |
|||
resp.Error = true |
|||
resp.Response = err.Error() |
|||
} else { |
|||
jValues, err := json.Marshal(values) |
|||
if err != nil { |
|||
resp.Error = true |
|||
resp.Response = err.Error() |
|||
} else { |
|||
resp.Response = fmt.Sprintf("%s", jValues) |
|||
} |
|||
} |
|||
} |
|||
|
|||
if op == "check" { |
|||
if len(c.ProofData) < 1 { |
|||
resp.Error = true |
|||
resp.Response = "proofData not provided" |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
var t *tree.Tree |
|||
if len(c.RootHash) > 1 { //if rootHash specified
|
|||
t, err = MkTrees[c.CensusID].Snapshot(c.RootHash) |
|||
if err != nil { |
|||
log.Printf("Snapshot error: %s", err.Error()) |
|||
resp.Error = true |
|||
resp.Response = "invalid root hash" |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
} else { //if rootHash not specified use current merkletree
|
|||
t = MkTrees[c.CensusID] |
|||
} |
|||
|
|||
validProof, err := t.CheckProof([]byte(c.ClaimData), c.ProofData) |
|||
if err != nil { |
|||
resp.Error = true |
|||
resp.Response = err.Error() |
|||
reply(&resp, w) |
|||
return |
|||
} |
|||
if validProof { |
|||
resp.Response = "valid" |
|||
} else { |
|||
resp.Response = "invalid" |
|||
} |
|||
} |
|||
|
|||
reply(&resp, w) |
|||
} |
|||
|
|||
func addCorsHeaders(w *http.ResponseWriter, req *http.Request) { |
|||
(*w).Header().Set("Access-Control-Allow-Origin", "*") |
|||
(*w).Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") |
|||
(*w).Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") |
|||
} |
|||
|
|||
func Listen(port int, proto string) { |
|||
srv := &http.Server{ |
|||
Addr: fmt.Sprintf(":%d", port), |
|||
ReadHeaderTimeout: 4 * time.Second, |
|||
ReadTimeout: 4 * time.Second, |
|||
WriteTimeout: 4 * time.Second, |
|||
IdleTimeout: 3 * time.Second, |
|||
} |
|||
|
|||
http.HandleFunc("/addClaim", func(w http.ResponseWriter, r *http.Request) { |
|||
addCorsHeaders(&w, r) |
|||
|
|||
if r.Method == http.MethodPost { |
|||
claimHandler(w, r, "add") |
|||
} else if r.Method != http.MethodOptions { |
|||
http.Error(w, "Not found", http.StatusNotFound) |
|||
} |
|||
}) |
|||
http.HandleFunc("/genProof", func(w http.ResponseWriter, r *http.Request) { |
|||
addCorsHeaders(&w, r) |
|||
|
|||
if r.Method == http.MethodPost { |
|||
claimHandler(w, r, "gen") |
|||
} else if r.Method != http.MethodOptions { |
|||
http.Error(w, "Not found", http.StatusNotFound) |
|||
} |
|||
}) |
|||
http.HandleFunc("/checkProof", func(w http.ResponseWriter, r *http.Request) { |
|||
addCorsHeaders(&w, r) |
|||
|
|||
if r.Method == http.MethodPost { |
|||
claimHandler(w, r, "check") |
|||
} else if r.Method != http.MethodOptions { |
|||
http.Error(w, "Not found", http.StatusNotFound) |
|||
} |
|||
}) |
|||
http.HandleFunc("/getRoot", func(w http.ResponseWriter, r *http.Request) { |
|||
addCorsHeaders(&w, r) |
|||
|
|||
if r.Method == http.MethodPost { |
|||
claimHandler(w, r, "root") |
|||
} else if r.Method != http.MethodOptions { |
|||
http.Error(w, "Not found", http.StatusNotFound) |
|||
} |
|||
}) |
|||
http.HandleFunc("/dump", func(w http.ResponseWriter, r *http.Request) { |
|||
addCorsHeaders(&w, r) |
|||
|
|||
if r.Method == http.MethodPost { |
|||
claimHandler(w, r, "dump") |
|||
} else if r.Method != http.MethodOptions { |
|||
http.Error(w, "Not found", http.StatusNotFound) |
|||
} |
|||
}) |
|||
|
|||
if proto == "https" { |
|||
log.Print("Starting server in https mode") |
|||
if err := srv.ListenAndServeTLS("server.crt", "server.key"); err != nil { |
|||
panic(err) |
|||
} |
|||
} |
|||
if proto == "http" { |
|||
log.Print("Starting server in http mode") |
|||
srv.SetKeepAlivesEnabled(false) |
|||
if err := srv.ListenAndServe(); err != nil { |
|||
panic(err) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,22 @@ |
|||
## dvote Tree |
|||
|
|||
Implementation of dvote tree structure. Currently based on iden3 merkle tree. |
|||
|
|||
Example of usage: |
|||
|
|||
``` |
|||
T := tree.Tree |
|||
if T.Init() != nil { fmt.Println("Cannot create tree database") } |
|||
err := T.AddClaim([]byte("Hello you!")) |
|||
if err != nil { |
|||
fmt.Println("Claim already exist") |
|||
} |
|||
mpHex, err := T.GenProof([]byte("Hello you!")) |
|||
fmt.Println(mpHex) |
|||
fmt.Println(T.CheckProof([]byte("Hello you!"), mpHex)) |
|||
T.Close() |
|||
``` |
|||
|
|||
#### To-Do |
|||
|
|||
Avoid duplicates on dump/snapshot |
@ -0,0 +1,138 @@ |
|||
package tree |
|||
|
|||
import ( |
|||
"bytes" |
|||
"errors" |
|||
"fmt" |
|||
"os/user" |
|||
|
|||
common3 "github.com/iden3/go-iden3/common" |
|||
mkcore "github.com/iden3/go-iden3/core" |
|||
db "github.com/iden3/go-iden3/db" |
|||
merkletree "github.com/iden3/go-iden3/merkletree" |
|||
) |
|||
|
|||
type Tree struct { |
|||
Storage string |
|||
Tree *merkletree.MerkleTree |
|||
DbStorage *db.LevelDbStorage |
|||
} |
|||
|
|||
func (t *Tree) Init(namespace string) error { |
|||
if len(t.Storage) < 1 { |
|||
if len(namespace) < 1 { |
|||
return errors.New("namespace not valid") |
|||
} |
|||
usr, err := user.Current() |
|||
if err == nil { |
|||
t.Storage = usr.HomeDir + "/.dvote/census/" + namespace |
|||
} else { |
|||
t.Storage = "./dvoteTree/" + namespace |
|||
} |
|||
} |
|||
mtdb, err := db.NewLevelDbStorage(t.Storage, false) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
mt, err := merkletree.NewMerkleTree(mtdb, 140) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
t.DbStorage = mtdb |
|||
t.Tree = mt |
|||
return nil |
|||
} |
|||
|
|||
func (t *Tree) Close() { |
|||
defer t.Tree.Storage().Close() |
|||
} |
|||
|
|||
func (t *Tree) GetClaim(data []byte) (*mkcore.ClaimBasic, error) { |
|||
if len(data) > 496/8 { |
|||
return nil, errors.New("claim data too large") |
|||
} |
|||
for i := len(data); i <= 496/8; i++ { |
|||
data = append(data, '\x00') |
|||
} |
|||
var indexSlot [400 / 8]byte |
|||
var dataSlot [496 / 8]byte |
|||
copy(indexSlot[:], data[:400/8]) |
|||
copy(dataSlot[:], data[:496/8]) |
|||
e := mkcore.NewClaimBasic(indexSlot, dataSlot) |
|||
return e, nil |
|||
} |
|||
|
|||
func (t *Tree) AddClaim(data []byte) error { |
|||
e, err := t.GetClaim(data) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return t.Tree.Add(e.Entry()) |
|||
} |
|||
|
|||
func (t *Tree) GenProof(data []byte) (string, error) { |
|||
e, err := t.GetClaim(data) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
mp, err := t.Tree.GenerateProof(e.Entry().HIndex()) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
mpHex := common3.HexEncode(mp.Bytes()) |
|||
return mpHex, nil |
|||
} |
|||
|
|||
func (t *Tree) CheckProof(data []byte, mpHex string) (bool, error) { |
|||
mpBytes, err := common3.HexDecode(mpHex) |
|||
if err != nil { |
|||
return false, err |
|||
} |
|||
mp, err := merkletree.NewProofFromBytes(mpBytes) |
|||
if err != nil { |
|||
return false, err |
|||
} |
|||
e, err := t.GetClaim(data) |
|||
if err != nil { |
|||
return false, err |
|||
} |
|||
return merkletree.VerifyProof(t.Tree.RootKey(), mp, |
|||
e.Entry().HIndex(), e.Entry().HValue()), nil |
|||
} |
|||
|
|||
func (t *Tree) GetRoot() string { |
|||
return common3.HexEncode(t.Tree.RootKey().Bytes()) |
|||
} |
|||
|
|||
func (t *Tree) GetIndex(data []byte) (string, error) { |
|||
e, err := t.GetClaim(data) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
index, err := t.Tree.GetDataByIndex(e.Entry().HIndex()) |
|||
return index.String(), err |
|||
} |
|||
|
|||
func (t *Tree) Dump() ([]string, error) { |
|||
var response []string |
|||
err := t.Tree.Walk(nil, func(n *merkletree.Node) { |
|||
if n.Type == merkletree.NodeTypeLeaf { |
|||
data := bytes.Trim(n.Value()[65:], "\x00") |
|||
response = append(response, fmt.Sprintf("%s", data)) |
|||
} |
|||
}) |
|||
return response, err |
|||
} |
|||
|
|||
func (t *Tree) Snapshot(root string) (*Tree, error) { |
|||
var rootHash merkletree.Hash |
|||
snapshotTree := new(Tree) |
|||
rootBytes, err := common3.HexDecode(root) |
|||
if err != nil { |
|||
return snapshotTree, err |
|||
} |
|||
copy(rootHash[:32], rootBytes) |
|||
mt, err := t.Tree.Snapshot(&rootHash) |
|||
snapshotTree.Tree = mt |
|||
return snapshotTree, err |
|||
} |