Compare commits

...

67 Commits

Author SHA1 Message Date
Stephen McQuay 9ceb1865f9
pm ls <pkg> lists installed files
Fixes #13
2018-05-07 21:10:28 -07:00
Stephen McQuay 51c8c920a3
Adds pm rm 2018-03-06 21:46:25 -08:00
Stephen McQuay f07b5a7d0e
Adds simplest Removable check
This will also eventually need some satisfiability love.
2018-03-06 20:37:44 -08:00
Stephen McQuay bd06d484c2
Stubs out pm rm 2018-03-06 20:35:03 -08:00
Stephen McQuay 9ecd4e6f79
Always clean up cache 2018-03-06 00:22:49 -08:00
Stephen McQuay 830f9c348d
Adds pm ls 2018-03-06 00:10:46 -08:00
Stephen McQuay ba1b8c4706
Track installed packages 2018-03-06 00:10:31 -08:00
Stephen McQuay 78ebdd60ae
expand root works 2018-03-05 23:43:42 -08:00
Stephen McQuay 509433dd3d
Implements pre/post install scripts 2018-03-05 23:28:15 -08:00
Stephen McQuay 56eebc8c34
Expand into install dir 2018-03-05 23:06:46 -08:00
Stephen McQuay c883b0ec34
verify package contents 2018-03-05 22:13:06 -08:00
Stephen McQuay b1f0949c49
Validates in-cache .pkg signature 2018-03-04 00:50:30 -08:00
Stephen McQuay 68942b6cdd
pm install downloads packages 2018-03-03 22:52:01 -08:00
Stephen McQuay f3c3ce11a4
Meta can return their web location 2018-03-03 22:33:53 -08:00
Stephen McQuay 570c0d885f
Don't use pointer receiver 2018-03-03 22:33:53 -08:00
Stephen McQuay 63c5def07f
stub out fake installs of available packages 2018-03-03 22:33:53 -08:00
Stephen McQuay b5d3b6d213
Export LoadAvailable 2018-03-03 22:33:52 -08:00
Stephen McQuay 14a456f006
Adds EXTRAORDINARILY Installable implementation
We want the simplest satisfiability solver we can have, but one that
chases transitive dependencies. This one only checks the requested
packages exist in the availability database.
2018-03-03 22:33:52 -08:00
Stephen McQuay b869bd250c
Stub out install subcommand 2018-03-03 22:33:52 -08:00
Stephen McQuay bc8a4a1905
Use correct receiver name 2018-03-03 22:33:52 -08:00
Stephen McQuay ada0fcbf0f
Update docs to reflect implicit namespaces 2018-03-03 14:53:12 -08:00
Stephen McQuay 2dbe1ca006
Namespace is not needed
We should be able to parse out the Path from the URL and use that as the
namespace.
2018-03-02 23:23:28 -08:00
Stephen McQuay eef69d299a
Adds makefile 2018-03-02 23:23:23 -08:00
Stephen McQuay 52a4a7ed7b
go vet/golint 2018-03-02 23:23:21 -08:00
Stephen McQuay da43058d0e
Fix tests from function renames 2018-03-02 23:23:19 -08:00
Stephen McQuay 6ce1c4bdcb
Stub temporary pm version 2018-03-02 23:23:18 -08:00
Stephen McQuay 97de548a26
Make available traversal code reusable 2018-03-02 23:23:16 -08:00
Stephen McQuay 3fd56a8276
Add context (url) for failed pull 2018-03-02 23:23:14 -08:00
Stephen McQuay dd3fecc699
Rearrange some code, adds pm av 2018-03-02 23:23:13 -08:00
Stephen McQuay 74d360f6d7
Adds pm pull 2018-03-02 23:23:11 -08:00
Stephen McQuay 2a0220cddc
Adds doc comments 2018-03-02 23:23:08 -08:00
Stephen McQuay f21ded2b77
Adds Available
this is the database for available packages
2018-03-02 23:23:06 -08:00
Stephen McQuay 49f82610a3
Stubs in pm pull 2018-03-02 23:23:04 -08:00
Stephen McQuay d534949255
Track yaml dep 2018-03-02 23:23:02 -08:00
Stephen McQuay 848960f1c8
Use version in the .pkg filename 2018-03-02 23:23:00 -08:00
Stephen McQuay 63c38d5e92
Added pm.Meta 2018-03-02 23:22:57 -08:00
Stephen McQuay 4b79c0c765
Adds remote tests 2018-02-28 23:20:34 -08:00
Stephen McQuay a0c652ecb8
Make required directories on remote call 2018-02-28 23:20:34 -08:00
Stephen McQuay 28975b7890
Adds pm remote {add,rm,list} 2018-02-28 23:20:34 -08:00
Stephen McQuay c42433e12f
Fail on missing args 2018-02-28 20:41:25 -08:00
Stephen McQuay 92b937936e
Stub in the commands 2018-02-28 20:00:25 -08:00
Stephen McQuay 8a70658b3f
Not all commands need long versions 2018-02-28 19:50:55 -08:00
Stephen McQuay 0fe65c2c8a
Update /x/crypto 2018-02-27 22:14:50 -08:00
Stephen McQuay 1af17cb228
Adds pm pkg create 2018-02-27 21:46:09 -08:00
Stephen McQuay 65e6137c37
Implements FindSecretEntity 2018-02-27 20:47:04 -08:00
Stephen McQuay 302f738bea
pkg shouldn't have to search for keys 2018-02-27 20:47:04 -08:00
Stephen McQuay 344148be2d
Stub out fetching secret key entity 2018-02-27 20:47:04 -08:00
Stephen McQuay 6066c3bd55
Simple pre-flight checks
More of this to come ...
2018-02-27 20:47:04 -08:00
Stephen McQuay 13ab26491b
Stub out pm package create 2018-02-27 20:47:04 -08:00
Stephen McQuay 0cee922f12
Sign takes an *openpgp.Entity 2018-02-27 20:43:40 -08:00
Stephen McQuay e043384c67
Adds pm key remove <id> 2018-02-26 19:33:45 -08:00
Stephen McQuay 040b438cd1
Consistently align usage 2018-02-26 19:07:33 -08:00
Stephen McQuay 1f49d14e58
Adds dep files 2018-02-25 20:37:30 -08:00
Stephen McQuay 2a69f39e4b
Adds pm keyring verify <file> <sig> 2018-02-25 20:10:18 -08:00
Stephen McQuay ff137c7036
Adds pm keyring sign 2018-02-25 20:10:15 -08:00
Stephen McQuay 72bbf22cbd
Adds pm key import 2018-02-25 20:10:13 -08:00
Stephen McQuay 6f41544659
Adds pm key export 2018-02-25 20:10:10 -08:00
Stephen McQuay e18aee5b4f
Validate keyring input
Linc helped me discover this bug by being into pushing buttons on my
kc71 and watching the colors change.
2018-02-25 20:10:10 -08:00
Stephen McQuay a00e058178
Adds pm key ls 2018-02-25 20:10:10 -08:00
Stephen McQuay 9f921135f7
Adds pm key create 2018-02-25 20:10:09 -08:00
Stephen McQuay b22d99bf53
Creates pgp dir if it doesn't exist 2018-02-25 20:10:07 -08:00
Stephen McQuay 4d8f3f0e79
Creates openpgp.Entity 2018-02-25 20:10:06 -08:00
Stephen McQuay d333acfdbd
Stubs out passing args to keyring creation 2018-02-25 20:10:03 -08:00
Stephen McQuay ea383b7a34
parse name/email 2018-02-25 20:10:03 -08:00
Stephen McQuay 280ba234c8
Adds env subcommand 2018-02-25 20:10:00 -08:00
Stephen McQuay 5495f69b5a
Stubs out pm key create subcommand 2018-02-25 20:09:58 -08:00
Stephen McQuay 20d0b556ea
Stubs in keyring subcommand 2018-02-25 20:09:49 -08:00
20 changed files with 2444 additions and 3 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
*.swp
vendor

41
Gopkg.lock generated Normal file
View File

@ -0,0 +1,41 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/pkg/errors"
packages = ["."]
revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
version = "v0.8.0"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = [
"cast5",
"openpgp",
"openpgp/armor",
"openpgp/elgamal",
"openpgp/errors",
"openpgp/packet",
"openpgp/s2k"
]
revision = "8c653846df49742c4c85ec37e5d9f8d3ba657895"
[[projects]]
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5"
version = "v2.1.1"
[[projects]]
name = "mcquay.me/fs"
packages = ["."]
revision = "d40468470ff3ca131b1eaf3d99ca05c2dd3bcdfc"
version = "v1.0.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "a4d32970fbd71fcaec77a9ad03f9bbe56005cd80f00fff4329b314a3458e8d1f"
solver-name = "gps-cdcl"
solver-version = 1

46
Gopkg.toml Normal file
View File

@ -0,0 +1,46 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
# [prune]
# non-go = false
# go-tests = true
# unused-packages = true
[[constraint]]
name = "github.com/pkg/errors"
version = "0.8.0"
[[constraint]]
branch = "master"
name = "golang.org/x/crypto"
[[constraint]]
name = "mcquay.me/fs"
version = "1.0.0"
[prune]
go-tests = true
unused-packages = true
[[constraint]]
name = "gopkg.in/yaml.v2"
version = "2.1.1"

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
default: lint test
.PHONY: lint
lint:
@golint $(shell go list mcquay.me/pm/...)
@go vet $(shell go list mcquay.me/pm/...)
.PHONY: test
test:
@go test -cover $(shell go list mcquay.me/pm/...)

View File

@ -49,7 +49,6 @@ following files:
```yaml
name: foo
version: 2.3.29
namespace: /darwin/amd64
description: Foo is the world's simplest frobnicator
deps: [baz, bar@0.9.2]
```
@ -88,8 +87,8 @@ The example remote url:
encodes a remote that is served over `https` on the host `pm.mcquay.me` and
informs the client to pull packages from the `/darwin/amd64/testing` namespace,
specified by the Path. `pm pull` will collect available package information
from the remote for a given namespace and will populate its local database with
the contents of the response. `pm` can then list available packages, and
from configured remote and will populate its local database with the contents
of the response. `pm` can then list available packages, and the user can then
request that they be installed.
As a practical example a client can be configured to pull from two `remotes`

187
available.go Normal file
View File

@ -0,0 +1,187 @@
package pm
import (
"fmt"
"net/url"
"sort"
"strings"
"github.com/pkg/errors"
)
// Name exists to document the keys in Available
type Name string
// Names is a slice of names ... with sorting!
type Names []Name
func (n Names) Len() int { return len(n) }
func (n Names) Swap(a, b int) { n[a], n[b] = n[b], n[a] }
func (n Names) Less(a, b int) bool { return n[a] < n[b] }
// Version exists to document the keys in Available
type Version string
// Versions is a slice of Version ... with sorting!
type Versions []Version
// TODO (sm): make this semver sort?
func (v Versions) Len() int { return len(v) }
func (v Versions) Swap(a, b int) { v[a], v[b] = v[b], v[a] }
func (v Versions) Less(a, b int) bool { return v[a] < v[b] }
type label struct {
n Name
v Version
}
type labels []label
// TODO (sm): make this semver sort?
func (n labels) Len() int { return len(n) }
func (n labels) Swap(a, b int) { n[a], n[b] = n[b], n[a] }
func (n labels) Less(a, b int) bool {
if n[a].n != n[b].n {
return n[a].n < n[b].n
}
return n[a].v < n[b].v
}
// Available is the structure used to represent the collection of all packages
// that can be installed.
type Available map[Name]map[Version]Meta
// Get returns the meta stored at a[n][v] or an error explaining why it could
// not be Get.
func (a Available) Get(n Name, v Version) (Meta, error) {
if _, ok := a[n]; !ok {
return Meta{}, errors.Errorf("could not find package named %q", n)
}
if v == "" {
if len(a[n]) == 0 {
return Meta{}, errors.Errorf("no configured versions for %q", n)
}
vers := Versions{}
for ver := range a[n] {
vers = append(vers, ver)
}
sort.Sort(vers)
v = vers[len(vers)-1]
}
if _, ok := a[n][v]; !ok {
return Meta{}, errors.Errorf("could not find %v@%v in database", n, v)
}
return a[n][v], nil
}
// Add inserts m into a.
func (a Available) Add(m Meta) error {
if _, err := m.Valid(); err != nil {
return errors.Wrap(err, "invalid meta")
}
if _, ok := a[Name(m.Name)]; !ok {
a[m.Name] = map[Version]Meta{}
}
a[m.Name][m.Version] = m
return nil
}
// Update inserts all data from o into a.
func (a Available) Update(o Available) error {
for _, vers := range o {
for _, m := range vers {
if err := a.Add(m); err != nil {
return errors.Wrap(err, "adding")
}
}
}
return nil
}
// SetRemote adds the information in the url to the database.
func (a Available) SetRemote(u url.URL) {
for n, vers := range a {
for v := range vers {
m := a[n][v]
m.Remote = u
a[n][v] = m
}
}
}
// Traverse returns a chan of Meta that will be sanely sorted.
func (a Available) Traverse() <-chan Meta {
r := make(chan Meta)
go func() {
names := Names{}
nvs := map[Name]Versions{}
for n, vers := range a {
names = append(names, n)
for v := range vers {
nvs[n] = append(nvs[n], v)
}
sort.Sort(nvs[n])
}
sort.Sort(names)
for _, n := range names {
for _, v := range nvs[n] {
r <- a[n][v]
}
}
close(r)
}()
return r
}
func labelForString(s string) (label, error) {
r := label{}
c := strings.Count(s, "@")
switch c {
case 0:
r.n = Name(s)
case 1:
sp := strings.Split(s, "@")
r.n, r.v = Name(sp[0]), Version(sp[1])
default:
return r, fmt.Errorf("unexpected number of '@' found, got %v, want 1", c)
}
if r.n == "" {
return r, fmt.Errorf("name cannot be empty")
}
return r, nil
}
// Installable calculates if the packages requested in "in" can be installed.
func (a Available) Installable(in []string) (Metas, error) {
ls := labels{}
for _, i := range in {
l, err := labelForString(i)
if err != nil {
return nil, errors.Wrap(err, "parsing name/version")
}
ls = append(ls, l)
}
seen := map[Name]bool{}
for _, l := range ls {
if _, ok := seen[l.n]; ok {
return nil, fmt.Errorf("can only ask to install %q once", l.n)
}
seen[l.n] = true
}
ms := Metas{}
for _, l := range ls {
m, err := a.Get(l.n, l.v)
if err != nil {
return ms, errors.Wrapf(err, "getting %v", l)
}
ms = append(ms, m)
}
return ms, nil
}

105
available_test.go Normal file
View File

@ -0,0 +1,105 @@
package pm
import (
"errors"
"net/url"
"testing"
)
func TestAvailableAdd(t *testing.T) {
tests := []struct {
label string
m Meta
count int
err error
}{
{
label: "good",
m: Meta{Name: "a", Version: "v1.0.0", Description: "test"},
count: 1,
},
{
label: "bad meta",
m: Meta{Name: "a"},
count: 1,
err: errors.New("missing"),
},
{
label: "dupe is last in",
m: Meta{Name: "a", Version: "v1.0.0", Description: "better version"},
count: 1,
},
{
label: "another good",
m: Meta{Name: "a", Version: "v1.0.0", Description: "better version"},
count: 1,
},
}
a := Available{}
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
if err := a.Add(test.m); (err == nil) != (test.err == nil) {
t.Fatalf("adding meta%v", err)
}
if got, want := len(a), test.count; got != want {
t.Fatalf("unexpected length after Add: got %v, want %v", got, want)
}
})
}
if got, want := a["a"]["v1.0.0"].Description, "better version"; got != want {
t.Fatalf("version: got %v, want %v", got, want)
}
}
func TestAvailableUpdate(t *testing.T) {
a := Available{}
if err := a.Add(Meta{Name: "a", Version: "v1.0.0", Description: "test"}); err != nil {
t.Fatalf("add: %v", err)
}
if err := a.Add(Meta{Name: "b", Version: "v2.0.0", Description: "test"}); err != nil {
t.Fatalf("add: %v", err)
}
b := Available{}
a.Update(b)
if got, want := len(a), 2; got != want {
t.Fatalf("len after empty update: got %v, want %v", got, want)
}
if err := b.Add(Meta{Name: "a", Version: "v1.0.0", Description: "test last in"}); err != nil {
t.Fatalf("add: %v", err)
}
if err := b.Add(Meta{Name: "b", Version: "v2.1.0", Description: "test"}); err != nil {
t.Fatalf("add: %v", err)
}
if err := a.Update(b); err != nil {
t.Fatalf("update: %v", err)
}
if got, want := len(a), 2; got != want {
t.Fatalf("len after update: got %v, want %v", got, want)
}
if got, want := len(a["a"]), 1; got != want {
t.Fatalf("len after update: got %v, want %v", got, want)
}
if got, want := len(a["b"]), 2; got != want {
t.Fatalf("len after update: got %v, want %v", got, want)
}
if got, want := a["a"]["v1.0.0"].Description, "test last in"; got != want {
t.Fatalf("last in didn't override")
}
u, err := url.Parse("https://pm.mcquay.me/darwin/amd64")
if err != nil {
t.Fatalf("parsing url: %v", err)
}
a.SetRemote(*u)
if got, want := a["a"]["v1.0.0"].Remote, *u; got != want {
t.Fatalf("last in didn't override")
}
}

275
cmd/pm/main.go Normal file
View File

@ -0,0 +1,275 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"github.com/pkg/errors"
"mcquay.me/fs"
"mcquay.me/pm/db"
"mcquay.me/pm/keyring"
"mcquay.me/pm/pkg"
)
// Version stores the current version, and is updated at build time.
const Version = "dev"
const usage = `pm: simple, cross-platform system package manager
subcommands:
available (av) -- print out all installable packages
environ (env) -- print environment information
install (in) -- install packages
keyring (key) -- interact with pm's OpenPGP keyring
ls -- list installed packages
package (pkg) -- create packages
pull -- fetch all available packages from all configured remotes
remote -- configure remote pmd servers
rm -- remove packages
version (v) -- print version information
`
const keyUsage = `pm keyring: interact with pm's OpenPGP keyring
subcommands:
create (c) -- create a fresh keypair
export (e) -- export a public key to stdout
import (i) -- import a public key from stdin
ls -- list configured key info
rm -- remove a key from the keyring
sign (s) -- sign a file
verify (v) -- verify a detached signature
`
const pkgUsage = `pm package: generate pm-compatible packages
subcommands:
create (c) -- create a fresh keypair
`
const remoteUsage = `pm remote: configure remote pmd servers
subcommands:
add (a) -- add a URI
ls -- list configured remotes
rm -- remove a URI
`
func main() {
if len(os.Args) < 2 {
fatalf("pm: missing subcommand\n\n%v", usage)
}
cmd := os.Args[1]
root := os.Getenv("PM_ROOT")
if root == "" {
root = "/usr/local"
}
signID := os.Getenv("PM_PGP_ID")
switch cmd {
case "env", "environ":
fmt.Printf("PM_ROOT=%q\n", root)
fmt.Printf("PM_PGP_ID=%q\n", signID)
case "key", "keyring":
if len(os.Args[1:]) < 2 {
fatalf("pm keyring: insufficient args\n\nusage: %v", keyUsage)
}
sub, args := os.Args[2], os.Args[3:]
switch sub {
case "ls":
if err := keyring.ListKeys(root, os.Stdout); err != nil {
fatalf("listing keypair: %v\n", err)
}
case "c", "create":
var name, email string
s := bufio.NewScanner(os.Stdin)
fmt.Printf("name: ")
s.Scan()
if err := s.Err(); err != nil {
fatalf("reading name: %v\n", err)
}
name = s.Text()
fmt.Printf("email: ")
s.Scan()
if err := s.Err(); err != nil {
fatalf("reading email: %v\n", err)
}
email = s.Text()
if err := os.Stdin.Close(); err != nil {
fatalf("%v\n", err)
}
if err := keyring.NewKeyPair(root, name, email); err != nil {
fatalf("creating keypair: %v\n", err)
}
case "export", "e":
if len(args) != 1 {
fatalf("missing email argument\n")
}
email := args[0]
if err := keyring.Export(root, os.Stdout, email); err != nil {
fatalf("exporting public key for %q: %v\n", email, err)
}
case "sign", "s":
if signID == "" {
fatalf("must set PM_PGP_ID\n")
}
e, err := keyring.FindSecretEntity(root, signID)
if err != nil {
fatalf("find secret key: %v\n", err)
}
if err := keyring.Sign(e, os.Stdin, os.Stdout); err != nil {
fatalf("signing: %v\n", err)
}
case "verify", "v":
if len(args) != 2 {
fatalf("usage: pm key verify <file> <sig>\n")
}
fn, sn := args[0], args[1]
ff, err := os.Open(fn)
if err != nil {
fatalf("opening %q: %v\n", fn, err)
}
defer ff.Close()
sf, err := os.Open(sn)
if err != nil {
fatalf("opening %q: %v\n", fn, err)
}
defer sf.Close()
if err := keyring.Verify(root, ff, sf); err != nil {
fatalf("detached sig verify: %v\n", err)
}
case "i", "import":
if err := keyring.Import(root, os.Stdin); err != nil {
fatalf("importing key: %v\n", err)
}
case "rm":
if len(args) != 1 {
fatalf("missing key id\n\nusage: pm key remove <id>\n")
}
id := args[0]
if err := keyring.Remove(root, id); err != nil {
fatalf("removing key for %q: %v\n", id, err)
}
default:
fatalf("unknown keyring subcommand: %q\n\nusage: %v", sub, keyUsage)
}
case "package", "pkg":
if len(os.Args[1:]) < 2 {
fatalf("pm package: insufficient args\n\nusage: %v", pkgUsage)
}
sub := os.Args[2]
switch sub {
case "create", "creat", "c":
if signID == "" {
fatalf("must set PM_PGP_ID\n")
}
args := os.Args[3:]
if len(args) != 1 {
fatalf("usage: pm package create <directory>\n")
}
dir := args[0]
e, err := keyring.FindSecretEntity(root, signID)
if err != nil {
fatalf("find secret key: %v\n", err)
}
if err := pkg.Create(e, dir); err != nil {
fatalf("creating package: %v\n", err)
}
default:
fatalf("unknown package subcommand: %q\n\nusage: %v", sub, pkgUsage)
}
case "remote":
if len(os.Args[1:]) < 2 {
fatalf("pm remote: insufficient args\n\nusage: %v", remoteUsage)
}
sub := os.Args[2]
args := os.Args[3:]
if err := mkdirs(root); err != nil {
fatalf("making pm var directories: %v\n", err)
}
switch sub {
case "add", "a":
if len(args) < 1 {
fatalf("missing arg\n\nusage: pm remote add [<uris>]\n")
}
if err := db.AddRemotes(root, args); err != nil {
fatalf("remote add: %v\n", err)
}
case "rm":
if len(args) < 1 {
fatalf("missing arg\n\nusage: pm remote rm [<uris>]\n")
}
if err := db.RemoveRemotes(root, args); err != nil {
fatalf("remote remove: %v\n", err)
}
case "ls":
if err := db.ListRemotes(root, os.Stdout); err != nil {
fatalf("list: %v\n", err)
}
default:
fatalf("unknown package subcommand: %q\n\nusage: %v", sub, remoteUsage)
}
case "pull":
if err := db.Pull(root); err != nil {
fatalf("pulling available packages: %v\n", err)
}
case "available", "av":
if err := db.ListAvailable(root, os.Stdout); err != nil {
fatalf("pulling available packages: %v\n", err)
}
case "install", "in":
if len(os.Args[1:]) < 2 {
fatalf("pm install: insufficient args\n\nusage: pm install [pkg1, pkg2, ..., pkgN]\n")
}
pkgs := os.Args[2:]
if err := pkg.Install(root, pkgs); err != nil {
fatalf("installing: %v\n", err)
}
case "ls":
if len(os.Args[1:]) == 1 {
if err := db.ListInstalled(root, os.Stdout); err != nil {
fatalf("listing installed: %v\n", err)
}
} else {
if err := db.ListInstalledFiles(root, os.Stdout, os.Args[2:]); err != nil {
fatalf("listing installed: %v\n", err)
}
}
case "rm":
if len(os.Args[1:]) < 2 {
fatalf("pm rm: insufficient args\n\nusage: pm rm [pkg1, pkg2, ..., pkgN]\n")
}
pkgs := os.Args[2:]
if err := pkg.Remove(root, pkgs); err != nil {
fatalf("removing: %v\n", err)
}
case "version", "v":
fmt.Printf("pm: version %v\n", Version)
default:
fatalf("uknown subcommand %q\n\nusage: %v", cmd, usage)
}
}
func fatalf(f string, args ...interface{}) {
fmt.Fprintf(os.Stderr, f, args...)
os.Exit(1)
}
func mkdirs(root string) error {
d := filepath.Join(root, "var", "lib", "pm")
if !fs.Exists(d) {
if err := os.MkdirAll(d, 0700); err != nil {
return errors.Wrap(err, "mk pm dir")
}
}
return nil
}

22
cs.go Normal file
View File

@ -0,0 +1,22 @@
package pm
import (
"bufio"
"fmt"
"io"
"strings"
)
// ParseCS returns a parsed checksum file.
func ParseCS(f io.Reader) (map[string]string, error) {
cs := map[string]string{}
s := bufio.NewScanner(f)
for s.Scan() {
elems := strings.Split(s.Text(), "\t")
if len(elems) != 2 {
return nil, fmt.Errorf("manifest format error; got %d elements, want 2", len(elems))
}
cs[elems[1]] = elems[0]
}
return cs, nil
}

99
db/available.go Normal file
View File

@ -0,0 +1,99 @@
package db
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/pkg/errors"
"mcquay.me/fs"
"mcquay.me/pm"
)
const an = "var/lib/pm/available.json"
// Pull updates the available package database.
func Pull(root string) error {
db, err := load(root)
if err != nil {
return errors.Wrap(err, "loading db")
}
o := pm.Available{}
// Order here is important: the guarantee made is that any packages that
// exist in multiple remotes will be fetched by the first configured
// remote, which is why we traverse the database in reverse.
//
// TODO (sm): make this concurrent
for i := range db {
u := db[len(db)-i-1]
resp, err := http.Get(u.String() + "/available.json")
if err != nil {
return errors.Wrap(err, "http get")
}
a := pm.Available{}
if err := json.NewDecoder(resp.Body).Decode(&a); err != nil {
return errors.Wrapf(err, "decode remote available for %q", u.String())
}
a.SetRemote(u)
o.Update(a)
}
if err := saveAvailable(root, o); err != nil {
return errors.Wrap(err, "saving available db")
}
return nil
}
// ListAvailable prints all installable packages
func ListAvailable(root string, w io.Writer) error {
db, err := LoadAvailable(root)
if err != nil {
return errors.Wrap(err, "loading")
}
for m := range db.Traverse() {
fmt.Fprintf(w, "%v\t%v\t%v\n", m.Name, m.Version, m.Remote.String())
}
return nil
}
// LoadAvailable returns the collection of available packages
func LoadAvailable(root string) (pm.Available, error) {
r := pm.Available{}
dbn := filepath.Join(root, rn)
if !fs.Exists(dbn) {
return r, nil
}
f, err := os.Open(filepath.Join(root, an))
if err != nil {
return r, errors.Wrap(err, "open")
}
if err := json.NewDecoder(f).Decode(&r); err != nil {
return r, errors.Wrap(err, "decoding db")
}
return r, nil
}
func saveAvailable(root string, db pm.Available) error {
f, err := os.Create(filepath.Join(root, an))
if err != nil {
return errors.Wrap(err, "create")
}
enc := json.NewEncoder(f)
enc.SetIndent("", "\t")
if err := enc.Encode(&db); err != nil {
return errors.Wrap(err, "decoding db")
}
if err := f.Close(); err != nil {
return errors.Wrap(err, "close db")
}
return nil
}

138
db/installed.go Normal file
View File

@ -0,0 +1,138 @@
package db
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"github.com/pkg/errors"
"mcquay.me/fs"
"mcquay.me/pm"
)
const in = "var/lib/pm/installed.json"
// AddInstalled adds m to the installed package database.
func AddInstalled(root string, m pm.Meta) error {
db, err := loadi(root)
if err != nil {
return errors.Wrap(err, "loading installed db")
}
db[m.Name] = m
return savei(root, db)
}
// RemoveInstalled adds m to the installed package database.
func RemoveInstalled(root string, m pm.Meta) error {
db, err := loadi(root)
if err != nil {
return errors.Wrap(err, "loading installed db")
}
delete(db, m.Name)
return savei(root, db)
}
// IsInstalled checks if m is in the installed package database.
func IsInstalled(root string, m pm.Meta) (bool, error) {
db, err := loadi(root)
if err != nil {
return false, errors.Wrap(err, "loading installed db")
}
_, r := db[m.Name]
return r, nil
}
// ListInstalled pretty prints the installed database to w.
func ListInstalled(root string, w io.Writer) error {
db, err := loadi(root)
if err != nil {
return errors.Wrap(err, "loading installed db")
}
for m := range db.Traverse() {
fmt.Fprintf(w, "%v\t%v\t%v\n", m.Name, m.Version, m.Remote.String())
}
return nil
}
// ListInstalledFiles prints the contents of a package.
func ListInstalledFiles(root string, w io.Writer, names []string) error {
for _, name := range names {
ok, err := IsInstalled(root, pm.Meta{Name: pm.Name(name)})
if err != nil {
return errors.Wrap(err, "is installed")
}
if !ok {
return fmt.Errorf("%v not installed", name)
}
}
for _, name := range names {
fn := filepath.Join(root, "var", "lib", "pm", "installed", name, "bom.sha256")
f, err := os.Open(fn)
if err != nil {
return errors.Wrapf(err, "opening %v's bom", name)
}
bom, err := pm.ParseCS(f)
if err != nil {
return errors.Wrapf(err, "parsing %v's bom", name)
}
if err := f.Close(); err != nil {
return errors.Wrapf(err, "closing %v's bom", name)
}
ks := []string{}
for k := range bom {
ks = append(ks, k)
}
sort.Strings(ks)
for _, k := range ks {
fmt.Fprintf(w, "%v\n", k)
}
}
return nil
}
func LoadInstalled(root string) (pm.Installed, error) {
return loadi(root)
}
func loadi(root string) (pm.Installed, error) {
r := pm.Installed{}
dbn := filepath.Join(root, in)
if !fs.Exists(dbn) {
return r, nil
}
f, err := os.Open(dbn)
if err != nil {
return r, errors.Wrap(err, "open")
}
if err := json.NewDecoder(f).Decode(&r); err != nil {
return r, errors.Wrap(err, "decoding db")
}
return r, nil
}
func savei(root string, db pm.Installed) error {
f, err := os.Create(filepath.Join(root, in))
if err != nil {
return errors.Wrap(err, "create")
}
enc := json.NewEncoder(f)
enc.SetIndent("", "\t")
if err := enc.Encode(&db); err != nil {
return errors.Wrap(err, "decoding db")
}
if err := f.Close(); err != nil {
return errors.Wrap(err, "close db")
}
return nil
}

150
db/remote.go Normal file
View File

@ -0,0 +1,150 @@
package db
import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"github.com/pkg/errors"
"mcquay.me/fs"
)
// DB is a slice of available URI
type DB []url.URL
const rn = "var/lib/pm/remotes.json"
// AddRemotes appends the provided uri to the list of configured remotes.
func AddRemotes(root string, uris []string) error {
db, err := load(root)
if err != nil {
return errors.Wrap(err, "loading")
}
dbm := map[string]bool{}
for _, u := range db {
dbm[u.String()] = true
}
for _, uri := range uris {
pu, err := url.Parse(uri)
if err != nil {
return errors.Wrap(err, "url parse")
}
u := strip(*pu)
if _, ok := dbm[u.String()]; ok {
return fmt.Errorf("%q already in db", u.String())
}
db = append(db, u)
}
return save(root, db)
}
// RemoveRemotes removes the given uri from the list of configured remotes.
func RemoveRemotes(root string, uris []string) error {
db, err := load(root)
if err != nil {
return errors.Wrap(err, "loading")
}
rms := map[string]bool{}
for _, uri := range uris {
pu, err := url.Parse(uri)
if err != nil {
return errors.Wrap(err, "url parse")
}
u := strip(*pu)
rms[u.String()] = true
}
o := DB{}
for _, d := range db {
if _, ok := rms[d.String()]; !ok {
o = append(o, d)
}
}
if len(o) == len(db) {
return errors.New("found no matching remotes")
}
return save(root, o)
}
// ListRemotes prints all configured remotes to w.
func ListRemotes(root string, w io.Writer) error {
db, err := load(root)
if err != nil {
return errors.Wrap(err, "loading")
}
for _, u := range db {
fmt.Fprintf(w, "%s\n", u.String())
}
return nil
}
func load(root string) (DB, error) {
r := DB{}
dbn := filepath.Join(root, rn)
if !fs.Exists(dbn) {
return r, nil
}
f, err := os.Open(filepath.Join(root, rn))
if err != nil {
return r, errors.Wrap(err, "open")
}
if err := json.NewDecoder(f).Decode(&r); err != nil {
return r, errors.Wrap(err, "decoding db")
}
return r, nil
}
func save(root string, db DB) error {
f, err := os.Create(filepath.Join(root, rn))
if err != nil {
return errors.Wrap(err, "create")
}
enc := json.NewEncoder(f)
enc.SetIndent("", "\t")
if err := enc.Encode(&db); err != nil {
return errors.Wrap(err, "decoding db")
}
if err := f.Close(); err != nil {
return errors.Wrap(err, "close db")
}
return nil
}
// strip removes all fields we don't currently need.
func strip(u url.URL) url.URL {
return url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: u.Path,
}
}
func mkdirs(root string) error {
d, _ := filepath.Split(filepath.Join(root, rn))
if !fs.Exists(d) {
if err := os.MkdirAll(d, 0700); err != nil {
return errors.Wrap(err, "mk pm dir")
}
}
return nil
}

157
db/remote_test.go Normal file
View File

@ -0,0 +1,157 @@
package db
import (
"bytes"
"io/ioutil"
"os"
"strings"
"testing"
)
// TODO (sm): add more tests, including
// - empty add
// - removing db to empty
// - bad uris
func dirMe(t *testing.T) (string, func()) {
root, err := ioutil.TempDir("", "pm-tests-")
if err != nil {
t.Fatalf("tmpdir: %v", err)
}
if err := mkdirs(root); err != nil {
t.Fatalf("making pm dirs: %v", err)
}
return root, func() {
if err := os.RemoveAll(root); err != nil {
t.Fatalf("cleanup: %v", err)
}
}
}
func TestAdd(t *testing.T) {
root, del := dirMe(t)
defer del()
{
db, err := load(root)
if err != nil {
t.Fatalf("load: %v", err)
}
if got, want := len(db), 0; got != want {
t.Fatalf("empty db not empty: got %v, want %v", got, want)
}
}
bad := []string{
"http\ns://\nFoo|n",
}
if err := AddRemotes(root, bad); err == nil {
t.Fatalf("didn't detect bad url")
}
uris := []string{
"https://pm.mcquay.me/darwin/amd64",
}
if err := AddRemotes(root, uris); err != nil {
t.Fatalf("add: %v", err)
}
db, err := load(root)
if err != nil {
t.Fatalf("load: %v", err)
}
if got, want := len(db), len(uris); got != want {
t.Fatalf("unepected number of uris; got %v, want %v", got, want)
}
for _, u := range uris {
found := false
for _, d := range db {
if d.String() == u {
found = true
}
}
if !found {
t.Fatalf("did not find %v in the db", u)
}
}
if err := AddRemotes(root, uris); err == nil {
t.Fatalf("did not detect duplicate, and should have")
}
}
func TestRemove(t *testing.T) {
root, del := dirMe(t)
defer del()
if err := RemoveRemotes(root, nil); err == nil {
t.Fatalf("should have returned error on empty db")
}
uris := []string{
"https://pm.mcquay.me/foo",
"https://pm.mcquay.me/bar",
"https://pm.mcquay.me/baz",
}
if err := RemoveRemotes(root, uris); err == nil {
t.Fatalf("should have returned error asking to remove many uri on empty db")
}
if err := AddRemotes(root, uris); err != nil {
t.Fatalf("add: %v", err)
}
if err := RemoveRemotes(root, uris[1:2]); err != nil {
t.Fatalf("remove: %v", err)
}
db, err := load(root)
if err != nil {
t.Fatalf("load: %v", err)
}
if got, want := len(db), len(uris)-1; got != want {
t.Fatalf("unepected number of uris; got %v, want %v", got, want)
}
found := false
for _, d := range db {
if d.String() == uris[1] {
found = true
}
}
if found {
for _, v := range db {
t.Logf("%v", v.String())
}
t.Fatalf("failed to remove %v", uris[1:2])
}
}
func TestList(t *testing.T) {
root, del := dirMe(t)
defer del()
uris := []string{
"https://pm.mcquay.me/foo",
"https://pm.mcquay.me/bar",
"https://pm.mcquay.me/baz",
}
if err := AddRemotes(root, uris); err != nil {
t.Fatalf("add: %v", err)
}
buf := &bytes.Buffer{}
if err := ListRemotes(root, buf); err != nil {
t.Fatalf("list: %v", err)
}
for _, u := range uris {
if !strings.Contains(buf.String(), u) {
t.Fatalf("could not find %q in output\n%v", u, buf.String())
}
}
}

73
installed.go Normal file
View File

@ -0,0 +1,73 @@
package pm
import (
"errors"
"fmt"
"sort"
"strings"
)
// Installed tracks installed packages.
type Installed map[Name]Meta
// Traverse returns a chan of Meta that will be sanely sorted.
func (i Installed) Traverse() <-chan Meta {
r := make(chan Meta)
go func() {
names := Names{}
for n := range i {
names = append(names, n)
}
sort.Sort(names)
for _, n := range names {
r <- i[n]
}
close(r)
}()
return r
}
// Removable calculates if the packages requested in "in" can all be removed.
func (i Installed) Removable(names []string) (Metas, error) {
inm := map[Name]bool{}
// XXX (sm): here we simply check if the package exists; eventually we'll
// have to check transitive dependencies, and deal with explicitly and
// implicitly installed packages.
found := map[Name]Meta{}
for _, name := range names {
n := Name(name)
inm[n] = true
if m, ok := i[n]; ok {
found[n] = m
}
}
if len(found) > len(inm) {
return nil, errors.New("should not have been able to find more than asked for, but did; internals are inconsistent.")
} else if len(inm) > len(found) {
// user asked for something that isn't installed.
missing := []string{}
for _, name := range names {
if _, ok := found[Name(name)]; !ok {
missing = append(missing, name)
}
}
return nil, fmt.Errorf("packages not installed: %v", strings.Join(missing, ", "))
}
if len(found) != len(inm) {
return nil, fmt.Errorf("escapes logic")
}
// XXX (sm): the ordering here will also eventually depend on transitive
// dependencies.
r := Metas{}
for _, m := range found {
r = append(r, m)
}
return r, nil
}

343
keyring/keyring.go Normal file
View File

@ -0,0 +1,343 @@
package keyring
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
"mcquay.me/fs"
)
// NewKeyPair creates and adds a new OpenPGP keypair to an existing keyring.
func NewKeyPair(root, name, email string) error {
if name == "" {
return errors.New("name cannot be empty")
}
if email == "" {
return errors.New("email cannot be empty")
}
if strings.ContainsAny(email, "()<>\x00") {
return fmt.Errorf("email %q contains invalid chars", email)
}
if err := ensureDir(root); err != nil {
return errors.Wrap(err, "can't find or create pgp dir")
}
srn, prn := getNames(root)
secs, pubs, err := getELs(srn, prn)
if err != nil {
return errors.Wrap(err, "getting existing keyrings")
}
fresh, err := openpgp.NewEntity(name, "pm", email, nil)
if err != nil {
errors.Wrap(err, "new entity")
}
pr, err := os.Create(prn)
if err != nil {
return errors.Wrap(err, "opening pubring")
}
sr, err := os.Create(srn)
if err != nil {
return errors.Wrap(err, "opening secring")
}
for _, e := range secs {
if err := e.SerializePrivate(sr, nil); err != nil {
return errors.Wrapf(err, "serializing old private key: %v", e.PrimaryKey.KeyIdString())
}
}
// order is critical here; if we don't serialize the private key of fresh
// first, the later steps fail.
if err := fresh.SerializePrivate(sr, nil); err != nil {
return errors.Wrapf(err, "serializing fresh private %v", fresh.PrimaryKey.KeyIdString())
}
if err := sr.Close(); err != nil {
return errors.Wrap(err, "closing secring")
}
for _, e := range pubs {
if err := e.Serialize(pr); err != nil {
return errors.Wrapf(err, "serializing %v", e.PrimaryKey.KeyIdString())
}
}
if err := fresh.Serialize(pr); err != nil {
return errors.Wrapf(err, "serializing %v", fresh.PrimaryKey.KeyIdString())
}
if err := pr.Close(); err != nil {
return errors.Wrap(err, "closing pubring")
}
return nil
}
// ListKeys prints keyring information to w.
func ListKeys(root string, w io.Writer) error {
if err := ensureDir(root); err != nil {
return errors.Wrap(err, "can't find or create pgp dir")
}
srn, prn := getNames(root)
secs, pubs, err := getELs(srn, prn)
if err != nil {
return errors.Wrap(err, "getting existing keyrings")
}
for _, s := range secs {
names := []string{}
for _, v := range s.Identities {
names = append(names, v.Name)
}
fmt.Fprintf(w, "sec: %+v:\t%v\n", s.PrimaryKey.KeyIdShortString(), strings.Join(names, ","))
}
for _, p := range pubs {
names := []string{}
for _, v := range p.Identities {
names = append(names, v.Name)
}
fmt.Fprintf(w, "pub: %+v:\t%v\n", p.PrimaryKey.KeyIdShortString(), strings.Join(names, ","))
}
return nil
}
// Export prints pubkey information associated with email to w.
func Export(root string, w io.Writer, email string) error {
if err := ensureDir(root); err != nil {
return errors.Wrap(err, "can't find or create pgp dir")
}
srn, prn := getNames(root)
_, pubs, err := getELs(srn, prn)
if err != nil {
return errors.Wrap(err, "getting existing keyrings")
}
e, err := findKey(pubs, email)
if err != nil {
return errors.Wrap(err, "find key")
}
aw, err := armor.Encode(w, openpgp.PublicKeyType, nil)
if err != nil {
return errors.Wrap(err, "creating armor encoder")
}
if err := e.Serialize(aw); err != nil {
return errors.Wrap(err, "serializing key")
}
if err := aw.Close(); err != nil {
return errors.Wrap(err, "closing armor encoder")
}
fmt.Fprintf(w, "\n")
return nil
}
// Import parses public key information from w and adds it to the public
// keyring.
func Import(root string, w io.Reader) error {
el, err := openpgp.ReadArmoredKeyRing(w)
if err != nil {
return errors.Wrap(err, "reading keyring")
}
if err := ensureDir(root); err != nil {
return errors.Wrap(err, "can't find or create pgp dir")
}
srn, prn := getNames(root)
_, pubs, err := getELs(srn, prn)
if err != nil {
return errors.Wrap(err, "getting existing keyrings")
}
foreign := openpgp.EntityList{}
exist := map[uint64]bool{}
for _, p := range pubs {
exist[p.PrimaryKey.KeyId] = true
}
for _, e := range el {
if _, ok := exist[e.PrimaryKey.KeyId]; !ok {
foreign = append(foreign, e)
}
}
if len(foreign) < 1 {
return errors.New("no new key material found")
}
pubs = append(pubs, foreign...)
pr, err := os.Create(prn)
if err != nil {
return errors.Wrap(err, "opening pubring")
}
for _, e := range pubs {
if err := e.Serialize(pr); err != nil {
return errors.Wrapf(err, "serializing %v", e.PrimaryKey.KeyIdString())
}
}
if err := pr.Close(); err != nil {
return errors.Wrap(err, "closing pubring")
}
return nil
}
// Sign takes an id and a reader and writes the signature for that id to sig.
func Sign(key *openpgp.Entity, in io.Reader, sig io.Writer) error {
if err := openpgp.ArmoredDetachSign(sig, key, in, nil); err != nil {
return errors.Wrap(err, "armored detach sign")
}
fmt.Fprintf(sig, "\n")
return nil
}
// Verify verifies a file's deatched signature.
func Verify(root string, file, sig io.Reader) error {
if err := ensureDir(root); err != nil {
return errors.Wrap(err, "can't find or create pgp dir")
}
srn, prn := getNames(root)
_, pubs, err := getELs(srn, prn)
if err != nil {
return errors.Wrap(err, "getting existing keyrings")
}
if _, err = openpgp.CheckArmoredDetachedSignature(pubs, file, sig); err != nil {
return errors.Wrap(err, "check sig")
}
return nil
}
// Remove removes public key information for a given id.
//
// It skips public keys that have matching secret keys, and does not effect
// private keys.
func Remove(root string, id string) error {
if err := ensureDir(root); err != nil {
return errors.Wrap(err, "can't find or create pgp dir")
}
srn, prn := getNames(root)
secs, pubs, err := getELs(srn, prn)
if err != nil {
return errors.Wrap(err, "getting existing keyrings")
}
victim, err := findKey(pubs, id)
if err != nil {
return errors.Wrapf(err, "finding key %q", id)
}
pr, err := os.Create(prn)
if err != nil {
return errors.Wrap(err, "opening pubring")
}
var rerr error
for _, p := range pubs {
if victim.PrimaryKey.KeyId == p.PrimaryKey.KeyId {
if len(secs.KeysById(victim.PrimaryKey.KeyId)) == 0 {
continue
}
rerr = fmt.Errorf("skipping pubkey with matching privkey: %v", p.PrimaryKey.KeyIdShortString())
}
if err := p.Serialize(pr); err != nil {
return errors.Wrapf(err, "serializing %v", p.PrimaryKey.KeyIdString())
}
}
if err := pr.Close(); err != nil {
return errors.Wrap(err, "closing pubring")
}
return rerr
}
func pGPDir(root string) string {
return filepath.Join(root, "var", "lib", "pm", "pgp")
}
func ensureDir(root string) error {
d := pGPDir(root)
if !fs.Exists(d) {
if err := os.MkdirAll(d, 0700); err != nil {
return errors.Wrap(err, "mk pgp dir")
}
}
return nil
}
func getNames(root string) (string, string) {
srn := filepath.Join(pGPDir(root), "secring.gpg")
prn := filepath.Join(pGPDir(root), "pubring.gpg")
return srn, prn
}
func getELs(secring, pubring string) (openpgp.EntityList, openpgp.EntityList, error) {
var sr, pr openpgp.EntityList
if fs.Exists(secring) {
f, err := os.Open(secring)
if err != nil {
return nil, nil, errors.Wrap(err, "opening secring")
}
sr, err = openpgp.ReadKeyRing(f)
if err != nil {
return nil, nil, errors.Wrap(err, "read sec key ring")
}
if err := f.Close(); err != nil {
return nil, nil, errors.Wrap(err, "closing keyring")
}
}
if fs.Exists(pubring) {
f, err := os.Open(pubring)
if err != nil {
return nil, nil, errors.Wrap(err, "opening pubring")
}
pr, err = openpgp.ReadKeyRing(f)
if err != nil {
return nil, nil, errors.Wrap(err, "read pub key ring")
}
if err := f.Close(); err != nil {
return nil, nil, errors.Wrap(err, "closing keyring")
}
}
return sr, pr, nil
}
func findKey(el openpgp.EntityList, id string) (*openpgp.Entity, error) {
var e *openpgp.Entity
if strings.Contains(id, "@") {
es := openpgp.EntityList{}
for _, p := range el {
for _, v := range p.Identities {
if id == v.UserId.Email {
es = append(es, p)
}
}
}
if len(es) == 1 {
return es[0], nil
}
if len(es) > 1 {
return nil, errors.New("too many keys matched; try searching by key id?")
}
} else {
for _, p := range el {
if id == p.PrimaryKey.KeyIdShortString() {
return p, nil
}
}
}
return e, fmt.Errorf("key %q not found", id)
}
// FindSecretEntity searches for id in the secret keyring.
func FindSecretEntity(root, id string) (*openpgp.Entity, error) {
if err := ensureDir(root); err != nil {
return nil, errors.Wrap(err, "can't find or create pgp dir")
}
srn, prn := getNames(root)
secs, _, err := getELs(srn, prn)
if err != nil {
return nil, errors.Wrap(err, "getting existing keyrings")
}
return findKey(secs, id)
}

47
meta.go Normal file
View File

@ -0,0 +1,47 @@
package pm
import (
"errors"
"fmt"
"net/url"
)
// Meta tracks metadata for a package
type Meta struct {
Name Name `json:"name"`
Version Version `json:"version"`
Description string `json:"description"`
Remote url.URL `json:"remote"`
}
// Valid validates the contents of a Meta for requires fields.
func (m Meta) Valid() (bool, error) {
if m.Name == "" {
return false, errors.New("name cannot be empty")
}
if m.Version == "" {
return false, errors.New("version cannot be empty")
}
if m.Description == "" {
return false, errors.New("description cannot be empty")
}
return true, nil
}
// Pkg returns the string name the .pkg should have on disk.
func (m Meta) Pkg() string {
return fmt.Sprintf("%s-%s.pkg", m.Name, m.Version)
}
// URL returns the http location of this package.
func (m Meta) URL() string {
return fmt.Sprintf("%s/%s", m.Remote.String(), m.Pkg())
}
func (m Meta) String() string {
return m.URL()
}
// Metas is a slice of Meta
type Metas []Meta

85
meta_test.go Normal file
View File

@ -0,0 +1,85 @@
package pm
import (
"bytes"
"encoding/json"
"errors"
"testing"
)
func TestValid(t *testing.T) {
tests := []struct {
label string
m Meta
ok bool
err error
}{
{
label: "valid",
m: Meta{
Name: "heat",
Version: "1.1.0",
Description: "some description",
},
ok: true,
},
{
label: "missing name",
m: Meta{
Version: "1.1.0",
Description: "some description",
},
err: errors.New("name"),
},
{
label: "missing version",
m: Meta{
Name: "heat",
Description: "some description",
},
err: errors.New("version"),
},
{
label: "missing description",
m: Meta{
Name: "heat",
Version: "1.1.0",
},
err: errors.New("description"),
},
}
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
ok, err := test.m.Valid()
if got, want := ok, test.ok; got != want {
t.Fatalf("validity: got %v, want %v", got, want)
}
if got, want := err, test.err; (err == nil) != (test.err == nil) {
t.Fatalf("error: got %v, want %v", got, want)
}
})
}
}
func TestJsonRoundTrip(t *testing.T) {
b := Meta{
Name: "heat",
Version: "1.1.0",
Description: "make heat using cpus",
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(&b); err != nil {
t.Fatalf("encode: %v", err)
}
a := Meta{}
if err := json.NewDecoder(buf).Decode(&a); err != nil {
t.Fatalf("decode: %v", err)
}
if b != a {
t.Fatalf("a != b: %v != %v", a, b)
}
}

359
pkg/install.go Normal file
View File

@ -0,0 +1,359 @@
package pkg
import (
"archive/tar"
"bufio"
"compress/bzip2"
"crypto/sha256"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/pkg/errors"
"mcquay.me/fs"
"mcquay.me/pm"
"mcquay.me/pm/db"
"mcquay.me/pm/keyring"
)
const cache = "var/cache/pm"
const installed = "var/lib/pm/installed"
// Install fetches and installs pkgs from appropriate remotes.
func Install(root string, pkgs []string) error {
av, err := db.LoadAvailable(root)
if err != nil {
return errors.Wrap(err, "loading available db")
}
ms, err := av.Installable(pkgs)
if err != nil {
return errors.Wrap(err, "checking ability to install")
}
cacheDir := filepath.Join(root, cache)
if !fs.Exists(cacheDir) {
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return errors.Wrap(err, "creating non-existent cache dir")
}
}
if !fs.IsDir(cacheDir) {
return errors.Errorf("%q is not a directory!", cacheDir)
}
installedDir := filepath.Join(root, installed)
if !fs.Exists(installedDir) {
if err := os.MkdirAll(installedDir, 0755); err != nil {
return errors.Wrap(err, "creating non-existent cache dir")
}
}
if !fs.IsDir(cacheDir) {
return errors.Errorf("%q is not a directory!", cacheDir)
}
if err := download(cacheDir, ms); err != nil {
return errors.Wrap(err, "downloading")
}
for _, m := range ms {
if err := install(root, m); err != nil {
return errors.Wrapf(err, "installing %v", m.Name)
}
}
return nil
}
func download(cache string, ms pm.Metas) error {
// TODO (sm): concurrently fetch
for _, m := range ms {
resp, err := http.Get(m.URL())
if err != nil {
return errors.Wrap(err, "http get")
}
fn := filepath.Join(cache, m.Pkg())
f, err := os.Create(fn)
if err != nil {
return errors.Wrap(err, "creating")
}
if n, err := io.Copy(f, resp.Body); err != nil {
return errors.Wrapf(err, "copy %q to disk after %d bytes", m.URL(), n)
}
if err := resp.Body.Close(); err != nil {
return errors.Wrap(err, "closing resp body")
}
}
return nil
}
func verifyManifestIntegrity(root string, m pm.Meta) error {
pn := filepath.Join(root, cache, m.Pkg())
man, err := getReadCloser(pn, "manifest.sha256")
if err != nil {
return errors.Wrap(err, "getting manifest reader")
}
sig, err := getReadCloser(pn, "manifest.sha256.asc")
if err != nil {
return errors.Wrap(err, "getting manifest reader")
}
if err := keyring.Verify(root, man, sig); err != nil {
return errors.Wrap(err, "verifying manifest")
}
if err := man.Close(); err != nil {
return errors.Wrap(err, "closing manifest reader")
}
if err := sig.Close(); err != nil {
return errors.Wrap(err, "closing manifest signature reader")
}
return nil
}
func expandPkgContents(root string, m pm.Meta) error {
pn := filepath.Join(root, cache, m.Pkg())
man, err := getReadCloser(pn, "manifest.sha256")
if err != nil {
return errors.Wrap(err, "getting manifest reader")
}
ip := filepath.Join(root, installed, string(m.Name))
if err := os.MkdirAll(ip, 0755); err != nil {
return errors.Wrapf(err, "making install dir for %q", m.Name)
}
cs := map[string]string{}
s := bufio.NewScanner(man)
for s.Scan() {
elems := strings.Split(s.Text(), "\t")
if len(elems) != 2 {
return errors.Errorf("manifest format error; got %d elements, want 2", len(elems))
}
cs[elems[1]] = elems[0]
}
if err := man.Close(); err != nil {
return errors.Wrap(err, "closing manifest reader")
}
if err := s.Err(); err != nil {
return errors.Wrap(err, "scanning manifest")
}
pf, err := os.Open(pn)
if err != nil {
return errors.Wrap(err, "opening pkg file")
}
tr := tar.NewReader(pf)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return errors.Wrap(err, "tar traversal")
}
if hdr.Name == "manifest.sha256" || hdr.Name == "manifest.sha256.asc" {
continue
}
if hdr.FileInfo().IsDir() {
if hdr.Name != "bin" {
return errors.Errorf("%v is unexpected", hdr.Name)
}
if err := os.MkdirAll(filepath.Join(ip, hdr.Name), hdr.FileInfo().Mode()); err != nil {
return errors.Wrapf(err, "mkdir for %v", hdr.Name)
}
continue
}
sha, ok := cs[hdr.Name]
if !ok {
return errors.Errorf("extra file %q found in tarfile!", hdr.Name)
}
name := filepath.Join(ip, hdr.Name)
sr := sha256.New()
var o io.WriteCloser
o = close{ioutil.Discard}
if hdr.Name != "root.tar.bz2" {
f, err := os.OpenFile(filepath.Join(ip, hdr.Name), os.O_WRONLY|os.O_CREATE, hdr.FileInfo().Mode())
if err != nil {
return errors.Wrap(err, "open file in install dir")
}
o = f
}
w := io.MultiWriter(o, sr)
if n, err := io.Copy(w, tr); err != nil {
return errors.Wrapf(err, "copying file %q after %v bytes", hdr.Name, n)
}
if sha != fmt.Sprintf("%x", sr.Sum(nil)) {
return errors.Errorf("%q checksum was incorrect", hdr.Name)
}
if err := o.Close(); err != nil {
return errors.Wrapf(err, "closing %v", name)
}
}
return nil
}
type tarSlurper struct {
f *os.File
tr *tar.Reader
}
func (ts *tarSlurper) Close() error {
return ts.f.Close()
}
func (ts *tarSlurper) Read(p []byte) (int, error) {
return ts.tr.Read(p)
}
func getReadCloser(tn, fn string) (io.ReadCloser, error) {
pf, err := os.Open(tn)
if err != nil {
return nil, errors.Wrap(err, "opening pkg file")
}
tr := tar.NewReader(pf)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, errors.Wrap(err, "tar traversal")
}
if hdr.Name == fn {
return &tarSlurper{pf, tr}, nil
}
}
return nil, errors.Errorf("%q not found", fn)
}
// close should be used to wrap ioutil.Discard to give it a noop Close method.
type close struct {
io.Writer
}
func (close) Close() error {
return nil
}
func script(root string, m pm.Meta, name string) error {
bin := filepath.Join(root, installed, string(m.Name), "bin", name)
if !fs.Exists(bin) {
return nil
}
cmd := exec.Command(bin)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func expandRoot(root string, m pm.Meta) error {
bomn := filepath.Join(root, installed, string(m.Name), "bom.sha256")
bf, err := os.Open(bomn)
if err != nil {
return errors.Wrap(err, "opening bom")
}
cs := map[string]string{}
s := bufio.NewScanner(bf)
for s.Scan() {
elems := strings.Split(s.Text(), "\t")
if len(elems) != 2 {
return errors.Errorf("manifest format error; got %d elements, want 2", len(elems))
}
cs[elems[1]] = elems[0]
}
pn := filepath.Join(root, cache, m.Pkg())
tbz, err := getReadCloser(pn, "root.tar.bz2")
if err != nil {
return errors.Wrap(err, "getting root.tar.bz2 reader")
}
tr := tar.NewReader(bzip2.NewReader(tbz))
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return errors.Wrap(err, "tar traversal")
}
if hdr.FileInfo().IsDir() {
d := filepath.Join(root, hdr.Name)
if err := os.MkdirAll(d, hdr.FileInfo().Mode()); err != nil {
return errors.Wrapf(err, "making directory %q", d)
}
continue
}
f, err := os.OpenFile(filepath.Join(root, hdr.Name), os.O_WRONLY|os.O_CREATE, hdr.FileInfo().Mode())
if err != nil {
return errors.Wrapf(err, "open output file %q", hdr.Name)
}
if n, err := io.Copy(f, tr); err != nil {
return errors.Wrapf(err, "copy file %q after %v bytes", hdr.Name, n)
}
if err := f.Close(); err != nil {
return errors.Wrapf(err, "closing %q", hdr.Name)
}
}
return nil
}
func install(root string, m pm.Meta) error {
defer func() {
cached := filepath.Join(root, cache, m.Pkg())
if !fs.Exists(cached) {
return
}
if err := os.Remove(cached); err != nil {
log.Printf("cleaning up cache: %v", err)
}
}()
already, err := db.IsInstalled(root, m)
if err != nil {
return errors.Wrapf(err, "is installed %v", m.Name)
}
if already {
return errors.Errorf("%v already installed!", m.Name)
}
if err := verifyManifestIntegrity(root, m); err != nil {
return errors.Wrap(err, "verifying pkg integrity")
}
if err := expandPkgContents(root, m); err != nil {
if err := os.RemoveAll(filepath.Join(root, installed, string(m.Name))); err != nil {
err = errors.Wrap(err, "cleaning up")
}
return errors.Wrap(err, "verifying pkg contents")
}
if err := script(root, m, "pre-install"); err != nil {
return errors.Wrap(err, "pre-install")
}
if err := expandRoot(root, m); err != nil {
return errors.Wrap(err, "root expansion")
}
if err := script(root, m, "post-install"); err != nil {
return errors.Wrap(err, "pre-install")
}
if err := db.AddInstalled(root, m); err != nil {
return errors.Wrapf(err, "adding ", m.Name)
}
return nil
}

243
pkg/pkg.go Normal file
View File

@ -0,0 +1,243 @@
package pkg
import (
"archive/tar"
"compress/bzip2"
"crypto/sha256"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pkg/errors"
"golang.org/x/crypto/openpgp"
yaml "gopkg.in/yaml.v2"
"mcquay.me/fs"
"mcquay.me/pm"
"mcquay.me/pm/keyring"
)
var validNames map[string]bool
var crypto []string
func init() {
validNames = map[string]bool{
"root.tar.bz2": true,
"meta.yaml": true,
"bin/pre-install": true,
"bin/post-install": true,
"bin/pre-upgrade": true,
"bin/post-upgrade": true,
"bin/pre-remove": true,
"bin/post-remove": true,
}
crypto = []string{
"bom.sha256",
"manifest.sha256",
"manifest.sha256.asc",
}
}
// Create traverses the contents of dir and emits a valid pkg, signed by id
func Create(key *openpgp.Entity, dir string) error {
if !fs.Exists(dir) {
return fmt.Errorf("%q: doesn't exist", dir)
}
if !fs.IsDir(dir) {
return fmt.Errorf("%q: is not a directory", dir)
}
if err := clean(dir); err != nil {
return errors.Wrap(err, "cleaning up directory")
}
files := []string{}
found := map[string]bool{}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if path == dir {
return nil
}
if info.IsDir() {
return nil
}
p := strings.TrimPrefix(path, dir)[1:]
if _, ok := validNames[p]; !ok {
return fmt.Errorf("unxpected filename: %q", p)
}
if strings.HasPrefix(p, "bin") && info.Mode()&0100 == 0 {
return fmt.Errorf("%q is not executable", path)
}
files = append(files, p)
found[p] = true
return nil
})
if err != nil {
return errors.Wrap(err, "checking directory contents")
}
if _, ok := found["root.tar.bz2"]; !ok {
return fmt.Errorf("did not find root.tar.bz2")
}
if _, ok := found["meta.yaml"]; !ok {
return fmt.Errorf("did not find meta.yaml")
}
mf, err := os.Open(filepath.Join(dir, "meta.yaml"))
if err != nil {
return errors.Wrap(err, "opening metadata file")
}
md := pm.Meta{}
if err := yaml.NewDecoder(mf).Decode(&md); err != nil {
return errors.Wrap(err, "decoding metadata file")
}
if _, err := md.Valid(); err != nil {
return errors.Wrap(err, "invalid metadata")
}
f, err := os.Open(filepath.Join(dir, "root.tar.bz2"))
if err != nil {
return errors.Wrap(err, "opening overlay tarball")
}
bom, err := os.Create(filepath.Join(dir, "bom.sha256"))
if err != nil {
return errors.Wrap(err, "creating bom.sha256")
}
tr := tar.NewReader(bzip2.NewReader(f))
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
errors.Wrap(err, "traversing tarball")
}
if hdr.FileInfo().IsDir() {
continue
}
s := sha256.New()
if c, err := io.Copy(s, tr); err != nil {
return errors.Wrapf(err, "copy after %d bytes", c)
}
fmt.Fprintf(bom, "%x\t%s\n", s.Sum(nil), hdr.Name)
}
if err := bom.Close(); err != nil {
return errors.Wrap(err, "closing bom")
}
files = append(files, "bom.sha256")
sort.Strings(files)
manifest, err := os.Create(filepath.Join(dir, "manifest.sha256"))
if err != nil {
return errors.Wrap(err, "creating manifest.sha256")
}
for _, fn := range files {
full := filepath.Join(dir, fn)
f, err := os.Open(full)
if err != nil {
return errors.Wrap(err, "opening file for manifest checksumming")
}
s := sha256.New()
if c, err := io.Copy(s, f); err != nil {
return errors.Wrapf(err, "copy after %d bytes", c)
}
if err := f.Close(); err != nil {
return errors.Wrapf(err, "closing %q", f.Name())
}
fmt.Fprintf(manifest, "%x\t%s\n", s.Sum(nil), fn)
}
if err := manifest.Close(); err != nil {
return errors.Wrap(err, "closing manifest")
}
sig, err := os.Create(filepath.Join(dir, "manifest.sha256.asc"))
if err != nil {
return errors.Wrap(err, "creating sig file")
}
mfi, err := os.Open(filepath.Join(dir, "manifest.sha256"))
if err != nil {
return errors.Wrap(err, "opening manifest")
}
if err := keyring.Sign(key, mfi, sig); err != nil {
return errors.Wrap(err, "signing")
}
files = append(files, "manifest.sha256")
files = append(files, "manifest.sha256.asc")
sort.Strings(files)
tn, pn := filepath.Split(dir)
tn = filepath.Join(tn, fmt.Sprintf("%v-%v.pkg", pn, md.Version))
tf, err := os.Create(tn)
if err != nil {
return errors.Wrap(err, "opening final .pkg")
}
tw := tar.NewWriter(tf)
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if path == dir {
return nil
}
p := strings.TrimPrefix(path, dir)[1:]
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return errors.Wrap(err, "file info header")
}
hdr.Name = p
if err := tw.WriteHeader(hdr); err != nil {
return errors.Wrapf(err, "writing tar header for %v", p)
}
// only need to do real writing for non-directories
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return errors.Wrap(err, "opening file for tar creation")
}
if c, err := io.Copy(tw, f); err != nil {
log.Printf("%+v", err == io.EOF)
return errors.Wrapf(err, "copy after %d bytes", c)
}
if err := f.Close(); err != nil {
return errors.Wrap(err, "closing file during tar creation")
}
return nil
})
if err != nil {
return errors.Wrap(err, "traversing the prepared directory")
}
if err := tw.Close(); err != nil {
return errors.Wrap(err, "closing tar writer")
}
if err := tf.Close(); err != nil {
return errors.Wrap(err, "closing final .pkg")
}
return nil
}
func clean(root string) error {
for _, f := range crypto {
path := filepath.Join(root, f)
if !fs.Exists(path) {
continue
}
if err := os.Remove(path); err != nil {
return errors.Wrapf(err, "removing %q", path)
}
}
return nil
}

61
pkg/remove.go Normal file
View File

@ -0,0 +1,61 @@
package pkg
import (
"os"
"path/filepath"
"github.com/pkg/errors"
"mcquay.me/pm"
"mcquay.me/pm/db"
)
// Remove uninstalls packages.
func Remove(root string, pkgs []string) error {
iDB, err := db.LoadInstalled(root)
if err != nil {
return errors.Wrap(err, "loading available db")
}
ms, err := iDB.Removable(pkgs)
if err != nil {
return errors.Wrap(err, "checking ability to remove")
}
for _, m := range ms {
if err := script(root, m, "pre-remove"); err != nil {
return errors.Wrap(err, "pre-remove")
}
mdir := filepath.Join(root, installed, string(m.Name))
bom := filepath.Join(mdir, "bom.sha256")
bf, err := os.Open(bom)
if err != nil {
return errors.Wrapf(err, "%q: opening bom", m.Name)
}
cs, err := pm.ParseCS(bf)
if err != nil {
return errors.Wrapf(err, "%q: parsing bom", m.Name)
}
for n := range cs {
if err := os.Remove(filepath.Join(root, n)); err != nil {
return errors.Wrapf(err, "pkg %q", m.Name)
}
}
if err := script(root, m, "post-remove"); err != nil {
return errors.Wrap(err, "post-remove")
}
if err := db.RemoveInstalled(root, m); err != nil {
return errors.Wrapf(err, "removing %q", m.Name)
}
if err := os.RemoveAll(mdir); err != nil {
return errors.Wrapf(err, "%q: removing pm install dir", m.Name)
}
}
return nil
}