initial support for torrent tag store in DHT

This commit is contained in:
Arvid Norberg 2009-09-27 03:38:41 +00:00
parent 1017a716ef
commit f36688a364
13 changed files with 502 additions and 63 deletions

View File

@ -277,6 +277,7 @@ if(build_tests)
test_buffer
test_storage
test_torrent
test_dht
test_transfer
test_piece_picker
test_fast_extension

View File

@ -49,6 +49,7 @@ namespace libtorrent
bool TORRENT_EXPORT is_space(char c);
char TORRENT_EXPORT to_lower(char c);
int TORRENT_EXPORT split_string(char const** tags, int buf_size, char* in);
bool TORRENT_EXPORT string_begins_no_case(char const* s1, char const* s2);
bool TORRENT_EXPORT string_equal_no_case(char const* s1, char const* s2);

View File

@ -117,8 +117,6 @@ namespace libtorrent { namespace dht
void on_bootstrap(std::vector<std::pair<node_entry, std::string> > const&);
void send_packet(libtorrent::entry const& e, udp::endpoint const& addr, int send_flags);
void incoming_error(char const* msg, lazy_entry const& e, udp::endpoint const& ep);
node_impl m_dht;
libtorrent::aux::session_impl& m_ses;
rate_limited_udp_socket& m_sock;

View File

@ -87,6 +87,48 @@ struct torrent_entry
std::set<peer_entry> peers;
};
// this is the entry for a torrent that has been published
// in the DHT.
struct search_torrent_entry
{
// the tags of the torrent. The key of
// this entry is the sha-1 hash of one of
// these tags. The counter is the number of
// times a tag has been included in a publish
// call. The counters are periodically
// decremented by a factor, so that the
// popularity ratio between the tags is
// maintained. The decrement is rounded down.
std::map<std::string, int> tags;
// this is the sum of all values in the tags
// map. It is only an optimization to avoid
// recalculating it constantly
int total_tag_points;
// the name of the torrent
std::map<std::string, int> name;
int total_name_points;
// increase the popularity counters for this torrent
void publish(std::string const& name, char const* in_tags[], int num_tags);
// return a score of how well this torrent matches
// the given set of tags. Each word in the string
// (separated by a space) is considered a tag.
// tags with 2 letters or fewer are ignored
int match(char const* tags[], int num_tags) const;
// this is called once every hour, and will
// decrement the popularity counters of the
// tags. Returns true if this entry should
// be deleted
bool tick();
void get_name(std::string& t) const;
void get_tags(std::string& t) const;
};
inline bool operator<(peer_entry const& lhs, peer_entry const& rhs)
{
return lhs.addr.address() == rhs.addr.address()
@ -119,9 +161,21 @@ private:
std::string m_token;
};
struct count_peers
{
int& count;
count_peers(int& c): count(c) {}
void operator()(std::pair<libtorrent::dht::node_id
, libtorrent::dht::torrent_entry> const& t)
{
count += t.second.peers.size();
}
};
class node_impl : boost::noncopyable
{
typedef std::map<node_id, torrent_entry> table_t;
typedef std::map<std::pair<node_id, sha1_hash>, search_torrent_entry> search_table_t;
public:
node_impl(libtorrent::aux::session_impl& ses
, void (*f)(void*, entry const&, udp::endpoint const&, int)
@ -138,6 +192,14 @@ public:
void unreachable(udp::endpoint const& ep);
void incoming(msg const& m);
int num_torrents() const { return m_map.size(); }
int num_peers() const
{
int ret = 0;
std::for_each(m_map.begin(), m_map.end(), count_peers(ret));
return ret;
}
void refresh();
void refresh_bucket(int bucket);
int bucket_size(int bucket);
@ -147,16 +209,12 @@ public:
iterator begin() const { return m_table.begin(); }
iterator end() const { return m_table.end(); }
typedef table_t::iterator data_iterator;
node_id const& nid() const { return m_id; }
boost::tuple<int, int> size() const{ return m_table.size(); }
size_type num_global_nodes() const
{ return m_table.num_global_nodes(); }
data_iterator begin_data() { return m_map.begin(); }
data_iterator end_data() { return m_map.end(); }
int data_size() const { return int(m_map.size()); }
#ifdef TORRENT_DHT_VERBOSE_LOGGING
@ -208,11 +266,9 @@ protected:
// is called when a find data request is received. Should
// return false if the data is not stored on this node. If
// the data is stored, it should be serialized into 'data'.
bool on_find(sha1_hash const& info_hash, std::vector<tcp::endpoint>& peers) const;
// this is called when a store request is received. The data
// is store-parameters and the data to be stored.
void on_announce(msg const& m, msg& reply);
bool lookup_peers(sha1_hash const& info_hash, entry& reply) const;
bool lookup_torrents(sha1_hash const& target, entry& reply
, char* tags) const;
dht_settings const& m_settings;
@ -239,6 +295,7 @@ public:
private:
table_t m_map;
search_table_t m_search_map;
ptime m_last_tracker_tick;

View File

@ -59,6 +59,20 @@ namespace libtorrent
big_number() {}
static big_number max()
{
big_number ret;
memset(ret.m_number, 0xff, size);
return ret;
}
static big_number min()
{
big_number ret;
memset(ret.m_number, 0, size);
return ret;
}
explicit big_number(char const* s)
{
if (s == 0) clear();

View File

@ -660,6 +660,7 @@ namespace libtorrent
, search_branching(5)
, service_port(0)
, max_fail_count(20)
, max_torrent_search_reply(20)
{}
// the maximum number of peers to send in a
@ -677,6 +678,10 @@ namespace libtorrent
// the maximum number of times a node can fail
// in a row before it is removed from the table.
int max_fail_count;
// the max number of torrents to return in a
// torrent search query to the DHT
int max_torrent_search_reply;
};
#endif

View File

@ -100,6 +100,26 @@ namespace libtorrent
return (c >= 'A' && c <= 'Z') ? c - 'A' + 'a' : c;
}
int split_string(char const** tags, int buf_size, char* in)
{
int ret = 0;
char* i = in;
for (;*i; ++i)
{
if (!is_print(*i) || is_space(*i))
{
*i = 0;
if (ret == buf_size) return ret;
continue;
}
if (i == in || i[-1] == 0)
{
tags[ret++] = i;
}
}
return ret;
}
bool string_begins_no_case(char const* s1, char const* s2)
{
while (*s1 != 0)

View File

@ -73,17 +73,6 @@ namespace
{
const int tick_period = 1; // minutes
struct count_peers
{
int& count;
count_peers(int& c): count(c) {}
void operator()(std::pair<libtorrent::dht::node_id
, libtorrent::dht::torrent_entry> const& t)
{
count += t.second.peers.size();
}
};
template <class EndpointType>
void read_endpoint_list(libtorrent::entry const* n, std::vector<EndpointType>& epl)
{
@ -365,11 +354,10 @@ namespace libtorrent { namespace dht
m_dht.print_state(st);
// count torrents
int torrents = std::distance(m_dht.begin_data(), m_dht.end_data());
int torrents = m_dht.num_torrents();
// count peers
int peers = 0;
std::for_each(m_dht.begin_data(), m_dht.end_data(), count_peers(peers));
int peers = m_dht.num_peers();
std::ofstream pc("dht_stats.log", first ? std::ios_base::trunc : std::ios_base::app);
if (first)

View File

@ -61,6 +61,89 @@ void incoming_error(entry& e, char const* msg);
using detail::write_endpoint;
int search_torrent_entry::match(char const* in_tags[], int num_tags) const
{
int ret = 0;
for (int i = 0; i < num_tags; ++i)
{
char const* t = in_tags[i];
std::map<std::string, int>::const_iterator i = tags.find(t);
if (i == tags.end()) continue;
// weigh the score by how popular this tag is in this torrent
ret += 100 * i->second / total_tag_points;
}
return ret;
}
bool search_torrent_entry::tick()
{
int sum = 0;
for (std::map<std::string, int>::iterator i = tags.begin()
, end(tags.end()); i != end;)
{
i->second = (i->second * 2) / 3;
sum += i->second;
if (i->second > 0) { ++i; continue; }
tags.erase(i++);
}
total_tag_points = sum;
sum = 0;
for (std::map<std::string, int>::iterator i = name.begin()
, end(name.end()); i != end;)
{
i->second = (i->second * 2) / 3;
sum += i->second;
if (i->second > 0) { ++i; continue; }
name.erase(i++);
}
total_name_points = sum;
return total_tag_points == 0;
}
void search_torrent_entry::publish(std::string const& torrent_name, char const* in_tags[]
, int num_tags)
{
for (int i = 0; i < num_tags; ++i)
{
char const* t = in_tags[i];
std::map<std::string, int>::iterator i = tags.find(t);
if (i != tags.end())
++i->second;
else
tags[t] = 1;
++total_tag_points;
// TODO: limit the number of tags
}
name[torrent_name] += 1;
++total_name_points;
// TODO: limit the number of names
}
void search_torrent_entry::get_name(std::string& t) const
{
std::map<std::string, int>::const_iterator max = name.begin();
for (std::map<std::string, int>::const_iterator i = name.begin()
, end(name.end()); i != end; ++i)
{
if (i->second > max->second) max = i;
}
t = max->first;
}
void search_torrent_entry::get_tags(std::string& t) const
{
for (std::map<std::string, int>::const_iterator i = tags.begin()
, end(tags.end()); i != end; ++i)
{
if (i != tags.begin()) t += " ";
t += i->first;
}
}
#ifdef _MSC_VER
namespace
{
@ -96,6 +179,9 @@ void purge_peers(std::set<peer_entry>& peers)
void nop() {}
// TODO: the session_impl argument could be an alert reference
// instead, and make the dht_tracker less dependent on session_impl
// which would make it simpler to unit test
node_impl::node_impl(libtorrent::aux::session_impl& ses
, void (*f)(void*, entry const&, udp::endpoint const&, int)
, dht_settings const& settings
@ -407,7 +493,7 @@ time_duration node_impl::connection_timeout()
m_last_tracker_tick = now;
// look through all peers and see if any have timed out
for (data_iterator i = begin_data(), end(end_data()); i != end;)
for (table_t::iterator i = m_map.begin(), end(m_map.end()); i != end;)
{
torrent_entry& t = i->second;
node_id const& key = i->first;
@ -425,18 +511,6 @@ time_duration node_impl::connection_timeout()
return d;
}
void node_impl::on_announce(msg const& m, msg& reply)
{
}
namespace
{
tcp::endpoint get_endpoint(peer_entry const& p)
{
return p.addr;
}
}
void node_impl::status(session_status& s)
{
mutex_t::scoped_lock l(m_mutex);
@ -453,7 +527,54 @@ void node_impl::status(session_status& s)
}
}
bool node_impl::on_find(sha1_hash const& info_hash, std::vector<tcp::endpoint>& peers) const
bool node_impl::lookup_torrents(sha1_hash const& target
, entry& reply, char* tags) const
{
// if (m_ses.m_alerts.should_post<dht_find_torrents_alert>())
// m_ses.m_alerts.post_alert(dht_find_torrents_alert(info_hash));
search_table_t::const_iterator first, last;
first = m_search_map.lower_bound(std::make_pair(target, sha1_hash::min()));
last = m_search_map.upper_bound(std::make_pair(target, sha1_hash::max()));
if (first == last) return false;
std::string tags_copy(tags);
char const* in_tags[20];
int num_tags = 0;
num_tags = split_string(in_tags, 20, &tags_copy[0]);
typedef std::pair<int, search_table_t::const_iterator> sort_item;
std::vector<sort_item> result;
for (; first != last; ++first)
{
result.push_back(std::make_pair(
first->second.match(in_tags, num_tags), first));
}
std::sort(result.begin(), result.end()
, boost::bind(&sort_item::first, _1) > boost::bind(&sort_item::first, _2));
int num = (std::min)((int)result.size(), m_settings.max_torrent_search_reply);
entry::list_type& pe = reply["values"].list();
for (int i = 0; i < num; ++i)
{
pe.push_back(entry());
entry::list_type& e = pe.back().list();
// push name
e.push_back(entry());
result[i].second->second.get_name(e.back().string());
// push tags
e.push_back(entry());
result[i].second->second.get_tags(e.back().string());
// push info-hash
e.push_back(entry());
e.back().string() = result[i].second->first.second.to_string();
}
return true;
}
bool node_impl::lookup_peers(sha1_hash const& info_hash, entry& reply) const
{
if (m_ses.m_alerts.should_post<dht_get_peers_alert>())
m_ses.m_alerts.post_alert(dht_get_peers_alert(info_hash));
@ -464,11 +585,32 @@ bool node_impl::on_find(sha1_hash const& info_hash, std::vector<tcp::endpoint>&
torrent_entry const& v = i->second;
int num = (std::min)((int)v.peers.size(), m_settings.max_peers_reply);
peers.clear();
peers.reserve(num);
random_sample_n(boost::make_transform_iterator(v.peers.begin(), &get_endpoint)
, boost::make_transform_iterator(v.peers.end(), &get_endpoint)
, std::back_inserter(peers), num);
int t = 0;
int m = 0;
std::set<peer_entry>::iterator iter = v.peers.begin();
entry::list_type& pe = reply["values"].list();
std::string endpoint;
while (m < num)
{
if ((std::rand() / (RAND_MAX + 1.f)) * (num - t) >= num - m)
{
++iter;
++t;
}
else
{
endpoint.resize(18);
std::string::iterator out = endpoint.begin();
write_endpoint(iter->addr, out);
endpoint.resize(out - endpoint.begin());
pe.push_back(entry(endpoint));
++iter;
++t;
++m;
}
}
return true;
}
@ -556,7 +698,6 @@ void node_impl::incoming_request(msg const& m, entry& e)
entry& reply = e["r"];
m_rpc.add_our_id(reply);
if (strcmp(query, "ping") == 0)
{
// we already have 't' and 'id' in the response
@ -579,25 +720,10 @@ void node_impl::incoming_request(msg const& m, entry& e)
m_table.find_node(info_hash, n, 0);
write_nodes_entry(reply, n);
peers_t p;
on_find(info_hash, p);
if (!p.empty())
{
entry::list_type& pe = reply["values"].list();
std::string endpoint;
for (peers_t::const_iterator i = p.begin()
, end(p.end()); i != end; ++i)
{
endpoint.resize(18);
std::string::iterator out = endpoint.begin();
write_endpoint(*i, out);
endpoint.resize(out - endpoint.begin());
pe.push_back(entry(endpoint));
}
lookup_peers(info_hash, reply);
#ifdef TORRENT_DHT_VERBOSE_LOGGING
TORRENT_LOG(node) << " values: " << p.size();
TORRENT_LOG(node) << " values: " << reply["values"].list().size();
#endif
}
}
else if (strcmp(query, "find_node") == 0)
{
@ -610,7 +736,6 @@ void node_impl::incoming_request(msg const& m, entry& e)
sha1_hash target(target_ent->string_ptr());
nodes_t n;
// always return nodes as well as peers
m_table.find_node(target, n, 0);
write_nodes_entry(reply, n);
}
@ -662,6 +787,101 @@ void node_impl::incoming_request(msg const& m, entry& e)
if (i != v.peers.end()) v.peers.erase(i++);
v.peers.insert(i, e);
}
else if (strcmp(query, "find_torrent") == 0)
{
lazy_entry const* target_ent = arg_ent->dict_find_string("target");
if (target_ent == 0 || target_ent->string_length() != 20)
{
incoming_error(e, "missing 'target' key");
return;
}
lazy_entry const* tags_ent = arg_ent->dict_find_string("tags");
if (tags_ent == 0)
{
incoming_error(e, "missing 'tags' key");
return;
}
reply["token"] = generate_token(m.addr, target_ent->string_ptr());
sha1_hash target(target_ent->string_ptr());
nodes_t n;
// always return nodes as well as torrents
m_table.find_node(target, n, 0);
write_nodes_entry(reply, n);
lookup_torrents(target, reply, (char*)tags_ent->string_cstr());
}
else if (strcmp(query, "announce_torrent") == 0)
{
lazy_entry const* target_ent = arg_ent->dict_find_string("target");
if (target_ent == 0 || target_ent->string_length() != 20)
{
incoming_error(e, "missing 'target' key");
return;
}
lazy_entry const* info_hash_ent = arg_ent->dict_find_string("info_hash");
if (info_hash_ent == 0 || info_hash_ent->string_length() != 20)
{
incoming_error(e, "missing 'target' key");
return;
}
lazy_entry const* name_ent = arg_ent->dict_find_string("name");
if (name_ent == 0)
{
incoming_error(e, "missing 'name' key");
return;
}
lazy_entry const* tags_ent = arg_ent->dict_find_string("tags");
if (tags_ent == 0)
{
incoming_error(e, "missing 'tags' key");
return;
}
// if (m_ses.m_alerts.should_post<dht_announce_torrent_alert>())
// m_ses.m_alerts.post_alert(dht_announce_torrent_alert(
// m.addr.address(), name, tags, info_hash));
lazy_entry const* token = arg_ent->dict_find_string("token");
if (!token)
{
incoming_error(e, "missing 'token' key in announce");
return;
}
if (!verify_token(token->string_value(), target_ent->string_ptr(), m.addr))
{
incoming_error(e, "invalid token in announce");
return;
}
sha1_hash target(target_ent->string_ptr());
sha1_hash info_hash(info_hash_ent->string_ptr());
// the token was correct. That means this
// node is not spoofing its address. So, let
// the table get a chance to add it.
m_table.node_seen(id, m.addr);
search_table_t::iterator i = m_search_map.find(std::make_pair(target, info_hash));
if (i == m_search_map.end())
{
boost::tie(i, boost::tuples::ignore)
= m_search_map.insert(std::make_pair(std::make_pair(target, info_hash)
, search_torrent_entry()));
}
char const* in_tags[20];
int num_tags = 0;
num_tags = split_string(in_tags, 20, (char*)tags_ent->string_cstr());
i->second.publish(name_ent->string_value(), in_tags, num_tags);
}
else
{
// if we don't recognize the message but there's a

View File

@ -35,6 +35,7 @@ test-suite libtorrent :
[ run test_primitives.cpp ]
[ run test_ip_filter.cpp ]
[ run test_hasher.cpp ]
[ run test_dht.cpp ]
[ run test_storage.cpp ]
[ run test_upnp.cpp ]

View File

@ -8,6 +8,7 @@ test_programs = \
test_hasher \
test_http_connection \
test_ip_filter \
test_dht \
test_lsd \
test_metadata_extension \
test_natpmp \
@ -40,6 +41,7 @@ libtest_la_SOURCES = main.cpp setup_transfer.cpp
test_auto_unchoke_SOURCES = test_auto_unchoke.cpp
test_bandwidth_limiter_SOURCES = test_bandwidth_limiter.cpp
test_bdecode_performance_SOURCES = test_bdecode_performance.cpp
test_dht_SOURCES = test_dht.cpp
test_bencoding_SOURCES = test_bencoding.cpp
test_buffer_SOURCES = test_buffer.cpp
test_fast_extension_SOURCES = test_fast_extension.cpp

88
test/test_dht.cpp Normal file
View File

@ -0,0 +1,88 @@
/*
Copyright (c) 2008, Arvid Norberg
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the distribution.
* Neither the name of the author nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
*/
#include "libtorrent/session.hpp"
#include "test.hpp"
int test_main()
{
using namespace libtorrent;
int dht_port = 48199;
session ses(fingerprint("LT", 0, 1, 0, 0), std::make_pair(dht_port, 49000));
// DHT should be running on port 48199 now
io_service ios;
error_code ec;
datagram_socket sock(ios);
sock.open(udp::v4(), ec);
TEST_CHECK(!ec);
if (ec) std::cout << ec.message() << std::endl;
char const ping_msg[] = "d1:ad2:id20:00000000000000000001e1:q4:ping1:t2:101:y1:qe";
// ping
sock.send_to(asio::buffer(ping_msg, sizeof(ping_msg) - 1)
, udp::endpoint(address::from_string("127.0.0.1"), dht_port), 0, ec);
TEST_CHECK(!ec);
if (ec) std::cout << ec.message() << std::endl;
char inbuf[1600];
udp::endpoint ep;
int size = sock.receive_from(asio::buffer(inbuf, sizeof(inbuf)), ep, 0, ec);
TEST_CHECK(!ec);
if (ec) std::cout << ec.message() << std::endl;
lazy_entry pong;
int ret = lazy_bdecode(inbuf, inbuf + size, pong);
TEST_CHECK(ret == 0);
if (ret != 0) return 1;
TEST_CHECK(pong.type() == lazy_entry::dict_t);
if (pong.type() != lazy_entry::dict_t) return 1;
lazy_entry const* t = pong.dict_find_string("t");
TEST_CHECK(t);
if (t) TEST_CHECK(t->string_value() == "10");
lazy_entry const* y = pong.dict_find_string("y");
TEST_CHECK(y);
if (y) TEST_CHECK(y->string_value() == "r");
return 0;
}

View File

@ -44,6 +44,7 @@ POSSIBILITY OF SUCH DAMAGE.
#ifndef TORRENT_DISABLE_DHT
#include "libtorrent/kademlia/node_id.hpp"
#include "libtorrent/kademlia/routing_table.hpp"
#include "libtorrent/kademlia/node.hpp"
#endif
#include <boost/tuple/tuple.hpp>
#include <boost/tuple/tuple_comparison.hpp>
@ -365,6 +366,49 @@ int test_main()
{
using namespace libtorrent;
#ifndef TORRENT_DISABLE_DHT
// test search_torrent_entry
dht::search_torrent_entry ste1;
dht::search_torrent_entry ste2;
char const* ste1_tags[] = {"tag1", "tag2", "tag3", "tag4"};
ste1.publish("ste1", ste1_tags, 4);
char const* ste11_tags[] = {"tag2", "tag3"};
ste1.publish("ste1", ste11_tags, 2);
char const* ste2_tags[] = {"tag1", "tag2", "tag5", "tag6"};
ste2.publish("ste2", ste2_tags, 4);
char const* ste21_tags[] = {"tag1", "tag5"};
ste2.publish("ste2", ste21_tags, 2);
char const* test_tags1[] = {"tag1", "tag2"};
char const* test_tags2[] = {"tag3", "tag2"};
int m1 = ste1.match(test_tags1, 2);
int m2 = ste2.match(test_tags1, 2);
TEST_CHECK(m1 == m2);
m1 = ste1.match(test_tags2, 2);
m2 = ste2.match(test_tags2, 2);
TEST_CHECK(m1 > m2);
#endif
// test split_string
char const* tags[10];
char tags_str[] = " this is\ta test\t string\x01to be split and it cannot "
"extend over the limit of elements \t";
int ret = split_string(tags, 10, tags_str);
TEST_CHECK(ret == 10);
TEST_CHECK(strcmp(tags[0], "this") == 0);
TEST_CHECK(strcmp(tags[1], "is") == 0);
TEST_CHECK(strcmp(tags[2], "a") == 0);
TEST_CHECK(strcmp(tags[3], "test") == 0);
TEST_CHECK(strcmp(tags[4], "string") == 0);
TEST_CHECK(strcmp(tags[5], "to") == 0);
TEST_CHECK(strcmp(tags[6], "be") == 0);
TEST_CHECK(strcmp(tags[7], "split") == 0);
TEST_CHECK(strcmp(tags[8], "and") == 0);
TEST_CHECK(strcmp(tags[9], "it") == 0);
// test snprintf
char msg[10];