Skip to content

Commit e25b7be

Browse files
authored
Merge pull request #6 from leducp/sanitize_shm_name
Sanitize SHM name to ensure valid POSIX path
2 parents d787ddf + 38ca73a commit e25b7be

8 files changed

Lines changed: 252 additions & 5 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ endif()
4545
add_library(kickmsg STATIC
4646
src/types.cc
4747
src/Hash.cc
48+
src/Naming.cc
4849
src/Publisher.cc
4950
src/Subscriber.cc
5051
src/Region.cc

include/kickmsg/Naming.h

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#ifndef KICKMSG_NAMING_H
2+
#define KICKMSG_NAMING_H
3+
4+
#include <string>
5+
#include <string_view>
6+
7+
namespace kickmsg
8+
{
9+
/// Sanitize a user-supplied name component (namespace / topic / channel /
10+
/// owner / tag) into something POSIX shm_open will accept.
11+
///
12+
/// POSIX requires shm names to start with a single '/' and contain no
13+
/// further '/' characters; Linux additionally constrains the remainder
14+
/// to a single path component under /dev/shm. This helper produces the
15+
/// "after the leading slash" fragment for one component — callers
16+
/// assemble the final "/<prefix>_<topic>" path themselves.
17+
///
18+
/// Rules (human-readable, no hashing):
19+
/// - strip leading '/' — lets callers pass ROS-style absolute names
20+
/// like "/robot/arm" without producing "//…" or embedded slashes
21+
/// - interior '/' becomes '.' — preserves hierarchy visually
22+
/// ("robot/arm/joint1" -> "robot.arm.joint1")
23+
/// - POSIX "portable filename" chars [A-Za-z0-9._-] pass through
24+
/// - everything else becomes '_' — deterministic, no collisions
25+
/// between benign inputs, still eyeballable in `ls /dev/shm`
26+
///
27+
/// Throws std::invalid_argument if the result would be empty (e.g. input
28+
/// is "", "/", "///") — a blank component would produce ambiguous names
29+
/// like "/prefix_" that silently collide across callers. \p what is
30+
/// interpolated into the exception message ("namespace", "topic", etc.).
31+
std::string sanitize_shm_component(std::string_view s, char const* what);
32+
}
33+
34+
#endif

include/kickmsg/Node.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ namespace kickmsg
2828
class Node
2929
{
3030
public:
31+
// Name components (node name, namespace/prefix, topic, channel,
32+
// owner, tag) are sanitized into a POSIX-shm-compatible form:
33+
// leading '/' is stripped, interior '/' becomes '.', and any char
34+
// outside [A-Za-z0-9._-] becomes '_'. This lets callers pass
35+
// ROS-style paths like "/robot/arm/joint1" directly — the region
36+
// ends up at "/<prefix>_robot.arm.joint1" in /dev/shm, still
37+
// human-readable (no hashing). A component that sanitizes to the
38+
// empty string throws std::invalid_argument.
3139
Node(std::string const& name, std::string const& prefix = "kickmsg");
3240

3341
// Explicit non-copyable / move-only. Node already holds SharedRegion

src/Naming.cc

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#include "kickmsg/Naming.h"
2+
3+
#include <cctype>
4+
#include <stdexcept>
5+
#include <string>
6+
7+
namespace kickmsg
8+
{
9+
std::string sanitize_shm_component(std::string_view s, char const* what)
10+
{
11+
std::string out;
12+
out.reserve(s.size());
13+
bool leading = true;
14+
for (char c : s)
15+
{
16+
if (leading && c == '/')
17+
{
18+
continue; // strip any leading '/' (including repeats)
19+
}
20+
leading = false;
21+
if (c == '/')
22+
{
23+
out.push_back('.');
24+
}
25+
else if (std::isalnum(static_cast<unsigned char>(c))
26+
|| c == '_' || c == '-' || c == '.')
27+
{
28+
out.push_back(c);
29+
}
30+
else
31+
{
32+
out.push_back('_');
33+
}
34+
}
35+
if (out.empty())
36+
{
37+
throw std::invalid_argument(
38+
std::string("kickmsg::sanitize_shm_component: ") + what
39+
+ " name is empty after sanitization (input: '"
40+
+ std::string(s) + "')");
41+
}
42+
return out;
43+
}
44+
}

src/Node.cc

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
#include "kickmsg/Node.h"
22

3+
#include "kickmsg/Naming.h"
4+
35
namespace kickmsg
46
{
57
Node::Node(std::string const& name, std::string const& prefix)
6-
: name_{name}
7-
, prefix_{prefix}
8+
: name_{sanitize_shm_component(name, "node")}
9+
, prefix_{sanitize_shm_component(prefix, "namespace")}
810
{
911
}
1012

@@ -136,17 +138,22 @@ namespace kickmsg
136138

137139
std::string Node::make_topic_name(char const* topic) const
138140
{
139-
return "/" + prefix_ + "_" + topic;
141+
// prefix_ is pre-sanitized in the ctor; topic is user-supplied on
142+
// each call and may be a ROS-style "/a/b/c" path.
143+
return "/" + prefix_ + "_" + sanitize_shm_component(topic, "topic");
140144
}
141145

142146
std::string Node::make_broadcast_name(char const* channel) const
143147
{
144-
return "/" + prefix_ + "_broadcast_" + channel;
148+
return "/" + prefix_ + "_broadcast_"
149+
+ sanitize_shm_component(channel, "channel");
145150
}
146151

147152
std::string Node::make_mailbox_name(char const* owner, char const* tag) const
148153
{
149-
return "/" + prefix_ + "_" + owner + "_mbx_" + tag;
154+
return "/" + prefix_ + "_"
155+
+ sanitize_shm_component(owner, "mailbox owner") + "_mbx_"
156+
+ sanitize_shm_component(tag, "mailbox tag");
150157
}
151158

152159
SharedRegion* Node::find_region(std::string const& shm_name)

tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ add_executable(kickmsg_unit
33
unit/region-t.cc
44
unit/publisher-t.cc
55
unit/subscriber-t.cc
6+
unit/naming-t.cc
67
unit/node-t.cc
78
)
89
target_link_libraries(kickmsg_unit PRIVATE kickmsg GTest::gmock_main)

tests/unit/naming-t.cc

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#include <stdexcept>
2+
#include <string>
3+
4+
#include <gtest/gtest.h>
5+
6+
#include "kickmsg/Naming.h"
7+
8+
using kickmsg::sanitize_shm_component;
9+
10+
// --- Pass-through for already-valid inputs -------------------------------
11+
12+
TEST(SanitizeShmComponent, PlainAlnumIsUnchanged)
13+
{
14+
EXPECT_EQ(sanitize_shm_component("topic", "topic"), "topic");
15+
EXPECT_EQ(sanitize_shm_component("sensor42", "topic"), "sensor42");
16+
}
17+
18+
TEST(SanitizeShmComponent, PortableFilenameCharsSurvive)
19+
{
20+
// POSIX portable filename set [A-Za-z0-9._-] must pass through verbatim
21+
// — this is what allows `ls /dev/shm` output to stay human-readable.
22+
EXPECT_EQ(sanitize_shm_component("a.b_c-d.42", "topic"), "a.b_c-d.42");
23+
}
24+
25+
// --- Leading slash handling (ROS-style absolute names) -------------------
26+
27+
TEST(SanitizeShmComponent, StripsSingleLeadingSlash)
28+
{
29+
EXPECT_EQ(sanitize_shm_component("/topic", "topic"), "topic");
30+
}
31+
32+
TEST(SanitizeShmComponent, StripsRepeatedLeadingSlashes)
33+
{
34+
// "///a" -> "a" (not ".a" or "..a") — otherwise a user who typed "//x"
35+
// by accident would silently get a different region than "/x".
36+
EXPECT_EQ(sanitize_shm_component("///topic", "topic"), "topic");
37+
}
38+
39+
// --- Interior slash becomes '.' ------------------------------------------
40+
41+
TEST(SanitizeShmComponent, InteriorSlashBecomesDot)
42+
{
43+
EXPECT_EQ(sanitize_shm_component("robot/arm", "topic"),
44+
"robot.arm");
45+
EXPECT_EQ(sanitize_shm_component("robot/arm/joint1", "topic"),
46+
"robot.arm.joint1");
47+
}
48+
49+
TEST(SanitizeShmComponent, LeadingAndInteriorSlashesCombine)
50+
{
51+
// The leading-slash strip must not fire for interior slashes after
52+
// real characters have been seen.
53+
EXPECT_EQ(sanitize_shm_component("/robot/arm/joint1", "topic"),
54+
"robot.arm.joint1");
55+
}
56+
57+
// --- Fallback mapping for everything else --------------------------------
58+
59+
TEST(SanitizeShmComponent, InvalidCharsBecomeUnderscore)
60+
{
61+
EXPECT_EQ(sanitize_shm_component("hello world", "topic"), "hello_world");
62+
EXPECT_EQ(sanitize_shm_component("a:b;c", "topic"), "a_b_c");
63+
EXPECT_EQ(sanitize_shm_component("with\ttab", "topic"), "with_tab");
64+
EXPECT_EQ(sanitize_shm_component("weird@name!", "topic"), "weird_name_");
65+
}
66+
67+
TEST(SanitizeShmComponent, UnicodeBytesBecomeUnderscores)
68+
{
69+
// Multi-byte UTF-8 sequences: each byte fails isalnum and maps to '_'.
70+
// We don't care about exact width — only that the output stays within
71+
// the portable set. "é" is 0xC3 0xA9 -> "__".
72+
auto out = sanitize_shm_component("caf\xC3\xA9", "topic");
73+
EXPECT_EQ(out, "caf__");
74+
}
75+
76+
// --- Idempotency ---------------------------------------------------------
77+
78+
TEST(SanitizeShmComponent, Idempotent)
79+
{
80+
// Sanitize twice -> same as sanitize once. Matters because Node
81+
// sanitizes the prefix in the ctor, and make_topic_name could
82+
// conceivably be called with the prefix as an input somewhere.
83+
auto once = sanitize_shm_component("/robot/arm joint1", "topic");
84+
auto twice = sanitize_shm_component(once, "topic");
85+
EXPECT_EQ(once, "robot.arm_joint1");
86+
EXPECT_EQ(twice, once);
87+
}
88+
89+
// --- Error cases ---------------------------------------------------------
90+
91+
TEST(SanitizeShmComponent, EmptyInputThrows)
92+
{
93+
EXPECT_THROW(sanitize_shm_component("", "topic"), std::invalid_argument);
94+
}
95+
96+
TEST(SanitizeShmComponent, OnlySlashesThrows)
97+
{
98+
// After stripping leading '/'s there is nothing left — same category
99+
// of error as an empty input.
100+
EXPECT_THROW(sanitize_shm_component("/", "topic"), std::invalid_argument);
101+
EXPECT_THROW(sanitize_shm_component("///", "topic"), std::invalid_argument);
102+
}
103+
104+
TEST(SanitizeShmComponent, WhatIsIncludedInErrorMessage)
105+
{
106+
// The `what` label is the only way the caller can tell which of the
107+
// several calls in make_topic_name / make_mailbox_name failed — this
108+
// test pins that contract so refactors don't accidentally drop it.
109+
try
110+
{
111+
sanitize_shm_component("", "namespace");
112+
FAIL() << "expected std::invalid_argument";
113+
}
114+
catch (std::invalid_argument const& e)
115+
{
116+
std::string msg = e.what();
117+
EXPECT_NE(msg.find("namespace"), std::string::npos) << msg;
118+
}
119+
}

tests/unit/node-t.cc

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,39 @@ TEST_F(NodeTest, SubscribeOrCreateTwiceReusesSameRegion)
294294
EXPECT_STREQ(schema_view->name, "shared/Type");
295295
}
296296

297+
TEST_F(NodeTest, RosStyleTopicNamesAreSanitizedIntoShmPath)
298+
{
299+
// End-to-end check that Node feeds topic/namespace through
300+
// sanitize_shm_component: ROS-style absolute paths with interior '/'
301+
// must round-trip into a POSIX-valid "/<prefix>_robot.arm.joint1"
302+
// region, reachable by a peer that passes the same raw topic string.
303+
track("/test.ns_robot.arm.joint1");
304+
305+
kickmsg::Node pub_node("drv", "/test/ns");
306+
auto pub = pub_node.advertise("/robot/arm/joint1", small_cfg());
307+
308+
kickmsg::Node sub_node("log", "/test/ns");
309+
auto sub = sub_node.subscribe("/robot/arm/joint1");
310+
311+
uint32_t val = 7;
312+
ASSERT_GE(pub.send(&val, sizeof(val)), 0);
313+
auto got = sub.try_receive();
314+
ASSERT_TRUE(got.has_value());
315+
EXPECT_EQ(std::memcmp(got->data(), &val, sizeof(val)), 0);
316+
317+
// Also confirm Node::name() / prefix() return the sanitized form so
318+
// callers can log/introspect the actual identifiers in use.
319+
EXPECT_EQ(pub_node.prefix(), "test.ns");
320+
EXPECT_EQ(pub_node.name(), "drv");
321+
}
322+
323+
TEST_F(NodeTest, EmptyTopicNameThrows)
324+
{
325+
kickmsg::Node node("n", "test");
326+
EXPECT_THROW(node.advertise("", small_cfg()), std::invalid_argument);
327+
EXPECT_THROW(node.advertise("/", small_cfg()), std::invalid_argument);
328+
}
329+
297330
TEST_F(NodeTest, MailboxMultipleWriters)
298331
{
299332
track("/test_owner_mbx_inbox");

0 commit comments

Comments
 (0)