owpengram-server/internal/store/postgres/authorization.go
2026-09-09 02:49:30 +03:00

596 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package postgres
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AuthorizationStore 用 PostgreSQL 实现 store.AuthorizationStore。
type AuthorizationStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewAuthorizationStore 基于 pgx 连接池(或事务)创建 AuthorizationStore。
func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore {
return &AuthorizationStore{db: db, q: sqlcgen.New(db)}
}
func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error {
if a.Hash == 0 {
a.Hash = authorizationHash(a.AuthKeyID)
}
err := withAuthIdentityTx(ctx, s.db, "bind authorization", func(tx pgx.Tx) error {
return bindAuthorization(ctx, tx, a)
})
if err != nil {
return fmt.Errorf("upsert authorization: %w", err)
}
return nil
}
// bindAuthorization 把 auth_key→user 绑定和设备 update baseline 作为同一个状态边界提交。
//
// 锁顺序固定为:目标 user advisory/row → auth_keys 母行 →
// user_update_watermarks → user_update_retention → 目标 update_states。其中 watermark
// 与 retention 两个 row lock 的顺序和 pruneConfirmedUserPrefixTx 一致,使新授权的
// observed baseline 和 retained floor 不会交叉提交成静默空洞。母行锁又能在首次
// authorization 尚不存在时串行化同一
// raw auth key 的并发登录/换号。user 锁与账号 tombstone 使用同一顺序;因此 Bind
// 要么先提交并被随后删除事务撤销,要么等删除提交后看见 tombstone 并拒绝,不能在
// 删除事务枚举 authorization 之后重新绑定账号。
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
keyID := authKeyIDToInt64(a.AuthKeyID)
tx, ok := db.(pgx.Tx)
if !ok {
return fmt.Errorf("bind authorization requires a transaction")
}
if err := lockUsersForUpdate(ctx, tx, a.UserID); err != nil {
return fmt.Errorf("lock authorization user: %w", err)
}
var active bool
if err := db.QueryRow(ctx, `
SELECT deleted_at IS NULL
FROM users
WHERE id = $1
FOR UPDATE`, a.UserID).Scan(&active); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrUserNotFound
}
return fmt.Errorf("lock authorization user row: %w", err)
}
if !active {
return domain.ErrAccountDeleted
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{keyID}); err != nil {
return err
}
var (
lockedKeyID int64
expiresAt int
authLayer int
layerObservationID int64
)
if err := db.QueryRow(ctx, `
SELECT auth_key_id, expires_at, layer, layer_observation_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, keyID).Scan(&lockedKeyID, &expiresAt, &authLayer, &layerObservationID); err != nil {
return fmt.Errorf("lock auth key for authorization: %w", err)
}
if expiresAt != 0 {
return store.ErrAuthKeyNotPermanent
}
if authLayer < 0 || layerObservationID < 0 || (layerObservationID > 0 && authLayer == 0) {
return fmt.Errorf(
"authorization auth-key layer invariant violation: auth key %x has layer %d observation %d",
a.AuthKeyID, authLayer, layerObservationID,
)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
VALUES ($1, 0)
ON CONFLICT (user_id) DO NOTHING`, a.UserID); err != nil {
return fmt.Errorf("ensure authorization user update watermark: %w", err)
}
var currentPts int
if err := db.QueryRow(ctx, `
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1
FOR UPDATE`, a.UserID).Scan(&currentPts); err != nil {
return fmt.Errorf("lock authorization user update watermark: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_retention (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING`, a.UserID); err != nil {
return fmt.Errorf("ensure authorization user update retention: %w", err)
}
var retainedFloor int
if err := db.QueryRow(ctx, `
SELECT retained_through_pts
FROM user_update_retention
WHERE user_id = $1
FOR UPDATE`, a.UserID).Scan(&retainedFloor); err != nil {
return fmt.Errorf("lock authorization user update retention: %w", err)
}
if retainedFloor > currentPts {
return fmt.Errorf(
"authorization update baseline invariant violation: user %d retained floor %d exceeds contiguous watermark %d",
a.UserID, retainedFloor, currentPts,
)
}
// 每次 Bind 都是一次显式登录 baselinedelivered pts 推进到已锁定的账号连续水位;
// observed 只推进到已删除的 retained floor不把 live tail 伪装成客户端确认。
// 历史遗留的 state 若超出账号 contiguous watermark必须 fail-fast不得用
// GREATEST 把非法 future cursor 保留下来。WHERE 也封住“预检后并发插入”的竞态。
tag, err := db.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
VALUES ($1, $2, $3, 0, EXTRACT(EPOCH FROM now())::int, 0, $4)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = GREATEST(update_states.pts, EXCLUDED.pts),
qts = GREATEST(update_states.qts, EXCLUDED.qts),
date = GREATEST(update_states.date, EXCLUDED.date),
seq = GREATEST(update_states.seq, EXCLUDED.seq),
observed_pts = GREATEST(update_states.observed_pts, EXCLUDED.observed_pts),
updated_at = now()
WHERE update_states.pts >= 0
AND update_states.pts <= $3
AND update_states.observed_pts <= $3`, keyID, a.UserID, currentPts, retainedFloor)
if err != nil {
return fmt.Errorf("upsert authorization update baseline: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf(
"authorization update baseline invariant violation: auth key %x user %d has pts or observed_pts outside contiguous watermark %d",
a.AuthKeyID, a.UserID, currentPts,
)
}
if _, err := db.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = $1
AND user_id <> $2`, keyID, a.UserID); err != nil {
return fmt.Errorf("delete stale cross-user update states: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (auth_key_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
hash = EXCLUDED.hash,
layer = EXCLUDED.layer,
device_model = EXCLUDED.device_model,
platform = EXCLUDED.platform,
system_version = EXCLUDED.system_version,
api_id = EXCLUDED.api_id,
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
password_pending = EXCLUDED.password_pending,
created_at = now(),
active_at = now()`,
keyID, a.UserID, a.Hash, int32(authLayer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
); err != nil {
return fmt.Errorf("write authorization: %w", err)
}
return nil
}
func (s *AuthorizationStore) ByAuthKey(ctx context.Context, id [8]byte) (domain.Authorization, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations WHERE auth_key_id = $1`, authKeyIDToInt64(id))
a := domain.Authorization{AuthKeyID: id}
if err := row.Scan(
&a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("get authorization: %w", err)
}
return a, true, nil
}
func (s *AuthorizationStore) UpdateClientInfo(ctx context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
if _, err := s.db.Exec(ctx, `
UPDATE authorizations SET
layer = CASE WHEN $2 > 0 THEN $2 ELSE layer END,
device_model = CASE WHEN $3 <> '' THEN $3 ELSE device_model END,
platform = CASE WHEN $4 <> '' THEN $4 ELSE platform END,
system_version = CASE WHEN $5 <> '' THEN $5 ELSE system_version END,
api_id = CASE WHEN $6 <> 0 THEN $6 ELSE api_id END,
app_version = CASE WHEN $7 <> '' THEN $7 ELSE app_version END,
ip = CASE WHEN $8 <> '' THEN $8 ELSE ip END,
active_at = now()
WHERE auth_key_id = $1`,
authKeyIDToInt64(id), int32(info.Layer), info.DeviceModel, info.Platform,
info.SystemVersion, int32(info.APIID), info.AppVersion, info.IP,
); err != nil {
return fmt.Errorf("update authorization client info: %w", err)
}
return nil
}
// MarkPasswordPassed atomically promotes only the pending identity whose
// password was just verified. A concurrent cross-user Bind must not let A's
// proof clear B's password_pending flag.
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte, expectedUserID int64) error {
tag, err := s.db.Exec(ctx, `
UPDATE authorizations SET password_pending = false, created_at = now(), active_at = now()
WHERE auth_key_id = $1 AND user_id = $2 AND password_pending`, authKeyIDToInt64(id), expectedUserID)
if err != nil {
return fmt.Errorf("mark authorization password passed: %w", err)
}
if tag.RowsAffected() != 1 {
return store.ErrAuthorizationStateChanged
}
return nil
}
func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) {
rows, err := s.q.ListAuthorizationsByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list authorizations by user: %w", err)
}
out := make([]domain.Authorization, 0, len(rows))
for _, row := range rows {
out = append(out, authorizationFromRow(row))
}
return out, nil
}
func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error {
if err := s.q.DeleteAuthorization(ctx, authKeyIDToInt64(id)); err != nil {
return fmt.Errorf("delete authorization: %w", err)
}
return nil
}
func (s *AuthorizationStore) DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
row := s.db.QueryRow(ctx, `
DELETE FROM authorizations
WHERE user_id = $1 AND hash = $2
RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at`, userID, hash)
var a domain.Authorization
var authKeyID int64
if err := row.Scan(
&authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.CreatedAt, &a.ActiveAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("delete authorization by hash: %w", err)
}
a.AuthKeyID = authKeyIDFromInt64(authKeyID)
return a, true, nil
}
// RevokeByHash 是远程踢设备的持久化事实入口:删除业务 authorization 与
// device update state但保留 permanent/temp 协议 key 和 binding。这样被踢客户端
// 重连后仍可完成 MTProto 解密,并由 RPC gate 返回 AUTH_KEY_UNREGISTERED若先删除
// 协议 key客户端只能收到 transport -404无法可靠清理本地登录态。
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
var (
a domain.Authorization
found bool
)
err := withAuthIdentityTx(ctx, s.db, "revoke authorization by hash", func(tx pgx.Tx) error {
var err error
a, found, err = revokeByHashTx(ctx, tx, userID, hash)
return err
})
if err != nil {
return domain.Authorization{}, false, err
}
return a, found, nil
}
// revokeByHashTx deliberately uses separate READ COMMITTED statements. The first
// lookup is only a candidate. Bind locks auth_keys before changing authorization
// ownership, so revocation must lock the same parent row and then re-read the
// owner/hash from a fresh statement snapshot. Otherwise an A->B re-login that
// commits while revoke waits can be deleted using A's stale target snapshot.
func revokeByHashTx(ctx context.Context, tx pgx.Tx, userID, hash int64) (domain.Authorization, bool, error) {
var candidate int64
if err := tx.QueryRow(ctx, `
SELECT auth_key_id
FROM authorizations
WHERE user_id = $1 AND hash = $2`, userID, hash).Scan(&candidate); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("select revoke candidate by hash: %w", err)
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
var locked int64
if err := tx.QueryRow(ctx, `
SELECT auth_key_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, candidate).Scan(&locked); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("lock revoke auth key by hash: %w", err)
}
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE auth_key_id = $1 AND user_id = $2 AND hash = $3
FOR UPDATE`, candidate, userID, hash))
if err != nil {
return domain.Authorization{}, false, fmt.Errorf("revalidate revoke authorization by hash: %w", err)
}
if !found {
return domain.Authorization{}, false, nil
}
if err := deleteRevokedAuthorizationStateTx(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
return a, true, nil
}
func (s *AuthorizationStore) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
rows, err := s.db.Query(ctx, `
DELETE FROM authorizations
WHERE user_id = $1 AND auth_key_id <> $2
RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at`, userID, authKeyIDToInt64(keepAuthKeyID))
if err != nil {
return nil, fmt.Errorf("delete authorizations by user: %w", err)
}
defer rows.Close()
out := make([]domain.Authorization, 0)
for rows.Next() {
var a domain.Authorization
var authKeyID int64
if err := rows.Scan(
&authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.CreatedAt, &a.ActiveAt,
); err != nil {
return nil, fmt.Errorf("scan deleted authorization: %w", err)
}
a.AuthKeyID = authKeyIDFromInt64(authKeyID)
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate deleted authorizations: %w", err)
}
return out, nil
}
// RevokeByUserExcept 批量删除业务 authorization保留 keepAuthKeyID 对应的当前设备;
// 被撤销设备的协议 key/binding 保留,以便重连后取得 RPC 401 并完成客户端退出。
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
var out []domain.Authorization
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
var err error
out, err = revokeByUserExceptTx(ctx, tx, userID, authKeyIDToInt64(keepAuthKeyID))
return err
})
return out, err
}
func revokeByUserExceptTx(ctx context.Context, tx pgx.Tx, userID, keepAuthKeyID int64) ([]domain.Authorization, error) {
candidateRows, err := tx.Query(ctx, `
SELECT auth_key_id
FROM authorizations
WHERE user_id = $1 AND auth_key_id <> $2
ORDER BY auth_key_id`, userID, keepAuthKeyID)
if err != nil {
return nil, fmt.Errorf("select revoke candidates by user: %w", err)
}
candidates := make([]int64, 0)
for candidateRows.Next() {
var id int64
if err := candidateRows.Scan(&id); err != nil {
candidateRows.Close()
return nil, fmt.Errorf("scan revoke candidate by user: %w", err)
}
candidates = append(candidates, id)
}
if err := candidateRows.Err(); err != nil {
candidateRows.Close()
return nil, fmt.Errorf("iterate revoke candidates by user: %w", err)
}
candidateRows.Close()
if len(candidates) == 0 {
return []domain.Authorization{}, nil
}
if err := lockPermanentAuthIdentities(ctx, tx, candidates); err != nil {
return nil, err
}
// Advisory keys are already all held in final int32-hash order. Parent rows
// are then locked by their real bigint IDs for deterministic batch behavior.
lockRows, err := tx.Query(ctx, `
SELECT auth_key_id
FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])
ORDER BY auth_key_id
FOR UPDATE`, candidates)
if err != nil {
return nil, fmt.Errorf("lock revoke auth keys by user: %w", err)
}
for lockRows.Next() {
var ignored int64
if err := lockRows.Scan(&ignored); err != nil {
lockRows.Close()
return nil, fmt.Errorf("scan locked revoke auth key: %w", err)
}
}
if err := lockRows.Err(); err != nil {
lockRows.Close()
return nil, fmt.Errorf("iterate locked revoke auth keys: %w", err)
}
lockRows.Close()
// This is intentionally a new statement snapshot after all parent locks.
// Keys that changed owner while waiting are omitted and must remain intact.
rows, err := tx.Query(ctx, `
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations
WHERE user_id = $1
AND auth_key_id <> $2
AND auth_key_id = ANY($3::bigint[])
ORDER BY created_at, auth_key_id
FOR UPDATE`, userID, keepAuthKeyID, candidates)
if err != nil {
return nil, fmt.Errorf("revalidate revoke authorizations by user: %w", err)
}
out := make([]domain.Authorization, 0)
for rows.Next() {
a, err := scanRevokedAuthorizationRow(rows)
if err != nil {
rows.Close()
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate revoked authorizations: %w", err)
}
rows.Close()
if len(out) == 0 {
return out, nil
}
targets := make([]int64, len(out))
for i := range out {
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
}
if err := deleteRevokedAuthorizationStateTx(ctx, tx, targets); err != nil {
return nil, err
}
return out, nil
}
func deleteRevokedAuthorizationStateTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM authorizations
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked authorizations: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked authorizations: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}
// deleteProtocolAuthIdentitiesTx permanently removes permanent identities and
// their derived temp keys. Remote account.resetAuthorization/
// auth.resetAuthorizations must not use this helper: those clients need the
// protocol key long enough to reconnect and receive an RPC-level 401.
func deleteProtocolAuthIdentitiesTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM auth_keys
WHERE auth_key_id IN (
SELECT temp_auth_key_id
FROM temp_auth_key_bindings
WHERE perm_auth_key_id = ANY($1::bigint[])
)`, authKeyIDs); err != nil {
return fmt.Errorf("delete temporary auth keys: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete protocol identity update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete permanent auth keys: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}
func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
return domain.Authorization{
AuthKeyID: authKeyIDFromInt64(row.AuthKeyID),
UserID: row.UserID,
Hash: row.Hash,
Layer: int(row.Layer),
DeviceModel: row.DeviceModel,
Platform: row.Platform,
SystemVersion: row.SystemVersion,
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
CreatedAt: row.CreatedAt.Time,
ActiveAt: row.ActiveAt.Time,
}
}
type authorizationScanner interface {
Scan(dest ...any) error
}
func scanRevokedAuthorization(row authorizationScanner) (domain.Authorization, bool, error) {
a, err := scanRevokedAuthorizationRow(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, err
}
return a, true, nil
}
func scanRevokedAuthorizationRow(row authorizationScanner) (domain.Authorization, error) {
var a domain.Authorization
var authKeyID int64
if err := row.Scan(
&authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt,
); err != nil {
return domain.Authorization{}, err
}
a.AuthKeyID = authKeyIDFromInt64(authKeyID)
return a, nil
}
func authorizationHash(authKeyID [8]byte) int64 {
sum := sha256.Sum256(authKeyID[:])
hash := int64(binary.LittleEndian.Uint64(sum[:8]))
if hash == 0 {
return 1
}
return hash
}