| 1 | // Control-plane apps: the flag that says "this app IS the platform". |
| 2 | // |
| 3 | // It gates DESTRUCTION, not promotion, and the asymmetry is the whole design. |
| 4 | // Deploying the console should be as ordinary as deploying any app — that is |
| 5 | // what this slice exists to make true — so a promote gate would defeat it: |
| 6 | // `stable`'s fresh-auth 403 has no API-side step-up, and `carlos deploy` |
| 7 | // log.Fatalfs on a failed promote AFTER the ship has already landed |
| 8 | // (deploy.go), so any gate there would leave every console deploy half-done. |
| 9 | // Destroying the console is the act that deserves a wall, and it had none: |
| 10 | // handlePurgeApp will delete apps/<sqid>/console/** — the release history the |
| 11 | // fleet is currently running — behind an owner check, a typed name and a |
| 12 | // passkey, none of which knows what this app is. |
| 13 | // |
| 14 | // Refused OUTRIGHT rather than behind fresh auth. A gesture proves intent, |
| 15 | // and intent is not the thing in doubt: an operator who means to delete the |
| 16 | // console can clear the flag first, which is a separate, deliberate act with |
| 17 | // its own record. |
| 18 | // |
| 19 | // The one promote-side restriction is about WHO, not whether: a person with |
| 20 | // ops membership may roll the control plane, a service credential may not. |
| 21 | // |
| 22 | // The CLEAR door lives here too (handleAppControlPlaneClear), rather than in |
| 23 | // appsettings.go with the settings tab's other writes, because the refusals it |
| 24 | // unlocks are in this file and the two must be read together: every refusal |
| 25 | // above ends "clear the control-plane flag first", and this is the only place |
| 26 | // that sentence can be acted on. |
| 27 | package console |
| 28 | |
| 29 | import ( |
| 30 | "bytes" |
| 31 | "context" |
| 32 | "crypto/subtle" |
| 33 | "debug/buildinfo" |
| 34 | "debug/elf" |
| 35 | "errors" |
| 36 | "fmt" |
| 37 | "log" |
| 38 | "net/http" |
| 39 | "runtime/debug" |
| 40 | "strings" |
| 41 | ) |
| 42 | |
| 43 | // controlPlaneRefusal answers "may this door proceed?" for a destructive verb. |
| 44 | // msg is "" when the app is not the control plane and the caller may carry on; |
| 45 | // otherwise msg is the sentence to show and status the code a JSON door should |
| 46 | // send with it. |
| 47 | // |
| 48 | // The sentence NAMES THE PAGE that can clear the flag. An operator meeting one |
| 49 | // of these refusals is by definition someone the platform has just stopped, |
| 50 | // and "clear the control-plane flag first" without saying where is the same |
| 51 | // stranded-operator problem the clear door was built to end. |
| 52 | // |
| 53 | // Callers MUST be past their own membership gate first — the byte-identical |
| 54 | // 404 protects account membership, and consulting the flag before it would |
| 55 | // turn this refusal into an oracle for which accounts hold a control-plane |
| 56 | // app. |
| 57 | // |
| 58 | // A lookup failure REFUSES too, with a 503 rather than a 403: the alternative |
| 59 | // — treating an unreadable row as "not the control plane" — makes a database |
| 60 | // blip the way past the guard. The error itself is logged, never returned: |
| 61 | // the caller gets one fixed sentence, as with every other store failure here. |
| 62 | func (s *Server) controlPlaneRefusal(acct *Account, app, verb string) (msg string, status int) { |
| 63 | cp, _, err := s.store.AppFlags(acct.ID, app) |
| 64 | if err != nil { |
| 65 | log.Printf("console: control-plane check %s in account %s: %v", app, acct.Sqid, err) |
| 66 | return "Could not check whether " + app + " is the control plane — nothing was changed. Try again.", |
| 67 | http.StatusServiceUnavailable |
| 68 | } |
| 69 | if !cp { |
| 70 | return "", 0 |
| 71 | } |
| 72 | return app + " is marked as the control plane, so it cannot be " + verb + |
| 73 | " — clear the flag first on the app's Settings tab (" + s.appSettingsURL(acct, app) + |
| 74 | ") if you really mean to.", http.StatusForbidden |
| 75 | } |
| 76 | |
| 77 | // appSettingsURL is where both halves of the flag live, said in full: a CLI |
| 78 | // caller has no page to be already looking at, and a browser flash pointing at |
| 79 | // an absolute URL costs nothing. |
| 80 | func (s *Server) appSettingsURL(acct *Account, app string) string { |
| 81 | return s.cfg.Origin + appSettingsURL(acct.Sqid, app, "advanced") |
| 82 | } |
| 83 | |
| 84 | // refuseControlPlane is controlPlaneRefusal for the JSON doors: it writes the |
| 85 | // refusal and returns true when the caller must stop. Browser doors render |
| 86 | // pages and set flashes, so they call controlPlaneRefusal directly and hand |
| 87 | // msg to their own back()/fail() closure. |
| 88 | func (s *Server) refuseControlPlane(w http.ResponseWriter, acct *Account, app, verb string) bool { |
| 89 | msg, status := s.controlPlaneRefusal(acct, app, verb) |
| 90 | if msg == "" { |
| 91 | return false |
| 92 | } |
| 93 | jsonError(w, status, msg) |
| 94 | return true |
| 95 | } |
| 96 | |
| 97 | // refuseServiceControlPlane is the release-authority gate: a service |
| 98 | // credential cannot move a control-plane app's channel pointer, nor shorten |
| 99 | // the history behind it; a PERSON with ops membership can, and must be able |
| 100 | // to — gating a person on promote would break `carlos deploy -app console`, |
| 101 | // the command this work exists to make ordinary. |
| 102 | // |
| 103 | // action is a whole CLAUSE, not a participle ("promote it", "roll it back", |
| 104 | // "change its release retention"), because the sentence it lands in has to |
| 105 | // read as English for three different verbs and "cannot roll back it" is not |
| 106 | // English. |
| 107 | // |
| 108 | // The flag is only read for a service, so a person's promote does not grow a |
| 109 | // database round trip it has no use for. |
| 110 | func (s *Server) refuseServiceControlPlane(w http.ResponseWriter, p Principal, accountID int64, app, action string) bool { |
| 111 | if p.Kind == PrincipalUser { |
| 112 | return false |
| 113 | } |
| 114 | cp, _, err := s.store.AppFlags(accountID, app) |
| 115 | if err != nil { |
| 116 | log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err) |
| 117 | jsonError(w, http.StatusServiceUnavailable, |
| 118 | "could not check this app's control-plane status — nothing was changed") |
| 119 | return true |
| 120 | } |
| 121 | if !cp { |
| 122 | return false |
| 123 | } |
| 124 | jsonError(w, http.StatusForbidden, |
| 125 | app+" is the control plane — a service credential cannot "+action+"; a person must") |
| 126 | return true |
| 127 | } |
| 128 | |
| 129 | // controlPlaneAppsIn lists the apps in an account carrying the control-plane |
| 130 | // flag, TRASHED ROWS INCLUDED: a trashed app still owns its prefix in the |
| 131 | // bucket — the release history the fleet is running is exactly what a trash |
| 132 | // marker does not destroy — so it is still a reason to refuse the account's |
| 133 | // retirement. (AppCount, handleDeleteAccount's older guard, counts the same |
| 134 | // way for the same reason.) |
| 135 | func (s *Store) controlPlaneAppsIn(accountID int64) ([]string, error) { |
| 136 | rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND control_plane = 1 ORDER BY app`, accountID) |
| 137 | if err != nil { |
| 138 | return nil, err |
| 139 | } |
| 140 | defer rows.Close() |
| 141 | var out []string |
| 142 | for rows.Next() { |
| 143 | var app string |
| 144 | if err := rows.Scan(&app); err != nil { |
| 145 | return nil, err |
| 146 | } |
| 147 | out = append(out, app) |
| 148 | } |
| 149 | return out, rows.Err() |
| 150 | } |
| 151 | |
| 152 | // controlPlaneAccountRefusal is the same guard one level up: an account |
| 153 | // holding a control-plane app cannot be RETIRED either. Refusing the app's own |
| 154 | // destructive doors while the account above it could still be deleted would be |
| 155 | // a guard with a published bypass, which reads as protection and is not. |
| 156 | // |
| 157 | // msg is "" when the account is free to go. A lookup failure refuses, on the |
| 158 | // same reasoning as controlPlaneRefusal: a database blip must not be the way |
| 159 | // past the guard. |
| 160 | // |
| 161 | // In practice handleDeleteAccount's AppCount refusal already catches every |
| 162 | // account this could fire on, since an account holding ANY app row is refused |
| 163 | // — but that guard is about cascading deletes, is written to change for its |
| 164 | // own reasons, and sits BELOW the passkey. This one is about the flag, names |
| 165 | // the app, and refuses before a gesture is spent. |
| 166 | func (s *Server) controlPlaneAccountRefusal(accountID int64) string { |
| 167 | apps, err := s.store.controlPlaneAppsIn(accountID) |
| 168 | if err != nil { |
| 169 | log.Printf("console: control-plane check for account %d: %v", accountID, err) |
| 170 | return "Could not check whether this account holds a control-plane app. Nothing was deleted." |
| 171 | } |
| 172 | if len(apps) == 0 { |
| 173 | return "" |
| 174 | } |
| 175 | return "“" + strings.Join(apps, "”, “") + "” " + isare(len(apps)) + |
| 176 | " marked as the control plane, so this account cannot be deleted — clear the control-plane flag first if you really mean to." |
| 177 | } |
| 178 | |
| 179 | func isare(n int) string { |
| 180 | if n == 1 { |
| 181 | return "is" |
| 182 | } |
| 183 | return "are" |
| 184 | } |
| 185 | |
| 186 | // handleAppControlPlaneClear takes the flag off, which is the only way past |
| 187 | // every refusal in this file. |
| 188 | // |
| 189 | // BROWSER ONLY, and that is the design rather than a gap (spec §6). Setting |
| 190 | // the flag is easy — the CLI, at claim time — and clearing it is hard: the |
| 191 | // owner, in a browser, with a passkey. There is deliberately no API door: the |
| 192 | // console's fresh-auth 403 is a dead end for an API caller (the same defect |
| 193 | // that makes a promote gate unworkable), so a CLI clear would have to be |
| 194 | // either ungated, which defeats the flag, or unusable, which is worse than |
| 195 | // absent. |
| 196 | // |
| 197 | // The gate order is handleAppRename's, unchanged: |
| 198 | // |
| 199 | // accountApp — session, then the app's byte-identical 404. |
| 200 | // CSRF — constant-time, before any pending action can be parked. |
| 201 | // owner — a member probing this gets a stranger's answer. |
| 202 | // nothing to clear — a cheap refusal, so an already-clear app never costs a |
| 203 | // passkey touch. |
| 204 | // requireFreshAuth — LAST, so no redirect into Keymail ever happens for |
| 205 | // someone who should have got a 404. |
| 206 | // |
| 207 | // No serial bump: the flag is this console's own row and no box parses it. |
| 208 | func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Request) { |
| 209 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 210 | if !ok { |
| 211 | return |
| 212 | } |
| 213 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 214 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 215 | return |
| 216 | } |
| 217 | if role != RoleOwner { |
| 218 | http.NotFound(w, r) |
| 219 | return |
| 220 | } |
| 221 | back := func(kind, msg string) { |
| 222 | s.setFlash(w, kind, msg) |
| 223 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther) |
| 224 | } |
| 225 | // Fail CLOSED on an unreadable row, like every other reader of this flag: |
| 226 | // clearing what may already be clear is harmless, but a lookup error here |
| 227 | // would otherwise be reported to the operator as a successful clear. |
| 228 | cp, _, err := s.store.AppFlags(acct.ID, app) |
| 229 | if err != nil { |
| 230 | log.Printf("console: control-plane clear read %s/%s: %v", acct.Sqid, app, err) |
| 231 | back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.") |
| 232 | return |
| 233 | } |
| 234 | if !cp { |
| 235 | back("err", app+" is not marked as the control plane, so there is nothing to clear.") |
| 236 | return |
| 237 | } |
| 238 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 239 | return |
| 240 | } |
| 241 | if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error { |
| 242 | return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, false) |
| 243 | }); err != nil { |
| 244 | log.Printf("console: control-plane clear %s/%s: %v", acct.Sqid, app, err) |
| 245 | back("err", "Could not clear the control-plane flag — nothing was changed. Try again.") |
| 246 | return |
| 247 | } |
| 248 | s.audit(acct, app, addr, "control-plane", "cleared the control-plane flag") |
| 249 | back("ok", "Cleared the control-plane flag on "+app+" — it can now be renamed, moved and deleted like any other app.") |
| 250 | } |
| 251 | |
| 252 | // handleAppControlPlaneSet is the other direction, and the reason the flag is |
| 253 | // not a trap: an owner who clears it must be able to put it back without a |
| 254 | // database write on a box — the platform's own rule is that a job with no |
| 255 | // API/CLI door is a product gap, not a license to open sqlite. |
| 256 | // |
| 257 | // Deliberately NO fresh auth (spec §6). The asymmetry is the whole design: |
| 258 | // setting the flag only ADDS refusals, so the dangerous direction is clearing, |
| 259 | // and that is the one that costs a passkey. Making setting expensive would |
| 260 | // discourage the safe act and teach nobody anything. |
| 261 | // |
| 262 | // Owner-only all the same: declaring an app to BE the platform decides what |
| 263 | // the account's other owners can no longer delete, which is not a member's |
| 264 | // call. |
| 265 | func (s *Server) handleAppControlPlaneSet(w http.ResponseWriter, r *http.Request) { |
| 266 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 267 | if !ok { |
| 268 | return |
| 269 | } |
| 270 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 271 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 272 | return |
| 273 | } |
| 274 | if role != RoleOwner { |
| 275 | http.NotFound(w, r) |
| 276 | return |
| 277 | } |
| 278 | back := func(kind, msg string) { |
| 279 | s.setFlash(w, kind, msg) |
| 280 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther) |
| 281 | } |
| 282 | defer s.lockApp(acct.Sqid, app)() |
| 283 | if msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane"); msg != "" { |
| 284 | back("err", msg) |
| 285 | return |
| 286 | } |
| 287 | cp, _, err := s.store.AppFlags(acct.ID, app) |
| 288 | if err != nil { |
| 289 | log.Printf("console: control-plane set read %s/%s: %v", acct.Sqid, app, err) |
| 290 | back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.") |
| 291 | return |
| 292 | } |
| 293 | if cp { |
| 294 | back("err", app+" is already marked as the control plane.") |
| 295 | return |
| 296 | } |
| 297 | if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error { |
| 298 | return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, true) |
| 299 | }); err != nil { |
| 300 | if errors.Is(err, ErrTemporaryApp) { |
| 301 | // A keep cannot race it: under the lock above, nothing can turn a |
| 302 | // permanent app temporary, so a fresh ErrTemporaryApp here means |
| 303 | // the lock did its job and this is the message to show. |
| 304 | msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane") |
| 305 | back("err", msg) |
| 306 | return |
| 307 | } |
| 308 | log.Printf("console: control-plane set %s/%s: %v", acct.Sqid, app, err) |
| 309 | back("err", "Could not mark this app as the control plane — nothing was changed. Try again.") |
| 310 | return |
| 311 | } |
| 312 | s.audit(acct, app, addr, "control-plane", "marked "+app+" as the control plane") |
| 313 | back("ok", "Marked "+app+" as the control plane — trashing, deleting, renaming and moving it are refused until the flag is cleared.") |
| 314 | } |
| 315 | |
| 316 | // --- Ship-time refusals ----------------------------------------------------- |
| 317 | // |
| 318 | // The flag gates DESTRUCTION above; it also buys two REFUSALS at the ship |
| 319 | // door, and they are here rather than in apicli.go for the same reason the |
| 320 | // clear door is: they are the flag's meaning, and the file that says what the |
| 321 | // flag means is this one. |
| 322 | |
| 323 | // targetMachine maps an artifact part name — which IS the declared target, |
| 324 | // "linux-arm64" and friends — to the ELF machine a binary for it must carry. |
| 325 | // Unknown targets are not refused: this guard exists to catch a wrong build of |
| 326 | // a target we know, not to bound the target vocabulary, which targetRe |
| 327 | // already does. |
| 328 | var targetMachine = map[string]elf.Machine{ |
| 329 | "linux-arm64": elf.EM_AARCH64, |
| 330 | "linux-amd64": elf.EM_X86_64, |
| 331 | } |
| 332 | |
| 333 | // buildSymbol is the symbol the release recipe stamps. Matched as a SUFFIX of |
| 334 | // the symbol path rather than the whole of it, so a vendored or renamed module |
| 335 | // prefix does not silently turn every artifact into "carries no |
| 336 | // version.Build" — but as a suffix of the PATH only, never of the whole |
| 337 | // argument (see embeddedBuild). |
| 338 | const buildSymbol = "internal/version.Build" |
| 339 | |
| 340 | // checkControlPlaneArtifacts refuses two ways a control-plane ship can be |
| 341 | // wrong in a manner nothing downstream would catch. |
| 342 | // |
| 343 | // ARCHITECTURE. An amd64 binary on a Graviton box has happened and briefly |
| 344 | // broke one. `carlos deploy` defaults -target to linux-arm64, which protects |
| 345 | // the careful caller and nobody else; this closes the same door against a |
| 346 | // hand-run `carlos ship`, a CI job, or a script written next year. |
| 347 | // |
| 348 | // EMBEDDED BUILD. version.Build is set by ldflags the operator has to |
| 349 | // remember. Forget it and the artifact says "dev" forever: the roll SUCCEEDS, |
| 350 | // the deploy watch's probe never matches, and a perfectly healthy deploy |
| 351 | // reports failure — the exact inverse of the lie the probe exists to kill. |
| 352 | // Caught here it is one sentence at the door instead of a timeout nobody can |
| 353 | // explain. |
| 354 | // |
| 355 | // Both read the bytes that actually arrived, and both are stdlib. Verified |
| 356 | // against a real arm64 console build that `-s -w` strips neither the ELF |
| 357 | // header nor the build info: debug/buildinfo still reports |
| 358 | // Settings["-ldflags"] verbatim. |
| 359 | // |
| 360 | // CONTROL-PLANE APPS ONLY, and that scoping is the ruling, not an oversight: |
| 361 | // a tenant may legitimately ship an artifact we would guess wrong about, and |
| 362 | // refusing their ship on our inference is not our call. The console is |
| 363 | // different because we own the boxes it lands on and know what they are. |
| 364 | // |
| 365 | // version is the RESOLVED version of this ship — after minting, never the |
| 366 | // meta part's field, which is empty on a target-minted ship. |
| 367 | func checkControlPlaneArtifacts(files map[string][]byte, version string) error { |
| 368 | for name, b := range files { |
| 369 | want, known := targetMachine[name] |
| 370 | if !known { |
| 371 | continue |
| 372 | } |
| 373 | // No Close: elf.NewFile over a bytes.Reader has nothing to close — |
| 374 | // the file half of debug/elf is elf.Open's, and calling Close here |
| 375 | // would only ever return nil. |
| 376 | f, err := elf.NewFile(bytes.NewReader(b)) |
| 377 | if err != nil { |
| 378 | return fmt.Errorf("artifact %s is not a readable ELF binary: %v", name, err) |
| 379 | } |
| 380 | // Machine against machine, not machine against target name: the |
| 381 | // operator's fix is a GOARCH, so the sentence has to compare the two |
| 382 | // things a GOARCH can be. |
| 383 | if f.Machine != want { |
| 384 | return fmt.Errorf("artifact %s is %s machine code, but target %s wants %s — rebuild it with GOOS/GOARCH matching the target it is shipped under", |
| 385 | name, machineName(f.Machine), name, machineName(want)) |
| 386 | } |
| 387 | bi, err := buildinfo.Read(bytes.NewReader(b)) |
| 388 | if err != nil { |
| 389 | return fmt.Errorf("artifact %s carries no Go build info: %v", name, err) |
| 390 | } |
| 391 | // The empty case gets its own sentence because it is the COMMON one: |
| 392 | // a build with the ldflag simply forgotten. "was built with |
| 393 | // version.Build=\"\"" would be true and would still leave the |
| 394 | // operator working out that a link flag is missing. |
| 395 | got := embeddedBuild(bi) |
| 396 | if got == "" { |
| 397 | return fmt.Errorf("artifact %s was linked without its build stamp, so it would report %q forever and the deploy watch would never go green — rebuild with -ldflags %q", |
| 398 | name, "dev", `-s -w -X github.com/carlosframework/platform/internal/version.Build=`+version) |
| 399 | } |
| 400 | if got != version { |
| 401 | return fmt.Errorf("artifact %s was built with version.Build=%q but is being shipped as %q — rebuild with -ldflags %q, or ship it under the version it was built as", |
| 402 | name, got, version, `-s -w -X github.com/carlosframework/platform/internal/version.Build=`+version) |
| 403 | } |
| 404 | } |
| 405 | return nil |
| 406 | } |
| 407 | |
| 408 | // embeddedBuild pulls internal/version.Build out of a binary's recorded |
| 409 | // -ldflags, returning "" when the flag is absent — which is exactly the build |
| 410 | // that reports "dev" on the wire. |
| 411 | // |
| 412 | // Scanned FIELD BY FIELD rather than with one search over the whole string, so |
| 413 | // that both spellings the linker accepts are read the same way: `-X` and its |
| 414 | // assignment as separate arguments, and `-X=path=value` as one. |
| 415 | // |
| 416 | // The match is ANCHORED: each field is cut at its FIRST `=`, and the suffix |
| 417 | // test applies to the symbol path on the left. Searching the raw field |
| 418 | // instead — which an earlier version did — would let the needle be found |
| 419 | // inside another flag's VALUE (`-X main.Notes=internal/version.Build=decoy`), |
| 420 | // so a binary carrying no build stamp at all could report someone else's |
| 421 | // string as its build. |
| 422 | func embeddedBuild(bi *debug.BuildInfo) string { |
| 423 | for _, s := range bi.Settings { |
| 424 | if s.Key != "-ldflags" { |
| 425 | continue |
| 426 | } |
| 427 | for _, f := range strings.Fields(s.Value) { |
| 428 | // The one-argument spelling; the two-argument one arrives here |
| 429 | // as the assignment on its own, with the bare `-X` skipped by |
| 430 | // the Cut below. |
| 431 | path, val, ok := strings.Cut(strings.TrimPrefix(f, "-X="), "=") |
| 432 | if !ok { |
| 433 | continue |
| 434 | } |
| 435 | if path == buildSymbol || strings.HasSuffix(path, "/"+buildSymbol) { |
| 436 | return val |
| 437 | } |
| 438 | } |
| 439 | } |
| 440 | return "" |
| 441 | } |
| 442 | |
| 443 | // machineName says an ELF machine the way the operator's build command says |
| 444 | // it. Anything outside the two targets the platform ships falls back to the |
| 445 | // elf constant, which is still more use than a number. |
| 446 | func machineName(m elf.Machine) string { |
| 447 | switch m { |
| 448 | case elf.EM_AARCH64: |
| 449 | return "arm64" |
| 450 | case elf.EM_X86_64: |
| 451 | return "amd64" |
| 452 | } |
| 453 | return m.String() |
| 454 | } |
| 455 | |