forked from osmocom/wireshark
A simplified version of tvbuffs:
- Essentially no changes from current dissector de facto tvbuff usage; - Do away with 'usage_counts' and with 'used_in' GSLists; - Manage tvb chains via a simple doubly linked list. - API changes: a. tvb_increment_usage_count() and tvb_decrement_usage_count() no longer exist; b. tvb_free_chain() can only be called for the 'top-level' (initial) tvb of a chain) or for a tvb not in a chain. c. tvb_free() now just calls tvb_free_chain() [should have no impact on existing dissectors]. svn path=/trunk/; revision=40264
This commit is contained in:
parent
ff30e7df57
commit
14309d2c72
|
@ -49,17 +49,15 @@ typedef struct {
|
|||
} tvb_comp_t;
|
||||
|
||||
struct tvbuff {
|
||||
/* Doubly linked list pointers */
|
||||
tvbuff_t *next;
|
||||
tvbuff_t *previous;
|
||||
|
||||
/* Record-keeping */
|
||||
tvbuff_type type;
|
||||
gboolean initialized;
|
||||
guint usage_count;
|
||||
struct tvbuff *ds_tvb; /**< data source top-level tvbuff */
|
||||
|
||||
/** The tvbuffs in which this tvbuff is a member
|
||||
* (that is, a backing tvbuff for a TVBUFF_SUBSET
|
||||
* or a member for a TVB_COMPOSITE) */
|
||||
GSList *used_in;
|
||||
|
||||
/** TVBUFF_SUBSET and TVBUFF_COMPOSITE keep track
|
||||
* of the other tvbuff's they use */
|
||||
union {
|
||||
|
|
197
epan/tvbuff.c
197
epan/tvbuff.c
|
@ -66,16 +66,16 @@ tvb_init(tvbuff_t *tvb, const tvbuff_type type)
|
|||
tvb_backing_t *backing;
|
||||
tvb_comp_t *composite;
|
||||
|
||||
tvb->type = type;
|
||||
tvb->initialized = FALSE;
|
||||
tvb->usage_count = 1;
|
||||
tvb->length = 0;
|
||||
tvb->reported_length = 0;
|
||||
tvb->free_cb = NULL;
|
||||
tvb->real_data = NULL;
|
||||
tvb->raw_offset = -1;
|
||||
tvb->used_in = NULL;
|
||||
tvb->ds_tvb = NULL;
|
||||
tvb->previous = NULL;
|
||||
tvb->next = NULL;
|
||||
tvb->type = type;
|
||||
tvb->initialized = FALSE;
|
||||
tvb->length = 0;
|
||||
tvb->reported_length = 0;
|
||||
tvb->free_cb = NULL;
|
||||
tvb->real_data = NULL;
|
||||
tvb->raw_offset = -1;
|
||||
tvb->ds_tvb = NULL;
|
||||
|
||||
switch(type) {
|
||||
case TVBUFF_REAL_DATA:
|
||||
|
@ -83,17 +83,17 @@ tvb_init(tvbuff_t *tvb, const tvbuff_type type)
|
|||
break;
|
||||
|
||||
case TVBUFF_SUBSET:
|
||||
backing = &tvb->tvbuffs.subset;
|
||||
backing = &tvb->tvbuffs.subset;
|
||||
backing->tvb = NULL;
|
||||
backing->offset = 0;
|
||||
backing->length = 0;
|
||||
break;
|
||||
|
||||
case TVBUFF_COMPOSITE:
|
||||
composite = &tvb->tvbuffs.composite;
|
||||
composite->tvbs = NULL;
|
||||
composite->start_offsets = NULL;
|
||||
composite->end_offsets = NULL;
|
||||
composite = &tvb->tvbuffs.composite;
|
||||
composite->tvbs = NULL;
|
||||
composite->start_offsets = NULL;
|
||||
composite->end_offsets = NULL;
|
||||
break;
|
||||
|
||||
default:
|
||||
|
@ -131,7 +131,7 @@ tvb_new_octet_aligned(tvbuff_t *tvb, guint32 bit_offset, gint32 no_of_bits)
|
|||
{
|
||||
tvbuff_t *sub_tvb = NULL;
|
||||
guint32 byte_offset;
|
||||
gint32 datalen, i;
|
||||
gint32 datalen, i;
|
||||
guint8 left, right, remaining_bits, *buf;
|
||||
const guint8 *data;
|
||||
|
||||
|
@ -177,7 +177,7 @@ tvb_new_octet_aligned(tvbuff_t *tvb, guint32 bit_offset, gint32 no_of_bits)
|
|||
buf[datalen-1] &= left_aligned_bitmask[remaining_bits];
|
||||
|
||||
sub_tvb = tvb_new_child_real_data(tvb, buf, datalen, datalen);
|
||||
|
||||
|
||||
return sub_tvb;
|
||||
}
|
||||
|
||||
|
@ -191,102 +191,74 @@ tvb_new_with_subset(const guint subset_tvb_offset, const guint subset_tvb_length
|
|||
return tvb;
|
||||
}
|
||||
|
||||
void
|
||||
tvb_free(tvbuff_t* tvb)
|
||||
static void
|
||||
tvb_free_internal(tvbuff_t* tvb)
|
||||
{
|
||||
tvbuff_t *member_tvb;
|
||||
tvb_comp_t *composite;
|
||||
GSList *slist;
|
||||
|
||||
tvb->usage_count--;
|
||||
DISSECTOR_ASSERT(tvb);
|
||||
|
||||
if (tvb->usage_count == 0) {
|
||||
switch (tvb->type) {
|
||||
case TVBUFF_REAL_DATA:
|
||||
if (tvb->free_cb) {
|
||||
/*
|
||||
* XXX - do this with a union?
|
||||
*/
|
||||
tvb->free_cb((gpointer)tvb->real_data);
|
||||
}
|
||||
break;
|
||||
|
||||
case TVBUFF_SUBSET:
|
||||
/* This will be NULL if tvb_new_subset() fails because
|
||||
* reported_length < -1 */
|
||||
if (tvb->tvbuffs.subset.tvb) {
|
||||
tvb_decrement_usage_count(tvb->tvbuffs.subset.tvb, 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case TVBUFF_COMPOSITE:
|
||||
composite = &tvb->tvbuffs.composite;
|
||||
for (slist = composite->tvbs; slist != NULL ; slist = slist->next) {
|
||||
member_tvb = slist->data;
|
||||
tvb_decrement_usage_count(member_tvb, 1);
|
||||
}
|
||||
|
||||
g_slist_free(composite->tvbs);
|
||||
|
||||
g_free(composite->start_offsets);
|
||||
g_free(composite->end_offsets);
|
||||
if (tvb->real_data) {
|
||||
/*
|
||||
* XXX - do this with a union?
|
||||
*/
|
||||
g_free((gpointer)tvb->real_data);
|
||||
}
|
||||
|
||||
break;
|
||||
switch (tvb->type) {
|
||||
case TVBUFF_REAL_DATA:
|
||||
if (tvb->free_cb) {
|
||||
/*
|
||||
* XXX - do this with a union?
|
||||
*/
|
||||
tvb->free_cb((gpointer)tvb->real_data);
|
||||
}
|
||||
break;
|
||||
|
||||
if (tvb->used_in) {
|
||||
g_slist_free(tvb->used_in);
|
||||
case TVBUFF_SUBSET:
|
||||
/* Nothing */
|
||||
break;
|
||||
|
||||
case TVBUFF_COMPOSITE:
|
||||
composite = &tvb->tvbuffs.composite;
|
||||
|
||||
g_slist_free(composite->tvbs);
|
||||
|
||||
g_free(composite->start_offsets);
|
||||
g_free(composite->end_offsets);
|
||||
if (tvb->real_data) {
|
||||
/*
|
||||
* XXX - do this with a union?
|
||||
*/
|
||||
g_free((gpointer)tvb->real_data);
|
||||
}
|
||||
break;
|
||||
|
||||
g_slice_free(tvbuff_t, tvb);
|
||||
default:
|
||||
DISSECTOR_ASSERT_NOT_REACHED();
|
||||
}
|
||||
|
||||
g_slice_free(tvbuff_t, tvb);
|
||||
}
|
||||
|
||||
guint
|
||||
tvb_increment_usage_count(tvbuff_t* tvb, const guint count)
|
||||
/* XXX: just call tvb_free_chain();
|
||||
* Not removed so that existing dissectors using tvb_free() need not be changed.
|
||||
* I'd argue that existing calls to tvb_free() should have actually beeen
|
||||
* calls to tvb_free_chain() although the calls were OK as long as no
|
||||
* subsets, etc had been created on the tvb. */
|
||||
void
|
||||
tvb_free(tvbuff_t *tvb)
|
||||
{
|
||||
tvb->usage_count += count;
|
||||
|
||||
return tvb->usage_count;
|
||||
}
|
||||
|
||||
guint
|
||||
tvb_decrement_usage_count(tvbuff_t* tvb, const guint count)
|
||||
{
|
||||
if (tvb->usage_count <= count) {
|
||||
tvb->usage_count = 1;
|
||||
tvb_free(tvb);
|
||||
return 0;
|
||||
}
|
||||
else {
|
||||
tvb->usage_count -= count;
|
||||
return tvb->usage_count;
|
||||
}
|
||||
|
||||
tvb_free_chain(tvb);
|
||||
}
|
||||
|
||||
void
|
||||
tvb_free_chain(tvbuff_t* tvb)
|
||||
{
|
||||
GSList *slist;
|
||||
|
||||
/* Recursively call tvb_free_chain() */
|
||||
for (slist = tvb->used_in; slist != NULL ; slist = slist->next) {
|
||||
tvb_free_chain( (tvbuff_t*)slist->data );
|
||||
tvbuff_t *next_tvb;
|
||||
DISSECTOR_ASSERT(tvb);
|
||||
DISSECTOR_ASSERT((tvb->previous==NULL) && "tvb_free_chain(): tvb must be initial tvb in chain");
|
||||
while (tvb) {
|
||||
next_tvb=tvb->next;
|
||||
DISSECTOR_ASSERT(((next_tvb==NULL) || (tvb==next_tvb->previous)) && "tvb_free_chain(): corrupt tvb chain ?");
|
||||
tvb_free_internal(tvb);
|
||||
tvb = next_tvb;
|
||||
}
|
||||
|
||||
/* Stop the recursion */
|
||||
tvb_free(tvb);
|
||||
}
|
||||
|
||||
|
||||
|
||||
void
|
||||
tvb_set_free_cb(tvbuff_t* tvb, const tvbuff_free_cb_t func)
|
||||
{
|
||||
|
@ -296,10 +268,15 @@ tvb_set_free_cb(tvbuff_t* tvb, const tvbuff_free_cb_t func)
|
|||
}
|
||||
|
||||
static void
|
||||
add_to_used_in_list(tvbuff_t *tvb, tvbuff_t *used_in)
|
||||
add_to_chain(tvbuff_t *parent, tvbuff_t *child)
|
||||
{
|
||||
tvb->used_in = g_slist_prepend(tvb->used_in, used_in);
|
||||
tvb_increment_usage_count(tvb, 1);
|
||||
DISSECTOR_ASSERT(parent && child);
|
||||
DISSECTOR_ASSERT(!child->next && !child->previous);
|
||||
child->next = parent->next;
|
||||
child->previous = parent;
|
||||
if (parent->next)
|
||||
parent->next->previous = child;
|
||||
parent->next = child;
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -309,7 +286,7 @@ tvb_set_child_real_data_tvbuff(tvbuff_t* parent, tvbuff_t* child)
|
|||
DISSECTOR_ASSERT(parent->initialized);
|
||||
DISSECTOR_ASSERT(child->initialized);
|
||||
DISSECTOR_ASSERT(child->type == TVBUFF_REAL_DATA);
|
||||
add_to_used_in_list(parent, child);
|
||||
add_to_chain(parent, child);
|
||||
}
|
||||
|
||||
static void
|
||||
|
@ -349,7 +326,6 @@ tvb_new_real_data(const guint8* data, const guint length, const gint reported_le
|
|||
* so its data source tvbuff is itself.
|
||||
*/
|
||||
tvb->ds_tvb = tvb;
|
||||
|
||||
return tvb;
|
||||
}
|
||||
|
||||
|
@ -516,7 +492,7 @@ tvb_set_subset_no_exceptions(tvbuff_t *tvb, tvbuff_t *backing, const gint report
|
|||
tvb->reported_length = reported_length;
|
||||
}
|
||||
tvb->initialized = TRUE;
|
||||
add_to_used_in_list(backing, tvb);
|
||||
add_to_chain(backing, tvb);
|
||||
|
||||
/* Optimization. If the backing buffer has a pointer to contiguous, real data,
|
||||
* then we can point directly to our starting offset in that buffer */
|
||||
|
@ -594,6 +570,20 @@ tvb_new_subset_remaining(tvbuff_t *backing, const gint backing_offset)
|
|||
return tvb;
|
||||
}
|
||||
|
||||
/*
|
||||
* Composite tvb
|
||||
*
|
||||
* 1. A composite tvb is automatically chained to its first member when the
|
||||
* tvb is finalized.
|
||||
* This means that composite tvb members must all be in the same chain.
|
||||
* ToDo: enforce this: By searching the chain?
|
||||
*/
|
||||
tvbuff_t*
|
||||
tvb_new_composite(void)
|
||||
{
|
||||
return tvb_new(TVBUFF_COMPOSITE);
|
||||
}
|
||||
|
||||
void
|
||||
tvb_composite_append(tvbuff_t* tvb, tvbuff_t* member)
|
||||
{
|
||||
|
@ -603,7 +593,6 @@ tvb_composite_append(tvbuff_t* tvb, tvbuff_t* member)
|
|||
DISSECTOR_ASSERT(tvb->type == TVBUFF_COMPOSITE);
|
||||
composite = &tvb->tvbuffs.composite;
|
||||
composite->tvbs = g_slist_append( composite->tvbs, member );
|
||||
add_to_used_in_list(tvb, member);
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -615,14 +604,8 @@ tvb_composite_prepend(tvbuff_t* tvb, tvbuff_t* member)
|
|||
DISSECTOR_ASSERT(tvb->type == TVBUFF_COMPOSITE);
|
||||
composite = &tvb->tvbuffs.composite;
|
||||
composite->tvbs = g_slist_prepend( composite->tvbs, member );
|
||||
add_to_used_in_list(tvb, member);
|
||||
}
|
||||
|
||||
tvbuff_t*
|
||||
tvb_new_composite(void)
|
||||
{
|
||||
return tvb_new(TVBUFF_COMPOSITE);
|
||||
}
|
||||
|
||||
void
|
||||
tvb_composite_finalize(tvbuff_t* tvb)
|
||||
|
@ -653,7 +636,7 @@ tvb_composite_finalize(tvbuff_t* tvb)
|
|||
composite->end_offsets[i] = tvb->length - 1;
|
||||
i++;
|
||||
}
|
||||
|
||||
add_to_chain((tvbuff_t *)composite->tvbs->data, tvb); /* chain composite tvb to first member */
|
||||
tvb->initialized = TRUE;
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,6 @@
|
|||
* virtual data.
|
||||
*/
|
||||
|
||||
|
||||
/** The different types of tvbuff's */
|
||||
typedef enum {
|
||||
TVBUFF_REAL_DATA,
|
||||
|
@ -61,6 +60,30 @@ typedef enum {
|
|||
struct tvbuff;
|
||||
typedef struct tvbuff tvbuff_t;
|
||||
|
||||
/**
|
||||
* tvbuffs: dissector use and management
|
||||
*
|
||||
* Consider a collection of tvbs as being a chain or stack of tvbs.
|
||||
*
|
||||
* The top-level dissector (packet.c) pushes the initial tvb onto the stack
|
||||
* (starts the chain) and then calls a sub-dissector which in turn calls the next
|
||||
* sub-dissector and so on. Each sub-dissector may chain additional tvbs to
|
||||
* the tvb handed to that dissector. After dissection is complete and control has
|
||||
* returned to the top-level dissector, the chain of tvbs (stack) is free'd
|
||||
* via a call to tvb_free_chain() (in epan_dissect_cleanup()).
|
||||
*
|
||||
* A dissector:
|
||||
* - Can chain new tvbs (real, subset, composite) to the tvb
|
||||
* handed to the dissector via tvb_subset(), tvb_new_child_real_data(), etc.
|
||||
* (Subset and Composite tvbs should reference only tvbs which are
|
||||
* already part of the chain).
|
||||
* - Must not save a pointer to a tvb handed to the dissector for
|
||||
* use when dissecting another frame; A higher level function
|
||||
* may very well free the chain). This also applies to any tvbs chained
|
||||
* by the dissector to the tvb handed to the dissector.
|
||||
* - Can create its own tvb chain (using tvb_new_real_data() which
|
||||
* the dissector is free to manage as desired. */
|
||||
|
||||
/** TVBUFF_REAL_DATA contains a guint8* that points to real data.
|
||||
* The data is allocated and contiguous.
|
||||
*
|
||||
|
@ -77,63 +100,44 @@ typedef struct tvbuff tvbuff_t;
|
|||
* Once a tvbuff is create/initialized/finalized, the tvbuff is read-only.
|
||||
* That is, it cannot point to any other data. A new tvbuff must be created if
|
||||
* you want a tvbuff that points to other data.
|
||||
*
|
||||
* tvbuff's are normally chained together to allow efficient de-allocation of tvbuff's.
|
||||
*
|
||||
*/
|
||||
|
||||
typedef void (*tvbuff_free_cb_t)(void*);
|
||||
|
||||
/** Returns a pointer to a newly initialized tvbuff. Note that
|
||||
* tvbuff's of types TVBUFF_SUBSET and TVBUFF_COMPOSITE
|
||||
* require further initialization via the appropriate functions */
|
||||
* require further initialization via the appropriate functions. */
|
||||
extern tvbuff_t* tvb_new(tvbuff_type);
|
||||
|
||||
/** Extracs from bit offset number of bits and
|
||||
* Returns a pointer to a newly initialized tvbuff. with the bits
|
||||
* octet aligned.
|
||||
/** Extracts 'number of bits' starting at 'bit offset'.
|
||||
* Returns a pointer to a newly initialized ep_alloc'd REAL_DATA
|
||||
* tvbuff with the bits octet aligned.
|
||||
*/
|
||||
extern tvbuff_t* tvb_new_octet_aligned(tvbuff_t *tvb, guint32 bit_offset, gint32 no_of_bits);
|
||||
|
||||
|
||||
/** Marks a tvbuff for freeing. The guint8* data of a TVBUFF_REAL_DATA
|
||||
* is *never* freed by the tvbuff routines. The tvbuff itself is actually freed
|
||||
* once its usage count drops to 0.
|
||||
*
|
||||
* Usage counts increment for any time the tvbuff is
|
||||
* used as a member of another tvbuff, i.e., as the backing buffer for
|
||||
* a TVBUFF_SUBSET or as a member of a TVBUFF_COMPOSITE.
|
||||
*
|
||||
* Although you may call tvb_free(), the tvbuff may still be in use
|
||||
* by other tvbuff's (TVBUFF_SUBSET or TVBUFF_COMPOSITE), so it is not
|
||||
* safe, unless you know otherwise, to free your guint8* data. If you
|
||||
* cannot be sure that your TVBUFF_REAL_DATA is not in use by another
|
||||
* tvbuff, register a callback with tvb_set_free_cb(); when your tvbuff
|
||||
* is _really_ freed, then your callback will be called, and at that time
|
||||
* you can free your original data.
|
||||
*
|
||||
* The caller can artificially increment/decrement the usage count
|
||||
* with tvbuff_increment_usage_count()/tvbuff_decrement_usage_count().
|
||||
*/
|
||||
/** Free a tvbuff_t and all tvbuffs chained from it
|
||||
* The tvbuff must be 'the 'head' (initial) tvb of a chain or
|
||||
* must not be in a chain.
|
||||
* If specified, a callback to free the tvbuff data will be invoked
|
||||
* for each tvbuff free'd */
|
||||
extern void tvb_free(tvbuff_t*);
|
||||
|
||||
/** Free the tvbuff_t and all tvbuff's created from it. */
|
||||
/** Free the tvbuff_t and all tvbuffs chained from it.
|
||||
* The tvbuff must be 'the 'head' (initial) tvb of a chain or
|
||||
* must not be in a chain.
|
||||
* If specified, a callback to free the tvbuff data will be invoked
|
||||
* for each tvbuff free'd */
|
||||
extern void tvb_free_chain(tvbuff_t*);
|
||||
|
||||
/** Both return the new usage count, after the increment or decrement */
|
||||
extern guint tvb_increment_usage_count(tvbuff_t*, const guint count);
|
||||
|
||||
/** If a decrement causes the usage count to drop to 0, a the tvbuff
|
||||
* is immediately freed. Be sure you know exactly what you're doing
|
||||
* if you decide to use this function, as another tvbuff could
|
||||
* still have a pointer to the just-freed tvbuff, causing corrupted data
|
||||
* or a segfault in the future */
|
||||
extern guint tvb_decrement_usage_count(tvbuff_t*, const guint count);
|
||||
|
||||
/** Set a callback function to call when a tvbuff is actually freed
|
||||
* (once the usage count drops to 0). One argument is passed to
|
||||
* that callback --- a void* that points to the real data.
|
||||
* Obviously, this only applies to a TVBUFF_REAL_DATA tvbuff. */
|
||||
* One argument is passed to that callback --- a void* that points
|
||||
* to the real data. Obviously, this only applies to a
|
||||
* TVBUFF_REAL_DATA tvbuff. */
|
||||
extern void tvb_set_free_cb(tvbuff_t*, const tvbuff_free_cb_t);
|
||||
|
||||
|
||||
/** Attach a TVBUFF_REAL_DATA tvbuff to a parent tvbuff. This connection
|
||||
* is used during a tvb_free_chain()... the "child" TVBUFF_REAL_DATA acts
|
||||
* as if is part of the chain-of-creation of the parent tvbuff, although it
|
||||
|
@ -141,22 +145,24 @@ extern void tvb_set_free_cb(tvbuff_t*, const tvbuff_free_cb_t);
|
|||
* run some operation on it, like decryption or decompression, and make a new
|
||||
* tvbuff from it, yet want the new tvbuff to be part of the chain. The reality
|
||||
* is that the new tvbuff *is* part of the "chain of creation", but in a way
|
||||
* that these tvbuff routines is ignorant of. Use this function to make
|
||||
* that these tvbuff routines are ignorant of. Use this function to make
|
||||
* the tvbuff routines knowledgable of this fact. */
|
||||
extern void tvb_set_child_real_data_tvbuff(tvbuff_t* parent, tvbuff_t* child);
|
||||
|
||||
extern tvbuff_t* tvb_new_child_real_data(tvbuff_t* parent, const guint8* data, const guint length,
|
||||
const gint reported_length);
|
||||
|
||||
/**Sets parameters for TVBUFF_REAL_DATA. Can throw ReportedBoundsError. */
|
||||
/** Sets parameters for TVBUFF_REAL_DATA. Can throw ReportedBoundsError. */
|
||||
extern void tvb_set_real_data(tvbuff_t*, const guint8* data, const guint length,
|
||||
const gint reported_length);
|
||||
|
||||
/** Combination of tvb_new() and tvb_set_real_data(). Can throw ReportedBoundsError. */
|
||||
/** Combination of tvb_new() and tvb_set_real_data(). Can throw ReportedBoundsError.
|
||||
* Normally, a callback to free the data should be registered using tvb_set_free_cb();
|
||||
* when this tvbuff is freed, then your callback will be called, and at that time
|
||||
* you can free your original data. */
|
||||
extern tvbuff_t* tvb_new_real_data(const guint8* data, const guint length,
|
||||
const gint reported_length);
|
||||
|
||||
|
||||
/** Define the subset of the backing buffer to use.
|
||||
*
|
||||
* 'backing_offset' can be negative, to indicate bytes from
|
||||
|
@ -291,7 +297,8 @@ extern guint16 tvb_get_bits16(tvbuff_t *tvb, gint bit_offset, const gint no_of_b
|
|||
extern guint32 tvb_get_bits32(tvbuff_t *tvb, gint bit_offset, const gint no_of_bits, const guint encoding);
|
||||
extern guint64 tvb_get_bits64(tvbuff_t *tvb, gint bit_offset, const gint no_of_bits, const guint encoding);
|
||||
|
||||
/* Fetch a specified number of bits from bit offset in a tvb, but allow number
|
||||
/**
|
||||
* Fetch a specified number of bits from bit offset in a tvb, but allow number
|
||||
* of bits to range between 1 and 32. If the requested number of bits is known
|
||||
* beforehand, or its range can be handled by a single function of the group
|
||||
* above, use one of them instead.
|
||||
|
|
Loading…
Reference in New Issue