mirror of
git://git.gnupg.org/gnupg.git
synced 2025-07-02 22:46:30 +02:00
New gpg-agent command to list key information.
Gpgsm does now print the S/N of cards. Consider ephemeral keys during listing an export.
This commit is contained in:
parent
59d7a54e72
commit
a9c317a95c
20 changed files with 601 additions and 180 deletions
|
@ -1,3 +1,16 @@
|
|||
2009-03-06 Werner Koch <wk@g10code.com>
|
||||
|
||||
* command.c (cmd_keyinfo): New command.
|
||||
(register_commands): Register it.
|
||||
(agent_write_status): Make sure not to print LR or CR.
|
||||
* divert-scd.c (ask_for_card): Factor shadow info parsing out to ...
|
||||
* protect.c (parse_shadow_info): New.
|
||||
* findkey.c (agent_key_from_file): Use make_canon_sexp.
|
||||
(agent_write_private_key, unprotect, read_key_file)
|
||||
(agent_key_available): Use bin2hex.
|
||||
(agent_key_info_from_file): New.
|
||||
(read_key_file): Log no error message for ENOENT.
|
||||
|
||||
2009-03-05 Werner Koch <wk@g10code.com>
|
||||
|
||||
* divert-scd.c (getpin_cb): Support flag 'P'. Change max_digits
|
||||
|
@ -2227,7 +2240,7 @@ Fri Aug 18 14:27:14 CEST 2000 Werner Koch <wk@openit.de>
|
|||
|
||||
|
||||
Copyright 2001, 2002, 2003, 2004, 2005,
|
||||
2007 Free Software Foundation, Inc.
|
||||
2007, 2008, 2009 Free Software Foundation, Inc.
|
||||
|
||||
This file is free software; as a special exception the author gives
|
||||
unlimited permission to copy and/or distribute it, with or without
|
||||
|
|
|
@ -233,6 +233,9 @@ gpg_error_t agent_public_key_from_file (ctrl_t ctrl,
|
|||
const unsigned char *grip,
|
||||
gcry_sexp_t *result);
|
||||
int agent_key_available (const unsigned char *grip);
|
||||
gpg_error_t agent_key_info_from_file (ctrl_t ctrl, const unsigned char *grip,
|
||||
int *r_keytype,
|
||||
unsigned char **r_shadow_info);
|
||||
|
||||
/*-- call-pinentry.c --*/
|
||||
void initialize_module_call_pinentry (void);
|
||||
|
@ -294,6 +297,8 @@ int agent_shadow_key (const unsigned char *pubkey,
|
|||
unsigned char **result);
|
||||
int agent_get_shadow_info (const unsigned char *shadowkey,
|
||||
unsigned char const **shadow_info);
|
||||
gpg_error_t parse_shadow_info (const unsigned char *shadow_info,
|
||||
char **r_hexsn, char **r_idstr);
|
||||
|
||||
|
||||
/*-- trustlist.c --*/
|
||||
|
|
161
agent/command.c
161
agent/command.c
|
@ -1,6 +1,6 @@
|
|||
/* command.c - gpg-agent command handler
|
||||
* Copyright (C) 2001, 2002, 2003, 2004, 2005,
|
||||
* 2006, 2008 Free Software Foundation, Inc.
|
||||
* 2006, 2008, 2009 Free Software Foundation, Inc.
|
||||
*
|
||||
* This file is part of GnuPG.
|
||||
*
|
||||
|
@ -30,6 +30,9 @@
|
|||
#include <ctype.h>
|
||||
#include <unistd.h>
|
||||
#include <assert.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <dirent.h>
|
||||
|
||||
#include <assuan.h>
|
||||
|
||||
|
@ -308,8 +311,21 @@ agent_write_status (ctrl_t ctrl, const char *keyword, ...)
|
|||
*p++ = ' ';
|
||||
n++;
|
||||
}
|
||||
for ( ; *text && n < DIM (buf)-2; n++)
|
||||
*p++ = *text++;
|
||||
for ( ; *text && n < DIM (buf)-3; n++, text++)
|
||||
{
|
||||
if (*text == '\n')
|
||||
{
|
||||
*p++ = '\\';
|
||||
*p++ = 'n';
|
||||
}
|
||||
else if (*text == '\r')
|
||||
{
|
||||
*p++ = '\\';
|
||||
*p++ = 'r';
|
||||
}
|
||||
else
|
||||
*p++ = *text;
|
||||
}
|
||||
}
|
||||
*p = 0;
|
||||
err = assuan_write_status (ctx, keyword, buf);
|
||||
|
@ -806,7 +822,145 @@ cmd_readkey (assuan_context_t ctx, char *line)
|
|||
}
|
||||
|
||||
|
||||
|
||||
/* KEYINFO [--list] <keygrip>
|
||||
|
||||
Return information about the key specified by the KEYGRIP. If the
|
||||
key is not available GPG_ERR_NOT_FOUND is returned. If the option
|
||||
--list is given the keygrip is ignored and information about all
|
||||
available keys are returned. The information is returned as a
|
||||
status line with this format:
|
||||
|
||||
KEYINFO <keygrip> <type> <serialno> <idstr>
|
||||
|
||||
KEYGRIP is the keygrip.
|
||||
|
||||
TYPE is describes the type of the key:
|
||||
'D' - Regular key stored on disk,
|
||||
'T' - Key is stored on a smartcard (token).
|
||||
'-' - Unknown type.
|
||||
|
||||
SERIALNO is an ASCII string with the serial number of the
|
||||
smartcard. If the serial number is not known a single
|
||||
dash '-' is used instead.
|
||||
|
||||
IDSTR is the IDSTR used to distinguish keys on a smartcard. If it
|
||||
is not known a dash is used instead.
|
||||
|
||||
More information may be added in the future.
|
||||
*/
|
||||
static gpg_error_t
|
||||
do_one_keyinfo (ctrl_t ctrl, const unsigned char *grip)
|
||||
{
|
||||
gpg_error_t err;
|
||||
char hexgrip[40+1];
|
||||
int keytype;
|
||||
unsigned char *shadow_info = NULL;
|
||||
char *serialno = NULL;
|
||||
char *idstr = NULL;
|
||||
const char *keytypestr;
|
||||
|
||||
err = agent_key_info_from_file (ctrl, grip, &keytype, &shadow_info);
|
||||
if (err)
|
||||
goto leave;
|
||||
|
||||
/* Reformat the grip so that we use uppercase as good style. */
|
||||
bin2hex (grip, 20, hexgrip);
|
||||
|
||||
if (keytype == PRIVATE_KEY_CLEAR
|
||||
|| keytype == PRIVATE_KEY_PROTECTED)
|
||||
keytypestr = "D";
|
||||
else if (keytype == PRIVATE_KEY_SHADOWED)
|
||||
keytypestr = "T";
|
||||
else
|
||||
keytypestr = "-";
|
||||
|
||||
if (shadow_info)
|
||||
{
|
||||
err = parse_shadow_info (shadow_info, &serialno, &idstr);
|
||||
if (err)
|
||||
goto leave;
|
||||
}
|
||||
|
||||
err = agent_write_status (ctrl, "KEYINFO",
|
||||
hexgrip,
|
||||
keytypestr,
|
||||
serialno? serialno : "-",
|
||||
idstr? idstr : "-",
|
||||
NULL);
|
||||
leave:
|
||||
xfree (shadow_info);
|
||||
xfree (serialno);
|
||||
xfree (idstr);
|
||||
return err;
|
||||
}
|
||||
|
||||
|
||||
static int
|
||||
cmd_keyinfo (assuan_context_t ctx, char *line)
|
||||
{
|
||||
ctrl_t ctrl = assuan_get_pointer (ctx);
|
||||
int err;
|
||||
unsigned char grip[20];
|
||||
DIR *dir = NULL;
|
||||
int list_mode;
|
||||
|
||||
list_mode = has_option (line, "--list");
|
||||
line = skip_options (line);
|
||||
|
||||
if (list_mode)
|
||||
{
|
||||
char *dirname;
|
||||
struct dirent *dir_entry;
|
||||
char hexgrip[41];
|
||||
|
||||
dirname = make_filename_try (opt.homedir, GNUPG_PRIVATE_KEYS_DIR, NULL);
|
||||
if (!dirname)
|
||||
{
|
||||
err = gpg_error_from_syserror ();
|
||||
goto leave;
|
||||
}
|
||||
dir = opendir (dirname);
|
||||
if (!dir)
|
||||
{
|
||||
err = gpg_error_from_syserror ();
|
||||
xfree (dirname);
|
||||
goto leave;
|
||||
}
|
||||
xfree (dirname);
|
||||
|
||||
while ( (dir_entry = readdir (dir)) )
|
||||
{
|
||||
if (strlen (dir_entry->d_name) != 44
|
||||
|| strcmp (dir_entry->d_name + 40, ".key"))
|
||||
continue;
|
||||
strncpy (hexgrip, dir_entry->d_name, 40);
|
||||
hexgrip[40] = 0;
|
||||
|
||||
if ( hex2bin (hexgrip, grip, 20) < 0 )
|
||||
continue; /* Bad hex string. */
|
||||
|
||||
err = do_one_keyinfo (ctrl, grip);
|
||||
if (err)
|
||||
goto leave;
|
||||
}
|
||||
err = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
err = parse_keygrip (ctx, line, grip);
|
||||
if (err)
|
||||
goto leave;
|
||||
err = do_one_keyinfo (ctrl, grip);
|
||||
}
|
||||
|
||||
leave:
|
||||
if (dir)
|
||||
closedir (dir);
|
||||
if (err && gpg_err_code (err) != GPG_ERR_NOT_FOUND)
|
||||
log_error ("command keyinfo failed: %s\n", gpg_strerror (err));
|
||||
return err;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
@ -1574,6 +1728,7 @@ register_commands (assuan_context_t ctx)
|
|||
{ "GETEVENTCOUNTER",cmd_geteventcounter },
|
||||
{ "ISTRUSTED", cmd_istrusted },
|
||||
{ "HAVEKEY", cmd_havekey },
|
||||
{ "KEYINFO", cmd_keyinfo },
|
||||
{ "SIGKEY", cmd_sigkey },
|
||||
{ "SETKEY", cmd_sigkey },
|
||||
{ "SETKEYDESC", cmd_setkeydesc },
|
||||
|
|
|
@ -28,16 +28,14 @@
|
|||
#include <sys/stat.h>
|
||||
|
||||
#include "agent.h"
|
||||
#include "sexp-parse.h"
|
||||
#include "i18n.h"
|
||||
#include "sexp-parse.h"
|
||||
|
||||
|
||||
static int
|
||||
ask_for_card (ctrl_t ctrl, const unsigned char *shadow_info, char **r_kid)
|
||||
{
|
||||
int rc, i;
|
||||
const unsigned char *s;
|
||||
size_t n;
|
||||
char *serialno;
|
||||
int no_card = 0;
|
||||
char *desc;
|
||||
|
@ -45,39 +43,19 @@ ask_for_card (ctrl_t ctrl, const unsigned char *shadow_info, char **r_kid)
|
|||
int want_sn_displen;
|
||||
|
||||
*r_kid = NULL;
|
||||
s = shadow_info;
|
||||
if (*s != '(')
|
||||
return gpg_error (GPG_ERR_INV_SEXP);
|
||||
s++;
|
||||
n = snext (&s);
|
||||
if (!n)
|
||||
return gpg_error (GPG_ERR_INV_SEXP);
|
||||
want_sn = xtrymalloc (n*2+1);
|
||||
if (!want_sn)
|
||||
return out_of_core ();
|
||||
for (i=0; i < n; i++)
|
||||
sprintf (want_sn+2*i, "%02X", s[i]);
|
||||
s += n;
|
||||
|
||||
rc = parse_shadow_info (shadow_info, &want_sn, &want_kid);
|
||||
if (rc)
|
||||
return rc;
|
||||
|
||||
/* We assume that a 20 byte serial number is a standard one which
|
||||
seems to have the property to have a zero in the last nibble. We
|
||||
don't display this '0' because it may confuse the user */
|
||||
has the property to have a zero in the last nibble (Due to BCD
|
||||
representation). We don't display this '0' because it may
|
||||
confuse the user. */
|
||||
want_sn_displen = strlen (want_sn);
|
||||
if (want_sn_displen == 20 && want_sn[19] == '0')
|
||||
want_sn_displen--;
|
||||
|
||||
n = snext (&s);
|
||||
if (!n)
|
||||
return gpg_error (GPG_ERR_INV_SEXP);
|
||||
want_kid = xtrymalloc (n+1);
|
||||
if (!want_kid)
|
||||
{
|
||||
gpg_error_t tmperr = out_of_core ();
|
||||
xfree (want_sn);
|
||||
return tmperr;
|
||||
}
|
||||
memcpy (want_kid, s, n);
|
||||
want_kid[n] = 0;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
rc = agent_card_serialno (ctrl, &serialno);
|
||||
|
|
130
agent/findkey.c
130
agent/findkey.c
|
@ -56,14 +56,12 @@ int
|
|||
agent_write_private_key (const unsigned char *grip,
|
||||
const void *buffer, size_t length, int force)
|
||||
{
|
||||
int i;
|
||||
char *fname;
|
||||
FILE *fp;
|
||||
char hexgrip[40+4+1];
|
||||
int fd;
|
||||
|
||||
for (i=0; i < 20; i++)
|
||||
sprintf (hexgrip+2*i, "%02X", grip[i]);
|
||||
bin2hex (grip, 20, hexgrip);
|
||||
strcpy (hexgrip+40, ".key");
|
||||
|
||||
fname = make_filename (opt.homedir, GNUPG_PRIVATE_KEYS_DIR, hexgrip, NULL);
|
||||
|
@ -307,14 +305,12 @@ unprotect (ctrl_t ctrl, const char *desc_text,
|
|||
{
|
||||
struct pin_entry_info_s *pi;
|
||||
struct try_unprotect_arg_s arg;
|
||||
int rc, i;
|
||||
int rc;
|
||||
unsigned char *result;
|
||||
size_t resultlen;
|
||||
char hexgrip[40+1];
|
||||
|
||||
for (i=0; i < 20; i++)
|
||||
sprintf (hexgrip+2*i, "%02X", grip[i]);
|
||||
hexgrip[40] = 0;
|
||||
bin2hex (grip, 20, hexgrip);
|
||||
|
||||
/* First try to get it from the cache - if there is none or we can't
|
||||
unprotect it, we fall back to ask the user */
|
||||
|
@ -425,7 +421,7 @@ unprotect (ctrl_t ctrl, const char *desc_text,
|
|||
static gpg_error_t
|
||||
read_key_file (const unsigned char *grip, gcry_sexp_t *result)
|
||||
{
|
||||
int i, rc;
|
||||
int rc;
|
||||
char *fname;
|
||||
FILE *fp;
|
||||
struct stat st;
|
||||
|
@ -436,8 +432,7 @@ read_key_file (const unsigned char *grip, gcry_sexp_t *result)
|
|||
|
||||
*result = NULL;
|
||||
|
||||
for (i=0; i < 20; i++)
|
||||
sprintf (hexgrip+2*i, "%02X", grip[i]);
|
||||
bin2hex (grip, 20, hexgrip);
|
||||
strcpy (hexgrip+40, ".key");
|
||||
|
||||
fname = make_filename (opt.homedir, GNUPG_PRIVATE_KEYS_DIR, hexgrip, NULL);
|
||||
|
@ -445,7 +440,8 @@ read_key_file (const unsigned char *grip, gcry_sexp_t *result)
|
|||
if (!fp)
|
||||
{
|
||||
rc = gpg_error_from_syserror ();
|
||||
log_error ("can't open `%s': %s\n", fname, strerror (errno));
|
||||
if (gpg_err_code (rc) != GPG_ERR_ENOENT)
|
||||
log_error ("can't open `%s': %s\n", fname, strerror (errno));
|
||||
xfree (fname);
|
||||
return rc;
|
||||
}
|
||||
|
@ -488,11 +484,11 @@ read_key_file (const unsigned char *grip, gcry_sexp_t *result)
|
|||
|
||||
|
||||
/* Return the secret key as an S-Exp in RESULT after locating it using
|
||||
the grip. Returns NULL in RESULT if the operation should be
|
||||
diverted to a token; SHADOW_INFO will point then to an allocated
|
||||
S-Expression with the shadow_info part from the file. CACHE_MODE
|
||||
defines now the cache shall be used. DESC_TEXT may be set to
|
||||
present a custom description for the pinentry. */
|
||||
the GRIP. Stores NULL at RESULT if the operation shall be diverted
|
||||
to a token; in this case an allocated S-expression with the
|
||||
shadow_info part from the file is stored at SHADOW_INFO.
|
||||
CACHE_MODE defines now the cache shall be used. DESC_TEXT may be
|
||||
set to present a custom description for the pinentry. */
|
||||
gpg_error_t
|
||||
agent_key_from_file (ctrl_t ctrl, const char *desc_text,
|
||||
const unsigned char *grip, unsigned char **shadow_info,
|
||||
|
@ -513,20 +509,11 @@ agent_key_from_file (ctrl_t ctrl, const char *desc_text,
|
|||
return rc;
|
||||
|
||||
/* For use with the protection functions we also need the key as an
|
||||
canonical encoded S-expression in abuffer. Create this buffer
|
||||
canonical encoded S-expression in a buffer. Create this buffer
|
||||
now. */
|
||||
len = gcry_sexp_sprint (s_skey, GCRYSEXP_FMT_CANON, NULL, 0);
|
||||
assert (len);
|
||||
buf = xtrymalloc (len);
|
||||
if (!buf)
|
||||
{
|
||||
rc = gpg_error_from_syserror ();
|
||||
gcry_sexp_release (s_skey);
|
||||
return rc;
|
||||
}
|
||||
len = gcry_sexp_sprint (s_skey, GCRYSEXP_FMT_CANON, buf, len);
|
||||
assert (len);
|
||||
|
||||
rc = make_canon_sexp (s_skey, &buf, &len);
|
||||
if (rc)
|
||||
return rc;
|
||||
|
||||
switch (agent_private_key_type (buf))
|
||||
{
|
||||
|
@ -842,19 +829,94 @@ agent_public_key_from_file (ctrl_t ctrl,
|
|||
int
|
||||
agent_key_available (const unsigned char *grip)
|
||||
{
|
||||
int i;
|
||||
int result;
|
||||
char *fname;
|
||||
char hexgrip[40+4+1];
|
||||
|
||||
for (i=0; i < 20; i++)
|
||||
sprintf (hexgrip+2*i, "%02X", grip[i]);
|
||||
bin2hex (grip, 20, hexgrip);
|
||||
strcpy (hexgrip+40, ".key");
|
||||
|
||||
fname = make_filename (opt.homedir, GNUPG_PRIVATE_KEYS_DIR, hexgrip, NULL);
|
||||
i = !access (fname, R_OK)? 0 : -1;
|
||||
result = !access (fname, R_OK)? 0 : -1;
|
||||
xfree (fname);
|
||||
return i;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Return the information about the secret key specified by the binary
|
||||
keygrip GRIP. If the key is a shadowed one the shadow information
|
||||
will be stored at the address R_SHADOW_INFO as an allocated
|
||||
S-expression. */
|
||||
gpg_error_t
|
||||
agent_key_info_from_file (ctrl_t ctrl, const unsigned char *grip,
|
||||
int *r_keytype, unsigned char **r_shadow_info)
|
||||
{
|
||||
gpg_error_t err;
|
||||
unsigned char *buf;
|
||||
size_t len;
|
||||
int keytype;
|
||||
|
||||
(void)ctrl;
|
||||
|
||||
if (r_keytype)
|
||||
*r_keytype = PRIVATE_KEY_UNKNOWN;
|
||||
if (r_shadow_info)
|
||||
*r_shadow_info = NULL;
|
||||
|
||||
{
|
||||
gcry_sexp_t sexp;
|
||||
|
||||
err = read_key_file (grip, &sexp);
|
||||
if (err)
|
||||
{
|
||||
if (gpg_err_code (err) == GPG_ERR_ENOENT)
|
||||
return gpg_error (GPG_ERR_NOT_FOUND);
|
||||
else
|
||||
return err;
|
||||
}
|
||||
err = make_canon_sexp (sexp, &buf, &len);
|
||||
gcry_sexp_release (sexp);
|
||||
if (err)
|
||||
return err;
|
||||
}
|
||||
|
||||
keytype = agent_private_key_type (buf);
|
||||
switch (keytype)
|
||||
{
|
||||
case PRIVATE_KEY_CLEAR:
|
||||
break;
|
||||
case PRIVATE_KEY_PROTECTED:
|
||||
/* If we ever require it we could retrieve the comment fields
|
||||
from such a key. */
|
||||
break;
|
||||
case PRIVATE_KEY_SHADOWED:
|
||||
if (r_shadow_info)
|
||||
{
|
||||
const unsigned char *s;
|
||||
size_t n;
|
||||
|
||||
err = agent_get_shadow_info (buf, &s);
|
||||
if (!err)
|
||||
{
|
||||
n = gcry_sexp_canon_len (s, 0, NULL, NULL);
|
||||
assert (n);
|
||||
*r_shadow_info = xtrymalloc (n);
|
||||
if (!*r_shadow_info)
|
||||
err = gpg_error_from_syserror ();
|
||||
else
|
||||
memcpy (*r_shadow_info, s, n);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
err = gpg_error (GPG_ERR_BAD_SECKEY);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!err && r_keytype)
|
||||
*r_keytype = keytype;
|
||||
|
||||
xfree (buf);
|
||||
return err;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* protect.c - Un/Protect a secret key
|
||||
* Copyright (C) 1998, 1999, 2000, 2001, 2002,
|
||||
* 2003, 2007 Free Software Foundation, Inc.
|
||||
* 2003, 2007, 2009 Free Software Foundation, Inc.
|
||||
*
|
||||
* This file is part of GnuPG.
|
||||
*
|
||||
|
@ -1105,3 +1105,68 @@ agent_get_shadow_info (const unsigned char *shadowkey,
|
|||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/* Parse the canonical encoded SHADOW_INFO S-expression. On success
|
||||
the hex encoded serial number is returned as a malloced strings at
|
||||
R_HEXSN and the Id string as a malloced string at R_IDSTR. On
|
||||
error an error code is returned and NULL is stored at the result
|
||||
parameters addresses. If the serial number or the ID string is not
|
||||
required, NULL may be passed for them. */
|
||||
gpg_error_t
|
||||
parse_shadow_info (const unsigned char *shadow_info,
|
||||
char **r_hexsn, char **r_idstr)
|
||||
{
|
||||
const unsigned char *s;
|
||||
size_t n;
|
||||
|
||||
if (r_hexsn)
|
||||
*r_hexsn = NULL;
|
||||
if (r_idstr)
|
||||
*r_idstr = NULL;
|
||||
|
||||
s = shadow_info;
|
||||
if (*s != '(')
|
||||
return gpg_error (GPG_ERR_INV_SEXP);
|
||||
s++;
|
||||
n = snext (&s);
|
||||
if (!n)
|
||||
return gpg_error (GPG_ERR_INV_SEXP);
|
||||
|
||||
if (r_hexsn)
|
||||
{
|
||||
*r_hexsn = bin2hex (s, n, NULL);
|
||||
if (!*r_hexsn)
|
||||
return gpg_error_from_syserror ();
|
||||
}
|
||||
s += n;
|
||||
|
||||
n = snext (&s);
|
||||
if (!n)
|
||||
{
|
||||
if (r_hexsn)
|
||||
{
|
||||
xfree (*r_hexsn);
|
||||
*r_hexsn = NULL;
|
||||
}
|
||||
return gpg_error (GPG_ERR_INV_SEXP);
|
||||
}
|
||||
|
||||
if (r_idstr)
|
||||
{
|
||||
*r_idstr = xtrymalloc (n+1);
|
||||
if (!*r_idstr)
|
||||
{
|
||||
if (r_hexsn)
|
||||
{
|
||||
xfree (*r_hexsn);
|
||||
*r_hexsn = NULL;
|
||||
}
|
||||
return gpg_error_from_syserror ();
|
||||
}
|
||||
memcpy (*r_idstr, s, n);
|
||||
(*r_idstr)[n] = 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue