From 360f4adf726d2a4fad06ae042288173646fc7a20 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:24:07 +0000 Subject: [PATCH 1/3] feat(cli): syspi verify checks every installed copy under lib/ against the lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline by nature and writing nothing: every usage of .project.json has a lock entry, and every lock entry's installed copy (exported tree, .kpar, local directory) is what the lock recorded — its .project.json, every checksum its .meta.json records (project.MismatchedChecksums reports them all, where VerifyChecksums stops at the first), the tree or .kpar SHA-256 and the usages it declares. Every discrepancy is printed; the exit status is 1 on any. Co-Authored-By: jason.han --- README.md | 1 + changes/unreleased/verify.added.md | 1 + cmd/syspi/main.go | 30 ++++ docs/packages.md | 8 + internal/install/libpath.go | 35 +++++ internal/install/verify.go | 226 +++++++++++++++++++++++++++++ internal/install/verify_test.go | 116 +++++++++++++++ internal/project/meta.go | 32 ++++ tests/e2e/verify_test.go | 90 ++++++++++++ 9 files changed, 539 insertions(+) create mode 100644 changes/unreleased/verify.added.md create mode 100644 internal/install/libpath.go create mode 100644 internal/install/verify.go create mode 100644 internal/install/verify_test.go create mode 100644 tests/e2e/verify_test.go diff --git a/README.md b/README.md index da22cf2..a955a77 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ syspi init [-C dir] [-publisher P] [-name N] [-version V] write .project.json syspi add [@] [] 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] deterministic .kpar; checks tag == version in a tagged checkout ``` diff --git a/changes/unreleased/verify.added.md b/changes/unreleased/verify.added.md new file mode 100644 index 0000000..a96b520 --- /dev/null +++ b/changes/unreleased/verify.added.md @@ -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. diff --git a/cmd/syspi/main.go b/cmd/syspi/main.go index 2ee2eb2..8c42448 100644 --- a/cmd/syspi/main.go +++ b/cmd/syspi/main.go @@ -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 @@ -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": @@ -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)) diff --git a/docs/packages.md b/docs/packages.md index 09daf1a..9465372 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -144,6 +144,14 @@ lib/ The directory name is `-_` (`_` 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` has a lock entry, 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 diff --git a/internal/install/libpath.go b/internal/install/libpath.go new file mode 100644 index 0000000..2fcde4c --- /dev/null +++ b/internal/install/libpath.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "path/filepath" + + "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). +func libPath(libDir string, e *lock.Entry) string { + stem := (&project.Project{Name: e.Name, Publisher: e.Publisher, Version: e.Version}).InstallName() + switch e.Kind { + case lock.KindVCS: + return filepath.Join(libDir, stem) + case lock.KindArchive: + return filepath.Join(libDir, stem+project.ArchiveExt) + } + return "" +} + +// 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) +} diff --git a/internal/install/verify.go b/internal/install/verify.go new file mode 100644 index 0000000..651988d --- /dev/null +++ b/internal/install/verify.go @@ -0,0 +1,226 @@ +// 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" +) + +// 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 { + o, err := origin.Parse(u.Resource) + if err != nil { + out = append(out, Discrepancy{Resource: u.Resource, Problem: err.Error()}) + continue + } + if lockedEntry(l, o) == nil { + out = append(out, Discrepancy{Resource: u.Resource, Problem: "usage of " + project.ProjectFile + " has no lock entry; run syspi lock"}) + } + } + libDir := filepath.Join(root, project.LibDir) + for i := range l.Packages { + e := &l.Packages[i] + 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(e)...) + } + } + return out, len(l.Packages), nil +} + +// lockedEntry finds the lock entry a usage resolves to: by repository key +// for a vcs resource, 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() { + 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)...) +} + +func verifyLocal(e *lock.Entry) []Discrepancy { + at := func(problem string) Discrepancy { + return Discrepancy{Resource: e.Resource, Path: e.Source, Problem: problem} + } + if st, err := os.Stat(e.Source); err != nil || !st.IsDir() { + return []Discrepancy{at("local directory missing")} + } + pj, err := project.ReadProject(e.Source) + 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 +} diff --git a/internal/install/verify_test.go b/internal/install/verify_test.go new file mode 100644 index 0000000..28cd6ea --- /dev/null +++ b/internal/install/verify_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Open-MBEE/SysPI/internal/project" +) + +func problems(ds []Discrepancy) string { + var s []string + for i := range ds { + s = append(s, ds[i].String()) + } + return strings.Join(s, "\n") +} + +func TestVerify(t *testing.T) { + widgets, _ := fixture(t) + wres := "git+file://" + filepath.ToSlash(widgets) + pkg := t.TempDir() + write(t, pkg, ".project.json", `{"name":"tools","publisher":"acme","version":"3.1.0"}`) + write(t, pkg, "Tools.sysml", "package Tools;\n") + kpar := filepath.Join(t.TempDir(), "acme-tools_3.1.0.kpar") + if _, err := project.Bundle(pkg, kpar, time.Unix(0, 0)); err != nil { + t.Fatal(err) + } + editable := t.TempDir() + write(t, editable, ".project.json", `{"name":"dev","version":"0.0.1"}`) + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + dir := consumer(t, + project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}, + project.Usage{Resource: kpar}, + project.Usage{Resource: editable}, + project.Usage{Resource: std}, + ) + if _, _, err := Verify(dir); err == nil || !strings.Contains(err.Error(), "no "+project.LockFile) { + t.Fatalf("verify without a lock: %v", err) + } + run(t, dir, Options{}) + ds, n, err := Verify(dir) + if err != nil || n != 5 || len(ds) != 0 { + t.Fatalf("fresh install: %d checked, %v\n%s", n, err, problems(ds)) + } + lockBefore, _ := os.ReadFile(filepath.Join(dir, project.LockFile)) + + // Every kind of discrepancy at once, each reported, nothing repaired. + write(t, dir, "lib/acme-widgets_1.1.0/Widgets.sysml", "package Widgets { part def Gadget; }\n") + write(t, dir, "lib/acme-widgets_1.1.0/Extra.sysml", "package Extra;\n") + write(t, dir, "lib/base_2.1.0/.project.json", `{"name":"base","version":"2.1.1","usage":[{"resource":"`+std+`"}]}`+"\n") + tampered := tamper(t, filepath.Join(dir, "lib", "acme-tools_3.1.0.kpar")) + os.Rename(tampered, filepath.Join(dir, "lib", "acme-tools_3.1.0.kpar")) + os.RemoveAll(editable) + p, _ := project.ReadProject(dir) + p.Usage = append(p.Usage, project.Usage{Resource: "https://www.omg.org/spec/KerML/20250201/Semantic-Library.kpar"}) + p.Write(dir) + + ds, n, err = Verify(dir) + if err != nil || n != 5 { + t.Fatalf("verify: %d checked, %v", n, err) + } + got := problems(ds) + for _, want := range []string{ + "https://www.omg.org/spec/KerML/20250201/Semantic-Library.kpar: usage of .project.json has no lock entry; run syspi lock", + "lib/acme-widgets_1.1.0: tree SHA-256 ", + "lib/acme-widgets_1.1.0: checksum mismatch for Widgets.sysml", + "lib/base_2.1.0: tree SHA-256 ", + "lib/base_2.1.0: .project.json says base 2.1.1, the lock base 2.1.0", + "lib/base_2.1.0: declares usage " + std + ", which the lock does not record", + "lib/acme-tools_3.1.0.kpar: SHA-256 ", + "lib/acme-tools_3.1.0.kpar: checksum mismatch for ", + "lib/base_2.1.0: checksum mismatch for .project.json", + editable + ": local directory missing", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q", want) + } + } + if len(ds) != 10 { + t.Errorf("%d discrepancies:\n%s", len(ds), got) + } + // Extra.sysml has no recorded checksum, so only the tree hash tells of it. + if strings.Contains(got, "Extra.sysml") { + t.Errorf("unrecorded member reported as a checksum problem:\n%s", got) + } + // The unlocked usage comes first, then the entries in resource order. + if ds[0].Path != "" { + t.Errorf("first discrepancy is not the unlocked usage: %+v", ds[0]) + } + for i := 2; i < len(ds); i++ { + if ds[i-1].Resource > ds[i].Resource { + t.Errorf("not in resource order: %q after %q", ds[i].Resource, ds[i-1].Resource) + } + } + if lockAfter, _ := os.ReadFile(filepath.Join(dir, project.LockFile)); string(lockAfter) != string(lockBefore) { + t.Error("verify wrote the lock") + } + if data, _ := os.ReadFile(filepath.Join(dir, "lib", "acme-widgets_1.1.0", "Widgets.sysml")); !strings.Contains(string(data), "Gadget") { + t.Error("verify repaired lib/") + } + + // A missing tree is one discrepancy, not a crash; a missing .meta.json + // of a tag that shipped one is reported. + os.RemoveAll(filepath.Join(dir, "lib", "base_2.1.0")) + os.Remove(filepath.Join(dir, "lib", "acme-widgets_1.1.0", ".meta.json")) + ds, _, _ = Verify(dir) + got = problems(ds) + if !strings.Contains(got, "lib/base_2.1.0: installed copy missing") || !strings.Contains(got, "lib/acme-widgets_1.1.0: .meta.json missing") { + t.Errorf("after removals:\n%s", got) + } +} diff --git a/internal/project/meta.go b/internal/project/meta.go index d02c63b..ac75e6f 100644 --- a/internal/project/meta.go +++ b/internal/project/meta.go @@ -288,3 +288,35 @@ func checkSums(rel string, data []byte, sums Checksums) error { } return nil } + +// MismatchedChecksums lists every member whose recorded checksum does not +// match the file in dir, and every recorded member that is missing — all of +// them, where VerifyChecksums stops at the first. +func MismatchedChecksums(dir string, m *Meta) ([]string, error) { + members, err := Members(dir) + if err != nil { + return nil, err + } + have := map[string]bool{} + var bad []string + for _, rel := range members { + have[rel] = true + sums, ok := m.Checksum[rel] + if !ok || !sums.Recorded() { + continue + } + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + if err != nil { + return nil, err + } + if err := checkSums(rel, data, sums); err != nil { + bad = append(bad, err.Error()) + } + } + for _, rel := range sortedKeys(m.Checksum) { + if !have[rel] { + bad = append(bad, MetaFile+" records a checksum for "+rel+", which is missing") + } + } + return bad, nil +} diff --git a/tests/e2e/verify_test.go b/tests/e2e/verify_test.go new file mode 100644 index 0000000..a479d15 --- /dev/null +++ b/tests/e2e/verify_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestVerify(t *testing.T) { + bin := syspiBinary(t) + root := t.TempDir() + gitRes := "git+file://" + filepath.ToSlash(gitFixture(t, root)) + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + + app := filepath.Join(root, "app") + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + if out, err := shErr(app, nil, bin, "verify"); err == nil || !strings.Contains(out, "no .project.lock.json") { + t.Fatalf("verify before lock: %v\n%s", err, out) + } + sh(t, app, bin, "add", gitRes+"@1.0.0") + sh(t, app, bin, "add", gitRes+"#packages/gears", "~1.1.0") + sh(t, app, bin, "add", std) + + out := sh(t, app, bin, "verify", "-offline") + if out != "3 package(s) checked against .project.lock.json: no discrepancy\n" { + t.Fatalf("verify:\n%s", out) + } + if out := sh(t, app, bin, "verify", "-q"); out != "" { + t.Fatalf("verify -q:\n%s", out) + } + + lockBefore, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")) + write(t, app, "lib/acme-gears_1.1.0/Gears.sysml", "package Gears { part def Cog; }\n") + os.RemoveAll(filepath.Join(app, "lib", "acme-widgets_1.0.0")) + out, err := shErr(app, nil, bin, "verify") + if err == nil { + t.Fatalf("verify passed on a tampered lib/:\n%s", out) + } + for _, want := range []string{ + "lib/acme-gears_1.1.0: tree SHA-256 ", + "lib/acme-gears_1.1.0: checksum mismatch for Gears.sysml", + "lib/acme-widgets_1.0.0: installed copy missing", + "syspi: 3 package(s) checked against .project.lock.json: 3 discrepancy(ies)", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } + if lockAfter, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")); string(lockAfter) != string(lockBefore) { + t.Error("verify wrote the lock") + } + if _, err := os.Stat(filepath.Join(app, "lib", "acme-widgets_1.0.0")); err == nil { + t.Error("verify reinstalled lib/acme-widgets_1.0.0") + } + // install repairs what verify reported; verify then passes. + sh(t, app, bin, "install") + if out := sh(t, app, bin, "verify", "-C", app); !strings.HasSuffix(out, "no discrepancy\n") { + t.Fatalf("after install:\n%s", out) + } + if out, err := shErr(app, nil, bin, "verify", "extra"); err == nil || !strings.Contains(out, "Usage: syspi verify") { + t.Fatalf("verify with an argument: %v\n%s", err, out) + } +} + +func TestVerifyAcrossVCS(t *testing.T) { + requireOrSkip(t, "hg", "svn", "svnadmin") + bin := syspiBinary(t) + root := t.TempDir() + for name, res := range map[string]string{ + "git": "git+file://" + filepath.ToSlash(gitFixture(t, root)), + "hg": "hg+file://" + filepath.ToSlash(hgFixture(t, root)), + "svn": "svn+file://" + strings.TrimPrefix(svnFixture(t, root), "file://"), + } { + app := filepath.Join(root, "app-"+name) + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + sh(t, app, bin, "add", res+"@1.1.0") + if out := sh(t, app, bin, "verify"); !strings.HasSuffix(out, "no discrepancy\n") { + t.Errorf("%s:\n%s", name, out) + } + os.Remove(filepath.Join(app, "lib", "acme-widgets_1.1.0", "Widgets.sysml")) + if out, err := shErr(app, nil, bin, "verify"); err == nil || !strings.Contains(out, "records a checksum for Widgets.sysml, which is missing") { + t.Errorf("%s after deleting a member: %v\n%s", name, err, out) + } + } +} From 6230c93426dd97935374e34b263a90f1ac764240 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:29:43 +0000 Subject: [PATCH 2/3] fix(install): a lock entry names only a direct child of lib/ Co-Authored-By: jason.han --- internal/install/libpath.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/install/libpath.go b/internal/install/libpath.go index 2fcde4c..aac9cf4 100644 --- a/internal/install/libpath.go +++ b/internal/install/libpath.go @@ -4,6 +4,7 @@ package install import ( "path/filepath" + "strings" "github.com/Open-MBEE/SysPI/internal/digest" "github.com/Open-MBEE/SysPI/internal/lock" @@ -12,16 +13,26 @@ import ( // 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). +// 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: - return filepath.Join(libDir, stem) + p = filepath.Join(libDir, stem) case lock.KindArchive: - return filepath.Join(libDir, stem+project.ArchiveExt) + p = filepath.Join(libDir, stem+project.ArchiveExt) + default: + return "" + } + if filepath.Dir(p) != filepath.Clean(libDir) { + return "" } - return "" + return p } // installedMatches reports whether the copy at p is the one the lock entry From 57ccfb144760a2ab308f1e439c22e09c09c218cb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:38:25 +0000 Subject: [PATCH 3/3] fix(install): verify checks each usage's constraint and location against its lock entry, the locked packages' usages too A usage whose constraint the lock entry no longer satisfies, a usage a locked package recorded that the lock does not cover, or a repository at another location (repo vs repo.git) spelled the same way all passed; each is now a discrepancy, as for the resolver. Co-Authored-By: jason.han --- docs/packages.md | 4 ++- internal/install/verify.go | 59 +++++++++++++++++++++++++-------- internal/install/verify_test.go | 58 ++++++++++++++++++++++++++------ 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/docs/packages.md b/docs/packages.md index 9465372..39daacc 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -145,7 +145,9 @@ The directory name is `-_` (`_` without 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` has a lock entry, and every lock entry's +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 diff --git a/internal/install/verify.go b/internal/install/verify.go index 651988d..b070bf3 100644 --- a/internal/install/verify.go +++ b/internal/install/verify.go @@ -13,6 +13,7 @@ import ( "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 @@ -56,32 +57,56 @@ func Verify(root string) ([]Discrepancy, int, error) { 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 { - o, err := origin.Parse(u.Resource) - if err != nil { - out = append(out, Discrepancy{Resource: u.Resource, Problem: err.Error()}) - continue - } - if lockedEntry(l, o) == nil { - out = append(out, Discrepancy{Resource: u.Resource, Problem: "usage of " + project.ProjectFile + " has no lock entry; run syspi lock"}) - } + 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(e)...) + 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 -// for a vcs resource, by the resource itself otherwise. +// 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()) @@ -91,7 +116,7 @@ func lockedEntry(l *lock.Lock, o *origin.Origin) *lock.Entry { if e.Kind != lock.KindVCS { continue } - if eo, err := origin.Parse(e.Resource); err == nil && eo.Key() == o.Key() { + if eo, err := origin.Parse(e.Resource); err == nil && eo.Key() == o.Key() && (e.Source == "" || locationKey(e.Source) == locationKey(o.Source)) { return e } } @@ -156,14 +181,20 @@ func verifyArchive(root, file string, e *lock.Entry) []Discrepancy { return append(out, verifyProject(at, a.Project, e)...) } -func verifyLocal(e *lock.Entry) []Discrepancy { +// 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(e.Source); err != nil || !st.IsDir() { + if st, err := os.Stat(dir); err != nil || !st.IsDir() { return []Discrepancy{at("local directory missing")} } - pj, err := project.ReadProject(e.Source) + pj, err := project.ReadProject(dir) if err != nil { return []Discrepancy{at(err.Error())} } diff --git a/internal/install/verify_test.go b/internal/install/verify_test.go index 28cd6ea..5028f6d 100644 --- a/internal/install/verify_test.go +++ b/internal/install/verify_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/Open-MBEE/SysPI/internal/lock" "github.com/Open-MBEE/SysPI/internal/project" ) @@ -21,7 +22,7 @@ func problems(ds []Discrepancy) string { } func TestVerify(t *testing.T) { - widgets, _ := fixture(t) + widgets, base := fixture(t) wres := "git+file://" + filepath.ToSlash(widgets) pkg := t.TempDir() write(t, pkg, ".project.json", `{"name":"tools","publisher":"acme","version":"3.1.0"}`) @@ -58,15 +59,26 @@ func TestVerify(t *testing.T) { os.RemoveAll(editable) p, _ := project.ReadProject(dir) p.Usage = append(p.Usage, project.Usage{Resource: "https://www.omg.org/spec/KerML/20250201/Semantic-Library.kpar"}) + // The widgets usage now wants a version the lock entry is not at, and a + // second spelling of the repository (with .git dropped) names another location. + p.Usage[0].VersionConstraint = "^1.2.0" + p.Usage = append(p.Usage, project.Usage{Resource: strings.TrimSuffix(wres, ".git") + "@1.1.0"}) p.Write(dir) + // A locked package's own usage the lock no longer covers. + lk, _, _ := lock.Read(dir, project.LockFile) + lockedWidgets := lk.Find(ident(t, wres)) + lk.Put(lock.Entry{Resource: "pkg:generic/other?vcs_url=git%2Bfile:%2F%2F%2Fno%2Fother", Kind: lock.KindVCS, Name: "other", Version: "1.0.0", VCS: "git", Source: "file:///no/other", SHA256: "0", Usage: []lock.Usage{{Resource: "git+file:///no/such", VersionConstraint: "^1.0.0"}, {Resource: "git+file://" + filepath.ToSlash(base), VersionConstraint: "^3.0.0"}}}) + os.WriteFile(filepath.Join(dir, project.LockFile), lk.Marshal(), 0o644) + lockBefore, _ = os.ReadFile(filepath.Join(dir, project.LockFile)) + _ = lockedWidgets ds, n, err = Verify(dir) - if err != nil || n != 5 { + if err != nil || n != 6 { t.Fatalf("verify: %d checked, %v", n, err) } got := problems(ds) for _, want := range []string{ - "https://www.omg.org/spec/KerML/20250201/Semantic-Library.kpar: usage of .project.json has no lock entry; run syspi lock", + ".project.json: usage of .project.json https://www.omg.org/spec/KerML/20250201/Semantic-Library.kpar: no lock entry; run syspi lock", "lib/acme-widgets_1.1.0: tree SHA-256 ", "lib/acme-widgets_1.1.0: checksum mismatch for Widgets.sysml", "lib/base_2.1.0: tree SHA-256 ", @@ -76,26 +88,36 @@ func TestVerify(t *testing.T) { "lib/acme-tools_3.1.0.kpar: checksum mismatch for ", "lib/base_2.1.0: checksum mismatch for .project.json", editable + ": local directory missing", + ".project.json: usage of .project.json " + wres + ": the lock entry is at 1.1.0, the usage wants ^1.2.0", + ".project.json: usage of .project.json " + strings.TrimSuffix(wres, ".git") + "@1.1.0: no lock entry; run syspi lock", + "lib/other_1.0.0: installed copy missing", + "pkg:generic/other?vcs_url=git%2Bfile:%2F%2F%2Fno%2Fother: usage of pkg:generic/other?vcs_url=git%2Bfile:%2F%2F%2Fno%2Fother git+file:///no/such: no lock entry; run syspi lock", + "pkg:generic/other?vcs_url=git%2Bfile:%2F%2F%2Fno%2Fother: usage of pkg:generic/other?vcs_url=git%2Bfile:%2F%2F%2Fno%2Fother git+file://" + filepath.ToSlash(base) + ": the lock entry is at 2.1.0, the usage wants ^3.0.0", } { if !strings.Contains(got, want) { t.Errorf("missing %q", want) } } - if len(ds) != 10 { + if len(ds) != 15 { t.Errorf("%d discrepancies:\n%s", len(ds), got) } // Extra.sysml has no recorded checksum, so only the tree hash tells of it. if strings.Contains(got, "Extra.sysml") { t.Errorf("unrecorded member reported as a checksum problem:\n%s", got) } - // The unlocked usage comes first, then the entries in resource order. - if ds[0].Path != "" { - t.Errorf("first discrepancy is not the unlocked usage: %+v", ds[0]) - } - for i := 2; i < len(ds); i++ { - if ds[i-1].Resource > ds[i].Resource { + // The project's usages come first, then the entries in resource order. + var seenEntry bool + for i := range ds { + if ds[i].Resource == project.ProjectFile { + if seenEntry { + t.Errorf("project usage after an entry: %+v", ds[i]) + } + continue + } + if seenEntry && ds[i-1].Resource > ds[i].Resource { t.Errorf("not in resource order: %q after %q", ds[i].Resource, ds[i-1].Resource) } + seenEntry = true } if lockAfter, _ := os.ReadFile(filepath.Join(dir, project.LockFile)); string(lockAfter) != string(lockBefore) { t.Error("verify wrote the lock") @@ -114,3 +136,19 @@ func TestVerify(t *testing.T) { t.Errorf("after removals:\n%s", got) } } + +// A relative local directory is read from the project root, as the resolver reads it. +func TestVerifyRelativeLocalDir(t *testing.T) { + dir := consumer(t, project.Usage{Resource: "pkgs/dev"}) + write(t, dir, "pkgs/dev/.project.json", `{"name":"dev","version":"0.0.1"}`) + run(t, dir, Options{}) + wd, _ := os.Getwd() + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatal(err) + } + defer os.Chdir(wd) + ds, _, err := Verify(dir) + if err != nil || len(ds) != 0 { + t.Fatalf("%v\n%s", err, problems(ds)) + } +}