2015-07-31 00:36:02 +03:00
|
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
//
|
|
|
|
// Contributor: Aaron Meihm ameihm@mozilla.com [:alm]
|
|
|
|
|
2018-07-11 20:11:22 +03:00
|
|
|
package mig /* import "github.com/mozilla/mig" */
|
2015-07-31 00:36:02 +03:00
|
|
|
|
|
|
|
// This file contains structures and functions related to the handling of
|
|
|
|
// manifests and state bundles by the MIG loader and API.
|
|
|
|
|
|
|
|
import (
|
|
|
|
"archive/tar"
|
|
|
|
"bytes"
|
|
|
|
"compress/gzip"
|
|
|
|
"crypto/sha256"
|
|
|
|
"encoding/base64"
|
2016-08-19 00:26:32 +03:00
|
|
|
"encoding/hex"
|
2015-07-31 00:36:02 +03:00
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
2016-02-09 23:50:12 +03:00
|
|
|
"io/ioutil"
|
2018-07-11 20:11:22 +03:00
|
|
|
"github.com/mozilla/mig/pgp"
|
2015-07-31 00:36:02 +03:00
|
|
|
"os"
|
|
|
|
"path"
|
|
|
|
"runtime"
|
|
|
|
"time"
|
|
|
|
)
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ManifestRecord describes a manifest record stored within the MIG database
|
2015-07-31 00:36:02 +03:00
|
|
|
type ManifestRecord struct {
|
|
|
|
ID float64 `json:"id"` // Manifest record ID
|
|
|
|
Name string `json:"name"` // The name of the manifest record
|
|
|
|
Content string `json:"content,omitempty"` // Full data contents of record
|
|
|
|
Timestamp time.Time `json:"timestamp"` // Record timestamp
|
|
|
|
Status string `json:"status"` // Record status
|
|
|
|
Target string `json:"target"` // Targetting parameters for record
|
|
|
|
Signatures []string `json:"signatures"` // Signatures applied to the record
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// Validate validates an existing manifest record
|
2016-02-09 23:50:12 +03:00
|
|
|
func (m *ManifestRecord) Validate() (err error) {
|
|
|
|
if m.Name == "" {
|
|
|
|
return fmt.Errorf("manifest has invalid name")
|
|
|
|
}
|
|
|
|
if m.Target == "" {
|
|
|
|
return fmt.Errorf("manifest has invalid target")
|
|
|
|
}
|
2016-02-24 01:21:28 +03:00
|
|
|
if m.Status != "staged" && m.Status != "active" && m.Status != "disabled" {
|
|
|
|
return fmt.Errorf("manifest has invalid status")
|
|
|
|
}
|
2016-02-09 23:50:12 +03:00
|
|
|
// Attempt to convert it to a response as part of validation
|
|
|
|
_, err = m.ManifestResponse()
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// Sign will sign a manifest record using the indicated key ID
|
2015-07-31 00:36:02 +03:00
|
|
|
func (m *ManifestRecord) Sign(keyid string, secring io.Reader) (sig string, err error) {
|
|
|
|
defer func() {
|
|
|
|
if e := recover(); e != nil {
|
|
|
|
err = fmt.Errorf("Sign() -> %v", e)
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Convert the record into entry format, and strip existing signatures
|
|
|
|
// before signing.
|
|
|
|
me, err := m.ManifestResponse()
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
2016-02-04 23:12:19 +03:00
|
|
|
me.Signatures = make([]string, 0)
|
2015-07-31 00:36:02 +03:00
|
|
|
buf, err := json.Marshal(me)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
sig, err = pgp.Sign(string(buf), keyid, secring)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ManifestResponse converts a manifest record into a manifest response
|
2015-07-31 00:36:02 +03:00
|
|
|
func (m *ManifestRecord) ManifestResponse() (ManifestResponse, error) {
|
|
|
|
ret := ManifestResponse{}
|
|
|
|
|
|
|
|
if len(m.Content) == 0 {
|
|
|
|
return ret, fmt.Errorf("manifest record has no content")
|
|
|
|
}
|
|
|
|
|
|
|
|
buf := bytes.NewBufferString(m.Content)
|
|
|
|
b64r := base64.NewDecoder(base64.StdEncoding, buf)
|
|
|
|
gzr, err := gzip.NewReader(b64r)
|
|
|
|
if err != nil {
|
|
|
|
return ret, err
|
|
|
|
}
|
|
|
|
tr := tar.NewReader(gzr)
|
|
|
|
for {
|
|
|
|
h, err := tr.Next()
|
|
|
|
if err != nil {
|
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
return ret, err
|
|
|
|
}
|
|
|
|
if h.Typeflag != tar.TypeReg {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
hash := sha256.New()
|
|
|
|
rbuf := make([]byte, 4096)
|
|
|
|
for {
|
|
|
|
n, err := tr.Read(rbuf)
|
|
|
|
if err != nil {
|
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
return ret, err
|
|
|
|
}
|
|
|
|
if n > 0 {
|
|
|
|
hash.Write(rbuf[:n])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_, entname := path.Split(h.Name)
|
|
|
|
|
|
|
|
newEntry := ManifestEntry{}
|
|
|
|
newEntry.Name = entname
|
|
|
|
newEntry.SHA256 = fmt.Sprintf("%x", hash.Sum(nil))
|
|
|
|
ret.Entries = append(ret.Entries, newEntry)
|
|
|
|
}
|
|
|
|
ret.Signatures = m.Signatures
|
|
|
|
|
|
|
|
return ret, nil
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ManifestObject returns the requested file object as a gzip compressed byte slice
|
2015-07-31 00:36:02 +03:00
|
|
|
// from the manifest record
|
|
|
|
func (m *ManifestRecord) ManifestObject(obj string) ([]byte, error) {
|
|
|
|
var bufw bytes.Buffer
|
|
|
|
var ret []byte
|
|
|
|
|
|
|
|
bufr := bytes.NewBufferString(m.Content)
|
|
|
|
b64r := base64.NewDecoder(base64.StdEncoding, bufr)
|
|
|
|
gzr, err := gzip.NewReader(b64r)
|
|
|
|
if err != nil {
|
|
|
|
return ret, err
|
|
|
|
}
|
|
|
|
tr := tar.NewReader(gzr)
|
|
|
|
found := false
|
|
|
|
for {
|
|
|
|
h, err := tr.Next()
|
|
|
|
if err != nil {
|
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
return ret, err
|
|
|
|
}
|
|
|
|
if h.Typeflag != tar.TypeReg {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
_, thisf := path.Split(h.Name)
|
|
|
|
if thisf != obj {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
found = true
|
|
|
|
gzw := gzip.NewWriter(&bufw)
|
|
|
|
buftemp := make([]byte, 4096)
|
|
|
|
for {
|
|
|
|
n, err := tr.Read(buftemp)
|
|
|
|
if err != nil {
|
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
return ret, err
|
|
|
|
}
|
|
|
|
if n > 0 {
|
|
|
|
_, err = gzw.Write(buftemp[:n])
|
|
|
|
if err != nil {
|
|
|
|
return ret, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
gzw.Close()
|
|
|
|
break
|
|
|
|
}
|
|
|
|
if !found {
|
|
|
|
return ret, fmt.Errorf("object %v not found in manifest", obj)
|
|
|
|
}
|
|
|
|
|
|
|
|
ret = bufw.Bytes()
|
|
|
|
return ret, nil
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ContentFromFile loads manifest content from a file on the file system (a gzip'd tar file),
|
|
|
|
// primarily utilized by mig-console during manifest creation operations
|
2016-02-09 23:50:12 +03:00
|
|
|
func (m *ManifestRecord) ContentFromFile(path string) (err error) {
|
|
|
|
var buf bytes.Buffer
|
|
|
|
fd, err := os.Open(path)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
defer fd.Close()
|
|
|
|
b64w := base64.NewEncoder(base64.StdEncoding, &buf)
|
|
|
|
b, err := ioutil.ReadAll(fd)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
_, err = b64w.Write(b)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
b64w.Close()
|
|
|
|
m.Content = buf.String()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// FileFromContent writes manifest content to a file on the file system
|
2016-06-15 13:22:25 +03:00
|
|
|
func (m *ManifestRecord) FileFromContent(path string) (err error) {
|
|
|
|
fd, err := os.Create(path)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
defer fd.Close()
|
|
|
|
bufr := bytes.NewBufferString(m.Content)
|
|
|
|
b64r := base64.NewDecoder(base64.StdEncoding, bufr)
|
|
|
|
b, err := ioutil.ReadAll(b64r)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
_, err = fd.Write(b)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ManifestParameters are sent from the loader to the API as part of
|
2015-07-31 00:36:02 +03:00
|
|
|
// a manifest request.
|
|
|
|
type ManifestParameters struct {
|
2016-02-11 01:40:14 +03:00
|
|
|
AgentIdentifier Agent `json:"agent"` // Agent context information
|
|
|
|
Object string `json:"object"` // Object being requested
|
2015-07-31 00:36:02 +03:00
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// Validate validetes a ManifestParameters type for correct formatting
|
2015-07-31 00:36:02 +03:00
|
|
|
func (m *ManifestParameters) Validate() error {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ValidateFetch validates the parameters included in a manifest request with an
|
|
|
|
// object fetch component
|
2015-07-31 00:36:02 +03:00
|
|
|
func (m *ManifestParameters) ValidateFetch() error {
|
|
|
|
err := m.Validate()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if m.Object == "" {
|
|
|
|
return fmt.Errorf("manifest fetch with no object")
|
|
|
|
}
|
|
|
|
return m.Validate()
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ManifestFetchResponse is the response to a manifest object fetch
|
2015-07-31 00:36:02 +03:00
|
|
|
type ManifestFetchResponse struct {
|
|
|
|
Data []byte `json:"data"`
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ManifestResponse is the response to a standard manifest request
|
2015-07-31 00:36:02 +03:00
|
|
|
type ManifestResponse struct {
|
2016-02-24 18:47:01 +03:00
|
|
|
LoaderName string `json:"loader_name"`
|
2015-07-31 00:36:02 +03:00
|
|
|
Entries []ManifestEntry `json:"entries"`
|
|
|
|
Signatures []string `json:"signatures"`
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// Validate validates a ManifestResponse type ensuring required content is present
|
2016-02-24 20:03:53 +03:00
|
|
|
func (m *ManifestResponse) Validate() error {
|
|
|
|
if m.LoaderName == "" {
|
|
|
|
return fmt.Errorf("manifest response has no loader name")
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// VerifySignatures verifies the signatures present in a manifest response against the keys
|
|
|
|
// present in keyring. It returns the number of valid unique signatures identified in the
|
|
|
|
// ManifestResponse.
|
2016-02-04 23:12:19 +03:00
|
|
|
func (m *ManifestResponse) VerifySignatures(keyring io.Reader) (validcnt int, err error) {
|
|
|
|
var sigs []string
|
|
|
|
|
|
|
|
// Copy signatures out of the response, and clear them as we do not
|
|
|
|
// include them as part of the JSON document in validation
|
|
|
|
sigs = make([]string, len(m.Signatures))
|
|
|
|
copy(sigs, m.Signatures)
|
|
|
|
m.Signatures = m.Signatures[:0]
|
|
|
|
|
2016-02-22 05:33:53 +03:00
|
|
|
mcopy := *m
|
|
|
|
|
|
|
|
// Also zero the loader name as it is not included in the signature
|
|
|
|
mcopy.LoaderName = ""
|
|
|
|
|
|
|
|
buf, err := json.Marshal(mcopy)
|
2016-02-04 23:12:19 +03:00
|
|
|
if err != nil {
|
|
|
|
return validcnt, err
|
|
|
|
}
|
2016-03-04 19:46:32 +03:00
|
|
|
// Create a copy of the keyring we can use during validation of each
|
|
|
|
// signature. We don't want to use the keyring directly as it is
|
|
|
|
// backed by a buffer and will be drained after verification of the
|
|
|
|
// first signature.
|
|
|
|
keycopy, err := ioutil.ReadAll(keyring)
|
|
|
|
if err != nil {
|
|
|
|
return validcnt, err
|
|
|
|
}
|
2016-08-19 00:26:32 +03:00
|
|
|
fpcache := make([]string, 0)
|
2016-02-04 23:12:19 +03:00
|
|
|
for _, x := range sigs {
|
2016-03-04 19:46:32 +03:00
|
|
|
keyreader := bytes.NewBuffer(keycopy)
|
2016-08-19 00:26:32 +03:00
|
|
|
valid, ent, err := pgp.Verify(string(buf), x, keyreader)
|
2016-02-04 23:12:19 +03:00
|
|
|
if err != nil {
|
|
|
|
return validcnt, err
|
|
|
|
}
|
|
|
|
if valid {
|
|
|
|
validcnt++
|
|
|
|
}
|
2016-08-19 00:26:32 +03:00
|
|
|
fp := hex.EncodeToString(ent.PrimaryKey.Fingerprint[:])
|
|
|
|
// Return an error if we have already cached this fingerprint
|
|
|
|
for _, x := range fpcache {
|
|
|
|
if x == fp {
|
|
|
|
err = fmt.Errorf("duplicate signature for fingerprint %v", fp)
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
fpcache = append(fpcache, fp)
|
2016-02-04 23:12:19 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// ManifestEntry describes an individual file element within a manifest
|
2015-07-31 00:36:02 +03:00
|
|
|
type ManifestEntry struct {
|
|
|
|
Name string `json:"name"` // Corresponds to a bundle name
|
|
|
|
SHA256 string `json:"sha256"` // SHA256 of entry
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// BundleDictionaryEntry is used to map tokens within the loader manifest to
|
2015-07-31 00:36:02 +03:00
|
|
|
// objects on the file system. We don't allow specification of an exact path
|
|
|
|
// for interrogation or manipulation in the manifest. This results in some
|
|
|
|
// restrictions but hardens the loader against making unauthorized changes
|
|
|
|
// to the file system.
|
|
|
|
type BundleDictionaryEntry struct {
|
2016-06-15 16:11:56 +03:00
|
|
|
Name string
|
|
|
|
Path string
|
|
|
|
SHA256 string
|
|
|
|
Perm os.FileMode
|
2015-07-31 00:36:02 +03:00
|
|
|
}
|
|
|
|
|
2017-09-11 23:34:06 +03:00
|
|
|
// The various bundle entry maps below map filenames in the manifest to the location
|
|
|
|
// these files should be installed on the target platform.
|
|
|
|
//
|
|
|
|
// Note: take care when modifying these values; changing an existing manifest entry
|
|
|
|
// could cause problems if fetched by an older version of the loader. The map keys
|
|
|
|
// should be static and generally not be modified to retain compatibility.
|
|
|
|
|
2015-07-31 00:36:02 +03:00
|
|
|
var bundleEntryLinux = []BundleDictionaryEntry{
|
2016-06-15 16:11:56 +03:00
|
|
|
{"mig-agent", "/sbin/mig-agent", "", 0700},
|
|
|
|
{"mig-loader", "/sbin/mig-loader", "", 0700},
|
|
|
|
{"configuration", "/etc/mig/mig-agent.cfg", "", 0600},
|
|
|
|
{"agentcert", "/etc/mig/agent.crt", "", 0644},
|
|
|
|
{"agentkey", "/etc/mig/agent.key", "", 0600},
|
|
|
|
{"cacert", "/etc/mig/ca.crt", "", 0644},
|
2017-09-11 23:34:06 +03:00
|
|
|
{"loaderconfig", "/etc/mig/mig-loader.cfg", "", 0600},
|
2015-07-31 00:36:02 +03:00
|
|
|
}
|
|
|
|
|
2016-02-12 01:06:03 +03:00
|
|
|
var bundleEntryDarwin = []BundleDictionaryEntry{
|
2016-06-15 16:11:56 +03:00
|
|
|
{"mig-agent", "/usr/local/bin/mig-agent", "", 0700},
|
|
|
|
{"mig-loader", "/usr/local/bin/mig-loader", "", 0700},
|
|
|
|
{"configuration", "/etc/mig/mig-agent.cfg", "", 0600},
|
|
|
|
{"agentcert", "/etc/mig/agent.crt", "", 0644},
|
|
|
|
{"agentkey", "/etc/mig/agent.key", "", 0600},
|
|
|
|
{"cacert", "/etc/mig/ca.crt", "", 0644},
|
2017-09-11 23:34:06 +03:00
|
|
|
{"loaderconfig", "/etc/mig/mig-loader.cfg", "", 0600},
|
2016-02-12 01:06:03 +03:00
|
|
|
}
|
|
|
|
|
2017-04-17 23:05:54 +03:00
|
|
|
var bundleEntryWindows = []BundleDictionaryEntry{
|
|
|
|
{"mig-agent", "C:\\mig\\mig-agent.exe", "", 0700},
|
|
|
|
{"mig-loader", "C:\\mig\\mig-loader.exe", "", 0700},
|
|
|
|
{"configuration", "C:\\mig\\mig-agent.cfg", "", 0600},
|
|
|
|
{"agentcert", "C:\\mig\\agent.crt", "", 0644},
|
|
|
|
{"agentkey", "C:\\mig\\agent.key", "", 0600},
|
|
|
|
{"cacert", "C:\\mig\\ca.crt", "", 0644},
|
2017-09-11 23:34:06 +03:00
|
|
|
{"loaderconfig", "C:\\mig\\mig-loader.cfg", "", 0600},
|
2017-04-17 23:05:54 +03:00
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// BundleDictionary maps GOOS platform names to specific bundle entry values
|
2015-07-31 00:36:02 +03:00
|
|
|
var BundleDictionary = map[string][]BundleDictionaryEntry{
|
2017-04-17 23:05:54 +03:00
|
|
|
"linux": bundleEntryLinux,
|
|
|
|
"darwin": bundleEntryDarwin,
|
|
|
|
"windows": bundleEntryWindows,
|
2015-07-31 00:36:02 +03:00
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// GetHostBundle returns the correct BundleDictionaryEntry given the platform the
|
|
|
|
// code is executing on
|
2015-07-31 00:36:02 +03:00
|
|
|
func GetHostBundle() ([]BundleDictionaryEntry, error) {
|
|
|
|
switch runtime.GOOS {
|
|
|
|
case "linux":
|
|
|
|
return bundleEntryLinux, nil
|
2016-02-12 01:06:03 +03:00
|
|
|
case "darwin":
|
|
|
|
return bundleEntryDarwin, nil
|
2017-04-17 23:05:54 +03:00
|
|
|
case "windows":
|
|
|
|
return bundleEntryWindows, nil
|
2015-07-31 00:36:02 +03:00
|
|
|
}
|
|
|
|
return nil, fmt.Errorf("no entry for %v in bundle dictionary", runtime.GOOS)
|
|
|
|
}
|
|
|
|
|
2017-10-26 23:27:44 +03:00
|
|
|
// HashBundle populates a slice of BundleDictionaryEntrys, adding the SHA256 checksums
|
2016-02-23 21:52:05 +03:00
|
|
|
// from the file system
|
2015-07-31 00:36:02 +03:00
|
|
|
func HashBundle(b []BundleDictionaryEntry) ([]BundleDictionaryEntry, error) {
|
|
|
|
ret := b
|
|
|
|
for i := range ret {
|
|
|
|
fd, err := os.Open(ret[i].Path)
|
|
|
|
if err != nil {
|
|
|
|
// If the file does not exist we don't treat this as as
|
|
|
|
// an error. This is likely in cases with embedded
|
|
|
|
// configurations. In this case we leave the SHA256 as
|
|
|
|
// an empty string.
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
h := sha256.New()
|
2016-06-15 16:11:56 +03:00
|
|
|
buf := make([]byte, 4096)
|
|
|
|
for {
|
|
|
|
n, err := fd.Read(buf)
|
2015-07-31 00:36:02 +03:00
|
|
|
if err != nil {
|
2016-06-15 16:11:56 +03:00
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
fd.Close()
|
2015-07-31 00:36:02 +03:00
|
|
|
return nil, err
|
|
|
|
}
|
2016-06-15 16:11:56 +03:00
|
|
|
if n > 0 {
|
|
|
|
h.Write(buf[:n])
|
2015-07-31 00:36:02 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
fd.Close()
|
|
|
|
ret[i].SHA256 = fmt.Sprintf("%x", h.Sum(nil))
|
|
|
|
}
|
|
|
|
return ret, nil
|
|
|
|
}
|