mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(oauth): split an oversized keyring token blob across entries Store every provider and MCP token in one keyring entry and the store stops working once the logins outgrow it. On macOS the secret rides inside a `security -i` command line capped at 4095 bytes, which leaves 3027 bytes of JSON for all logins combined, so a second OIDC login fails to save and every write after it fails too. Split a blob that does not fit across numbered entries and put a manifest in the anchor account. Chunks live in two alternating generations: a write fills the one that is not live, then replaces the manifest, so that single write is the commit point and a crash partway through still reads the previous generation. `zc1:` cannot prefix base64, so an entry written by an existing build is still recognised and read without a migration step. Reserve the range a write will occupy before occupying it. Without that, a write interrupted while filling a longer generation leaves chunks above the count the manifest records, and no later cleanup knows to delete them: a fragment of a token blob would stay in the keychain for good. Expose the per-entry budget from internal/keyring rather than hardcoding the macOS figure in the oauth store, sharing one line builder with Set so the budget and the boundary it describes cannot drift. Backends with no limit report so and keep the single-entry layout, so Linux is untouched. Refs #937 * fix(oauth): lock keyring reads, validate manifest digest, and harden chunk tests * test(oauth): assert Status blocks during keyring lock regression test * fix(oauth): sweep both chunk generations when a store regrows A shrink writes the blob back under the anchor, which replaces the manifest and takes the per-generation chunk counts with it. From then on nothing can name the chunks a failed cleanup left behind, so the growth branch's sweep of the target generation is the only one that will ever reach them — and it only ever targets family A. A keychain that refused one delete during the shrink therefore kept a superseded generation of access, ID and refresh tokens indefinitely, with no way for the user to know. Sweep the other generation alongside the target, and document that the reclaim waits for the next growth: a store that shrinks once and never grows again keeps the residue, which sweeping on every whole write would close at a cost of 128 `security` invocations per save on macOS. Also record why Load and Status take the cross-process lock, and give the missing-chunk error the same "log in again" advice the digest failure carries. Refs #937 * fix(oauth): serialize keyring operations under user-level lock Derive the keyring backend lock file path from the user's home directory rather than file store configuration, ensuring processes with distinct store paths share the same lock domain for the OS keychain. Refs #938 * Address review feedback on chunked keyring token store and recovery Refs #938 * Refine auth reset message and chunk format notation Refs #938 * Harden OAuth store reset lifecycle, keyring migration recovery, and completions. Refs #938 * Validate keyring cleanup markers, propagate lock resolution errors, and test reader concurrency. Refs #938 * Preserve live OAuth credentials when stale cleanup markers survive migration. Refs #937 * Verify JSON output from OAuth reset to cover its recovery response. Refs #937 * Verify that JSON OAuth reset actually removes stored logins. Refs #937 * Require confirmation before resetting shared OAuth credentials. Refs #937 --------- Co-authored-by: euxaristia <[email protected]>
415 lines
16 KiB
Go
415 lines
16 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/oauth"
|
|
"github.com/Gitlawb/zero/internal/provideroauth"
|
|
)
|
|
|
|
// withAuthStore points the provider OAuth store at a temp file for the test,
|
|
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
|
|
// ignore the temp path and hit the OS keychain.
|
|
func withAuthStore(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
|
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
|
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
|
return path
|
|
}
|
|
|
|
func TestRunAuthRejectsInvalidStorageMode(t *testing.T) {
|
|
withAuthStore(t)
|
|
// A mistyped value must fail fast, not silently fall back to plaintext while
|
|
// the user believes encryption is active.
|
|
t.Setenv("ZERO_OAUTH_STORAGE", "encryptd")
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatalf("invalid ZERO_OAUTH_STORAGE should fail, got success; stdout=%q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "ZERO_OAUTH_STORAGE") {
|
|
t.Fatalf("error should name the offending env var, stderr=%q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthStatusEmpty(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
|
t.Fatalf("status output = %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthStatusReportsLoginWithoutSecret(t *testing.T) {
|
|
path := withAuthStore(t)
|
|
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{
|
|
AccessToken: "super-secret", RefreshToken: "super-secret-rt", Account: "[email protected]",
|
|
}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "demo") || !strings.Contains(out, "[email protected]") {
|
|
t.Fatalf("status should show provider + account: %q", out)
|
|
}
|
|
if strings.Contains(out, "super-secret") {
|
|
t.Fatalf("status leaked token material: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRunAuthLogoutNothing(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "No stored credential for demo") {
|
|
t.Fatalf("logout output = %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthLoginValidation(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
// Missing provider.
|
|
if code := runWithDeps([]string{"auth", "login"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("login with no provider should fail")
|
|
}
|
|
// --json is rejected for the interactive login.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "login", "demo", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("login --json should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestRunAuthLoginUnknownProvider(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "login", "does-not-exist"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("unknown provider login should fail")
|
|
}
|
|
if !strings.Contains(stderr.String(), "not configured") {
|
|
t.Fatalf("stderr = %q, want not-configured error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthRefreshNoToken(t *testing.T) {
|
|
withAuthStore(t)
|
|
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "client") // so config resolves; refresh still fails (no token)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "refresh", "demo"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("refresh with no stored token should fail")
|
|
}
|
|
}
|
|
|
|
func TestRunAuthRejectsWrongFlags(t *testing.T) {
|
|
withAuthStore(t)
|
|
cases := [][]string{
|
|
{"auth", "login", "demo", "--watch"}, // watch is refresh-only
|
|
{"auth", "login", "demo", "--json"}, // json not for interactive login
|
|
{"auth", "status", "demo", "--device"}, // device is login-only
|
|
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
|
|
{"auth", "refresh", "demo", "--json"}, // json not for refresh
|
|
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
|
|
{"auth", "status", "--confirm"}, // confirm is reset-only
|
|
{"auth", "reset", "--confirm", "--watch"}, // confirmation does not allow unrelated flags
|
|
}
|
|
for _, args := range cases {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Errorf("args %v should be rejected, got success", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
// An unexpected arg/flag must fail fast, not silently run the login.
|
|
if code := runWithDeps([]string{"auth", "openrouter", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatalf("openrouter with an unexpected flag should fail; stdout=%q", stdout.String())
|
|
}
|
|
// --help still works.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "openrouter", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("openrouter --help should succeed, stderr=%q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{
|
|
userConfigPath: func() (string, error) { return configPath, nil },
|
|
openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) {
|
|
return "sk-openrouter-test", nil
|
|
},
|
|
})
|
|
|
|
if code != exitSuccess {
|
|
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "new API key saved") {
|
|
t.Fatalf("expected saved-key confirmation, got %q", stdout.String())
|
|
}
|
|
cfg := readCLIConfigFixture(t, configPath)
|
|
if cfg.ActiveProvider != "openrouter" || len(cfg.Providers) != 1 {
|
|
t.Fatalf("config = %#v", cfg)
|
|
}
|
|
profile := cfg.Providers[0]
|
|
if profile.Name != "openrouter" || profile.CatalogID != "openrouter" || !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
|
|
t.Fatalf("provider not stored-key sanitized: %#v", profile)
|
|
}
|
|
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, ok, err := store.Get("openrouter")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ok || key != "sk-openrouter-test" {
|
|
t.Fatalf("stored key = %q, %v", key, ok)
|
|
}
|
|
}
|
|
|
|
func TestRunAuthHelp(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d", code)
|
|
}
|
|
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "reset --confirm", "--device"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunAuthResetRequiresConfirmation(t *testing.T) {
|
|
for _, args := range [][]string{{}, {"--json"}} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
path := withAuthStore(t)
|
|
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
code := runAuthReset(args, &stdout, &stderr, appDeps{})
|
|
after, readErr := os.ReadFile(path)
|
|
if readErr != nil || !bytes.Equal(before, after) {
|
|
t.Fatalf("unconfirmed reset changed the store: %v", readErr)
|
|
}
|
|
if code == exitSuccess || !strings.Contains(stderr.String(), "--confirm") || stdout.Len() != 0 {
|
|
t.Fatalf("unconfirmed reset: exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunAuthResetJSON(t *testing.T) {
|
|
path := withAuthStore(t)
|
|
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "reset", "--confirm", "--json"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
var payload struct {
|
|
Reset bool `json:"reset"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode JSON: %v (stdout=%q)", err, stdout.String())
|
|
}
|
|
if !payload.Reset {
|
|
t.Fatalf("payload = %+v, want reset=true", payload)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("status exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
|
t.Fatalf("expected empty store after JSON reset, got: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuthReset(t *testing.T) {
|
|
path := withAuthStore(t)
|
|
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "secret"}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "reset", "--confirm"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Reset OAuth token store") {
|
|
t.Fatalf("stdout = %q", stdout.String())
|
|
}
|
|
|
|
// Verify store is now empty.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
|
t.Fatalf("status exit = %d", code)
|
|
}
|
|
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
|
t.Fatalf("expected empty store after reset, got: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
|
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
|
// authorize params), not the generic manager path. The generic login accepts
|
|
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
|
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
|
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
|
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
|
}
|
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
|
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
|
}
|
|
// Case-insensitive provider name should still route.
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("auth login ChatGPT --device should be rejected")
|
|
}
|
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
|
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
|
}
|
|
}
|
|
|
|
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
|
// must not be silently dropped on the ChatGPT path. The Codex client
|
|
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
|
// scopes are rejected up front rather than plumbed through.
|
|
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
|
withAuthStore(t)
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
|
t.Fatal("auth login chatgpt --scope should be rejected")
|
|
}
|
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
|
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
|
|
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
|
t.Fatalf("seed config: %v", err)
|
|
}
|
|
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
|
|
|
line := ensureLoginProviderProfile(deps, "chatgpt")
|
|
if !strings.Contains(line, `Added provider "chatgpt"`) {
|
|
t.Fatalf("expected added-provider guidance, got %q", line)
|
|
}
|
|
if !strings.Contains(line, "zero providers use chatgpt") {
|
|
t.Fatalf("expected switch hint, got %q", line)
|
|
}
|
|
|
|
cfg := readCLIConfigFixture(t, configPath)
|
|
if cfg.ActiveProvider != "opengateway" {
|
|
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
|
|
}
|
|
names := make([]string, 0, len(cfg.Providers))
|
|
for _, provider := range cfg.Providers {
|
|
names = append(names, provider.Name)
|
|
}
|
|
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
|
|
t.Fatalf("expected chatgpt profile appended, got %v", names)
|
|
}
|
|
|
|
// A second login must be a no-op with switch guidance, not a duplicate.
|
|
line = ensureLoginProviderProfile(deps, "chatgpt")
|
|
if !strings.Contains(line, "already configured") {
|
|
t.Fatalf("expected already-configured guidance, got %q", line)
|
|
}
|
|
cfg = readCLIConfigFixture(t, configPath)
|
|
if len(cfg.Providers) != 2 {
|
|
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
|
|
}
|
|
}
|
|
|
|
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
|
|
|
line := ensureLoginProviderProfile(deps, "chatgpt")
|
|
if !strings.Contains(line, "set it active") {
|
|
t.Fatalf("fresh config should adopt the login as active, got %q", line)
|
|
}
|
|
cfg := readCLIConfigFixture(t, configPath)
|
|
if cfg.ActiveProvider != "chatgpt" {
|
|
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
|
|
}
|
|
}
|
|
|
|
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
|
|
|
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
|
|
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
|
|
}
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
t.Fatalf("config must not be created for a non-catalog login")
|
|
}
|
|
}
|
|
|
|
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
var cfg config.FileConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
return cfg
|
|
}
|