From 6dd37766912de3ac9dd51b96c10ace60662de458 Mon Sep 17 00:00:00 2001 From: Christian Pointner Date: Tue, 12 Jan 2016 23:40:57 +0100 Subject: imported vantiy 0.1.1 and updated debian package actually build from source --- LICENSE | 25 +++ Makefile | 43 +++- README.md | 203 +++++++++++++++++ debian/control | 6 +- debian/rules | 8 +- debian/source/format | 2 +- debian/vanity.docs | 1 + debian/vanity.lintian-overrides | 2 +- main.go | 474 ++++++++++++++++++++++++++++++++++++++++ refs_test.go | 194 ++++++++++++++++ version.go | 143 ++++++++++++ version_test.go | 127 +++++++++++ 12 files changed, 1211 insertions(+), 17 deletions(-) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 debian/vanity.docs create mode 100644 main.go create mode 100644 refs_test.go create mode 100644 version.go create mode 100644 version_test.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8cdef0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +gopkg.in - versioned URLs for Go packages + +Copyright (c) 2014 - Gustavo Niemeyer + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile index 7d6197d..9cf51a6 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,38 @@ -VERSION := 0.1.1 -DEBARCHS := amd64 i386 armhf -EXECUTEABLES := $(DEBARCHS:%=vanity.%) +GOX_OSARCH ?= "darwin/amd64 linux/amd64 linux/arm freebsd/386 freebsd/amd64 linux/386 windows/386" +GOX_OUTPUT_DIR ?= bin +GH_ACCESS_TOKEN ?= Missing access token. +MESSAGE ?= Latest release. -all: $(EXECUTEABLES) +all: clean + @mkdir -p $(GOX_OUTPUT_DIR) && \ + gox -osarch=$(GOX_OSARCH) -output "$(GOX_OUTPUT_DIR)/{{.Dir}}_{{.OS}}_{{.Arch}}" && \ + gzip bin/vanity_darwin_* && \ + gzip bin/vanity_freebsd_* && \ + gzip bin/vanity_linux_* && \ + zip -r bin/vanity_windows_386.zip bin/vanity_windows_386.exe -vanity.%: vanity_linux_%.gz - cat $< | gunzip > $@ +require-version: + @if [[ -z "$$VERSION" ]]; then echo "Missing \$$VERSION"; exit 1; fi -vanity_linux_%.gz: - wget -nc "https://github.com/xiam/vanity/releases/download/v$(VERSION)/$(shell echo $@ | sed s/i386/386/ | sed s/armhf/arm/)" -O $@ +release: require-version + @RESP=$$(curl --silent --data '{ \ + "tag_name": "v$(VERSION)", \ + "name": "v$(VERSION)", \ + "body": "$(MESSAGE)", \ + "target_commitish": "$(git rev-parse --abbrev-ref HEAD)", \ + "draft": false, \ + "prerelease": false \ + }' "https://api.github.com/repos/xiam/vanity/releases?access_token=$(GH_ACCESS_TOKEN)") && \ + \ + UPLOAD_URL_TEMPLATE=$$(echo $$RESP | python -mjson.tool | grep upload_url | awk '{print $$2}' | sed s/,$$//g | sed s/'"'//g) && \ + if [[ -z "$$UPLOAD_URL_TEMPLATE" ]]; then echo $$RESP; exit 1; fi && \ + \ + for ASSET in $$(ls -1 bin/); do \ + UPLOAD_URL=$$(echo $$UPLOAD_URL_TEMPLATE | sed s/"{?name,label}"/"?access_token=$(GH_ACCESS_TOKEN)\&name=$$ASSET"/g) && \ + MIME_TYPE=$$(file --mime-type bin/$$ASSET | awk '{print $$2}') && \ + curl --silent -H "Content-Type: $$MIME_TYPE" --data-binary @bin/$$ASSET $$UPLOAD_URL > /dev/null && \ + echo "-> $$ASSET OK." \ + ; done clean: - rm -f vanity.* - rm -f vanity_linux_*.gz + @rm -rf $(GOX_OUTPUT_DIR) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f2f3a2 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# vanity + +Use `vanity` to provide pretty package names using your own custom domain: + +```go +import ( + "example.org/coolpkg" +) +``` + +## A simple example + +Let's see the available parameters: + +``` +vanity -h +Usage of ./gopkg: + -addr string + Serve HTTP at given address (default ":8080") + -repo-root string + Git repository root URL (e.g.: https://github.com/upper). + -vanity-root string + Vanity root URL (e.g.: https://upper.io). +``` + +Now run `vanity` on localhost: + +``` +vanity -addr localhost:8082 -repo-root https://github.com/golang \ +-vanity-root http://localhost:8082 +``` + +Using a different terminal session, try to `go get` a package from +`localhost:8082`: + +``` +go get -insecure -v localhost:8082/example/hello +``` + +`vanity` will tell `go get` to keep the import path and pull the source from a +different place. + +Output should be similar to: + +``` +Fetching https://localhost:8082/example/hello?go-get=1 +https fetch failed. +Fetching http://localhost:8082/example/hello?go-get=1 +Parsing meta tags from http://localhost:8082/example/hello?go-get=1 (status code 200) +get "localhost:8082/example/hello": found meta tag main.metaImport{Prefix:"localhost:8082/example", VCS:"git", RepoRoot:"http://localhost:8082/example"} at http://localhost:8082/example/hello?go-get=1 +get "localhost:8082/example/hello": verifying non-authoritative meta tag +Fetching https://localhost:8082/example?go-get=1 +https fetch failed. +Fetching http://localhost:8082/example?go-get=1 +Parsing meta tags from http://localhost:8082/example?go-get=1 (status code 200) +localhost:8082/example (download) +localhost:8082/example/hello +``` + +At the end of the `go get` program, you should have the example `hello` package +on `$GOPATH/src/localhost:8082/example/hello` and in your `$GOPATH/bin` +directory as well: + +``` +hello +Hello, Go examples! +``` + +### Versioning support + +`vanity` also comes with versioning support from the original +[http://gopkg.in](http://gopkg.in) with no extra cost. For instance, the +following import + +``` +go get -v example.org/coolpkg.v1 +``` + +automatically redirects to the `v1` branch on the github.com/username/coolpkg +repo, and it can also be imported that way: + +```go +import ( + "example.org/coolpkg.v1" +) +``` + +Oh, and `vanity` is not tied to GitHub at all, you can use any public git +repository with https support: + +``` +vanity -addr :80 -repo-root https://othergitsite.com/username -vanity-root https://example.org +``` + +## Deploy + +It is not recommended to run `vanity` directly, as `vanity` does not have a +nice welcome page nor instructions for humans, `vanity` only expects only +communication from `go get`, if any other kind of request is received it will +return a `404` error. + +But fear not, you can run `vanity` with `nginx` easily by using that fact, plus +you can have pretty documentation for humans at your custom domain and also be +able to use the same URL as an import path for `go get` with no effort. + +Let's see an example: + +``` +vanity -addr 127.0.0.1:9192 -repo-root https://github.com/upper \ +-vanity-root https://upper.io +``` + +This is the site configuration for nginx: + +```nginx +location / { + # First try with vanity. + try_files =404 @vanity; +} + +location @vanity { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_pass http://127.0.0.1:9192; + proxy_intercept_errors on; + recursive_error_pages on; + + set $pass 0; + if ($arg_go-get = 1) { + set $pass 1; + } + if ($request_uri ~ git-upload-pack) { + set $pass 1; + } + if ($pass = 0) { + return 404; + } + + error_page 404 = @real_location; +} + +location @real_location { + # Fallback location for when vanity returns 404. + ... +} +``` + +Let's see it live, use cURL to request `upper.io/db`: + +``` +curl "upper.io/db" -L +... +``` + +You'll see some HTML gibberish made for humans, now request it again but this +time using the `go-get=1` parameter (this is what `go get` does): + +``` +curl "upper.io/db?go-get=1" -L +... + + +... +``` + +And you'll see a reduced HTML page with special tags for `go get`. + +## License + +### gopkg.in + +This project was based on [gopkg.in](http://labix.org/gopkg.in) by [Gustavo +Niemeyer](http://labix.org/): + +``` +gopkg.in - versioned URLs for Go packages + +Copyright (c) 2014 - Gustavo Niemeyer + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/debian/control b/debian/control index 7e20985..4ace6d9 100644 --- a/debian/control +++ b/debian/control @@ -3,11 +3,11 @@ Maintainer: Christian Pointner Section: utils Priority: optional Standards-Version: 3.9.2 -Build-Depends: debhelper (>= 9), wget, ca-certificates +Build-Depends: debhelper (>= 9), golang-go Package: vanity -Architecture: amd64 i386 armhf -Depends: ${misc:Depends} +Architecture: any +Depends: ${misc:Depends}, ${shlibs:Depends}, adduser Description: makes golang packages go-getable Vanity can be used to make golang packages hosted on custom domains getable. It also has built-in support for versioning of golang packages diff --git a/debian/rules b/debian/rules index b985f0b..33d2893 100755 --- a/debian/rules +++ b/debian/rules @@ -2,11 +2,15 @@ %: dh $@ +override_dh_clean: + dh_clean + rm -f vanity + override_dh_auto_configure: override_dh_auto_build: - make vanity.$(DEB_BUILD_ARCH) + go build -o vanity override_dh_auto_install: install -d $$(pwd)/debian/tmp/usr/bin/ - install -m 755 $$(pwd)/vanity.$(DEB_BUILD_ARCH) $$(pwd)/debian/tmp/usr/bin/vanity + install -m 755 $$(pwd)/vanity $$(pwd)/debian/tmp/usr/bin/vanity diff --git a/debian/source/format b/debian/source/format index d3827e7..163aaf8 100644 --- a/debian/source/format +++ b/debian/source/format @@ -1 +1 @@ -1.0 +3.0 (quilt) diff --git a/debian/vanity.docs b/debian/vanity.docs new file mode 100644 index 0000000..42061c0 --- /dev/null +++ b/debian/vanity.docs @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/debian/vanity.lintian-overrides b/debian/vanity.lintian-overrides index 72e91b4..0408589 100644 --- a/debian/vanity.lintian-overrides +++ b/debian/vanity.lintian-overrides @@ -1,2 +1,2 @@ -vanity: statically-linked-binary usr/bin/vanity vanity: binary-without-manpage usr/bin/vanity +vanity: hardening-no-relro usr/bin/vanity \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..c935f13 --- /dev/null +++ b/main.go @@ -0,0 +1,474 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "text/template" + "time" +) + +var ( + addrFlag = flag.String("addr", ":8080", "Serve HTTP at given address") + socketFlag = flag.String("socket", "", "Serve HTTP at given UNIX socket") + vanityRootFlag = flag.String("vanity-root", "", "Vanity root URL (e.g.: https://upper.io).") + repoRootFlag = flag.String("repo-root", "", "Git repository root URL (e.g.: https://github.com/upper).") +) + +var packagePattern = regexp.MustCompile(`^/([a-zA-Z0-9]+)\.?(v[1-9][0-9]*)?(.*)$`) + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +const refsSuffix = ".git/info/refs?service=git-upload-pack" + +// Error messages. +var ( + ErrNoRepo = errors.New("repository not found") + ErrNoVersion = errors.New("version reference not found") +) + +func main() { + if err := run(); err != nil { + log.Fatalf("Could not start server: %q", err) + } +} + +func run() error { + flag.Parse() + + if *addrFlag == "" && *socketFlag == "" { + return fmt.Errorf("must provide -addr") + } + + if *repoRootFlag == "" { + return fmt.Errorf("must provide -repo-root") + } + + if *vanityRootFlag == "" { + return fmt.Errorf("must provide -vanity-root") + } + + repoRoot, err := NewRepoRoot(*repoRootFlag, *vanityRootFlag) + if err != nil { + return fmt.Errorf("could not parse -repo-root: %q", err) + } + + var listenAddr, listenNet string + + if *socketFlag != "" { + listenNet, listenAddr = "unix", *socketFlag + } else { + listenNet, listenAddr = "tcp", *addrFlag + } + + li, err := net.Listen(listenNet, listenAddr) + if err != nil { + return fmt.Errorf("Failed to bind to %s %s: %v", listenNet, listenAddr, err) + } + + http.HandleFunc("/", newHandler(repoRoot)) + + log.Printf("Listening at %s. %s -> %s", listenAddr, *vanityRootFlag, *repoRootFlag) + + return http.Serve(li, nil) +} + +var gogetTemplate = template.Must(template.New("").Parse(` + + + + + + +go get {{.VanityPath}} + + +`)) + +// RepoRoot represents a real repository and vanity name pair. +type RepoRoot struct { + vanityURL *url.URL + repoURL *url.URL + RepoHostPath string + VanityHostPath string +} + +func parseRepoURL(in string) (*url.URL, error) { + u, err := url.Parse(in) + if err != nil { + return nil, err + } + if u.Scheme == "" { + u.Scheme = "https" + } + return u, err +} + +// NewRepoRoot creates a RepoRoot +func NewRepoRoot(repoURL string, vanityURL string) (*RepoRoot, error) { + u, err := parseRepoURL(repoURL) + if err != nil { + return nil, err + } + + v, err := parseRepoURL(vanityURL) + if err != nil { + return nil, err + } + + return &RepoRoot{ + repoURL: u, + vanityURL: v, + RepoHostPath: u.Host + u.Path, + VanityHostPath: v.Host + v.Path, + }, nil +} + +// NewRepo creates a new repository. +func (root *RepoRoot) NewRepo(name string) *Repo { + return &Repo{ + Root: root, + Name: name, + FullVersion: InvalidVersion, + } +} + +// Repo represents a source code repository on GitHub. +type Repo struct { + Root *RepoRoot + + Name string + MajorVersion Version + + // FullVersion is the best version in AllVersions that matches MajorVersion. + // It defaults to InvalidVersion if there are no matches. + FullVersion Version + + // AllVersions holds all versions currently available in the repository, + // either coming from branch names or from tag names. Version zero (v0) + // is only present in the list if it really exists in the repository. + AllVersions VersionList +} + +// SetVersions records in the relevant fields the details about which +// package versions are available in the repository. +func (repo *Repo) SetVersions(all []Version) { + repo.AllVersions = all + for _, v := range repo.AllVersions { + if v.Major == repo.MajorVersion.Major && v.Unstable == repo.MajorVersion.Unstable && repo.FullVersion.Less(v) { + repo.FullVersion = v + } + } +} + +// RepoRoot returns the repository root, without a schema. +func (repo *Repo) RepoRoot() string { + return repo.Root.RepoHostPath + "/" + repo.Name +} + +// VanityRoot returns the vanity repository root, without a schema. +func (repo *Repo) VanityRoot() string { + return repo.Root.VanityHostPath + "/" + repo.Name +} + +// GitTree returns the repository tree name for the selected version. +func (repo *Repo) GitTree() string { + if repo.FullVersion == InvalidVersion { + return "master" + } + return repo.FullVersion.String() +} + +// VanityPath returns the real package path, without a schema. +func (repo *Repo) VanityPath() string { + return repo.VanityVersionRoot(repo.MajorVersion) +} + +// VanityURL returns the vanity package's URL. +func (repo *Repo) VanityURL() string { + return repo.Root.vanityURL.Scheme + "://" + repo.VanityPath() +} + +// RepoRootURL returns the real package's URL. +func (repo *Repo) RepoRootURL() string { + return repo.Root.repoURL.Scheme + "://" + repo.RepoRoot() +} + +// VanityVersionRoot returns the package's vanity root for the provided +// version, without a schema. +func (repo *Repo) VanityVersionRoot(version Version) string { + version.Minor = -1 + version.Patch = -1 + v := version.String() + if v == "v0" { + return repo.VanityRoot() + } + return repo.VanityRoot() + "." + v +} + +func newHandler(repoRoot *RepoRoot) func(http.ResponseWriter, *http.Request) { + return func(resp http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/health-check" { + resp.Write([]byte("ok")) + return + } + + log.Printf("%s requested %s", req.RemoteAddr, req.URL) + + if req.URL.Path == "/" { + sendNotFound(resp, "Missing package name.") + return + } + + u, err := url.Parse(req.URL.Path) + if err != nil { + sendError(resp, "Failed to parse request path") + return + } + + p := packagePattern.FindStringSubmatch(u.Path) + + pkgName := p[1] + version := p[2] + extra := p[3] + + repo := repoRoot.NewRepo(pkgName) + + if version == "" { + version = "v0" + } + + var ok bool + repo.MajorVersion, ok = parseVersion(version) + if !ok { + sendNotFound(resp, "Version %q improperly considered invalid; please warn the service maintainers.", version) + return + } + + var changed []byte + var versions VersionList + original, err := fetchRefs(repo) + if err == nil { + changed, versions, err = changeRefs(original, repo.MajorVersion) + repo.SetVersions(versions) + } + + switch err { + case nil: + // all ok + case ErrNoRepo: + sendNotFound(resp, "Git repository not found at https://%s", repo.RepoRoot()) + return + case ErrNoVersion: + major := repo.MajorVersion + suffix := "" + if major.Unstable { + major.Unstable = false + suffix = unstableSuffix + } + v := major.String() + sendNotFound(resp, `Git repository at https://%s has no branch or tag "%s%s", "%s.N%s" or "%s.N.M%s"`, repo.RepoRoot(), v, suffix, v, suffix, v, suffix) + return + default: + resp.WriteHeader(http.StatusBadGateway) + resp.Write([]byte(fmt.Sprintf("Cannot obtain refs from Git: %v", err))) + return + } + + switch extra { + case `/git-upload-pack`: + resp.Header().Set("Location", "https://"+repo.RepoRoot()+"/git-upload-pack") + resp.WriteHeader(http.StatusMovedPermanently) + return + case `/info/refs`: + resp.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") + resp.Write(changed) + return + } + + resp.Header().Set("Content-Type", "text/html") + if req.FormValue("go-get") == "1" { + // execute simple template when this is a go-get request + err = gogetTemplate.Execute(resp, repo) + if err != nil { + log.Printf("error executing go get template: %s\n", err) + } + return + } + + sendNotFound(resp, "Missing ?go-get=1 parameter.") + } +} + +func sendError(resp http.ResponseWriter, msg string, args ...interface{}) { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + resp.WriteHeader(http.StatusInternalServerError) + resp.Write([]byte(msg)) +} + +func sendNotFound(resp http.ResponseWriter, msg string, args ...interface{}) { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + resp.WriteHeader(http.StatusNotFound) + resp.Write([]byte(msg)) +} + +func fetchRefs(repo *Repo) (data []byte, err error) { + repoURL := repo.RepoRootURL() + refsSuffix + resp, err := httpClient.Get(repoURL) + if err != nil { + return nil, fmt.Errorf("cannot talk to git repository: %v", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case 200: + // ok + case 401, 404: + return nil, ErrNoRepo + default: + return nil, fmt.Errorf("error from git repository: %v", resp.Status) + } + + data, err = ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading from git: %v", err) + } + return data, err +} + +func changeRefs(data []byte, major Version) (changed []byte, versions VersionList, err error) { + var hlinei, hlinej int // HEAD reference line start/end + var mlinei, mlinej int // master reference line start/end + var vrefhash string + var vrefname string + var vrefv = InvalidVersion + + // Record all available versions, the locations of the master and HEAD lines, + // and details of the best reference satisfying the requested major version. + versions = make([]Version, 0) + sdata := string(data) + for i, j := 0, 0; i < len(data); i = j { + size, err := strconv.ParseInt(sdata[i:i+4], 16, 32) + if err != nil { + return nil, nil, fmt.Errorf("cannot parse refs line size: %s", string(data[i:i+4])) + } + if size == 0 { + size = 4 + } + j = i + int(size) + if j > len(sdata) { + return nil, nil, fmt.Errorf("incomplete refs data received from GitHub") + } + if sdata[0] == '#' { + continue + } + + hashi := i + 4 + hashj := strings.IndexByte(sdata[hashi:j], ' ') + if hashj < 0 || hashj != 40 { + continue + } + hashj += hashi + + namei := hashj + 1 + namej := strings.IndexAny(sdata[namei:j], "\n\x00") + if namej < 0 { + namej = j + } else { + namej += namei + } + + name := sdata[namei:namej] + + if name == "HEAD" { + hlinei = i + hlinej = j + } + if name == "refs/heads/master" { + mlinei = i + mlinej = j + } + + if strings.HasPrefix(name, "refs/heads/v") || strings.HasPrefix(name, "refs/tags/v") { + if strings.HasSuffix(name, "^{}") { + // Annotated tag is peeled off and overrides the same version just parsed. + name = name[:len(name)-3] + } + v, ok := parseVersion(name[strings.IndexByte(name, 'v'):]) + if ok && major.Contains(v) && (v == vrefv || !vrefv.IsValid() || vrefv.Less(v)) { + vrefv = v + vrefhash = sdata[hashi:hashj] + vrefname = name + } + if ok { + versions = append(versions, v) + } + } + } + + // If v0 was requested, accept the master as-is. + if major == (Version{0, -1, -1, false}) { + return data, nil, nil + } + + // If the file has no HEAD line or the version was not found, report as unavailable. + if hlinei == 0 || vrefhash == "" { + return nil, nil, ErrNoVersion + } + + var buf bytes.Buffer + buf.Grow(len(data) + 256) + + // Copy the header as-is. + buf.Write(data[:hlinei]) + + // Extract the original capabilities. + caps := "" + if i := strings.Index(sdata[hlinei:hlinej], "\x00"); i > 0 { + caps = strings.Replace(sdata[hlinei+i+1:hlinej-1], "symref=", "oldref=", -1) + } + + // Insert the HEAD reference line with the right hash and a proper symref capability. + var line string + if strings.HasPrefix(vrefname, "refs/heads/") { + if caps == "" { + line = fmt.Sprintf("%s HEAD\x00symref=HEAD:%s\n", vrefhash, vrefname) + } else { + line = fmt.Sprintf("%s HEAD\x00symref=HEAD:%s %s\n", vrefhash, vrefname, caps) + } + } else { + if caps == "" { + line = fmt.Sprintf("%s HEAD\n", vrefhash) + } else { + line = fmt.Sprintf("%s HEAD\x00%s\n", vrefhash, caps) + } + } + fmt.Fprintf(&buf, "%04x%s", 4+len(line), line) + + // Insert the master reference line. + line = fmt.Sprintf("%s refs/heads/master\n", vrefhash) + fmt.Fprintf(&buf, "%04x%s", 4+len(line), line) + + // Append the rest, dropping the original master line if necessary. + if mlinei > 0 { + buf.Write(data[hlinej:mlinei]) + buf.Write(data[mlinej:]) + } else { + buf.Write(data[hlinej:]) + } + + return buf.Bytes(), versions, nil +} diff --git a/refs_test.go b/refs_test.go new file mode 100644 index 0000000..55580c5 --- /dev/null +++ b/refs_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "fmt" + . "gopkg.in/check.v1" + "sort" +) + +var _ = Suite(&RefsSuite{}) + +type RefsSuite struct{} + +type refsTest struct { + summary string + original string + version string + changed string + versions []string +} + +var refsTests = []refsTest{{ + "Version v0 works even without any references", + reflines( + "hash1 HEAD", + ), + "v0", + reflines( + "hash1 HEAD", + ), + nil, +}, { + "Preserve original capabilities", + reflines( + "hash1 HEAD\x00caps", + ), + "v0", + reflines( + "hash1 HEAD\x00caps", + ), + nil, +}, { + "Matching major version branch", + reflines( + "00000000000000000000000000000000000hash1 HEAD", + "00000000000000000000000000000000000hash2 refs/heads/v0", + "00000000000000000000000000000000000hash3 refs/heads/v1", + "00000000000000000000000000000000000hash4 refs/heads/v2", + ), + "v1", + reflines( + "00000000000000000000000000000000000hash3 HEAD\x00symref=HEAD:refs/heads/v1", + "00000000000000000000000000000000000hash3 refs/heads/master", + "00000000000000000000000000000000000hash2 refs/heads/v0", + "00000000000000000000000000000000000hash3 refs/heads/v1", + "00000000000000000000000000000000000hash4 refs/heads/v2", + ), + []string{"v0", "v1", "v2"}, +}, { + "Matching minor version branch", + reflines( + "00000000000000000000000000000000000hash1 HEAD", + "00000000000000000000000000000000000hash2 refs/heads/v1.1", + "00000000000000000000000000000000000hash3 refs/heads/v1.3", + "00000000000000000000000000000000000hash4 refs/heads/v1.2", + ), + "v1", + reflines( + "00000000000000000000000000000000000hash3 HEAD\x00symref=HEAD:refs/heads/v1.3", + "00000000000000000000000000000000000hash3 refs/heads/master", + "00000000000000000000000000000000000hash2 refs/heads/v1.1", + "00000000000000000000000000000000000hash3 refs/heads/v1.3", + "00000000000000000000000000000000000hash4 refs/heads/v1.2", + ), + []string{"v1.1", "v1.2", "v1.3"}, +}, { + "Disable original symref capability", + reflines( + "00000000000000000000000000000000000hash1 HEAD\x00foo symref=bar baz", + "00000000000000000000000000000000000hash2 refs/heads/v1", + ), + "v1", + reflines( + "00000000000000000000000000000000000hash2 HEAD\x00symref=HEAD:refs/heads/v1 foo oldref=bar baz", + "00000000000000000000000000000000000hash2 refs/heads/master", + "00000000000000000000000000000000000hash2 refs/heads/v1", + ), + []string{"v1"}, +}, { + "Replace original master branch", + reflines( + "00000000000000000000000000000000000hash1 HEAD", + "00000000000000000000000000000000000hash1 refs/heads/master", + "00000000000000000000000000000000000hash2 refs/heads/v1", + ), + "v1", + reflines( + "00000000000000000000000000000000000hash2 HEAD\x00symref=HEAD:refs/heads/v1", + "00000000000000000000000000000000000hash2 refs/heads/master", + "00000000000000000000000000000000000hash2 refs/heads/v1", + ), + []string{"v1"}, +}, { + "Matching tag", + reflines( + "00000000000000000000000000000000000hash1 HEAD", + "00000000000000000000000000000000000hash2 refs/tags/v0", + "00000000000000000000000000000000000hash3 refs/tags/v1", + "00000000000000000000000000000000000hash4 refs/tags/v2", + ), + "v1", + reflines( + "00000000000000000000000000000000000hash3 HEAD", + "00000000000000000000000000000000000hash3 refs/heads/master", + "00000000000000000000000000000000000hash2 refs/tags/v0", + "00000000000000000000000000000000000hash3 refs/tags/v1", + "00000000000000000000000000000000000hash4 refs/tags/v2", + ), + []string{"v0", "v1", "v2"}, +}, { + "Tag peeling", + reflines( + "00000000000000000000000000000000000hash1 HEAD", + "00000000000000000000000000000000000hash2 refs/heads/master", + "00000000000000000000000000000000000hash3 refs/tags/v1", + "00000000000000000000000000000000000hash4 refs/tags/v1^{}", + "00000000000000000000000000000000000hash5 refs/tags/v2", + ), + "v1", + reflines( + "00000000000000000000000000000000000hash4 HEAD", + "00000000000000000000000000000000000hash4 refs/heads/master", + "00000000000000000000000000000000000hash3 refs/tags/v1", + "00000000000000000000000000000000000hash4 refs/tags/v1^{}", + "00000000000000000000000000000000000hash5 refs/tags/v2", + ), + []string{"v1", "v1", "v2"}, +}, { + "Matching unstable versions", + reflines( + "00000000000000000000000000000000000hash1 HEAD", + "00000000000000000000000000000000000hash2 refs/heads/master", + "00000000000000000000000000000000000hash3 refs/heads/v1", + "00000000000000000000000000000000000hash4 refs/heads/v1.1-unstable", + "00000000000000000000000000000000000hash5 refs/heads/v1.3-unstable", + "00000000000000000000000000000000000hash6 refs/heads/v1.2-unstable", + "00000000000000000000000000000000000hash7 refs/heads/v2", + ), + "v1-unstable", + reflines( + "00000000000000000000000000000000000hash5 HEAD\x00symref=HEAD:refs/heads/v1.3-unstable", + "00000000000000000000000000000000000hash5 refs/heads/master", + "00000000000000000000000000000000000hash3 refs/heads/v1", + "00000000000000000000000000000000000hash4 refs/heads/v1.1-unstable", + "00000000000000000000000000000000000hash5 refs/heads/v1.3-unstable", + "00000000000000000000000000000000000hash6 refs/heads/v1.2-unstable", + "00000000000000000000000000000000000hash7 refs/heads/v2", + ), + []string{"v1", "v1.1-unstable", "v1.2-unstable", "v1.3-unstable", "v2"}, +}} + +func reflines(lines ...string) string { + var buf bytes.Buffer + buf.WriteString("001e# service=git-upload-pack\n0000") + for _, l := range lines { + buf.WriteString(fmt.Sprintf("%04x%s\n", len(l)+5, l)) + } + buf.WriteString("0000") + return buf.String() +} + +func (s *RefsSuite) TestChangeRefs(c *C) { + for _, test := range refsTests { + c.Logf(test.summary) + + v, ok := parseVersion(test.version) + if !ok { + c.Fatalf("Test has an invalid version: %q", test.version) + } + + changed, versions, err := changeRefs([]byte(test.original), v) + c.Assert(err, IsNil) + + c.Assert(string(changed), Equals, test.changed) + + sort.Sort(versions) + + var vs []string + for _, v := range versions { + vs = append(vs, v.String()) + } + c.Assert(vs, DeepEquals, test.versions) + } +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..6fcb9c1 --- /dev/null +++ b/version.go @@ -0,0 +1,143 @@ +package main + +import ( + "fmt" +) + +// Version represents a version number. +// An element that is not present is represented as -1. +type Version struct { + Major int + Minor int + Patch int + Unstable bool +} + +const unstableSuffix = "-unstable" + +func (v Version) String() string { + if v.Major < 0 { + panic(fmt.Sprintf("cannot stringify invalid version (major is %d)", v.Major)) + } + suffix := "" + if v.Unstable { + suffix = unstableSuffix + } + if v.Minor < 0 { + return fmt.Sprintf("v%d%s", v.Major, suffix) + } + if v.Patch < 0 { + return fmt.Sprintf("v%d.%d%s", v.Major, v.Minor, suffix) + } + return fmt.Sprintf("v%d.%d.%d%s", v.Major, v.Minor, v.Patch, suffix) +} + +// Less returns whether v is less than other. +func (v Version) Less(other Version) bool { + if v.Major != other.Major { + return v.Major < other.Major + } + if v.Minor != other.Minor { + return v.Minor < other.Minor + } + if v.Patch != other.Patch { + return v.Patch < other.Patch + } + return v.Unstable && !other.Unstable +} + +// Contains returns whether version v contains version other. +// Version v is defined to contain version other when they both have the same Major +// version and v.Minor and v.Patch are either undefined or are equal to other's. +// +// For example, Version{1, 1, -1} contains both Version{1, 1, -1} and Version{1, 1, 2}, +// but not Version{1, -1, -1} or Version{1, 2, -1}. +// +// Unstable versions (-unstable) only contain unstable versions, and stable +// versions only contain stable versions. +func (v Version) Contains(other Version) bool { + if v.Unstable != other.Unstable { + return false + } + if v.Patch != -1 { + return v == other + } + if v.Minor != -1 { + return v.Major == other.Major && v.Minor == other.Minor + } + return v.Major == other.Major +} + +// IsValid returns trus if the version is valid. +func (v Version) IsValid() bool { + return v != InvalidVersion +} + +// InvalidVersion represents a version that can't be parsed. +var InvalidVersion = Version{-1, -1, -1, false} + +func parseVersion(s string) (v Version, ok bool) { + v = InvalidVersion + if len(s) < 2 { + return + } + if s[0] != 'v' { + return + } + vout := InvalidVersion + unstable := false + i := 1 + for _, vptr := range []*int{&vout.Major, &vout.Minor, &vout.Patch} { + *vptr, unstable, i = parseVersionPart(s, i) + if i < 0 { + return + } + if i == len(s) { + vout.Unstable = unstable + return vout, true + } + } + return +} + +func parseVersionPart(s string, i int) (part int, unstable bool, newi int) { + j := i + for j < len(s) && s[j] != '.' && s[j] != '-' { + j++ + } + if j == i || j-i > 1 && s[i] == '0' { + return -1, false, -1 + } + c := s[i] + for { + if c < '0' || c > '9' { + return -1, false, -1 + } + part *= 10 + part += int(c - '0') + if part < 0 { + return -1, false, -1 + } + i++ + if i == len(s) { + return part, false, i + } + c = s[i] + if i+1 < len(s) { + if c == '.' { + return part, false, i + 1 + } + if c == '-' && s[i:] == unstableSuffix { + return part, true, i + len(unstableSuffix) + } + } + } + panic("unreachable") +} + +// VersionList implements sort.Interface +type VersionList []Version + +func (vl VersionList) Len() int { return len(vl) } +func (vl VersionList) Less(i, j int) bool { return vl[i].Less(vl[j]) } +func (vl VersionList) Swap(i, j int) { vl[i], vl[j] = vl[j], vl[i] } diff --git a/version_test.go b/version_test.go new file mode 100644 index 0000000..22774fd --- /dev/null +++ b/version_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "testing" + + . "gopkg.in/check.v1" +) + +func Test(t *testing.T) { TestingT(t) } + +var _ = Suite(&VersionSuite{}) + +type VersionSuite struct{} + +var versionParseTests = []struct { + major int + minor int + patch int + dev bool + s string +}{ + {-1, -1, -1, false, "v"}, + {-1, -1, -1, false, "v-1"}, + {-1, -1, -1, false, "v-deb"}, + {-1, -1, -1, false, "v01"}, + {-1, -1, -1, false, "v1.01"}, + {-1, -1, -1, false, "a1"}, + {-1, -1, -1, false, "v1a"}, + {-1, -1, -1, false, "v1..2"}, + {-1, -1, -1, false, "v1.2.3.4"}, + {-1, -1, -1, false, "v1."}, + {-1, -1, -1, false, "v1.2."}, + {-1, -1, -1, false, "v1.2.3."}, + + {0, -1, -1, false, "v0"}, + {0, -1, -1, true, "v0-unstable"}, + {1, -1, -1, false, "v1"}, + {1, -1, -1, true, "v1-unstable"}, + {1, 2, -1, false, "v1.2"}, + {1, 2, -1, true, "v1.2-unstable"}, + {1, 2, 3, false, "v1.2.3"}, + {1, 2, 3, true, "v1.2.3-unstable"}, + {12, 34, 56, false, "v12.34.56"}, + {12, 34, 56, true, "v12.34.56-unstable"}, +} + +func (s *VersionSuite) TestParse(c *C) { + for _, t := range versionParseTests { + got, ok := parseVersion(t.s) + if t.major == -1 { + if ok || got != InvalidVersion { + c.Fatalf("version %q is invalid but parsed as %#v", t.s, got) + } + } else { + want := Version{t.major, t.minor, t.patch, t.dev} + if got != want { + c.Fatalf("version %q must parse as %#v, got %#v", t.s, want, got) + } + if got.String() != t.s { + c.Fatalf("version %q got parsed as %#v and stringified as %q", t.s, got, got.String()) + } + } + } +} + +var versionLessTests = []struct { + oneMajor, oneMinor, onePatch int + oneUnstable bool + twoMajor, twoMinor, twoPatch int + twoUnstable, less bool +}{ + {0, 0, 0, false, 0, 0, 0, false, false}, + {1, 0, 0, false, 1, 0, 0, false, false}, + {1, 0, 0, false, 1, 1, 0, false, true}, + {1, 0, 0, false, 2, 0, 0, false, true}, + {0, 1, 0, false, 0, 1, 0, false, false}, + {0, 1, 0, false, 0, 1, 1, false, true}, + {0, 0, 0, false, 0, 2, 0, false, true}, + {0, 0, 1, false, 0, 0, 1, false, false}, + {0, 0, 1, false, 0, 0, 2, false, true}, + + {0, 0, 0, false, 0, 0, 0, true, false}, + {0, 0, 0, true, 0, 0, 0, false, true}, + {0, 0, 1, true, 0, 0, 0, false, false}, +} + +func (s *VersionSuite) TestLess(c *C) { + for _, t := range versionLessTests { + one := Version{t.oneMajor, t.oneMinor, t.onePatch, t.oneUnstable} + two := Version{t.twoMajor, t.twoMinor, t.twoPatch, t.twoUnstable} + if one.Less(two) != t.less { + c.Fatalf("version %s < %s returned %v", one, two, !t.less) + } + } +} + +var versionContainsTests = []struct { + oneMajor, oneMinor, onePatch int + oneUnstable bool + twoMajor, twoMinor, twoPatch int + twoUnstable, contains bool +}{ + {12, 34, 56, false, 12, 34, 56, false, true}, + {12, 34, 56, false, 12, 34, 78, false, false}, + {12, 34, -1, false, 12, 34, 56, false, true}, + {12, 34, -1, false, 12, 78, 56, false, false}, + {12, -1, -1, false, 12, 34, 56, false, true}, + {12, -1, -1, false, 78, 34, 56, false, false}, + + {12, -1, -1, true, 12, -1, -1, false, false}, + {12, -1, -1, false, 12, -1, -1, true, false}, +} + +func (s *VersionSuite) TestContains(c *C) { + for _, t := range versionContainsTests { + one := Version{t.oneMajor, t.oneMinor, t.onePatch, t.oneUnstable} + two := Version{t.twoMajor, t.twoMinor, t.twoPatch, t.twoUnstable} + if one.Contains(two) != t.contains { + c.Fatalf("version %s.Contains(%s) returned %v", one, two, !t.contains) + } + } +} + +func (s *VersionSuite) TestIsValid(c *C) { + c.Assert(InvalidVersion.IsValid(), Equals, false) + c.Assert(Version{0, 0, 0, false}.IsValid(), Equals, true) +} -- cgit v1.2.3