[mike@mwxm4]
This commit is contained in:
+585
@@ -0,0 +1,585 @@
|
||||
// selfupdate.go — updating oneself from the releases of a Gitea instance.
|
||||
//
|
||||
// The file is meant to be copied: take it into another program, adjust the
|
||||
// configuration block below, hang `--update` and `--check-update` into the
|
||||
// options — done. It needs nothing but the standard library, and apart from
|
||||
// that block it brings no names that do not begin with "selfUpdate" or
|
||||
// "update".
|
||||
//
|
||||
// It assumes the layout build.sh produces: one release per version, whose tag
|
||||
// is the bare number (1.21.4, a leading "v" is allowed), holding one asset
|
||||
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
|
||||
// /api/v1/repos/<owner>/<repo>/releases/latest Gitea hands out the newest
|
||||
// release that is neither a draft nor a prerelease; GitHub speaks the same
|
||||
// route with different field names and is therefore not covered.
|
||||
//
|
||||
// Two things this copy does differently from the one in dns, both deliberate:
|
||||
//
|
||||
// - The release has to publish a "checksums.txt" — the file build.sh writes,
|
||||
// in the format of `shasum -a 256`. What is downloaded is weighed against
|
||||
// the sha256 named there for exactly this asset, and no checksum means no
|
||||
// update. gbld had that property before it fetched from a gitea, and it
|
||||
// does not give it up.
|
||||
// - The TLS certificate is verified, as it is everywhere else. dns turns
|
||||
// that off for machines with an outdated trust store; if you copy this
|
||||
// file to such a machine, that is the line to revisit — knowing that
|
||||
// anyone in the network path can then hand out both the binary and the
|
||||
// checksum that matches it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------ Configuration
|
||||
|
||||
var selfUpdate = selfUpdater{
|
||||
repo: "https://git.fhi.mpg.de/mike/gbld",
|
||||
asset: "gbld",
|
||||
current: version, // from gbld.go, set by -ldflags
|
||||
verify: []string{"-v"},
|
||||
every: 24 * time.Hour,
|
||||
quietEnv: "GBLD_NO_UPDATE",
|
||||
}
|
||||
|
||||
// updateChecksums is the asset holding the sha256 of every other asset, in the
|
||||
// format of `shasum -a 256`. A release without it is a release this program
|
||||
// refuses to install: what cannot be weighed is not put in place.
|
||||
const updateChecksums = "checksums.txt"
|
||||
|
||||
type selfUpdater struct {
|
||||
repo string // repo URL as in the browser: https://host/owner/repo
|
||||
asset string // base name of the assets, "-<goos>-<goarch>" is added
|
||||
current string // the running version
|
||||
verify []string // trial run of the download; empty skips it
|
||||
every time.Duration // how often to look on its own; 0 turns that off
|
||||
quietEnv string // this environment variable set: keep quiet as well
|
||||
}
|
||||
|
||||
// updateRefreshFlag is the option the program calls itself with, in the
|
||||
// background. It is deliberately absent from the help.
|
||||
const updateRefreshFlag = "--update-refresh"
|
||||
|
||||
// ------------------------------------------------------------ Looking by itself
|
||||
|
||||
// daily is the hook for the ordinary run of the program. It costs nothing: in
|
||||
// the foreground the network is never touched. What comes back is the line
|
||||
// pointing at a new version — or "", when there is nothing to say; what it
|
||||
// looks like is up to the caller. Should the note be older than `every`, daily
|
||||
// starts a background run on the side, whose answer the next call will find
|
||||
// waiting.
|
||||
func (u selfUpdater) daily() string {
|
||||
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() {
|
||||
return ""
|
||||
}
|
||||
st := u.loadState() // no file: the zero value, hence due at once
|
||||
|
||||
if time.Since(st.Checked) >= u.every {
|
||||
// The timestamp moves on before the asking, not after: otherwise two
|
||||
// simultaneous runs start two queries, and a server that is not in the
|
||||
// mood would get a new one on every call. If the note does not stay
|
||||
// put, nothing is asked either — else an unwritable cache directory
|
||||
// would mean one process per call.
|
||||
st.Checked = time.Now()
|
||||
if u.saveState(st) == nil {
|
||||
u.spawnRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset)
|
||||
}
|
||||
|
||||
// refresh is the background run: ask, write it down, stay quiet. The writing
|
||||
// down is done by latest; if the query fails, the old state remains.
|
||||
func (u selfUpdater) refresh() {
|
||||
_, _ = u.latest()
|
||||
}
|
||||
|
||||
// spawnRefresh calls this program once more, only to ask, and does not wait.
|
||||
// Without a Wait the child is adopted by init when this process ends — it thus
|
||||
// outlives the call, and the call's output stays untouched by it.
|
||||
func (u selfUpdater) spawnRefresh() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(exe, updateRefreshFlag)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
|
||||
if cmd.Start() == nil {
|
||||
cmd.Process.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// The hint is meant for the person sitting there. Running in a pipe, in a
|
||||
// script or under cron, the program neither asks nor says anything.
|
||||
func updateOnTerminal() bool {
|
||||
st, err := os.Stderr.Stat()
|
||||
return err == nil && st.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- Note
|
||||
|
||||
// updateState is what is left between two calls: when the last question was
|
||||
// asked and what came of it.
|
||||
type updateState struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
// The note lives in the cache directory, not in the configuration: if it gets
|
||||
// lost, the only cost is asking once too early.
|
||||
func (u selfUpdater) statePath() (string, error) {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, u.asset, "update.json"), nil
|
||||
}
|
||||
|
||||
func (u selfUpdater) loadState() updateState {
|
||||
var st updateState
|
||||
path, err := u.statePath()
|
||||
if err != nil {
|
||||
return st
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return st
|
||||
}
|
||||
json.Unmarshal(b, &st) // a broken file counts as none
|
||||
return st
|
||||
}
|
||||
|
||||
func (u selfUpdater) saveState(st updateState) error {
|
||||
path, err := u.statePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// By way of a file alongside, so that a simultaneous run never comes upon
|
||||
// half a JSON.
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ The work
|
||||
|
||||
// check only looks and touches nothing.
|
||||
func (u selfUpdater) check(w io.Writer) error {
|
||||
rel, err := u.latest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updateCompare(rel.TagName, u.current) <= 0 {
|
||||
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s is available, running %s\n %s\n run '%s --update' to install it\n",
|
||||
u.asset, rel.TagName, u.current, rel.HTMLURL, u.asset)
|
||||
return nil
|
||||
}
|
||||
|
||||
// install fetches the newest release and replaces the running file with it.
|
||||
func (u selfUpdater) install(w io.Writer) error {
|
||||
rel, err := u.latest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updateCompare(rel.TagName, u.current) <= 0 {
|
||||
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
|
||||
return nil
|
||||
}
|
||||
|
||||
want := u.assetName()
|
||||
src := rel.asset(want)
|
||||
if src == nil {
|
||||
names := make([]string, len(rel.Assets))
|
||||
for i, a := range rel.Assets {
|
||||
names[i] = a.Name
|
||||
}
|
||||
return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// Before a byte is fetched: without a checksum to weigh it against there is
|
||||
// nothing to install, and saying so now saves the download.
|
||||
sum, err := u.checksum(rel, want)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot locate the running binary: %w", err)
|
||||
}
|
||||
// An installed gbld is often a symlink into ./bin. What should be replaced is
|
||||
// the file behind it, not the link.
|
||||
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = real
|
||||
}
|
||||
mode := os.FileMode(0o755)
|
||||
if st, err := os.Stat(exe); err == nil {
|
||||
mode = st.Mode().Perm()
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "downloading %s %s (%s)\n", want, rel.TagName, updateSize(src.Size))
|
||||
tmp, err := u.download(src, exe, mode, sum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp) // only bites when the renaming below falls through
|
||||
|
||||
if err := u.probe(tmp, rel.TagName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateReplace(tmp, exe); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s %s → %s, at %s\n", u.asset, u.current, rel.TagName, exe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// assetName is what this build of this program is called in a release. Windows
|
||||
// carries the suffix its loader insists on; everywhere else the bare name.
|
||||
func (u selfUpdater) assetName() string {
|
||||
name := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH)
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// checksum fetches the release's checksums.txt and picks out the line for this
|
||||
// asset. Every way out of here that is not a sha256 is an error: a release
|
||||
// without checksums, one that does not mention this asset, a line that is not
|
||||
// a hash — none of them ends in an installed binary.
|
||||
func (u selfUpdater) checksum(rel updateRelease, name string) ([]byte, error) {
|
||||
src := rel.asset(updateChecksums)
|
||||
if src == nil {
|
||||
return nil, fmt.Errorf("release %s publishes no %s, refusing to install %s unchecked",
|
||||
rel.TagName, updateChecksums, name)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := updateGet(ctx, src.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // a list of hashes, not a payload
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read %s of release %s: %w", updateChecksums, rel.TagName, err)
|
||||
}
|
||||
return updateFindSum(body, name)
|
||||
}
|
||||
|
||||
// updateFindSum reads the format of `shasum -a 256`: hash, blanks, name. The
|
||||
// asterisk some tools put in front of the name in binary mode is not part of it.
|
||||
func updateFindSum(body []byte, name string) ([]byte, error) {
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 2 || strings.TrimPrefix(f[1], "*") != name {
|
||||
continue
|
||||
}
|
||||
sum, err := hex.DecodeString(f[0])
|
||||
if err != nil || len(sum) != sha256.Size {
|
||||
return nil, fmt.Errorf("%s holds no usable sha256 for %s", updateChecksums, name)
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s names no checksum for %s", updateChecksums, name)
|
||||
}
|
||||
|
||||
func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode, sum []byte) (string, error) {
|
||||
// The new file comes into being next to the old one: same filesystem, so
|
||||
// the renaming at the end is one atomic step and not half a copy. It also
|
||||
// comes into being before the first byte — a missing write permission ought
|
||||
// to show up before a few megabytes have gone down the wire.
|
||||
dir := filepath.Dir(exe)
|
||||
f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new")
|
||||
if err != nil {
|
||||
var pe *os.PathError // the path is in the message already
|
||||
if errors.As(err, &pe) {
|
||||
err = pe.Err
|
||||
}
|
||||
return "", fmt.Errorf("cannot write to %s: %w", dir, err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
|
||||
resp, err := updateGet(context.Background(), a.URL)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// The hash grows while the file is written: the bytes are weighed on their
|
||||
// way past, not read back afterwards.
|
||||
var h hash.Hash = sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(f, h), resp.Body)
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err == nil && a.Size > 0 && n != a.Size {
|
||||
err = fmt.Errorf("got %d of %d bytes from %s", n, a.Size, a.URL)
|
||||
}
|
||||
if err == nil && !bytes.Equal(h.Sum(nil), sum) {
|
||||
err = fmt.Errorf("%s does not match its published checksum: got %s, expected %s",
|
||||
a.Name, hex.EncodeToString(h.Sum(nil)), hex.EncodeToString(sum))
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Chmod(tmp, mode)
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
// probe calls the freshly fetched runner once. That catches a file that is
|
||||
// truncated, built for the wrong platform, or not executable in the first
|
||||
// place, before it replaces the running one.
|
||||
func (u selfUpdater) probe(path, tag string) error {
|
||||
if len(u.verify) == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, path, u.verify...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the downloaded binary does not run: %w", err)
|
||||
}
|
||||
if !strings.Contains(string(out), strings.TrimPrefix(tag, "v")) {
|
||||
return fmt.Errorf("the downloaded binary reports %q, expected %s",
|
||||
strings.TrimSpace(string(out)), tag)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateReplace swaps the running file for the new one.
|
||||
func updateReplace(tmp, exe string) error {
|
||||
if err := os.Rename(tmp, exe); err == nil {
|
||||
return nil
|
||||
}
|
||||
// Unix overwrites the file of a running program without complaint, Windows
|
||||
// does not: there the old one has to be got out of the way first. Deleting
|
||||
// it becomes possible when this process ends at the earliest — so the
|
||||
// tidying up is allowed to fail.
|
||||
old := exe + ".old"
|
||||
os.Remove(old)
|
||||
if err := os.Rename(exe, old); err != nil {
|
||||
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
||||
}
|
||||
if err := os.Rename(tmp, exe); err != nil {
|
||||
os.Rename(old, exe) // back to how it was
|
||||
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
||||
}
|
||||
os.Remove(old)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- Gitea
|
||||
|
||||
type updateRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Assets []updateAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type updateAsset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// asset picks one file out of a release by name, nil when it is not there.
|
||||
func (r updateRelease) asset(name string) *updateAsset {
|
||||
for i := range r.Assets {
|
||||
if r.Assets[i].Name == name {
|
||||
return &r.Assets[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u selfUpdater) latest() (updateRelease, error) {
|
||||
base, err := u.apiBase()
|
||||
if err != nil {
|
||||
return updateRelease{}, err
|
||||
}
|
||||
// The question is a small one; if it hangs, it does not hang for long. The
|
||||
// generous time limit of updateClient is meant for the download.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := updateGet(ctx, base+"/releases/latest")
|
||||
if err != nil {
|
||||
return updateRelease{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var rel updateRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return updateRelease{}, fmt.Errorf("unexpected answer from %s: %w", base, err)
|
||||
}
|
||||
if rel.TagName == "" {
|
||||
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
|
||||
}
|
||||
|
||||
// Every question that succeeds fills the note — no matter whether it came
|
||||
// from --update, from --check-update or from the background run.
|
||||
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// apiBase turns https://host/owner/repo into the API root of the repo.
|
||||
func (u selfUpdater) apiBase() (string, error) {
|
||||
bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo)
|
||||
|
||||
ref, err := url.Parse(strings.TrimSuffix(strings.TrimSuffix(u.repo, "/"), ".git"))
|
||||
if err != nil || ref.Host == "" {
|
||||
return "", bad
|
||||
}
|
||||
parts := strings.Split(strings.Trim(ref.Path, "/"), "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", bad
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil
|
||||
}
|
||||
|
||||
// One time limit for all of it: the look costs a few hundred milliseconds, the
|
||||
// download a few megabytes — both may hang, but not forever.
|
||||
//
|
||||
// Nothing is turned off here: the certificate of the gitea is verified like any
|
||||
// other. Together with the checksum from the release, that is what stands
|
||||
// between this program and a binary someone else picked — the probe below is
|
||||
// none of it, a hostile binary prints whatever version string is asked of it.
|
||||
var updateClient = &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
func updateGet(ctx context.Context, target string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "selfupdate.go (+"+runtime.GOOS+"/"+runtime.GOARCH+")")
|
||||
|
||||
resp, err := updateClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("GET %s: %s", target, resp.Status)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Numbers
|
||||
|
||||
// updateCompare compares two versions component by component, numerically, so
|
||||
// that 2.1.10 lands behind 2.1.9 and not in front of it. A leading "v" does not
|
||||
// count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number
|
||||
// makes the version older, not newer (2.1.6-rc1 < 2.1.6). The result is the one
|
||||
// of strings.Compare: -1, 0, 1.
|
||||
func updateCompare(a, b string) int {
|
||||
as := strings.Split(strings.TrimPrefix(a, "v"), ".")
|
||||
bs := strings.Split(strings.TrimPrefix(b, "v"), ".")
|
||||
|
||||
for i := 0; i < len(as) || i < len(bs); i++ {
|
||||
x, y := "0", "0"
|
||||
if i < len(as) {
|
||||
x = as[i]
|
||||
}
|
||||
if i < len(bs) {
|
||||
y = bs[i]
|
||||
}
|
||||
if c := updateComparePart(x, y); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func updateComparePart(a, b string) int {
|
||||
na, ra := updateSplitNum(a)
|
||||
nb, rb := updateSplitNum(b)
|
||||
switch {
|
||||
case na != nb:
|
||||
if na < nb {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
case ra == rb:
|
||||
return 0
|
||||
case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet
|
||||
return 1
|
||||
case rb == "":
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(ra, rb)
|
||||
}
|
||||
|
||||
// updateSplitNum separates "10-rc1" into 10 and "-rc1".
|
||||
func updateSplitNum(s string) (int, string) {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
n, _ := strconv.Atoi(s[:i])
|
||||
return n, s[i:]
|
||||
}
|
||||
|
||||
// updateSize is deliberately a small formatting of its own and not a helper
|
||||
// from the toolbox — the file is meant to stand on its own.
|
||||
func updateSize(b int64) string {
|
||||
const k = 1024
|
||||
switch {
|
||||
case b > k*k:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/k/k)
|
||||
case b > k:
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/k)
|
||||
default:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user