mirror of
https://gitlab.freedesktop.org/dbus/dbus.git
synced 2026-05-06 17:28:01 +02:00
2003-02-13 Havoc Pennington <hp@pobox.com>
* dbus/dbus-auth.c (handle_server_data_external_mech): args to dbus_credentials_match were backward * dbus/dbus-auth-script.c (_dbus_auth_script_run): support NO_CREDENTIALS and ROOT_CREDENTIALS * dbus/dbus-auth.c (_dbus_auth_do_work): move get_state() routine into here. Never process more commands after we've reached an end state; store further data as unused bytes. * test/data/auth/*: add more auth tests * dbus/dbus-auth-script.c (_dbus_auth_script_run): support EXPECT command to match exact string and EXPECT_UNUSED to match unused bytes * test/Makefile.am (dist-hook): fix to dist all the test stuff
This commit is contained in:
parent
c9ea8fac50
commit
5970d04af5
11 changed files with 314 additions and 49 deletions
20
ChangeLog
20
ChangeLog
|
|
@ -1,3 +1,23 @@
|
|||
2003-02-13 Havoc Pennington <hp@pobox.com>
|
||||
|
||||
* dbus/dbus-auth.c (handle_server_data_external_mech): args to
|
||||
dbus_credentials_match were backward
|
||||
|
||||
* dbus/dbus-auth-script.c (_dbus_auth_script_run): support
|
||||
NO_CREDENTIALS and ROOT_CREDENTIALS
|
||||
|
||||
* dbus/dbus-auth.c (_dbus_auth_do_work): move get_state() routine
|
||||
into here. Never process more commands after we've reached an
|
||||
end state; store further data as unused bytes.
|
||||
|
||||
* test/data/auth/*: add more auth tests
|
||||
|
||||
* dbus/dbus-auth-script.c (_dbus_auth_script_run): support EXPECT
|
||||
command to match exact string and EXPECT_UNUSED to match unused
|
||||
bytes
|
||||
|
||||
* test/Makefile.am (dist-hook): fix to dist all the test stuff
|
||||
|
||||
2003-02-12 Havoc Pennington <hp@pobox.com>
|
||||
|
||||
* dbus/dbus-string.c (_dbus_string_pop_line): fix to also strip
|
||||
|
|
|
|||
|
|
@ -111,3 +111,7 @@ dbus_test_SOURCES= \
|
|||
dbus-test-main.c
|
||||
|
||||
dbus_test_LDADD= $(DBUS_CLIENT_LIBS) libdbus-convenience.la libdbus-1.la
|
||||
|
||||
## mop up the gcov files
|
||||
clean-local:
|
||||
/bin/rm *.bb *.bbg *.da *.gcov || true
|
||||
|
|
@ -42,11 +42,15 @@
|
|||
* @{
|
||||
*/
|
||||
|
||||
/* this is slightly different from the other append_quoted_string
|
||||
* in dbus-message-builder.c
|
||||
*/
|
||||
static dbus_bool_t
|
||||
append_quoted_string (DBusString *dest,
|
||||
const DBusString *quoted)
|
||||
{
|
||||
dbus_bool_t in_quotes = FALSE;
|
||||
dbus_bool_t in_backslash = FALSE;
|
||||
int i;
|
||||
|
||||
i = 0;
|
||||
|
|
@ -55,8 +59,33 @@ append_quoted_string (DBusString *dest,
|
|||
unsigned char b;
|
||||
|
||||
b = _dbus_string_get_byte (quoted, i);
|
||||
|
||||
if (in_quotes)
|
||||
|
||||
if (in_backslash)
|
||||
{
|
||||
unsigned char a;
|
||||
|
||||
if (b == 'r')
|
||||
a = '\r';
|
||||
else if (b == 'n')
|
||||
a = '\n';
|
||||
else if (b == '\\')
|
||||
a = '\\';
|
||||
else
|
||||
{
|
||||
_dbus_warn ("bad backslashed byte %c\n", b);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (!_dbus_string_append_byte (dest, a))
|
||||
return FALSE;
|
||||
|
||||
in_backslash = FALSE;
|
||||
}
|
||||
else if (b == '\\')
|
||||
{
|
||||
in_backslash = TRUE;
|
||||
}
|
||||
else if (in_quotes)
|
||||
{
|
||||
if (b == '\'')
|
||||
in_quotes = FALSE;
|
||||
|
|
@ -222,6 +251,8 @@ _dbus_auth_script_run (const DBusString *filename)
|
|||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"CLIENT"))
|
||||
{
|
||||
DBusCredentials creds;
|
||||
|
||||
if (auth != NULL)
|
||||
{
|
||||
_dbus_warn ("already created a DBusAuth (CLIENT or SERVER given twice)\n");
|
||||
|
|
@ -234,10 +265,15 @@ _dbus_auth_script_run (const DBusString *filename)
|
|||
_dbus_warn ("no memory to create DBusAuth\n");
|
||||
goto out;
|
||||
}
|
||||
|
||||
_dbus_credentials_from_current_process (&creds);
|
||||
_dbus_auth_set_credentials (auth, &creds);
|
||||
}
|
||||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"SERVER"))
|
||||
{
|
||||
DBusCredentials creds;
|
||||
|
||||
if (auth != NULL)
|
||||
{
|
||||
_dbus_warn ("already created a DBusAuth (CLIENT or SERVER given twice)\n");
|
||||
|
|
@ -250,6 +286,9 @@ _dbus_auth_script_run (const DBusString *filename)
|
|||
_dbus_warn ("no memory to create DBusAuth\n");
|
||||
goto out;
|
||||
}
|
||||
|
||||
_dbus_credentials_from_current_process (&creds);
|
||||
_dbus_auth_set_credentials (auth, &creds);
|
||||
}
|
||||
else if (auth == NULL)
|
||||
{
|
||||
|
|
@ -257,6 +296,24 @@ _dbus_auth_script_run (const DBusString *filename)
|
|||
goto out;
|
||||
|
||||
}
|
||||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"NO_CREDENTIALS"))
|
||||
{
|
||||
DBusCredentials creds = { -1, -1, -1 };
|
||||
_dbus_auth_set_credentials (auth, &creds);
|
||||
}
|
||||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"ROOT_CREDENTIALS"))
|
||||
{
|
||||
DBusCredentials creds = { -1, 0, 0 };
|
||||
_dbus_auth_set_credentials (auth, &creds);
|
||||
}
|
||||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"SILLY_CREDENTIALS"))
|
||||
{
|
||||
DBusCredentials creds = { -1, 4312, 1232 };
|
||||
_dbus_auth_set_credentials (auth, &creds);
|
||||
}
|
||||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"SEND"))
|
||||
{
|
||||
|
|
@ -291,10 +348,49 @@ _dbus_auth_script_run (const DBusString *filename)
|
|||
_dbus_string_free (&to_send);
|
||||
goto out;
|
||||
}
|
||||
|
||||
/* Replace USERNAME_BASE64 with our username in base64 */
|
||||
{
|
||||
int where;
|
||||
|
||||
if (_dbus_string_find (&to_send, 0,
|
||||
"USERNAME_BASE64", &where))
|
||||
{
|
||||
DBusString username;
|
||||
|
||||
if (!_dbus_string_init (&username, _DBUS_INT_MAX))
|
||||
{
|
||||
_dbus_warn ("no memory for username\n");
|
||||
_dbus_string_free (&to_send);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (!_dbus_string_append_our_uid (&username))
|
||||
{
|
||||
_dbus_warn ("no memory for username\n");
|
||||
_dbus_string_free (&username);
|
||||
_dbus_string_free (&to_send);
|
||||
goto out;
|
||||
}
|
||||
|
||||
_dbus_string_delete (&to_send, where, strlen ("USERNAME_BASE64"));
|
||||
|
||||
if (!_dbus_string_base64_encode (&username, 0,
|
||||
&to_send, where))
|
||||
{
|
||||
_dbus_warn ("no memory to subst USERNAME_BASE64\n");
|
||||
_dbus_string_free (&username);
|
||||
_dbus_string_free (&to_send);
|
||||
goto out;
|
||||
}
|
||||
|
||||
_dbus_string_free (&username);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_dbus_auth_bytes_received (auth, &to_send))
|
||||
{
|
||||
_dbus_warn ("not enough memory to call bytes_received\n");
|
||||
_dbus_warn ("not enough memory to call bytes_received, or can't add bytes to auth object already in end state\n");
|
||||
_dbus_string_free (&to_send);
|
||||
goto out;
|
||||
}
|
||||
|
|
@ -360,6 +456,99 @@ _dbus_auth_script_run (const DBusString *filename)
|
|||
|
||||
_dbus_string_free (&received);
|
||||
}
|
||||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"EXPECT_UNUSED"))
|
||||
{
|
||||
DBusString expected;
|
||||
DBusString unused;
|
||||
|
||||
_dbus_string_delete_first_word (&line);
|
||||
|
||||
if (!_dbus_string_init (&expected, _DBUS_INT_MAX))
|
||||
{
|
||||
_dbus_warn ("no mem to allocate string expected\n");
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (!append_quoted_string (&expected, &line))
|
||||
{
|
||||
_dbus_warn ("failed to append quoted string line %d\n",
|
||||
line_no);
|
||||
_dbus_string_free (&expected);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (!_dbus_string_init (&unused, _DBUS_INT_MAX))
|
||||
{
|
||||
_dbus_warn ("no mem to allocate string unused\n");
|
||||
_dbus_string_free (&expected);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (!_dbus_auth_get_unused_bytes (auth, &unused))
|
||||
{
|
||||
_dbus_warn ("couldn't get unused bytes\n");
|
||||
_dbus_string_free (&expected);
|
||||
_dbus_string_free (&unused);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (_dbus_string_equal (&expected, &unused))
|
||||
{
|
||||
_dbus_string_free (&expected);
|
||||
_dbus_string_free (&unused);
|
||||
}
|
||||
else
|
||||
{
|
||||
const char *e1, *h1;
|
||||
_dbus_string_get_const_data (&expected, &e1);
|
||||
_dbus_string_get_const_data (&unused, &h1);
|
||||
_dbus_warn ("Expected unused bytes '%s' and have '%s'\n",
|
||||
e1, h1);
|
||||
_dbus_string_free (&expected);
|
||||
_dbus_string_free (&unused);
|
||||
goto out;
|
||||
}
|
||||
}
|
||||
else if (_dbus_string_starts_with_c_str (&line,
|
||||
"EXPECT"))
|
||||
{
|
||||
DBusString expected;
|
||||
|
||||
_dbus_string_delete_first_word (&line);
|
||||
|
||||
if (!_dbus_string_init (&expected, _DBUS_INT_MAX))
|
||||
{
|
||||
_dbus_warn ("no mem to allocate string expected\n");
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (!append_quoted_string (&expected, &line))
|
||||
{
|
||||
_dbus_warn ("failed to append quoted string line %d\n",
|
||||
line_no);
|
||||
_dbus_string_free (&expected);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (_dbus_string_equal_len (&expected, &from_auth,
|
||||
_dbus_string_get_length (&expected)))
|
||||
{
|
||||
_dbus_string_delete (&from_auth, 0,
|
||||
_dbus_string_get_length (&expected));
|
||||
_dbus_string_free (&expected);
|
||||
}
|
||||
else
|
||||
{
|
||||
const char *e1, *h1;
|
||||
_dbus_string_get_const_data (&expected, &e1);
|
||||
_dbus_string_get_const_data (&from_auth, &h1);
|
||||
_dbus_warn ("Expected exact string '%s' and have '%s'\n",
|
||||
e1, h1);
|
||||
_dbus_string_free (&expected);
|
||||
goto out;
|
||||
}
|
||||
}
|
||||
else
|
||||
goto parse_failed;
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@
|
|||
*
|
||||
* The file doc/dbus-sasl-profile.txt documents the network protocol
|
||||
* used for authentication.
|
||||
*
|
||||
* @todo some SASL profiles require sending the empty string as a
|
||||
* challenge/response, but we don't currently allow that in our
|
||||
* protocol.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -283,31 +287,6 @@ _dbus_auth_new (int size)
|
|||
return auth;
|
||||
}
|
||||
|
||||
static DBusAuthState
|
||||
get_state (DBusAuth *auth)
|
||||
{
|
||||
if (DBUS_AUTH_IS_SERVER (auth) &&
|
||||
DBUS_AUTH_SERVER (auth)->failures >=
|
||||
DBUS_AUTH_SERVER (auth)->max_failures)
|
||||
auth->need_disconnect = TRUE;
|
||||
|
||||
if (auth->need_disconnect)
|
||||
return DBUS_AUTH_STATE_NEED_DISCONNECT;
|
||||
else if (auth->authenticated)
|
||||
{
|
||||
if (_dbus_string_get_length (&auth->incoming) > 0)
|
||||
return DBUS_AUTH_STATE_AUTHENTICATED_WITH_UNUSED_BYTES;
|
||||
else
|
||||
return DBUS_AUTH_STATE_AUTHENTICATED;
|
||||
}
|
||||
else if (auth->needed_memory)
|
||||
return DBUS_AUTH_STATE_WAITING_FOR_MEMORY;
|
||||
else if (_dbus_string_get_length (&auth->outgoing) > 0)
|
||||
return DBUS_AUTH_STATE_HAVE_BYTES_TO_SEND;
|
||||
else
|
||||
return DBUS_AUTH_STATE_WAITING_FOR_INPUT;
|
||||
}
|
||||
|
||||
static void
|
||||
shutdown_mech (DBusAuth *auth)
|
||||
{
|
||||
|
|
@ -411,6 +390,7 @@ handle_server_data_external_mech (DBusAuth *auth,
|
|||
if (_dbus_string_get_length (&auth->identity) > 0)
|
||||
{
|
||||
/* Tried to send two auth identities, wtf */
|
||||
_dbus_verbose ("client tried to send auth identity, but we already have one\n");
|
||||
return send_rejected (auth);
|
||||
}
|
||||
else
|
||||
|
|
@ -453,7 +433,10 @@ handle_server_data_external_mech (DBusAuth *auth,
|
|||
{
|
||||
if (!_dbus_credentials_from_uid_string (&auth->identity,
|
||||
&desired_identity))
|
||||
return send_rejected (auth);
|
||||
{
|
||||
_dbus_verbose ("could not get credentials from uid string\n");
|
||||
return send_rejected (auth);
|
||||
}
|
||||
}
|
||||
|
||||
if (desired_identity.uid < 0)
|
||||
|
|
@ -462,8 +445,8 @@ handle_server_data_external_mech (DBusAuth *auth,
|
|||
return send_rejected (auth);
|
||||
}
|
||||
|
||||
if (_dbus_credentials_match (&auth->credentials,
|
||||
&desired_identity))
|
||||
if (_dbus_credentials_match (&desired_identity,
|
||||
&auth->credentials))
|
||||
{
|
||||
/* client has authenticated */
|
||||
_dbus_verbose ("authenticated client with UID %d matching socket credentials UID %d\n",
|
||||
|
|
@ -482,6 +465,9 @@ handle_server_data_external_mech (DBusAuth *auth,
|
|||
}
|
||||
else
|
||||
{
|
||||
_dbus_verbose ("credentials uid=%d gid=%d do not allow uid=%d gid=%d\n",
|
||||
auth->credentials.uid, auth->credentials.gid,
|
||||
desired_identity.uid, desired_identity.gid);
|
||||
return send_rejected (auth);
|
||||
}
|
||||
}
|
||||
|
|
@ -1061,7 +1047,7 @@ process_command (DBusAuth *auth)
|
|||
int i, j;
|
||||
dbus_bool_t retval;
|
||||
|
||||
_dbus_verbose (" trying process_command()\n");
|
||||
/* _dbus_verbose (" trying process_command()\n"); */
|
||||
|
||||
retval = FALSE;
|
||||
|
||||
|
|
@ -1296,10 +1282,7 @@ _dbus_auth_unref (DBusAuth *auth)
|
|||
*/
|
||||
DBusAuthState
|
||||
_dbus_auth_do_work (DBusAuth *auth)
|
||||
{
|
||||
if (DBUS_AUTH_IN_END_STATE (auth))
|
||||
return get_state (auth);
|
||||
|
||||
{
|
||||
auth->needed_memory = FALSE;
|
||||
|
||||
/* Max amount we'll buffer up before deciding someone's on crack */
|
||||
|
|
@ -1307,6 +1290,9 @@ _dbus_auth_do_work (DBusAuth *auth)
|
|||
|
||||
do
|
||||
{
|
||||
if (DBUS_AUTH_IN_END_STATE (auth))
|
||||
break;
|
||||
|
||||
if (_dbus_string_get_length (&auth->incoming) > MAX_BUFFER ||
|
||||
_dbus_string_get_length (&auth->outgoing) > MAX_BUFFER)
|
||||
{
|
||||
|
|
@ -1325,8 +1311,27 @@ _dbus_auth_do_work (DBusAuth *auth)
|
|||
}
|
||||
}
|
||||
while (process_command (auth));
|
||||
|
||||
return get_state (auth);
|
||||
|
||||
if (DBUS_AUTH_IS_SERVER (auth) &&
|
||||
DBUS_AUTH_SERVER (auth)->failures >=
|
||||
DBUS_AUTH_SERVER (auth)->max_failures)
|
||||
auth->need_disconnect = TRUE;
|
||||
|
||||
if (auth->need_disconnect)
|
||||
return DBUS_AUTH_STATE_NEED_DISCONNECT;
|
||||
else if (auth->authenticated)
|
||||
{
|
||||
if (_dbus_string_get_length (&auth->incoming) > 0)
|
||||
return DBUS_AUTH_STATE_AUTHENTICATED_WITH_UNUSED_BYTES;
|
||||
else
|
||||
return DBUS_AUTH_STATE_AUTHENTICATED;
|
||||
}
|
||||
else if (auth->needed_memory)
|
||||
return DBUS_AUTH_STATE_WAITING_FOR_MEMORY;
|
||||
else if (_dbus_string_get_length (&auth->outgoing) > 0)
|
||||
return DBUS_AUTH_STATE_HAVE_BYTES_TO_SEND;
|
||||
else
|
||||
return DBUS_AUTH_STATE_WAITING_FOR_INPUT;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1383,7 +1388,7 @@ _dbus_auth_bytes_sent (DBusAuth *auth,
|
|||
*
|
||||
* @param auth the auth conversation
|
||||
* @param str the received bytes.
|
||||
* @returns #FALSE if not enough memory to store the bytes.
|
||||
* @returns #FALSE if not enough memory to store the bytes or we were already authenticated.
|
||||
*/
|
||||
dbus_bool_t
|
||||
_dbus_auth_bytes_received (DBusAuth *auth,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
D-BUS Authentication
|
||||
===
|
||||
|
||||
|
|
@ -117,6 +116,9 @@ DATA Command
|
|||
contains a base64-encoded block of data to be interpreted
|
||||
according to the SASL mechanism in use.
|
||||
|
||||
Some SASL mechanisms support sending an "empty string";
|
||||
FIXME we need some way to do this.
|
||||
|
||||
BEGIN Command
|
||||
===
|
||||
|
||||
|
|
|
|||
|
|
@ -37,13 +37,13 @@ unbase64_LDADD=$(TEST_LIBS)
|
|||
break_loader_LDADD= $(TEST_LIBS)
|
||||
bus_test_LDADD=$(TEST_LIBS) $(top_builddir)/bus/libdbus-daemon.la
|
||||
|
||||
dist-hook:
|
||||
DIRS="data data/valid-messages data/invalid-messages data/incomplete-messages" ; \
|
||||
for D in $$DIRS; do \
|
||||
test -d $(distdir)/$$D || mkdir $(distdir)/$$D ; \
|
||||
done ; \
|
||||
FILES=`find -name "*.message"` ; \
|
||||
for F in $$FILES; do \
|
||||
echo '-- Disting file '$$F ; \
|
||||
cp $$F $(distdir)/$$F ; \
|
||||
dist-hook: \
|
||||
DIRS="data data/valid-messages data/invalid-messages data/incomplete-messages data/auth" ; \
|
||||
for D in $$DIRS; do \
|
||||
test -d $(distdir)/$$D || mkdir $(distdir)/$$D ; \
|
||||
done ; \
|
||||
FILES=`find -name "*.message" -o -name "*.message-raw" -o -name "*.auth-script"` ; \
|
||||
for F in $$FILES; do \
|
||||
echo '-- Disting file '$$F ; \
|
||||
cp $$F $(distdir)/$$F ; \
|
||||
done
|
||||
|
|
|
|||
8
test/data/auth/external-failed.auth-script
Normal file
8
test/data/auth/external-failed.auth-script
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
## this tests that auth of type EXTERNAL without credentials will fail
|
||||
|
||||
SERVER
|
||||
NO_CREDENTIALS
|
||||
SEND 'AUTH EXTERNAL USERNAME_BASE64'
|
||||
EXPECT_COMMAND REJECTED
|
||||
EXPECT_STATE WAITING_FOR_INPUT
|
||||
|
||||
10
test/data/auth/external-root.auth-script
Normal file
10
test/data/auth/external-root.auth-script
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
## this tests we can auth EXTERNAL as ourselves, with root credentials
|
||||
|
||||
SERVER
|
||||
ROOT_CREDENTIALS
|
||||
SEND 'AUTH EXTERNAL USERNAME_BASE64'
|
||||
EXPECT_COMMAND OK
|
||||
EXPECT_STATE WAITING_FOR_INPUT
|
||||
SEND 'BEGIN'
|
||||
EXPECT_STATE AUTHENTICATED
|
||||
|
||||
8
test/data/auth/external-silly.auth-script
Normal file
8
test/data/auth/external-silly.auth-script
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
## this tests we can't auth with silly credentials
|
||||
|
||||
SERVER
|
||||
SILLY_CREDENTIALS
|
||||
SEND 'AUTH EXTERNAL USERNAME_BASE64'
|
||||
EXPECT_COMMAND REJECTED
|
||||
EXPECT_STATE WAITING_FOR_INPUT
|
||||
|
||||
9
test/data/auth/external-successful.auth-script
Normal file
9
test/data/auth/external-successful.auth-script
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
## this tests a successful auth of type EXTERNAL
|
||||
|
||||
SERVER
|
||||
SEND 'AUTH EXTERNAL USERNAME_BASE64'
|
||||
EXPECT_COMMAND OK
|
||||
EXPECT_STATE WAITING_FOR_INPUT
|
||||
SEND 'BEGIN'
|
||||
EXPECT_STATE AUTHENTICATED
|
||||
|
||||
10
test/data/auth/extra-bytes.auth-script
Normal file
10
test/data/auth/extra-bytes.auth-script
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
## this tests that we have the expected extra bytes at the end
|
||||
|
||||
SERVER
|
||||
SEND 'AUTH EXTERNAL USERNAME_BASE64'
|
||||
EXPECT_COMMAND OK
|
||||
EXPECT_STATE WAITING_FOR_INPUT
|
||||
SEND 'BEGIN\r\nHello'
|
||||
EXPECT_STATE AUTHENTICATED_WITH_UNUSED_BYTES
|
||||
EXPECT_UNUSED 'Hello\r\n'
|
||||
EXPECT_STATE AUTHENTICATED
|
||||
Loading…
Add table
Reference in a new issue