github.com/sqlitebrowser/dio@v0.0.0-20240125125356-b587368e5c6b/cmd/shared.go (about)

     1  package cmd
     2  
     3  import (
     4  	"bytes"
     5  	"crypto/sha256"
     6  	"crypto/tls"
     7  	"crypto/x509"
     8  	"encoding/hex"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io/ioutil"
    13  	"log"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"path/filepath"
    18  	"runtime"
    19  	"strings"
    20  	"time"
    21  
    22  	"github.com/mitchellh/go-homedir"
    23  	rq "github.com/parnurzeal/gorequest"
    24  )
    25  
    26  // Check if the database with the given SHA256 checksum is in local cache.  If it's not then download and cache it
    27  func checkDBCache(db, shaSum string) (err error) {
    28  	if _, err = os.Stat(filepath.Join(".dio", db, "db", shaSum)); os.IsNotExist(err) {
    29  		var body []byte
    30  		_, body, err = retrieveDatabase(db, pullCmdBranch, pullCmdCommit)
    31  		if err != nil {
    32  			return
    33  		}
    34  
    35  		// Verify the SHA256 checksum of the new download
    36  		s := sha256.Sum256(body)
    37  		thisSum := hex.EncodeToString(s[:])
    38  		if thisSum != shaSum {
    39  			// The newly downloaded database file doesn't have the expected checksum.  Abort.
    40  			return errors.New(fmt.Sprintf("Aborting: newly downloaded database file should have "+
    41  				"checksum '%s', but data with checksum '%s' received\n", shaSum, thisSum))
    42  		}
    43  
    44  		// Write the database file to disk in the cache directory
    45  		err = ioutil.WriteFile(filepath.Join(".dio", db, "db", shaSum), body, 0644)
    46  	}
    47  	return
    48  }
    49  
    50  // Generate a stable SHA256 for a commit.
    51  func createCommitID(c commitEntry) string {
    52  	var b bytes.Buffer
    53  	b.WriteString(fmt.Sprintf("tree %s\n", c.Tree.ID))
    54  	if c.Parent != "" {
    55  		b.WriteString(fmt.Sprintf("parent %s\n", c.Parent))
    56  	}
    57  	for _, j := range c.OtherParents {
    58  		b.WriteString(fmt.Sprintf("parent %s\n", j))
    59  	}
    60  	b.WriteString(fmt.Sprintf("author %s <%s> %v\n", c.AuthorName, c.AuthorEmail,
    61  		c.Timestamp.UTC().Format(time.UnixDate)))
    62  	if c.CommitterEmail != "" {
    63  		b.WriteString(fmt.Sprintf("committer %s <%s> %v\n", c.CommitterName, c.CommitterEmail,
    64  			c.Timestamp.UTC().Format(time.UnixDate)))
    65  	}
    66  	b.WriteString("\n" + c.Message)
    67  	b.WriteByte(0)
    68  	s := sha256.Sum256(b.Bytes())
    69  	return hex.EncodeToString(s[:])
    70  }
    71  
    72  // Generate the SHA256 for a tree.
    73  // Tree entry structure is:
    74  // * [ entry type ] [ licence sha256] [ file sha256 ] [ file name ] [ last modified (timestamp) ] [ file size (bytes) ]
    75  func createDBTreeID(entries []dbTreeEntry) string {
    76  	var b bytes.Buffer
    77  	for _, j := range entries {
    78  		b.WriteString(string(j.EntryType))
    79  		b.WriteByte(0)
    80  		b.WriteString(string(j.LicenceSHA))
    81  		b.WriteByte(0)
    82  		b.WriteString(j.Sha256)
    83  		b.WriteByte(0)
    84  		b.WriteString(j.Name)
    85  		b.WriteByte(0)
    86  		b.WriteString(j.LastModified.Format(time.RFC3339))
    87  		b.WriteByte(0)
    88  		b.WriteString(fmt.Sprintf("%d\n", j.Size))
    89  	}
    90  	s := sha256.Sum256(b.Bytes())
    91  	return hex.EncodeToString(s[:])
    92  }
    93  
    94  // Returns true if a database has been changed on disk since the last commit
    95  func dbChanged(db string, meta metaData) (changed bool, err error) {
    96  	// Retrieve the sha256, file size, and last modified date from the head commit of the active branch
    97  	head, ok := meta.Branches[meta.ActiveBranch]
    98  	if !ok {
    99  		err = errors.New("Aborting: info for the active branch isn't found in the local branch cache")
   100  		return
   101  	}
   102  	c, ok := meta.Commits[head.Commit]
   103  	if !ok {
   104  		err = errors.New("Aborting: info for the head commit isn't found in the local commit cache")
   105  		return
   106  	}
   107  	metaSHASum := c.Tree.Entries[0].Sha256
   108  	metaFileSize := c.Tree.Entries[0].Size
   109  	metaLastModified := c.Tree.Entries[0].LastModified.Truncate(time.Second).UTC()
   110  
   111  	// If the file size or last modified date in the metadata are different from the current file info, then the
   112  	// local file has probably changed.  Well, "probably" for the last modified day, but "definitely" if the file
   113  	// size is different
   114  	fi, err := os.Stat(db)
   115  	if err != nil {
   116  		if os.IsNotExist(err) {
   117  			return false, nil
   118  		}
   119  		return
   120  	}
   121  	fileSize := fi.Size()
   122  	lastModified := fi.ModTime().Truncate(time.Second).UTC()
   123  	if metaFileSize != fileSize || !metaLastModified.Equal(lastModified) {
   124  		changed = true
   125  		return
   126  	}
   127  
   128  	// * If the file size and last modified date are still the same, we SHA256 checksum and compare the file *
   129  
   130  	// TODO: Should we only do this for smaller files (below some TBD threshold)?
   131  
   132  	// Read the database from disk, and calculate it's sha256
   133  	b, err := ioutil.ReadFile(db)
   134  	if err != nil {
   135  		return
   136  	}
   137  	if int64(len(b)) != fileSize {
   138  		err = errors.New(numFormat.Sprintf("Aborting: # of bytes read (%d) when reading the database "+
   139  			"doesn't match the database file size (%d)", len(b), fileSize))
   140  		return
   141  	}
   142  	s := sha256.Sum256(b)
   143  	shaSum := hex.EncodeToString(s[:])
   144  
   145  	// Check if a change has been made
   146  	if metaSHASum != shaSum {
   147  		changed = true
   148  	}
   149  	return
   150  }
   151  
   152  // Retrieves the list of databases available to the user
   153  var getDatabases = func(url string, user string) (dbList []dbListEntry, err error) {
   154  	resp, body, errs := rq.New().TLSClientConfig(&TLSConfig).
   155  		Get(fmt.Sprintf("%s/%s", url, user)).
   156  		Set("User-Agent", fmt.Sprintf("Dio %s", DIO_VERSION)).
   157  		EndBytes()
   158  	if errs != nil {
   159  		e := fmt.Sprintln("Errors when retrieving the database list:")
   160  		for _, err := range errs {
   161  			e += fmt.Sprintf(err.Error())
   162  		}
   163  		err = errors.New(e)
   164  		return
   165  	}
   166  	defer resp.Body.Close()
   167  	err = json.Unmarshal(body, &dbList)
   168  	if err != nil {
   169  		_, errInner := fmt.Fprintf(fOut, "Error retrieving database list: '%v'\n", err.Error())
   170  		if errInner != nil {
   171  			err = fmt.Errorf("%s: %s", err, errInner)
   172  			return
   173  		}
   174  	}
   175  	return
   176  }
   177  
   178  // Generates an initial default (production) configuration file.  Before it's useful, the user will need to fill out
   179  // their display name + provide a DB4S certificate file
   180  func generateConfig(cfgFile string) (err error) {
   181  	// Create the ".dio" directory in the users home folder, to store the configuration file in
   182  	var home string
   183  	home, err = homedir.Dir()
   184  	if err != nil {
   185  		return
   186  	}
   187  	if _, err = os.Stat(filepath.Join(home, ".dio")); os.IsNotExist(err) {
   188  		err = os.Mkdir(filepath.Join(home, ".dio"), 0770)
   189  		if err != nil {
   190  			return
   191  		}
   192  	}
   193  
   194  	// Download the Certificate Authority chain file
   195  	caURL := "https://github.com/sqlitebrowser/dio/raw/master/cert/ca-chain.cert.pem"
   196  	chainFile := filepath.Join(home, ".dio", "ca-chain.cert.pem")
   197  	resp, body, errs := rq.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).Get(caURL).
   198  		Set("User-Agent", fmt.Sprintf("Dio %s", DIO_VERSION)).
   199  		EndBytes()
   200  	if errs != nil {
   201  		e := fmt.Sprintln("errors when retrieving the CA chain file:")
   202  		for _, errInner := range errs {
   203  			e += fmt.Sprintf(errInner.Error())
   204  		}
   205  		return errors.New(e)
   206  	}
   207  	defer resp.Body.Close()
   208  	err = ioutil.WriteFile(chainFile, body, 0644)
   209  	if err != nil {
   210  		return err
   211  	}
   212  
   213  	// Generate the initial config file
   214  	var f *os.File
   215  	f, err = os.Create(cfgFile)
   216  	if err != nil {
   217  		return
   218  	}
   219  	defer f.Close()
   220  	lineEnd := "\n"
   221  	if runtime.GOOS == "windows" {
   222  		lineEnd = "\r\n"
   223  	}
   224  	certPath := fmt.Sprintf("%c%s", os.PathSeparator, filepath.Join("path", "to", "your", "certificate", "here"))
   225  	_, err = fmt.Fprint(f, `[certs]`+lineEnd)
   226  	_, err = fmt.Fprint(f, fmt.Sprintf(`cachain = '%s'%s`, chainFile, lineEnd))
   227  	_, err = fmt.Fprint(f, fmt.Sprintf(`cert = '%s'%s`, certPath, lineEnd))
   228  	_, err = fmt.Fprint(f, lineEnd)
   229  	_, err = fmt.Fprint(f, `[general]`+lineEnd)
   230  	_, err = fmt.Fprint(f, `cloud = 'https://db4s.dbhub.io'`+lineEnd)
   231  	_, err = fmt.Fprint(f, lineEnd)
   232  	_, err = fmt.Fprint(f, `[user]`+lineEnd)
   233  	_, err = fmt.Fprint(f, `name = 'Your Name'`+lineEnd)
   234  	return
   235  }
   236  
   237  // Returns the name of the default database, if one has been selected.  Returns an empty string if not
   238  func getDefaultDatabase() (db string, err error) {
   239  	// Check if the local defaults info exists
   240  	var z []byte
   241  	if z, err = ioutil.ReadFile(filepath.Join(".dio", "defaults.json")); err != nil {
   242  		if os.IsNotExist(err) {
   243  			return "", nil
   244  		}
   245  		return
   246  	}
   247  
   248  	// Read and parse the metadata
   249  	var y defaultSettings
   250  	err = json.Unmarshal([]byte(z), &y)
   251  	if err != nil {
   252  		return
   253  	}
   254  	if y.SelectedDatabase != "" {
   255  		db = y.SelectedDatabase
   256  	}
   257  	return
   258  }
   259  
   260  // Returns a map with the list of licences available on the remote server
   261  var getLicences = func() (list map[string]licenceEntry, err error) {
   262  	// Retrieve the database list from the cloud
   263  	resp, body, errs := rq.New().TLSClientConfig(&TLSConfig).Get(cloud+"/licence/list").
   264  		Set("User-Agent", fmt.Sprintf("Dio %s", DIO_VERSION)).
   265  		End()
   266  	if errs != nil {
   267  		e := fmt.Sprintln("errors when retrieving the licence list:")
   268  		for _, err := range errs {
   269  			e += fmt.Sprintf(err.Error())
   270  		}
   271  		return list, errors.New(e)
   272  	}
   273  	defer resp.Body.Close()
   274  
   275  	// Convert the JSON response to our licence entry structure
   276  	err = json.Unmarshal([]byte(body), &list)
   277  	if err != nil {
   278  		return list, errors.New(fmt.Sprintf("error retrieving licence list: '%v'\n", err.Error()))
   279  	}
   280  	return list, err
   281  }
   282  
   283  // getUserAndServer() returns the user name and server from a DBHub.io client certificate
   284  func getUserAndServer() (userAcc string, email string, certServer string, err error) {
   285  	if numCerts := len(TLSConfig.Certificates); numCerts == 0 {
   286  		err = errors.New("No client certificates installed.  Can't proceed.")
   287  		return
   288  	}
   289  
   290  	// Parse the client certificate
   291  	// TODO: Add support for multiple certificates
   292  	cert, err := x509.ParseCertificate(TLSConfig.Certificates[0].Certificate[0])
   293  	if err != nil {
   294  		err = errors.New("Couldn't parse cert")
   295  		return
   296  	}
   297  
   298  	// Extract the account name, email address, and associated server from the certificate
   299  	email = cert.Subject.CommonName
   300  	if email == "" {
   301  		// The common name field is empty in the client cert.  Can't proceed.
   302  		err = errors.New("Common name is blank in client certificate")
   303  		return
   304  	}
   305  	s := strings.Split(email, "@")
   306  	if len(s) < 2 {
   307  		err = errors.New("Missing information in client certificate")
   308  		return
   309  	}
   310  	userAcc = s[0]
   311  	certServer = s[1]
   312  	if userAcc == "" || certServer == "" {
   313  		// Missing details in common name field
   314  		err = errors.New("Missing information in client certificate")
   315  		return
   316  	}
   317  	return
   318  }
   319  
   320  // Loads the local metadata from disk (if present).  If not, then grab it from the remote server, storing it locally.
   321  //     Note - This is subtly different than calling updateMetadata() itself.  This function
   322  //     (loadMetadata()) is for use by commands which can use a local metadata cache all by itself
   323  //     (eg branch creation), but only if it already exists.  For those, it only calls the
   324  //     remote server when a local metadata cache doesn't exist.
   325  func loadMetadata(db string) (meta metaData, err error) {
   326  	// Check if the local metadata exists.  If not, pull it from the remote server
   327  	if _, err = os.Stat(filepath.Join(".dio", db, "metadata.json")); os.IsNotExist(err) {
   328  		_, err = updateMetadata(db, true)
   329  		if err != nil {
   330  			return
   331  		}
   332  	}
   333  
   334  	// Read and parse the metadata
   335  	var md []byte
   336  	md, err = ioutil.ReadFile(filepath.Join(".dio", db, "metadata.json"))
   337  	if err != nil {
   338  		return
   339  	}
   340  	err = json.Unmarshal([]byte(md), &meta)
   341  
   342  	// If the tag or release maps are missing, create initial empty ones.
   343  	// This is a safety check, not sure if it's really needed
   344  	if meta.Tags == nil {
   345  		meta.Tags = make(map[string]tagEntry)
   346  	}
   347  	if meta.Releases == nil {
   348  		meta.Releases = make(map[string]releaseEntry)
   349  	}
   350  	return
   351  }
   352  
   353  // Loads the local metadata cache for the requested database, if present.  Otherwise, (optionally) retrieve it from
   354  // the server.
   355  //   Note - this is suitable for use by read-only functions (eg: branch/tag list, log)
   356  //   as it doesn't store or change any metadata on disk
   357  var localFetchMetadata = func(db string, getRemote bool) (meta metaData, err error) {
   358  	md, err := ioutil.ReadFile(filepath.Join(".dio", db, "metadata.json"))
   359  	if err == nil {
   360  		err = json.Unmarshal([]byte(md), &meta)
   361  		return
   362  	}
   363  
   364  	// Can't read local metadata, and we're requested to not grab remote metadata.  So, nothing to do but exit
   365  	if !getRemote {
   366  		err = errors.New("No local metadata for the database exists")
   367  		return
   368  	}
   369  
   370  	// Can't read local metadata, but we're ok to grab the remote. So, use that instead
   371  	meta, _, err = retrieveMetadata(db)
   372  	return
   373  }
   374  
   375  // Merges old and new metadata
   376  func mergeMetadata(origMeta metaData, newMeta metaData) (mergedMeta metaData, err error) {
   377  	mergedMeta.Branches = make(map[string]branchEntry)
   378  	mergedMeta.Commits = make(map[string]commitEntry)
   379  	mergedMeta.Tags = make(map[string]tagEntry)
   380  	mergedMeta.Releases = make(map[string]releaseEntry)
   381  	if len(origMeta.Commits) > 0 {
   382  		// Start by check branches which exist locally
   383  		// TODO: Change sort order to be by alphabetical branch name, as the current unordered approach leads to
   384  		//       inconsistent output across runs
   385  		for brName, brData := range origMeta.Branches {
   386  			matchFound := false
   387  			for newBranch, newData := range newMeta.Branches {
   388  				if brName == newBranch {
   389  					// A branch with this name exists on both the local and remote server
   390  					matchFound = true
   391  					skipFurtherChecks := false
   392  
   393  					// Rewind back to the local root commit, making a list of the local commits IDs we pass through
   394  					var localList []string
   395  					localCommit := origMeta.Commits[brData.Commit]
   396  					localList = append(localList, localCommit.ID)
   397  					for localCommit.Parent != "" {
   398  						localCommit = origMeta.Commits[localCommit.Parent]
   399  						localList = append(localList, localCommit.ID)
   400  					}
   401  					localLength := len(localList) - 1
   402  
   403  					// Rewind back to the remote root commit, making a list of the remote commit IDs we pass through
   404  					var remoteList []string
   405  					remoteCommit := newMeta.Commits[newData.Commit]
   406  					remoteList = append(remoteList, remoteCommit.ID)
   407  					for remoteCommit.Parent != "" {
   408  						remoteCommit = newMeta.Commits[remoteCommit.Parent]
   409  						remoteList = append(remoteList, remoteCommit.ID)
   410  					}
   411  					remoteLength := len(remoteList) - 1
   412  
   413  					// Make sure the local and remote commits start out with the same commit ID
   414  					if localCommit.ID != remoteCommit.ID {
   415  						// The local and remote branches don't have a common root, so abort
   416  						err = errors.New(fmt.Sprintf("Local and remote branch %s don't have a common root.  "+
   417  							"Aborting.", brName))
   418  						return
   419  					}
   420  
   421  					// If there are more commits in the local branch than in the remote one, we keep the local branch
   422  					// as it probably means the user is adding stuff locally (prior to pushing to the server)
   423  					if localLength > remoteLength {
   424  						c := origMeta.Commits[brData.Commit]
   425  						mergedMeta.Commits[c.ID] = origMeta.Commits[c.ID]
   426  						for c.Parent != "" {
   427  							c = origMeta.Commits[c.Parent]
   428  							mergedMeta.Commits[c.ID] = origMeta.Commits[c.ID]
   429  						}
   430  
   431  						// Copy the local branch data
   432  						mergedMeta.Branches[brName] = brData
   433  					}
   434  
   435  					// We've wound back to the root commit for both the local and remote branch, and the root commit
   436  					// IDs match.  Now we walk forwards through the commits, comparing them.
   437  					branchesSame := true
   438  					for i := 0; i <= localLength; i++ {
   439  						lCommit := localList[localLength-i]
   440  						if i > remoteLength {
   441  							branchesSame = false
   442  						} else {
   443  							if lCommit != remoteList[remoteLength-i] {
   444  								// There are conflicting commits in this branch between the local metadata and the
   445  								// remote.  This will probably need to be resolved by user action.
   446  								branchesSame = false
   447  							}
   448  						}
   449  					}
   450  
   451  					// If the local branch commits are in the remote branch already, then we only need to check for
   452  					// newer commits in the remote branch
   453  					if branchesSame {
   454  						if remoteLength > localLength {
   455  							_, err = fmt.Fprintf(fOut, "  * Remote branch '%s' has %d new commit(s)... merged\n",
   456  								brName, remoteLength-localLength)
   457  							if err != nil {
   458  								return
   459  							}
   460  							for _, j := range remoteList {
   461  								mergedMeta.Commits[j] = newMeta.Commits[j]
   462  							}
   463  							mergedMeta.Branches[brName] = newMeta.Branches[brName]
   464  						} else {
   465  							// The local and remote branches are the same, so copy the local branch commits across to
   466  							// the merged data structure
   467  							_, err = fmt.Fprintf(fOut, "  * Branch '%s' is unchanged\n", brName)
   468  							if err != nil {
   469  								return
   470  							}
   471  							for _, j := range localList {
   472  								mergedMeta.Commits[j] = origMeta.Commits[j]
   473  							}
   474  							mergedMeta.Branches[brName] = brData
   475  						}
   476  						// No need to do further checks on this branch
   477  						skipFurtherChecks = true
   478  					}
   479  
   480  					if skipFurtherChecks == false && brData.Commit != newData.Commit {
   481  						_, err = fmt.Fprintf(fOut, "  * Branch '%s' has local changes, not on the server\n",
   482  							brName)
   483  						if err != nil {
   484  							return
   485  						}
   486  
   487  						// Copy across the commits from the local branch
   488  						localCommit := origMeta.Commits[brData.Commit]
   489  						mergedMeta.Commits[localCommit.ID] = origMeta.Commits[localCommit.ID]
   490  						for localCommit.Parent != "" {
   491  							localCommit = origMeta.Commits[localCommit.Parent]
   492  							mergedMeta.Commits[localCommit.ID] = origMeta.Commits[localCommit.ID]
   493  						}
   494  
   495  						// Copy across the branch data entry for the local branch
   496  						mergedMeta.Branches[brName] = brData
   497  					}
   498  					if skipFurtherChecks == false && brData.Description != newData.Description {
   499  						_, err = fmt.Fprintf(fOut, "  * Description for branch %s differs between the local "+
   500  							"and remote\n"+
   501  							"    * Local: '%s'\n"+
   502  							"    * Remote: '%s'\n", brName, brData.Description, newData.Description)
   503  						if err != nil {
   504  							return
   505  						}
   506  					}
   507  				}
   508  			}
   509  			if !matchFound {
   510  				// This seems to be a branch that's not on the server, so we keep it as-is
   511  				_, err = fmt.Fprintf(fOut, "  * Branch '%s' is local only, not on the server\n", brName)
   512  				if err != nil {
   513  					return
   514  				}
   515  				mergedMeta.Branches[brName] = brData
   516  
   517  				// Copy across the commits from the local branch
   518  				localCommit := origMeta.Commits[brData.Commit]
   519  				mergedMeta.Commits[localCommit.ID] = origMeta.Commits[localCommit.ID]
   520  				for localCommit.Parent != "" {
   521  					localCommit = origMeta.Commits[localCommit.Parent]
   522  					mergedMeta.Commits[localCommit.ID] = origMeta.Commits[localCommit.ID]
   523  				}
   524  
   525  				// Copy across the branch data entry for the local branch
   526  				mergedMeta.Branches[brName] = brData
   527  			}
   528  		}
   529  
   530  		// Add new branches
   531  		for remoteName, remoteData := range newMeta.Branches {
   532  			if _, ok := origMeta.Branches[remoteName]; ok == false {
   533  				// Copy their commit data
   534  				newCommit := newMeta.Commits[remoteData.Commit]
   535  				mergedMeta.Commits[newCommit.ID] = newMeta.Commits[newCommit.ID]
   536  				for newCommit.Parent != "" {
   537  					newCommit = newMeta.Commits[newCommit.Parent]
   538  					mergedMeta.Commits[newCommit.ID] = newMeta.Commits[newCommit.ID]
   539  				}
   540  
   541  				// Copy their branch data
   542  				mergedMeta.Branches[remoteName] = remoteData
   543  
   544  				_, err = fmt.Fprintf(fOut, "  * New remote branch '%s' merged\n", remoteName)
   545  				if err != nil {
   546  					return
   547  				}
   548  			}
   549  		}
   550  
   551  		// Preserve existing tags
   552  		for tagName, tagData := range origMeta.Tags {
   553  			mergedMeta.Tags[tagName] = tagData
   554  		}
   555  
   556  		// Add new tags
   557  		for tagName, tagData := range newMeta.Tags {
   558  			// Only add tags which aren't already in the merged metadata structure
   559  			if _, tagFound := mergedMeta.Tags[tagName]; tagFound == false {
   560  				// Also make sure its commit is in the commit list.  If it's not, then skip adding the tag
   561  				if _, commitFound := mergedMeta.Commits[tagData.Commit]; commitFound == true {
   562  					_, err = fmt.Fprintf(fOut, "  * New tag '%s' merged\n", tagName)
   563  					if err != nil {
   564  						return
   565  					}
   566  					mergedMeta.Tags[tagName] = tagData
   567  				}
   568  			}
   569  		}
   570  
   571  		// Preserve existing releases
   572  		for relName, relData := range origMeta.Releases {
   573  			mergedMeta.Releases[relName] = relData
   574  		}
   575  
   576  		// Add new releases
   577  		for relName, relData := range newMeta.Releases {
   578  			// Only add releases which aren't already in the merged metadata structure
   579  			if _, relFound := mergedMeta.Releases[relName]; relFound == false {
   580  				// Also make sure its commit is in the commit list.  If it's not, then skip adding the release
   581  				if _, commitFound := mergedMeta.Commits[relData.Commit]; commitFound == true {
   582  					_, err = fmt.Fprintf(fOut, "  * New release '%s' merged\n", relName)
   583  					if err != nil {
   584  						return
   585  					}
   586  					mergedMeta.Releases[relName] = relData
   587  				}
   588  			}
   589  		}
   590  
   591  		// Copy the default branch name from the remote server
   592  		mergedMeta.DefBranch = newMeta.DefBranch
   593  
   594  		// If an active (local) branch has been set, then copy it to the merged metadata.  Otherwise use the default
   595  		// branch as given by the remote server
   596  		if origMeta.ActiveBranch != "" {
   597  			mergedMeta.ActiveBranch = origMeta.ActiveBranch
   598  		} else {
   599  			mergedMeta.ActiveBranch = newMeta.DefBranch
   600  		}
   601  
   602  		_, err = fmt.Fprintln(fOut)
   603  		if err != nil {
   604  			return
   605  		}
   606  	} else {
   607  		// No existing metadata, so just copy across the remote metadata
   608  		mergedMeta = newMeta
   609  
   610  		// Use the remote default branch as the initial active (local) branch
   611  		mergedMeta.ActiveBranch = newMeta.DefBranch
   612  	}
   613  	return
   614  }
   615  
   616  // Retrieves a database from DBHub.io
   617  func retrieveDatabase(db string, branch string, commit string) (resp rq.Response, body []byte, err error) {
   618  	dbURL := fmt.Sprintf("%s/%s/%s", cloud, certUser, db)
   619  	req := rq.New().TLSClientConfig(&TLSConfig).Get(dbURL).
   620  		Set("User-Agent", fmt.Sprintf("Dio %s", DIO_VERSION))
   621  	if branch != "" {
   622  		req.Query(fmt.Sprintf("branch=%s", url.QueryEscape(branch)))
   623  	} else {
   624  		req.Query(fmt.Sprintf("commit=%s", url.QueryEscape(commit)))
   625  	}
   626  	var errs []error
   627  	resp, body, errs = req.EndBytes()
   628  	if errs != nil {
   629  		log.Print("Errors when downloading database:")
   630  		for _, err := range errs {
   631  			log.Print(err.Error())
   632  		}
   633  		err = errors.New("Error when downloading database")
   634  		return
   635  	}
   636  	if resp.StatusCode != http.StatusOK {
   637  		if resp.StatusCode == http.StatusNotFound {
   638  			if branch != "" {
   639  				err = errors.New(fmt.Sprintf("That database & branch '%s' aren't known on DBHub.io",
   640  					branch))
   641  				return
   642  			}
   643  			if commit != "" {
   644  				err = errors.New(fmt.Sprintf("Requested database not found with commit %s.",
   645  					commit))
   646  				return
   647  			}
   648  			err = errors.New("Requested database not found")
   649  			return
   650  		}
   651  		err = errors.New(fmt.Sprintf("Download failed with an error: HTTP status %d - '%v'\n",
   652  			resp.StatusCode, resp.Status))
   653  	}
   654  	return
   655  }
   656  
   657  // Retrieves database metadata from DBHub.io
   658  var retrieveMetadata = func(db string) (meta metaData, onCloud bool, err error) {
   659  	// Download the database metadata
   660  	resp, md, errs := rq.New().TLSClientConfig(&TLSConfig).Get(cloud+"/metadata/get").
   661  		Query(fmt.Sprintf("username=%s", url.QueryEscape(certUser))).
   662  		Query(fmt.Sprintf("folder=%s", "/")).
   663  		Query(fmt.Sprintf("dbname=%s", url.QueryEscape(db))).
   664  		Set("User-Agent", fmt.Sprintf("Dio %s", DIO_VERSION)).
   665  		End()
   666  
   667  	if errs != nil {
   668  		log.Print("Errors when downloading database metadata:")
   669  		for _, err := range errs {
   670  			log.Print(err.Error())
   671  		}
   672  		return metaData{}, false, errors.New("Error when downloading database metadata")
   673  	}
   674  	if resp.StatusCode == http.StatusNotFound {
   675  		return metaData{}, false, nil
   676  	}
   677  	if resp.StatusCode != http.StatusOK {
   678  		return metaData{}, false,
   679  			errors.New(fmt.Sprintf("Metadata download failed with an error: HTTP status %d - '%v'\n",
   680  				resp.StatusCode, resp.Status))
   681  	}
   682  	err = json.Unmarshal([]byte(md), &meta)
   683  	if err != nil {
   684  		return
   685  	}
   686  	return meta, true, nil
   687  }
   688  
   689  // Returns the name of the default database, if one has been selected.  Returns an empty string if not
   690  func saveDefaultDatabase(db string) (err error) {
   691  	// Load the local default info
   692  	var z []byte
   693  	var def defaultSettings
   694  	if z, err = ioutil.ReadFile(filepath.Join(".dio", "defaults.json")); err == nil {
   695  		err = json.Unmarshal([]byte(z), &def)
   696  		if err != nil {
   697  			return
   698  		}
   699  	} else {
   700  		// No local default info, so we use a new blank set instead
   701  		def = defaultSettings{}
   702  	}
   703  
   704  	// Save the new default database setting to disk
   705  	def.SelectedDatabase = db
   706  	var j []byte
   707  	j, err = json.MarshalIndent(def, "", "  ")
   708  	if err != nil {
   709  		return
   710  	}
   711  	err = ioutil.WriteFile(filepath.Join(".dio", "defaults.json"), j, 0644)
   712  	return
   713  }
   714  
   715  // Saves the metadata to a local cache
   716  func saveMetadata(db string, meta metaData) (err error) {
   717  	// Create the metadata directory if needed
   718  	if _, err = os.Stat(filepath.Join(".dio", db)); os.IsNotExist(err) {
   719  		// We create the "db" directory instead, as that'll be needed anyway and MkdirAll() ensures the .dio/<db>
   720  		// directory will be created on the way through
   721  		err = os.MkdirAll(filepath.Join(".dio", db, "db"), 0770)
   722  		if err != nil {
   723  			return
   724  		}
   725  	}
   726  
   727  	// Serialise the metadata to JSON
   728  	var jsonString []byte
   729  	jsonString, err = json.MarshalIndent(meta, "", "  ")
   730  	if err != nil {
   731  		return
   732  	}
   733  
   734  	// Write the updated metadata to disk
   735  	mdFile := filepath.Join(".dio", db, "metadata.json")
   736  	err = ioutil.WriteFile(mdFile, jsonString, 0644)
   737  	return err
   738  }
   739  
   740  // Saves metadata to the local cache, merging in with any existing metadata
   741  func updateMetadata(db string, saveMeta bool) (mergedMeta metaData, err error) {
   742  	// Check for existing metadata file, loading it if present
   743  	var md []byte
   744  	origMeta := metaData{}
   745  	md, err = ioutil.ReadFile(filepath.Join(".dio", db, "metadata.json"))
   746  	if err == nil {
   747  		err = json.Unmarshal([]byte(md), &origMeta)
   748  		if err != nil {
   749  			return
   750  		}
   751  	}
   752  
   753  	// Download the latest database metadata
   754  	_, err = fmt.Fprintln(fOut, "Updating metadata")
   755  	if err != nil {
   756  		return
   757  	}
   758  	newMeta, _, err := retrieveMetadata(db)
   759  	if err != nil {
   760  		return
   761  	}
   762  
   763  	// If we have existing local metadata, then merge the metadata from DBHub.io with it
   764  	if len(origMeta.Commits) > 0 {
   765  		mergedMeta, err = mergeMetadata(origMeta, newMeta)
   766  		if err != nil {
   767  			return
   768  		}
   769  	} else {
   770  		// No existing metadata, so just copy across the remote metadata
   771  		mergedMeta = newMeta
   772  
   773  		// Use the remote default branch as the initial active (local) branch
   774  		mergedMeta.ActiveBranch = newMeta.DefBranch
   775  	}
   776  
   777  	// Serialise the updated metadata to JSON
   778  	var jsonString []byte
   779  	jsonString, err = json.MarshalIndent(mergedMeta, "", "  ")
   780  	if err != nil {
   781  		errMsg := fmt.Sprintf("Error when JSON marshalling the merged metadata: %v\n", err)
   782  		log.Print(errMsg)
   783  		return
   784  	}
   785  
   786  	// If requested, write the updated metadata to disk
   787  	if saveMeta {
   788  		if _, err = os.Stat(filepath.Join(".dio", db)); os.IsNotExist(err) {
   789  			err = os.MkdirAll(filepath.Join(".dio", db), 0770)
   790  			if err != nil {
   791  				return
   792  			}
   793  		}
   794  		mdFile := filepath.Join(".dio", db, "metadata.json")
   795  		err = ioutil.WriteFile(mdFile, []byte(jsonString), 0644)
   796  	}
   797  	return
   798  }