This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new a1359783afc [fix](parquet) Bound legacy Bloom filter metadata reads
(#66820)
a1359783afc is described below
commit a1359783afc5cd56955f8cdd93cf64cf89528114
Author: Gabriel <[email protected]>
AuthorDate: Thu Aug 27 15:18:28 2026 +0800
[fix](parquet) Bound legacy Bloom filter metadata reads (#66820)
### What problem does this PR solve?
The legacy Parquet reader trusts Bloom filter offsets and sizes from
file metadata before it validates the complete on-disk layout. Malformed
metadata can therefore cause excessive allocation, invalid range access,
or incorrect row-group pruning when a payload is truncated or its
declared length contradicts the Bloom header.
### Scope
This PR is intentionally limited to the legacy Parquet V1 Bloom filter
read path under `be/src/format/parquet`.
In scope:
- Validate the Bloom filter offset and optional declared length before
allocation.
- Bound the header read independently from the metadata-controlled
length.
- Require the declared length, when present, to exactly match the
decoded header plus payload.
- Validate payload bounds, the 128 MiB per-filter limit, and 32-byte
split-block alignment.
- Read the payload into one memory-tracked owner and reject successful
short reads.
- Bound the V1 per-row-group Bloom filter cache to 16 MiB; larger valid
filters remain usable but may be read again for another predicate.
- Fall back conservatively on malformed metadata so it cannot produce
false-negative pruning.
Out of scope:
- The format V2/native Parquet reader.
- Generic Thrift deserialization limits or generated Thrift container
accounting.
- Parquet page index and page header memory accounting.
- DORIS-27978.
### Behavior and compatibility
- Valid legacy Bloom filters continue to participate in pruning.
- Older Parquet metadata without the optional `bloom_filter_length`
remains supported.
- Malformed, contradictory, out-of-range, oversized, misaligned, or
truncated Bloom filters are ignored conservatively.
- The Parquet wire format, write path, and format V2 reader are
unchanged.
### Testing
- `ParquetStatisticsTest.*` under ASAN.
- Clang-format 16.0.6 dry-run with `--Werror` on every changed C++
source and header.
- `git diff --check` and a five-file scope allowlist check.
---
.../parquet/parquet_block_split_bloom_filter.cpp | 39 ++++-
.../parquet/parquet_block_split_bloom_filter.h | 6 +
be/src/format/parquet/parquet_predicate.h | 69 +++++---
be/src/format/parquet/vparquet_reader.cpp | 10 ++
be/test/format/parquet/parquet_statistics_test.cpp | 176 +++++++++++++++++++++
5 files changed, 273 insertions(+), 27 deletions(-)
diff --git a/be/src/format/parquet/parquet_block_split_bloom_filter.cpp
b/be/src/format/parquet/parquet_block_split_bloom_filter.cpp
index 7f93681df03..b8cbacbfa9b 100644
--- a/be/src/format/parquet/parquet_block_split_bloom_filter.cpp
+++ b/be/src/format/parquet/parquet_block_split_bloom_filter.cpp
@@ -23,6 +23,22 @@
namespace doris {
+ParquetBlockSplitBloomFilter::~ParquetBlockSplitBloomFilter() {
+ if (_data == nullptr) {
+ return;
+ }
+ if (_is_write) {
+ g_write_bloom_filter_total_bytes << -static_cast<int64_t>(_size);
+ g_write_bloom_filter_num << -1;
+ } else {
+ g_read_bloom_filter_total_bytes << -static_cast<int64_t>(_size);
+ g_read_bloom_filter_num << -1;
+ }
+ g_total_bloom_filter_total_bytes << -static_cast<int64_t>(_size);
+ // The derived owner uses Doris's tracked allocator, so the base must not
delete this view.
+ _data = nullptr;
+}
+
// for write
Status ParquetBlockSplitBloomFilter::init(uint64_t filter_size,
segment_v2::HashStrategyPB strategy)
{
@@ -37,7 +53,8 @@ Status ParquetBlockSplitBloomFilter::init(uint64_t
filter_size,
}
_num_bytes = filter_size;
_size = _num_bytes;
- _data = new char[_size];
+ _owned_data = make_unique_buffer<char>(_size);
+ _data = _owned_data.get();
memset(_data, 0, _size);
_has_null = nullptr;
_is_write = true;
@@ -51,10 +68,20 @@ Status ParquetBlockSplitBloomFilter::init(uint64_t
filter_size,
// use deep copy to acquire the data
Status ParquetBlockSplitBloomFilter::init(const char* buf, size_t size,
segment_v2::HashStrategyPB strategy)
{
- if (size <= 1) {
+ if (buf == nullptr || size <= 1) {
return Status::InvalidArgument("invalid size:{}", size);
}
DCHECK(size > 1);
+ RETURN_IF_ERROR(init_for_read(size, strategy));
+ memcpy(_data, buf, size);
+ return Status::OK();
+}
+
+Status ParquetBlockSplitBloomFilter::init_for_read(size_t size,
+ segment_v2::HashStrategyPB
strategy) {
+ if (size <= 1) {
+ return Status::InvalidArgument("invalid size:{}", size);
+ }
if (strategy == XX_HASH_64) {
_hash_func = [](const void* buf, const int64_t len, const uint64_t
seed, void* out) {
auto h =
@@ -64,12 +91,8 @@ Status ParquetBlockSplitBloomFilter::init(const char* buf,
size_t size,
} else {
return Status::InvalidArgument("invalid strategy:{}", strategy);
}
- if (buf == nullptr) {
- return Status::InvalidArgument("buf is nullptr");
- }
-
- _data = new char[size];
- memcpy(_data, buf, size);
+ _owned_data = make_unique_buffer<char>(size);
+ _data = _owned_data.get();
_size = size;
_num_bytes = _size;
_has_null = nullptr;
diff --git a/be/src/format/parquet/parquet_block_split_bloom_filter.h
b/be/src/format/parquet/parquet_block_split_bloom_filter.h
index b27e9bab8fe..186ebb1931d 100644
--- a/be/src/format/parquet/parquet_block_split_bloom_filter.h
+++ b/be/src/format/parquet/parquet_block_split_bloom_filter.h
@@ -19,6 +19,7 @@
#include <stdint.h>
+#include "core/custom_allocator.h"
#include "storage/index/bloom_filter/bloom_filter.h"
namespace doris {
@@ -36,8 +37,11 @@ namespace doris {
// https://parquet.apache.org/docs/file-format/bloomfilter/
class ParquetBlockSplitBloomFilter : public segment_v2::BloomFilter {
public:
+ ~ParquetBlockSplitBloomFilter() override;
Status init(uint64_t filter_size, segment_v2::HashStrategyPB strategy)
override;
Status init(const char* buf, size_t size, segment_v2::HashStrategyPB
strategy) override;
+ Status init_for_read(size_t size, segment_v2::HashStrategyPB strategy);
+ char* mutable_data() { return _data; }
void add_bytes(const char* buf, size_t size) override;
bool test_bytes(const char* buf, size_t size) const override;
void set_has_null(bool has_null) override;
@@ -62,6 +66,8 @@ private:
};
private:
+ DorisUniqueBufferPtr<char> _owned_data;
+
void _set_masks(uint32_t key, BlockMask& block_mask) const {
for (int i = 0; i < BITS_SET_PER_BLOCK; ++i) {
block_mask.item[i] = key * SALT[i];
diff --git a/be/src/format/parquet/parquet_predicate.h
b/be/src/format/parquet/parquet_predicate.h
index a0f6c75bdef..ee0f3b60d83 100644
--- a/be/src/format/parquet/parquet_predicate.h
+++ b/be/src/format/parquet/parquet_predicate.h
@@ -441,45 +441,76 @@ public:
static Status read_bloom_filter(const tparquet::ColumnMetaData&
column_meta_data,
io::FileReaderSPtr file_reader,
io::IOContext* io_ctx,
ColumnStat* ans_stat) {
- size_t size;
if (!column_meta_data.__isset.bloom_filter_offset) {
return Status::NotSupported("Can not use this parquet bloom
filter.");
}
+ if (column_meta_data.bloom_filter_offset < 0 ||
+ (column_meta_data.__isset.bloom_filter_length &&
+ column_meta_data.bloom_filter_length <= 0)) {
+ return Status::Corruption("Invalid Parquet bloom filter offset or
declared length");
+ }
- if (column_meta_data.__isset.bloom_filter_length &&
- column_meta_data.bloom_filter_length > 0) {
- size = column_meta_data.bloom_filter_length;
- } else {
- size = BLOOM_FILTER_MAX_HEADER_LENGTH;
+ const uint64_t bloom_offset =
static_cast<uint64_t>(column_meta_data.bloom_filter_offset);
+ if (bloom_offset >= file_reader->size()) {
+ return Status::Corruption("Parquet bloom filter offset exceeds
file size");
}
+ const size_t available = file_reader->size() - bloom_offset;
+ const size_t declared_available =
+ column_meta_data.__isset.bloom_filter_length
+ ?
std::min<size_t>(column_meta_data.bloom_filter_length, available)
+ : available;
+ const size_t header_read_size =
+ std::min<size_t>(declared_available,
BLOOM_FILTER_MAX_HEADER_LENGTH);
size_t bytes_read = 0;
- std::vector<uint8_t> header_buffer(size);
+ std::vector<uint8_t> header_buffer(header_read_size);
RETURN_IF_ERROR(file_reader->read_at(column_meta_data.bloom_filter_offset,
- Slice(header_buffer.data(),
size), &bytes_read,
- io_ctx));
+ Slice(header_buffer.data(),
header_buffer.size()),
+ &bytes_read, io_ctx));
tparquet::BloomFilterHeader t_bloom_filter_header;
uint32_t t_bloom_filter_header_size =
static_cast<uint32_t>(bytes_read);
- RETURN_IF_ERROR(deserialize_thrift_msg(header_buffer.data(),
&t_bloom_filter_header_size,
- true, &t_bloom_filter_header));
+ if (!deserialize_thrift_msg(header_buffer.data(),
&t_bloom_filter_header_size, true,
+ &t_bloom_filter_header)
+ .ok()) {
+ return Status::Corruption("Malformed Parquet bloom filter header");
+ }
// TODO the bloom filter could be encrypted, too, so need to double
check that this is NOT the case
if (!t_bloom_filter_header.algorithm.__isset.BLOCK ||
!t_bloom_filter_header.compression.__isset.UNCOMPRESSED ||
- !t_bloom_filter_header.hash.__isset.XXHASH) {
+ !t_bloom_filter_header.hash.__isset.XXHASH ||
t_bloom_filter_header.numBytes <= 0) {
return Status::NotSupported("Can not use this parquet bloom
filter.");
}
- ans_stat->bloom_filter =
std::make_unique<ParquetBlockSplitBloomFilter>();
+ const int64_t payload_size = t_bloom_filter_header.numBytes;
+ if (payload_size < segment_v2::BloomFilter::MINIMUM_BYTES ||
+ payload_size > segment_v2::BloomFilter::MAXIMUM_BYTES ||
payload_size % 32 != 0) {
+ return Status::Corruption("Invalid Parquet bloom filter payload
size {}", payload_size);
+ }
+ const uint64_t total_size =
+ static_cast<uint64_t>(t_bloom_filter_header_size) +
payload_size;
+ if (total_size > available) {
+ return Status::Corruption("Parquet bloom filter range exceeds file
size");
+ }
+ const auto expected_declared_length = static_cast<int64_t>(total_size);
+ if (column_meta_data.__isset.bloom_filter_length &&
+ column_meta_data.bloom_filter_length != expected_declared_length) {
+ return Status::Corruption("Invalid Parquet bloom filter declared
length");
+ }
- std::vector<uint8_t> data_buffer(t_bloom_filter_header.numBytes);
+ auto bloom_filter = std::make_unique<ParquetBlockSplitBloomFilter>();
+ // Read directly into one tracked allocation so a valid maximum-size
filter is admitted
+ // against the task budget without doubling its peak memory during
initialization.
+
RETURN_IF_ERROR(bloom_filter->init_for_read(static_cast<size_t>(payload_size),
+
segment_v2::HashStrategyPB::XX_HASH_64));
RETURN_IF_ERROR(file_reader->read_at(
- column_meta_data.bloom_filter_offset +
t_bloom_filter_header_size,
- Slice(data_buffer.data(), t_bloom_filter_header.numBytes),
&bytes_read, io_ctx));
+ static_cast<size_t>(bloom_offset) + t_bloom_filter_header_size,
+ Slice(bloom_filter->mutable_data(), bloom_filter->size()),
&bytes_read, io_ctx));
+ if (bytes_read != bloom_filter->size()) {
+ return Status::Corruption("Truncated Parquet bloom filter
payload");
+ }
- RETURN_IF_ERROR(ans_stat->bloom_filter->init(
- reinterpret_cast<const char*>(data_buffer.data()),
t_bloom_filter_header.numBytes,
- segment_v2::HashStrategyPB::XX_HASH_64));
+ ans_stat->bloom_filter = std::move(bloom_filter);
return Status::OK();
}
diff --git a/be/src/format/parquet/vparquet_reader.cpp
b/be/src/format/parquet/vparquet_reader.cpp
index b5c4c01be40..b1b233aa0d5 100644
--- a/be/src/format/parquet/vparquet_reader.cpp
+++ b/be/src/format/parquet/vparquet_reader.cpp
@@ -1507,6 +1507,8 @@ Status ParquetReader::_process_column_stat_filter(
// Cache bloom filters for each column to avoid reading the same bloom
filter multiple times
// when there are multiple predicates on the same column
std::unordered_map<int, std::unique_ptr<ParquetBlockSplitBloomFilter>>
bloom_filter_cache;
+ constexpr size_t MAX_CACHED_BLOOM_FILTER_BYTES = 16 * 1024 * 1024;
+ size_t cached_bloom_filter_bytes = 0;
// Initialize output parameters
*filtered_by_min_max = false;
@@ -1565,6 +1567,7 @@ Status ParquetReader::_process_column_stat_filter(
auto cache_iter = bloom_filter_cache.find(parquet_col_id);
if (cache_iter != bloom_filter_cache.end()) {
// Bloom filter already loaded for this column, reuse
it
+ cached_bloom_filter_bytes -=
cache_iter->second->size();
stat->bloom_filter = std::move(cache_iter->second);
bloom_filter_cache.erase(cache_iter);
return stat->bloom_filter != nullptr;
@@ -1607,6 +1610,12 @@ Status ParquetReader::_process_column_stat_filter(
// After evaluating, if the bloom filter was used, cache it for
subsequent predicates
if (stat.bloom_filter) {
+ // Large filters remain tracked but are not retained across
predicates; this bounds the
+ // row-group cache independently of the number of predicate
columns.
+ if (stat.bloom_filter->size() >
+ MAX_CACHED_BLOOM_FILTER_BYTES - cached_bloom_filter_bytes) {
+ continue;
+ }
// Find the column id for caching
for (auto* slot : _tuple_descriptor->slots()) {
if
(_table_info_node_ptr->children_column_exists(slot->col_name())) {
@@ -1616,6 +1625,7 @@ Status ParquetReader::_process_column_stat_filter(
_file_metadata->schema().get_column(file_col_name);
int parquet_col_id = col_schema->physical_column_index;
if (stat.col_schema == col_schema) {
+ cached_bloom_filter_bytes += stat.bloom_filter->size();
bloom_filter_cache[parquet_col_id] =
std::move(stat.bloom_filter);
break;
}
diff --git a/be/test/format/parquet/parquet_statistics_test.cpp
b/be/test/format/parquet/parquet_statistics_test.cpp
index d52320c5169..eeae08326cf 100644
--- a/be/test/format/parquet/parquet_statistics_test.cpp
+++ b/be/test/format/parquet/parquet_statistics_test.cpp
@@ -17,16 +17,192 @@
#include <gtest/gtest.h>
+#include <algorithm>
+#include <cstring>
+#include <memory>
#include <regex>
+#include <vector>
#include "format/parquet/parquet_predicate.h"
+#include "util/thrift_util.h"
namespace doris {
+namespace {
+
+class BloomFilterFileReader final : public io::FileReader {
+public:
+ explicit BloomFilterFileReader(std::vector<uint8_t> data, size_t
logical_size = 0)
+ : _data(std::move(data)),
+ _logical_size(logical_size == 0 ? _data.size() : logical_size) {}
+
+ Status close() override {
+ _closed = true;
+ return Status::OK();
+ }
+
+ const io::Path& path() const override { return _path; }
+ size_t size() const override { return _logical_size; }
+ bool closed() const override { return _closed; }
+ int64_t mtime() const override { return 0; }
+ bool returned_short_nonzero_offset_read() const { return
_returned_short_nonzero_offset_read; }
+
+protected:
+ Status read_at_impl(size_t offset, Slice result, size_t* bytes_read,
+ const io::IOContext* io_ctx) override {
+ if (offset > _data.size()) {
+ return Status::IOError("Out of bounds");
+ }
+ *bytes_read = std::min(result.size, _data.size() - offset);
+ memcpy(result.data, _data.data() + offset, *bytes_read);
+ _returned_short_nonzero_offset_read |= offset > 0 && *bytes_read !=
result.size;
+ return Status::OK();
+ }
+
+private:
+ std::vector<uint8_t> _data;
+ size_t _logical_size;
+ io::Path _path = "parquet_bloom_filter_test";
+ bool _closed = false;
+ bool _returned_short_nonzero_offset_read = false;
+};
+
+Status read_test_bloom_filter(int32_t header_payload_size, size_t
actual_payload_size,
+ int32_t declared_length_adjustment = 0,
+ size_t logical_payload_size = 0, bool*
returned_short_read = nullptr,
+ bool* installed_bloom_filter = nullptr) {
+ tparquet::BloomFilterAlgorithm algorithm;
+ algorithm.__set_BLOCK(tparquet::SplitBlockAlgorithm());
+ tparquet::BloomFilterHash hash;
+ hash.__set_XXHASH(tparquet::XxHash());
+ tparquet::BloomFilterCompression compression;
+ compression.__set_UNCOMPRESSED(tparquet::Uncompressed());
+ tparquet::BloomFilterHeader header;
+ header.__set_numBytes(header_payload_size);
+ header.__set_algorithm(algorithm);
+ header.__set_hash(hash);
+ header.__set_compression(compression);
+
+ std::vector<uint8_t> file_bytes;
+ ThriftSerializer serializer(/*compact=*/true, /*initial_buffer_size=*/64);
+ RETURN_IF_ERROR(serializer.serialize(&header, &file_bytes));
+ const size_t header_size = file_bytes.size();
+ file_bytes.resize(header_size + actual_payload_size);
+
+ tparquet::ColumnMetaData metadata;
+ metadata.__set_bloom_filter_offset(0);
+ metadata.__set_bloom_filter_length(static_cast<int32_t>(file_bytes.size())
+
+ declared_length_adjustment);
+ const size_t logical_size =
+ logical_payload_size == 0 ? file_bytes.size() : header_size +
logical_payload_size;
+ auto reader =
std::make_shared<BloomFilterFileReader>(std::move(file_bytes), logical_size);
+ ParquetPredicate::ColumnStat stat;
+ Status status = ParquetPredicate::read_bloom_filter(metadata, reader,
nullptr, &stat);
+ if (returned_short_read != nullptr) {
+ *returned_short_read = reader->returned_short_nonzero_offset_read();
+ }
+ if (installed_bloom_filter != nullptr) {
+ *installed_bloom_filter = stat.bloom_filter != nullptr;
+ }
+ return status;
+}
+
+} // namespace
+
class ParquetStatisticsTest : public testing::Test {
public:
ParquetStatisticsTest() = default;
};
+TEST_F(ParquetStatisticsTest, reject_truncated_bloom_filter_payload) {
+ // The reader may legally return a short read at EOF, so accepting it
would initialize a
+ // Bloom filter whose missing bytes came from zero-filled process memory.
+ bool returned_short_read = false;
+ bool installed_bloom_filter = true;
+ EXPECT_FALSE(read_test_bloom_filter(/*header_payload_size=*/64,
/*actual_payload_size=*/32,
+ /*declared_length_adjustment=*/32,
+ /*logical_payload_size=*/64,
&returned_short_read,
+ &installed_bloom_filter)
+ .ok());
+ EXPECT_TRUE(returned_short_read);
+ EXPECT_FALSE(installed_bloom_filter);
+}
+
+TEST_F(ParquetStatisticsTest, reject_bloom_filter_range_beyond_file) {
+ bool returned_short_read = false;
+ EXPECT_FALSE(read_test_bloom_filter(/*header_payload_size=*/64,
/*actual_payload_size=*/32,
+ /*declared_length_adjustment=*/0,
+ /*logical_payload_size=*/0,
&returned_short_read)
+ .ok());
+ EXPECT_FALSE(returned_short_read);
+}
+
+TEST_F(ParquetStatisticsTest, reject_declared_bloom_filter_length_mismatch) {
+ // A present length describes exactly one header and payload. Treating it
as an upper bound can
+ // reinterpret a multi-block filter as a smaller filter and cause
false-negative pruning.
+ EXPECT_FALSE(
+ read_test_bloom_filter(/*header_payload_size=*/32,
/*actual_payload_size=*/64).ok());
+}
+
+TEST_F(ParquetStatisticsTest, reject_invalid_bloom_filter_block_sizes) {
+ EXPECT_FALSE(
+ read_test_bloom_filter(/*header_payload_size=*/16,
/*actual_payload_size=*/16).ok());
+ EXPECT_FALSE(
+ read_test_bloom_filter(/*header_payload_size=*/33,
/*actual_payload_size=*/33).ok());
+}
+
+TEST_F(ParquetStatisticsTest, reject_nonpositive_bloom_filter_declared_length)
{
+ const int32_t declared_length_adjustment = -1000;
+ EXPECT_FALSE(read_test_bloom_filter(/*header_payload_size=*/32,
/*actual_payload_size=*/32,
+ declared_length_adjustment)
+ .ok());
+}
+
+TEST_F(ParquetStatisticsTest, accept_valid_bloom_filter_layout) {
+ EXPECT_TRUE(
+ read_test_bloom_filter(/*header_payload_size=*/32,
/*actual_payload_size=*/32).ok());
+}
+
+TEST_F(ParquetStatisticsTest,
accept_bloom_filter_without_declared_length_before_trailing_bytes) {
+ constexpr int32_t present_value = 1;
+ ParquetBlockSplitBloomFilter source;
+ ASSERT_TRUE(source.init(segment_v2::BloomFilter::MINIMUM_BYTES,
+ segment_v2::HashStrategyPB::XX_HASH_64)
+ .ok());
+ source.add_bytes(reinterpret_cast<const char*>(&present_value),
sizeof(present_value));
+ int32_t absent_value = 2;
+ while (source.test_bytes(reinterpret_cast<const char*>(&absent_value),
sizeof(absent_value))) {
+ ++absent_value;
+ }
+
+ tparquet::BloomFilterAlgorithm algorithm;
+ algorithm.__set_BLOCK(tparquet::SplitBlockAlgorithm());
+ tparquet::BloomFilterHash hash;
+ hash.__set_XXHASH(tparquet::XxHash());
+ tparquet::BloomFilterCompression compression;
+ compression.__set_UNCOMPRESSED(tparquet::Uncompressed());
+ tparquet::BloomFilterHeader header;
+ header.__set_numBytes(static_cast<int32_t>(source.size()));
+ header.__set_algorithm(algorithm);
+ header.__set_hash(hash);
+ header.__set_compression(compression);
+ std::vector<uint8_t> file_bytes;
+ ThriftSerializer serializer(/*compact=*/true, /*initial_buffer_size=*/64);
+ ASSERT_TRUE(serializer.serialize(&header, &file_bytes).ok());
+ file_bytes.insert(file_bytes.end(), source.data(), source.data() +
source.size());
+ file_bytes.resize(file_bytes.size() + 64);
+
+ tparquet::ColumnMetaData metadata;
+ metadata.__set_bloom_filter_offset(0);
+ auto reader =
std::make_shared<BloomFilterFileReader>(std::move(file_bytes));
+ ParquetPredicate::ColumnStat stat;
+ ASSERT_TRUE(ParquetPredicate::read_bloom_filter(metadata, reader, nullptr,
&stat).ok());
+ ASSERT_NE(stat.bloom_filter, nullptr);
+ EXPECT_TRUE(stat.bloom_filter->test_bytes(reinterpret_cast<const
char*>(&present_value),
+ sizeof(present_value)));
+ EXPECT_FALSE(stat.bloom_filter->test_bytes(reinterpret_cast<const
char*>(&absent_value),
+ sizeof(absent_value)));
+}
+
TEST_F(ParquetStatisticsTest, test_try_read_old_utf8_stats) {
// [, bcé]: min is empty, max starts with ASCII
{
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]