Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ syspi init [-C dir] [-publisher P] [-name N] [-version V] write .project.json
syspi add <resource>[@<version>] [<constraint>] add a usage, resolve, install, lock
syspi install | sync resolve .project.json + lock, install what is missing or changed
syspi lock write .project.lock.json without installing
syspi verify check lib/ against the lock, offline; exit 1 on any discrepancy, write nothing
syspi bundle [-o out.kpar] [-tag T] <dir> deterministic .kpar; checks tag == version in a tagged checkout
```

Expand Down
1 change: 1 addition & 0 deletions changes/unreleased/verify.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `syspi verify` checks every lock entry's installed copy under `lib/` against the lock — `.project.json`, the checksums `.meta.json` records, the tree or `.kpar` SHA-256, the usages it declares — and that every usage of `.project.json` is locked; it prints every discrepancy, exits 1 on any, contacts no repository and writes nothing.
30 changes: 30 additions & 0 deletions cmd/syspi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Commands:
install resolve .project.json and the lock, install what is missing or changed
sync same as install
lock resolve and write .project.lock.json without installing
verify check every installed copy under lib/ against the lock; exit 1 on a discrepancy
bundle build a deterministic .kpar from a project directory
version print the version

Expand Down Expand Up @@ -69,6 +70,8 @@ func run(args []string, stdout, stderr io.Writer) int {
err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, false)
case "lock":
err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, true)
case "verify":
err = cmdVerify(args[1:], stdout, stderr)
case "bundle":
err = cmdBundle(ctx, args[1:], stdout, stderr)
case "version", "-version", "--version":
Expand Down Expand Up @@ -259,6 +262,33 @@ func cmdInstall(ctx context.Context, name string, args []string, stdout, stderr
return nil
}

func cmdVerify(args []string, stdout, stderr io.Writer) error {
var c commonFlags
fs := newFlagSet("verify", stderr, "[flags]")
c.bind(fs)
if err := parse(fs, args); err != nil {
return err
}
if fs.NArg() != 0 {
fs.Usage()
return errUsage
}
ds, n, err := install.Verify(c.dir)
if err != nil {
return err
}
for i := range ds {
fmt.Fprintln(stdout, ds[i].String())
}
if len(ds) > 0 {
return fmt.Errorf("%d package(s) checked against %s: %d discrepancy(ies)", n, project.LockFile, len(ds))
}
if !c.quiet {
fmt.Fprintf(stdout, "%d package(s) checked against %s: no discrepancy\n", n, project.LockFile)
}
return nil
}

func report(w io.Writer, res *install.Result) {
n := len(res.Lock.Packages)
fmt.Fprintf(w, "%d package(s) locked: %d installed, %d already present, %d not installed (standard libraries and local directories)", n, len(res.Installed), len(res.Reused), len(res.Skipped))
Expand Down
10 changes: 10 additions & 0 deletions docs/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ lib/
The directory name is `<publisher>-<name>_<version>` (`<name>_<version>` without a publisher),
the convention OpenSysML reads.

`syspi verify` checks the project against its lock without contacting a repository and without
writing anything: every usage of `.project.json`, and every usage a lock entry recorded, has a
lock entry at a version its constraint accepts (the same repository at the same location, so
`repo` and `repo.git` differ as they do for the resolver), and every lock entry's
installed copy — the exported tree or `.kpar` under `lib/`, the local directory read in place —
is what the lock recorded: its `.project.json` (publisher, name, version), every checksum its
`.meta.json` records, the tree hash or `.kpar` SHA-256, and the usages it declares against the
`usage` the lock entry recorded. Every discrepancy is printed, one per line, and the exit status
is 1 when there is any. Standard libraries are never installed, so there is nothing to check.

## Bundle reproducibility

`syspi bundle` writes members sorted with fixed 1980-01-01 ZIP timestamps and regenerates
Expand Down
46 changes: 46 additions & 0 deletions internal/install/libpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0

package install

import (
"path/filepath"
"strings"

"github.com/Open-MBEE/SysPI/internal/digest"
"github.com/Open-MBEE/SysPI/internal/lock"
"github.com/Open-MBEE/SysPI/internal/project"
)

// libPath is where under libDir the lock entry's installed copy lives: the
// exported tree of a vcs entry, the .kpar of an archive entry; "" for a
// package that is never installed (a local directory, a standard library)
// or whose name would not be a direct child of libDir.
func libPath(libDir string, e *lock.Entry) string {
stem := (&project.Project{Name: e.Name, Publisher: e.Publisher, Version: e.Version}).InstallName()
if stem == "." || stem == ".." || strings.ContainsAny(stem, `/\`) {
return ""
}
var p string
switch e.Kind {
case lock.KindVCS:
p = filepath.Join(libDir, stem)
case lock.KindArchive:
p = filepath.Join(libDir, stem+project.ArchiveExt)
default:
return ""
}
if filepath.Dir(p) != filepath.Clean(libDir) {
return ""
}
return p
}

// installedMatches reports whether the copy at p is the one the lock entry
// describes: the locked tree digest, or the locked .kpar digest.
func installedMatches(p string, e *lock.Entry) bool {
if e.Kind == lock.KindArchive {
cur, err := digest.File(p)
return err == nil && cur == e.SHA256
}
return installedTreeMatches(p, e)
}
257 changes: 257 additions & 0 deletions internal/install/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// SPDX-License-Identifier: Apache-2.0

package install

import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/Open-MBEE/SysPI/internal/digest"
"github.com/Open-MBEE/SysPI/internal/lock"
"github.com/Open-MBEE/SysPI/internal/origin"
"github.com/Open-MBEE/SysPI/internal/project"
"github.com/Open-MBEE/SysPI/internal/version"
)

// Discrepancy is one way the project disagrees with its lock: an installed
// copy under lib/ that is not what the lock recorded, or a usage the lock
// does not cover.
type Discrepancy struct {
// Resource is the lock entry concerned, or the usage of .project.json.
Resource string `json:"resource"`
// Path is the installed copy, relative to the project root; empty when
// the discrepancy is not about a file.
Path string `json:"path,omitempty"`
Problem string `json:"problem"`
}

func (d *Discrepancy) String() string {
if d.Path != "" {
return d.Path + ": " + d.Problem
}
return d.Resource + ": " + d.Problem
}

// Verify checks the project at root against its lock without contacting a
// repository and without writing anything: every usage of .project.json has
// a lock entry, and every lock entry's installed copy under lib/ (or local
// directory) is what the lock recorded — its .project.json, the checksums
// its .meta.json records, its tree or .kpar SHA-256 and the usages it
// declares. It returns every discrepancy, in resource order, and the number
// of lock entries checked.
func Verify(root string) ([]Discrepancy, int, error) {
proj, err := project.ReadProject(root)
if err != nil {
return nil, 0, err
}
l, ok, err := lock.Read(root, project.LockFile)
if err != nil {
return nil, 0, err
}
if !ok {
return nil, 0, fmt.Errorf("%s: no %s; run syspi lock or syspi install first", root, project.LockFile)
}
sort.Slice(l.Packages, func(i, j int) bool { return l.Packages[i].Resource < l.Packages[j].Resource })
var out []Discrepancy
for _, u := range proj.Usage {
out = append(out, verifyUsage(l, project.ProjectFile, "usage of "+project.ProjectFile, u)...)
}
libDir := filepath.Join(root, project.LibDir)
for i := range l.Packages {
e := &l.Packages[i]
for _, u := range e.Usage {
out = append(out, verifyUsage(l, e.Resource, "usage of "+e.Resource, project.Usage{Resource: u.Resource, VersionConstraint: u.VersionConstraint})...)
}
switch e.Kind {
case lock.KindVCS:
out = append(out, verifyTree(root, libPath(libDir, e), e)...)
case lock.KindArchive:
out = append(out, verifyArchive(root, libPath(libDir, e), e)...)
case lock.KindLocal:
out = append(out, verifyLocal(root, e)...)
}
}
return out, len(l.Packages), nil
}

// verifyUsage checks that a declared usage — of the project, or of a locked
// package — has a lock entry at a version its constraint accepts.
func verifyUsage(l *lock.Lock, requester, what string, u project.Usage) []Discrepancy {
at := func(problem string) []Discrepancy {
return []Discrepancy{{Resource: requester, Problem: what + " " + u.Resource + ": " + problem}}
}
o, err := origin.Parse(u.Resource)
if err != nil {
return at(err.Error())
}
e := lockedEntry(l, o)
if e == nil {
return at("no lock entry; run syspi lock")
}
c, err := usageConstraint(u)
if err != nil {
return at(err.Error())
}
if c.IsZero() || e.Version == "" {
return nil
}
if v, err := version.Parse(e.Version); err != nil || !c.Check(v) {
return at("the lock entry is at " + e.Version + ", the usage wants " + c.String())
}
return nil
}

// lockedEntry finds the lock entry a usage resolves to: by repository key
// and recorded location for a vcs resource (repo and repo.git differ, as
// the resolver has it), by the resource itself otherwise.
func lockedEntry(l *lock.Lock, o *origin.Origin) *lock.Entry {
if o.Kind != origin.VCS {
return l.Find(o.Identity())
}
for i := range l.Packages {
e := &l.Packages[i]
if e.Kind != lock.KindVCS {
continue
}
if eo, err := origin.Parse(e.Resource); err == nil && eo.Key() == o.Key() && (e.Source == "" || locationKey(e.Source) == locationKey(o.Source)) {
return e
}
}
return nil
}

func verifyTree(root, dir string, e *lock.Entry) []Discrepancy {
p := rel(root, dir)
at := func(problem string) Discrepancy { return Discrepancy{Resource: e.Resource, Path: p, Problem: problem} }
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return []Discrepancy{at("installed copy missing")}
}
var out []Discrepancy
var skip []string
if e.MetaGenerated {
skip = append(skip, project.MetaFile)
}
if d, err := digest.TreeExcept(dir, skip...); err != nil {
out = append(out, at(err.Error()))
} else if d != e.SHA256 {
out = append(out, at("tree SHA-256 "+d+" differs from the locked "+e.SHA256))
}
meta, hasMeta, err := project.ReadMeta(dir)
switch {
case err != nil:
out = append(out, at(err.Error()))
case !hasMeta:
out = append(out, at(project.MetaFile+" missing"))
default:
bad, err := project.MismatchedChecksums(dir, meta)
if err != nil {
out = append(out, at(err.Error()))
}
for _, b := range bad {
out = append(out, at(b))
}
}
pj, err := project.ReadProject(dir)
if err != nil {
return append(out, at(err.Error()))
}
return append(out, verifyProject(at, pj, e)...)
}

func verifyArchive(root, file string, e *lock.Entry) []Discrepancy {
p := rel(root, file)
at := func(problem string) Discrepancy { return Discrepancy{Resource: e.Resource, Path: p, Problem: problem} }
if st, err := os.Stat(file); err != nil || st.IsDir() {
return []Discrepancy{at("installed copy missing")}
}
var out []Discrepancy
if d, err := digest.File(file); err != nil {
out = append(out, at(err.Error()))
} else if d != e.SHA256 {
out = append(out, at("SHA-256 "+d+" differs from the locked "+e.SHA256))
}
a, err := project.OpenArchive(file)
if err != nil {
return append(out, at(strings.TrimPrefix(err.Error(), file+": ")))
}
defer a.Close()
return append(out, verifyProject(at, a.Project, e)...)
}

// verifyLocal reads the local directory as the resolver does: a relative
// source is relative to the project root.
func verifyLocal(root string, e *lock.Entry) []Discrepancy {
dir := e.Source
if !filepath.IsAbs(dir) {
dir = filepath.Join(root, filepath.FromSlash(dir))
}
at := func(problem string) Discrepancy {
return Discrepancy{Resource: e.Resource, Path: e.Source, Problem: problem}
}
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return []Discrepancy{at("local directory missing")}
}
pj, err := project.ReadProject(dir)
if err != nil {
return []Discrepancy{at(err.Error())}
}
return verifyProject(at, pj, e)
}

// verifyProject compares the .project.json of an installed copy with what
// the lock recorded of it: publisher, name, version and the declared usages.
func verifyProject(at func(string) Discrepancy, p *project.Project, e *lock.Entry) []Discrepancy {
var out []Discrepancy
if p.Publisher != e.Publisher || p.Name != e.Name || p.Version != e.Version {
out = append(out, at(fmt.Sprintf("%s says %s %s, the lock %s %s", project.ProjectFile, installName(p.Publisher, p.Name), p.Version, installName(e.Publisher, e.Name), e.Version)))
}
declared := make([]string, 0, len(p.Usage))
for _, u := range p.Usage {
declared = append(declared, usageText(u.Resource, u.VersionConstraint))
}
locked := make([]string, 0, len(e.Usage))
for _, u := range e.Usage {
locked = append(locked, usageText(u.Resource, u.VersionConstraint))
}
sort.Strings(declared)
sort.Strings(locked)
for _, u := range missingFrom(declared, locked) {
out = append(out, at("declares usage "+u+", which the lock does not record"))
}
for _, u := range missingFrom(locked, declared) {
out = append(out, at("the lock records usage "+u+", which "+project.ProjectFile+" no longer declares"))
}
return out
}

func installName(publisher, name string) string {
if publisher == "" {
return name
}
return publisher + "/" + name
}

func usageText(resource, constraint string) string {
if constraint == "" {
return resource
}
return resource + " " + constraint
}

// missingFrom lists the sorted strings of a that b lacks.
func missingFrom(a, b []string) []string {
have := map[string]bool{}
for _, s := range b {
have[s] = true
}
var out []string
for _, s := range a {
if !have[s] {
out = append(out, s)
}
}
return out
}
Loading
Loading