mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-10-13 17:05:07 +00:00
GetPermissionFromContext cannot fail. When no middleware put a *DataPermission in the context it returns the zero value, and the zero value's DataScope is the empty string - which is not one of the five scopes Permission recognises, so it takes the default branch and fails closed. The query is handed `1 = 0` and matches nothing. The endpoint then reports "not found" or "no permission" for rows that plainly exist, and only where enabledp is true. With data permissions off - the repository default - Permission returns the query untouched and the missing middleware costs nothing at all. A test suite and a CI that run on the default cannot see it. That is what happened to /api/v1/getinfo: it read the permission on a group carrying only the JWT middleware, so every login on a deployment with data permissions enabled ended in a 401 from the endpoint the browser calls immediately after signing in, and went back to the login page. Three /sys-api routes had the same shape. The check matches a handler to the group it is registered on, through the AST rather than through the text - a scratch grep for the same thing reported four false positives from a comment that happened to contain the function's name, and before that, a dozen from matching handler names across packages. Handlers are keyed by package, type and method, so two SysUser types are two handlers. Subgroups inherit their parent's chain, as gin does, and a `.Use` written below a registration still counts, because the chain is assembled before anything is served. Either half is a fix and the message says both, because which one is right depends on the route. A handler reading other people's rows wants the middleware. A handler reading the caller's own row - id from the token - wants no scope at all: DataScopeSelf matches on create_by, so scoping a self-read rejects every user who did not create their own account. Reporting only "add the middleware" would have turned /getinfo from broken into worse. Five tests: the mistake, both fixes, subgroup inheritance, and a same-named handler in another package. TestThisRepositoryIsClean covers the real tree, and it is what fails on the commit before this one - four findings, all real.
570 lines
20 KiB
Go
570 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/token"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Check names. They appear in every message and in the CI log, so they are
|
|
// what people will search for.
|
|
const (
|
|
checkModelTimeMix = "modeltime-mix"
|
|
checkMenuSort = "menu-sort-overflow"
|
|
checkMenuName = "menu-name-mismatch"
|
|
checkConfigValue = "config-value-truncation"
|
|
checkMenuIDConflict = "menu-id-collision"
|
|
checkImportBoundary = "contract-import-boundary"
|
|
checkShimAlias = "contract-shim-alias"
|
|
checkDataScopeRoute = "datascope-route-unguarded"
|
|
)
|
|
|
|
// Package paths, relative to the module. Spelled once so a module rename
|
|
// touches one place.
|
|
const (
|
|
pkgFrozenModels = "cmd/migrate/migration/models"
|
|
pkgRuntimeModel = "common/models"
|
|
pkgAdminModels = "app/admin/models"
|
|
)
|
|
|
|
// options are the run-time knobs. Only the frontend directory is one: every
|
|
// other check either applies or does not, with nothing to configure.
|
|
type options struct {
|
|
// UIDir is the go-admin-ui src directory. Empty disables checkMenuName,
|
|
// which is the only check that needs a second repository.
|
|
UIDir string
|
|
}
|
|
|
|
// runChecks runs every check over one parse of the tree.
|
|
func runChecks(s *snapshot, opt options) ([]Finding, error) {
|
|
var out []Finding
|
|
out = append(out, checkModelTimeMixing(s)...)
|
|
out = append(out, checkMenuSortOverflow(s)...)
|
|
out = append(out, checkConfigValueLength(s)...)
|
|
out = append(out, checkMenuIDCollisions(s)...)
|
|
out = append(out, checkContractImportBoundary(s)...)
|
|
out = append(out, checkContractShimAlias(s)...)
|
|
out = append(out, checkDataScopeRoutes(s)...)
|
|
|
|
if opt.UIDir != "" {
|
|
fs, err := checkMenuNames(s, opt.UIDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, fs...)
|
|
}
|
|
|
|
sortFindings(out)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *snapshot) pkg(rel string) string { return s.ModulePath + "/" + rel }
|
|
|
|
func (s *snapshot) finding(sev Severity, check string, sf *sourceFile, pos posLike, format string, args ...interface{}) Finding {
|
|
file, line, col := s.Pos(sf, pos.Pos())
|
|
return Finding{
|
|
Check: check,
|
|
Severity: sev.String(),
|
|
File: file,
|
|
Line: line,
|
|
Col: col,
|
|
Message: fmt.Sprintf(format, args...),
|
|
severity: sev,
|
|
}
|
|
}
|
|
|
|
type posLike interface{ Pos() token.Pos }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// check 1: the two ModelTime flavours
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// checkModelTimeMixing reports code that mixes the repository's two soft-delete
|
|
// shapes.
|
|
//
|
|
// cmd/migrate/migration/models.ModelTime declares a nullable gorm.DeletedAt;
|
|
// common/models.ModelTime declares the NOT NULL millisecond marker. Mixing them
|
|
// on one table is not a compile error and not a run-time error either: gorm
|
|
// scopes the nullable flavour as "WHERE deleted_at IS NULL" while live rows hold
|
|
// 0, so every row of the table becomes invisible and the feature reading it
|
|
// simply returns nothing. sys_columns and sys_tables sat in that state until
|
|
// 1786700004000 - the code generator listed no tables at all and reported no
|
|
// error.
|
|
//
|
|
// Two shapes are reported, and both are unambiguous:
|
|
//
|
|
// 1. a runtime model under app/ that embeds the frozen package's time struct -
|
|
// always wrong, that package is the shape the columns had before the
|
|
// conversion;
|
|
// 2. a migration ordered after the conversion that imports the frozen package
|
|
// - AGENTS.md states this rule, and the version/ directory already has a
|
|
// test for it; this extends it to version-local/, where third-party and
|
|
// downstream migrations live and where no test was watching.
|
|
//
|
|
// Not reported: that two model packages describe the same table with different
|
|
// flavours. That is true of a dozen tables on purpose - the frozen package is
|
|
// correct for the migrations that predate the conversion - so reporting it
|
|
// would be reporting the design.
|
|
func checkModelTimeMixing(s *snapshot) []Finding {
|
|
var out []Finding
|
|
frozen := s.pkg(pkgFrozenModels)
|
|
|
|
for _, sf := range s.Files {
|
|
if sf.isTest() {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(sf.Path, "app/") && sf.Imports(frozen) {
|
|
tables := tableNames(sf)
|
|
for name, st := range structTypes(sf) {
|
|
table, isModel := tables[name]
|
|
if !isModel {
|
|
continue
|
|
}
|
|
for _, emb := range embeddedTypes(sf, st) {
|
|
if emb[0] != frozen {
|
|
continue
|
|
}
|
|
out = append(out, s.finding(Error, checkModelTimeMix, sf, st,
|
|
"runtime model %s (table %s) embeds %s.%s, whose DeletedAt is the nullable pre-conversion shape;\n"+
|
|
" gorm will query this table with deleted_at IS NULL while live rows hold 0, and it will return nothing.\n"+
|
|
" Embed %s.ModelTime instead.",
|
|
name, table, pkgFrozenModels, emb[1], pkgRuntimeModel))
|
|
}
|
|
}
|
|
}
|
|
|
|
version, isMigration := migrationVersion(sf.Path)
|
|
if !isMigration || version <= softDeleteConversion || !sf.Imports(frozen) {
|
|
continue
|
|
}
|
|
spec := sf.ImportSpec(frozen)
|
|
out = append(out, s.finding(Error, checkModelTimeMix, sf, spec,
|
|
"migration %d is ordered after the soft-delete conversion (%d) but seeds through %s;\n"+
|
|
" that package writes a nullable deleted_at into a NOT NULL column, and reads through it match no rows.\n"+
|
|
" Use the runtime models under app/ instead.",
|
|
version, softDeleteConversion, pkgFrozenModels))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// softDeleteConversion is the version at which deleted_at stopped being a
|
|
// nullable timestamp and became the NOT NULL millisecond marker.
|
|
const softDeleteConversion = 1786700003000
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// check 2: menu sort overflows a tinyint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// checkMenuSortOverflow reports a seeded menu sort outside a tinyint.
|
|
//
|
|
// sys_menu.sort is `gorm:"size:4"`, which MySQL builds as a tinyint holding
|
|
// -128..127. sqlite ignores the width, so an overflowing value passes every
|
|
// local test and fails on a real install - with Error 1264, partway through a
|
|
// migration that is not transactional, leaving every later migration unapplied.
|
|
// That is how a seeded Sort: 900 once stopped the run before the soft-delete
|
|
// conversion and left nobody able to log in.
|
|
func checkMenuSortOverflow(s *snapshot) []Finding {
|
|
const (
|
|
min = -128
|
|
max = 127
|
|
)
|
|
var out []Finding
|
|
for _, sf := range s.Files {
|
|
if sf.isTest() {
|
|
continue
|
|
}
|
|
forEachStructLiteral(sf, func(lit structLiteral) {
|
|
if !s.isMenuModel(lit) {
|
|
return
|
|
}
|
|
expr, ok := field(lit.Lit, "Sort")
|
|
if !ok {
|
|
return
|
|
}
|
|
v, ok := intValue(sf, expr)
|
|
if !ok || (v >= min && v <= max) {
|
|
return
|
|
}
|
|
out = append(out, s.finding(Error, checkMenuSort, sf, expr,
|
|
"menu sort %d does not fit a tinyint (%d..%d);\n"+
|
|
" MySQL rejects it with Error 1264 and the migration stops there, leaving later migrations unapplied.",
|
|
v, min, max))
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// check 4: sys_config value truncation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// checkConfigValueLength reports a seeded sys_config value longer than the
|
|
// column.
|
|
//
|
|
// config_value is varchar(255). MySQL outside strict mode truncates rather than
|
|
// refusing, so the migration succeeds, the row is written, and the setting is
|
|
// silently half of what was intended.
|
|
//
|
|
// Counted in runes, not bytes, because varchar(255) counts characters - byte
|
|
// counting would flag Chinese values that fit.
|
|
func checkConfigValueLength(s *snapshot) []Finding {
|
|
const limit = 255
|
|
var out []Finding
|
|
for _, sf := range s.Files {
|
|
if sf.isTest() {
|
|
continue
|
|
}
|
|
forEachStructLiteral(sf, func(lit structLiteral) {
|
|
if lit.Name != "SysConfig" || !s.isModelPackage(lit.PkgPath) {
|
|
return
|
|
}
|
|
expr, ok := field(lit.Lit, "ConfigValue")
|
|
if !ok {
|
|
return
|
|
}
|
|
v, ok := stringValue(sf, expr)
|
|
if !ok {
|
|
return
|
|
}
|
|
if n := len([]rune(v)); n > limit {
|
|
out = append(out, s.finding(Error, checkConfigValue, sf, expr,
|
|
"sys_config.config_value is %d characters, over the varchar(%d) column;\n"+
|
|
" MySQL outside strict mode truncates instead of failing, so the migration succeeds with half the value.",
|
|
n, limit))
|
|
}
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// check 5: hard-coded menu ids colliding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// checkMenuIDCollisions reports the same menu id seeded from more than one file.
|
|
//
|
|
// menu_id is the primary key and every seed is an upsert, so two modules that
|
|
// pick the same id do not collide loudly - the second overwrites the first, and
|
|
// which one wins depends on migration order. One module's menu quietly becomes
|
|
// the other's.
|
|
//
|
|
// Only across files. Inside one file the same id appearing twice is the same
|
|
// menu being written and then referenced, which is how the seeds are written
|
|
// today and not a mistake.
|
|
func checkMenuIDCollisions(s *snapshot) []Finding {
|
|
type site struct {
|
|
sf *sourceFile
|
|
expr posLike
|
|
file string
|
|
line int
|
|
}
|
|
sites := map[int64][]site{}
|
|
|
|
for _, sf := range s.Files {
|
|
if sf.isTest() {
|
|
continue
|
|
}
|
|
forEachStructLiteral(sf, func(lit structLiteral) {
|
|
if !s.isMenuModel(lit) {
|
|
return
|
|
}
|
|
expr, ok := field(lit.Lit, "MenuId")
|
|
if !ok {
|
|
return
|
|
}
|
|
v, ok := intValue(sf, expr)
|
|
if !ok {
|
|
return
|
|
}
|
|
file, line, _ := s.Pos(sf, expr.Pos())
|
|
sites[v] = append(sites[v], site{sf: sf, expr: expr, file: file, line: line})
|
|
})
|
|
}
|
|
|
|
ids := make([]int64, 0, len(sites))
|
|
for id := range sites {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
|
|
var out []Finding
|
|
for _, id := range ids {
|
|
group := sites[id]
|
|
files := map[string]bool{}
|
|
for _, st := range group {
|
|
files[st.file] = true
|
|
}
|
|
if len(files) < 2 {
|
|
continue
|
|
}
|
|
sort.Slice(group, func(i, j int) bool {
|
|
if group[i].file != group[j].file {
|
|
return group[i].file < group[j].file
|
|
}
|
|
return group[i].line < group[j].line
|
|
})
|
|
related := make([]string, 0, len(group)-1)
|
|
for _, st := range group[1:] {
|
|
related = append(related, fmt.Sprintf("also at %s:%d", st.file, st.line))
|
|
}
|
|
f := s.finding(Error, checkMenuIDConflict, group[0].sf, group[0].expr,
|
|
"menu id %d is seeded from %d files;\n"+
|
|
" menu_id is the primary key and the seeds upsert, so whichever migration runs last overwrites the other's menu.",
|
|
id, len(files))
|
|
f.Related = related
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// check 6: the contract packages must not import app/
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// contractRoots are the trees that may not depend on a business module. core/
|
|
// does not exist in this repository yet and is listed because the boundary is
|
|
// declared for both in docs/contract.md; naming it here means the check is
|
|
// already in place the day the directory appears.
|
|
//
|
|
// Which of them actually exist is reported by ScannedContractRoots, because a
|
|
// root that is absent contributes nothing and a check that silently covers less
|
|
// than it claims is worse than no check: it leaves people believing a boundary
|
|
// is guarded when nothing is guarding it.
|
|
var contractRoots = []string{"common/", "core/"}
|
|
|
|
// ScannedContractRoots splits contractRoots by whether the snapshot actually
|
|
// holds files under them, so the summary can name what was covered.
|
|
func ScannedContractRoots(s *snapshot) (scanned, absent []string) {
|
|
for _, root := range contractRoots {
|
|
found := false
|
|
for _, sf := range s.Files {
|
|
if strings.HasPrefix(sf.Path, root) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found {
|
|
scanned = append(scanned, strings.TrimSuffix(root, "/"))
|
|
} else {
|
|
absent = append(absent, strings.TrimSuffix(root, "/"))
|
|
}
|
|
}
|
|
return scanned, absent
|
|
}
|
|
|
|
// checkContractImportBoundary reports a contract package importing app/.
|
|
//
|
|
// docs/contract.md promises four packages under common/ as the surface an app
|
|
// may build on. A promise like that stops being true the moment the surface
|
|
// imports one particular app: a fork that replaces app/admin then cannot
|
|
// compile common/middleware, and an app can no longer be built against the
|
|
// contract alone. Nothing about it fails visibly - it fails when somebody tries
|
|
// to take the framework apart, which is the whole point of the exercise.
|
|
//
|
|
// Test files count. A fork that drops app/admin should be able to run go test
|
|
// ./... too.
|
|
func checkContractImportBoundary(s *snapshot) []Finding {
|
|
appPrefix := s.ModulePath + "/app/"
|
|
var out []Finding
|
|
for _, sf := range s.Files {
|
|
inContract := false
|
|
for _, root := range contractRoots {
|
|
if strings.HasPrefix(sf.Path, root) {
|
|
inContract = true
|
|
break
|
|
}
|
|
}
|
|
if !inContract {
|
|
continue
|
|
}
|
|
for _, spec := range sf.Syntax.Imports {
|
|
path, err := strconv.Unquote(spec.Path.Value)
|
|
if err != nil || !strings.HasPrefix(path, appPrefix) {
|
|
continue
|
|
}
|
|
out = append(out, s.finding(Error, checkImportBoundary, sf, spec,
|
|
"%s is a contract package and imports %s;\n"+
|
|
" a fork that replaces or drops that app can then no longer compile the contract surface it was told to build on.\n"+
|
|
" Move what is shared down into common/, or out of the contract package.",
|
|
sf.Path, path))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// check 7: a shim of a core contract type must be an alias
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// coreModulePrefix and coreContractSegment together identify a package under
|
|
// core's contract namespace. Matched as prefix plus segment rather than as one
|
|
// literal path so that a major-version bump of core - which rewrites the
|
|
// /v2 in every import - does not quietly turn this check off.
|
|
const (
|
|
coreModulePrefix = "github.com/go-admin-team/go-admin-core/"
|
|
coreContractSegment = "/sdk/contract/"
|
|
)
|
|
|
|
// isCoreContractPkg reports whether an import path names one of core's
|
|
// contract packages.
|
|
func isCoreContractPkg(path string) bool {
|
|
return strings.HasPrefix(path, coreModulePrefix) && strings.Contains(path, coreContractSegment)
|
|
}
|
|
|
|
// checkContractShimAlias reports a shim of a core contract type that was
|
|
// written as a defined type instead of an alias.
|
|
//
|
|
// type ControlBy = models.ControlBy // alias: same type, same method set
|
|
// type ControlBy models.ControlBy // defined type: methods are gone
|
|
//
|
|
// The two lines differ by one character and by everything else. A defined type
|
|
// takes the underlying struct and none of the methods declared on it, so a
|
|
// model embedding the second one no longer has SetCreateBy or SetUpdateBy and
|
|
// no longer satisfies ActiveRecord - which is not a warning, it is a compile
|
|
// error, but only in code that actually uses the method set.
|
|
//
|
|
// That is why the compiler is not enough on its own. This repository exercises
|
|
// some of the contract types through interfaces and some not at all; the ones
|
|
// it does not exercise compile perfectly well as defined types here and break
|
|
// in a third-party application, or in a fork's own module, which is where
|
|
// nobody is looking. The check costs one field of the AST - a type alias
|
|
// records the position of its '=' - and covers the surface uniformly rather
|
|
// than covering whatever app/demo happens to touch this month.
|
|
//
|
|
// The trigger is the right-hand side, not a list of names: any type declared
|
|
// from a core contract package is one of these, whoever wrote it and whenever
|
|
// it was added. A type declared from a local struct literal is not caught by
|
|
// this - see ScannedShimAliases, which is what stops a run over a tree with no
|
|
// shims in it from reading as a clean bill of health.
|
|
func checkContractShimAlias(s *snapshot) []Finding {
|
|
var out []Finding
|
|
for _, sf := range s.Files {
|
|
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
|
qualifier, pkg, name, ok := qualifiedType(sf, ts.Type)
|
|
if !ok || !isCoreContractPkg(pkg) {
|
|
return
|
|
}
|
|
if ts.Assign.IsValid() {
|
|
return // "type X = pkg.Y", which is what it must be
|
|
}
|
|
out = append(out, s.finding(Error, checkShimAlias, sf, ts,
|
|
"%s is declared from %s.%s as a defined type, not an alias;\n"+
|
|
" a defined type keeps the fields and drops the method set, so anything embedding it stops satisfying\n"+
|
|
" the interfaces it satisfied before - here it may still compile, in a fork or a third-party app it does not.\n"+
|
|
" Write it as: type %s = %s.%s",
|
|
ts.Name.Name, qualifier, name, ts.Name.Name, qualifier, name))
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ScannedShimAliases counts the type aliases into core's contract packages the
|
|
// snapshot holds, so the summary can say whether checkContractShimAlias found
|
|
// anything to guard at all.
|
|
//
|
|
// Reported for the same reason ScannedContractRoots is: before the contract
|
|
// packages are lowered into core there are no shims here, the check has
|
|
// nothing to look at, and a run that printed nothing would look exactly like a
|
|
// run over a tree that passed.
|
|
func ScannedShimAliases(s *snapshot) int {
|
|
n := 0
|
|
for _, sf := range s.Files {
|
|
forEachTypeSpec(sf, func(ts *ast.TypeSpec) {
|
|
_, pkg, _, ok := qualifiedType(sf, ts.Type)
|
|
if ok && isCoreContractPkg(pkg) && ts.Assign.IsValid() {
|
|
n++
|
|
}
|
|
})
|
|
}
|
|
return n
|
|
}
|
|
|
|
// forEachTypeSpec visits every type declaration in the file, including the
|
|
// ones inside a parenthesised type block.
|
|
func forEachTypeSpec(sf *sourceFile, fn func(*ast.TypeSpec)) {
|
|
for _, decl := range sf.Syntax.Decls {
|
|
gen, ok := decl.(*ast.GenDecl)
|
|
if !ok || gen.Tok != token.TYPE {
|
|
continue
|
|
}
|
|
for _, spec := range gen.Specs {
|
|
if ts, ok := spec.(*ast.TypeSpec); ok {
|
|
fn(ts)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// qualifiedType resolves a type expression that names a type in another
|
|
// package, returning that package's import path and the type name. A bare
|
|
// identifier, a struct literal or anything else reports false: this asks
|
|
// specifically "is the right-hand side pkg.Name", which is the shape both a
|
|
// correct shim and the mistake it guards against are written in.
|
|
// The qualifier returned is the one written in this file, which is not
|
|
// path.Base of the import path whenever the import is aliased - and the shim
|
|
// files alias every one of them (contractmodels, contractdto). A message that
|
|
// suggests a fix has to spell it the way the file already does, or the line it
|
|
// tells the author to write does not compile.
|
|
func qualifiedType(sf *sourceFile, typ ast.Expr) (qualifier, pkgPath, name string, ok bool) {
|
|
sel, isSel := typ.(*ast.SelectorExpr)
|
|
if !isSel {
|
|
return "", "", "", false
|
|
}
|
|
ident, isIdent := sel.X.(*ast.Ident)
|
|
if !isIdent {
|
|
return "", "", "", false
|
|
}
|
|
p, found := sf.imports[ident.Name]
|
|
if !found {
|
|
return "", "", "", false
|
|
}
|
|
return ident.Name, p, sel.Sel.Name, true
|
|
}
|
|
|
|
// migrationVersion reads the 13-digit timestamp a migration file name starts
|
|
// with. Files outside the two migration directories are not migrations, however
|
|
// they are named.
|
|
func migrationVersion(rel string) (int64, bool) {
|
|
dir := path.Dir(rel)
|
|
if dir != "cmd/migrate/migration/version" && dir != "cmd/migrate/migration/version-local" {
|
|
return 0, false
|
|
}
|
|
name := path.Base(rel)
|
|
if len(name) < 13 {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.ParseInt(name[:13], 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// isMenuModel reports whether a literal describes a menu row, whichever of
|
|
// the two shapes it is written in.
|
|
//
|
|
// A host module seeds a menu by building the SysMenu model directly. An
|
|
// application installed from outside this repository cannot reach that type,
|
|
// so it describes the same row as a seed.MenuSpec and hands it to the host's
|
|
// Seeder. Both end up in sys_menu and both are subject to its column widths,
|
|
// so a check that knew only the first shape would go quiet exactly when the
|
|
// author is furthest from the schema it protects.
|
|
//
|
|
// That is not hypothetical: this repository's own reference application was
|
|
// written with a Sort of 200 - past the tinyint sys_menu.sort is built as -
|
|
// and this check passed it, because a MenuSpec is not a SysMenu.
|
|
func (s *snapshot) isMenuModel(lit structLiteral) bool {
|
|
if lit.Name == "MenuSpec" && isCoreContractPkg(lit.PkgPath) {
|
|
return true
|
|
}
|
|
return lit.Name == "SysMenu" && s.isModelPackage(lit.PkgPath)
|
|
}
|
|
|
|
func (s *snapshot) isModelPackage(path string) bool {
|
|
return path == s.pkg(pkgFrozenModels) || path == s.pkg(pkgAdminModels)
|
|
}
|