mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 12:01:15 +00:00
* feat(windows): port logos_socket_paths and add a cross target logos_socket_paths.cpp is the only POSIX-bound file in logos-protocol. All of it is unix-domain-socket machinery, and on Windows the local transport is named pipes (QLocalServer maps a name to \\.\pipe\<name>), where none of the assumptions hold: a pipe has no inode to lstat/chown/chmod -- access comes from a security descriptor set at CreateNamedPipe time -- and a pipe cannot outlive its last handle, so a hard-killed process leaves nothing behind. isSocketDead and reapStaleSockets are therefore not merely unimplemented on Windows, they are vacuous: the state they detect cannot arise. Both return the fail-closed answer (false / 0), matching the documented contract that an endpoint is never reported dead unless certain. applySocketPerms deliberately does NOT no-op. With no policy requested it returns true, as on POSIX. But when LOGOS_SOCKET_GROUP or LOGOS_SOCKET_MODE *are* set it fails with an explanatory error, because silently returning true would leave the endpoint more permissive than the operator asked for -- the one direction this file is careful never to go (cf. the chgrp-then-chmod ordering in the POSIX branch). Granting a pipe to a group needs a DACL plus a group->SID resolver; until that exists, refuse loudly. Also gates qt6.wrapQtAppsNoGuiHook behind !isWindows and sets dontWrapQtApps. Both halves are required: the hook does not even evaluate for a mingw host, it would be inert anyway (wrap-qt-apps-hook.sh skips anything that is not ELF or Mach-O), and qtbase's setup hook hard-errors in qtPreHook unless dontWrapQtApps is set. Header contract updated per function. POSIX branch unchanged and still compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: make the Boost.System component optional, not required find_package(Boost REQUIRED COMPONENTS system) hard-fails on Boost 1.89: Could not find a package configuration file provided by "boost_system" Boost.System has been header-only for years, and 1.89 finally dropped the compiled boost_system library, so no boost_systemConfig.cmake is installed at all. The COMPONENTS request was not gratuitous though -- on 1.87 the Boost::system imported target is only exported when the component is asked for, which is what the previous comment recorded. So ask optionally and fall back to Boost::headers, which supplies the same header-only error_code either way. The choice is by BOOST VERSION, not by platform: this is not a Windows quirk, it simply surfaced first there because the Windows target pins a newer nixpkgs (Boost 1.89) than the native one (Boost 1.87). Verified both ways -- native aarch64-darwin still selects Boost::system: -- Boost.System target: Boost::system (Boost 1.87.0) and the build completes unchanged. Also adds QT_HOST_PATH / QT_ADDITIONAL_HOST_PACKAGES_PREFIX_PATH for the Windows target. Qt6RemoteObjectsDependencies.cmake declares set(__qt_RemoteObjects_tool_deps "Qt6RemoteObjectsTools;6.11.1") and Qt6RemoteObjectsTools holds repc, which must RUN on the build machine -- so under cross it lives in the build-platform Qt, not the mingw one. Without these, find_package reports the thoroughly misleading "Expected Config file at <qtbase>/lib/cmake/Qt6RemoteObjects ... does NOT exist": the TARGET config is found fine; it is the HOST tool package that is missing. Every Qt-consuming repo will need this, so it should be hoisted into logos-nix rather than repeated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: declare the lp_* C ABI explicitly instead of relying on auto-export Adds LP_API (__declspec(dllexport) when building the shared library, default visibility elsewhere) to the 21 lp_* entry points, and defines LOGOS_PROTOCOL_BUILDING_SHARED for the shared target only, so the static archive leaves LP_API empty and its consumers need no import library. This is NOT a bug fix, contrary to what the concern in the Windows plan suggested. Measured on the cross-built DLL, before and after: before: export table 0x2ece (11982 symbols), lp_* present: 21 after: export table 0x15 ( 21 symbols), lp_* present: 21 GNU ld's PE auto-export was already exporting lp_* -- along with roughly twelve thousand other symbols. The worry was that logos_module_impl.h's __declspec(dllexport) would disable auto-export image-wide and silently drop lp_*; it does not, because no translation unit in logos_protocol includes that header (it is listed in PROTOCOL_SOURCES for IDE visibility only). What this does buy is worth having anyway: the exported surface is now the ABI we actually declare rather than whatever happens to have external linkage, it stops being contingent on auto-export staying enabled -- which the very next TU to gain a dllexport would silently end -- and it drops ~12k incidental symbols from the export table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: relax the Boost.System requirement in the EXPORTED cmake config too The previous commit fixed cpp/CMakeLists.txt but left logos-protocolConfig.cmake.in still doing find_dependency(Boost REQUIRED COMPONENTS system) so logos-protocol itself built fine on Boost 1.89 while every CONSUMER of its installed CMake package failed at configure time -- caught by logos-qt-sdk, which is the first downstream repo to be cross-built. Worth noting as a general trap: a package can be internally consistent and still ship a broken contract, because the exported config is a separate artifact from the build. Anything changed in one has to be checked in the other. Verified both directions: the Windows cross builds of logos-cpp-sdk and logos-qt-sdk now succeed, and a native aarch64-darwin logos-qt-sdk build -- which consumes this same config against Boost 1.87 -- still succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(windows): mark the types that must exist once per process PE has no symbol interposition. ELF and Mach-O interpose across the whole image set, so when liblogos_core exports TokenManager::instance() every other image binds to that one definition and the function-local `static TokenManager instance;` is genuinely a singleton. On Windows every image that links liblogos_protocol.a / liblogos_qt_sdk.a statically gets its own copy of the code and therefore its own statics -- measured: NINE images in the Basecamp payload each defined TokenManager::instance()::instance. The host saved a capability token into its copy, the UI plugin read its own empty copy, and every cross-module call was refused (29 "ModuleProxy: rejecting unauthorized call"). LOGOS_SHARED_API marks the affected types. It expands to __declspec(dllimport) only for a consumer that opts in with LOGOS_SHARED_USE_DLL, and to nothing everywhere else -- off Windows, and inside logos-protocol/logos-qt-sdk/liblogos_core themselves, so the static archives compile byte-identically to before. The dllimport is the load-bearing half, not the export: it rewrites the reference to go through __imp_, so the plain symbol is never undefined and GNU ld never pulls the archive member that would redefine it. Without it the link still succeeds, binds to the archive, and gives no diagnostic at all. logos_shared_api.h records both wrong answers -- export everything (collides with the static archive over LogosAPI) and export nothing (today's silent per-image statics) -- so neither gets reinvented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(windows): let checks and devShells take the arg forAllSystems now passes The cross-target commit added `inherit system;` to forAllSystems so the Windows arm could tell which target it was building, but left `checks` and `devShells` on the strict `({ pkgs }: ...)` pattern. A Nix attrset pattern without `...` is exact, so both stopped evaluating: error: function 'anonymous lambda' called with unexpected argument 'system' on EVERY platform, not just Windows -- `nix flake check` and `ws develop logos-protocol` are dead on this branch while they work on master. `packages` was unaffected because it goes through forAllTargets, which is why nothing caught it. Measured, same worktree, before and after: before: checks.aarch64-darwin -> the error above at flake.nix:52 after: checks.aarch64-darwin -> [ "tests" ] devShells.aarch64-darwin.default.name -> "nix-shell" packages -> [ aarch64-darwin aarch64-linux x86_64-darwin x86_64-linux x86_64-windows ] * chore(deps): re-pin logos-nix to the merged Windows overlay The cross overlay landed in logos-nix#2. This branch was locked to a pre-merge rev, which has no `lib.forAllTargets` and no `lib.mkWindowsPkgs`, so it could not evaluate standalone -- only against the unmerged branch. Level 2 of the Windows chain; L1 (logos-nix) is merged. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
232 lines
8.4 KiB
C++
232 lines
8.4 KiB
C++
#include "logos_socket_paths.h"
|
|
|
|
#include <cctype>
|
|
#include <cerrno>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <limits>
|
|
#include <vector>
|
|
|
|
#ifndef _WIN32
|
|
#include <dirent.h>
|
|
#include <fcntl.h>
|
|
#include <grp.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
#include <sys/un.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
namespace logos {
|
|
|
|
#ifdef _WIN32
|
|
|
|
// On Windows the local transport is backed by NAMED PIPES: QLocalServer maps a
|
|
// server name to \\.\pipe\<name>. Every assumption this file is built on is a
|
|
// unix-domain-socket assumption, and none of them survives the move:
|
|
//
|
|
// * a pipe has no filesystem inode, so there is nothing to lstat, chown or
|
|
// chmod — access is governed by a security descriptor supplied at
|
|
// CreateNamedPipe time, not by mode bits;
|
|
// * a pipe instance ceases to exist when its last handle closes, so a
|
|
// hard-killed process cannot leave a stale endpoint behind. There is no
|
|
// equivalent of the /tmp/logos_<name>_<instance> litter the reaper exists
|
|
// to clean up.
|
|
//
|
|
// So isSocketDead/reapStaleSockets are not merely unimplemented here, they are
|
|
// vacuous: the condition they detect cannot arise.
|
|
|
|
bool applySocketPerms(const std::string& absPath, std::string* errOut)
|
|
{
|
|
const char* grpEnv = std::getenv("LOGOS_SOCKET_GROUP");
|
|
const char* modeEnv = std::getenv("LOGOS_SOCKET_MODE");
|
|
const bool wantGroup = grpEnv && *grpEnv;
|
|
const bool wantMode = modeEnv && *modeEnv;
|
|
|
|
// Policy unset is the overwhelmingly common case and is a genuine no-op.
|
|
if (!wantGroup && !wantMode) return true;
|
|
|
|
// Policy SET, though, is a request we cannot honour. Returning true here
|
|
// would silently widen access relative to what the operator asked for --
|
|
// the one direction this file is careful never to go (see the chgrp-then-
|
|
// chmod ordering in the POSIX branch). Granting a Windows pipe to a group
|
|
// means building a DACL and resolving the group to a SID; until that
|
|
// exists, refuse loudly rather than pretend.
|
|
if (errOut) {
|
|
*errOut = "LOGOS_SOCKET_GROUP/LOGOS_SOCKET_MODE are not supported on "
|
|
"Windows: the local transport uses named pipes, which carry a "
|
|
"security descriptor rather than owner/group/mode. Unset them, "
|
|
"or run the node per-user (%LOCALAPPDATA%). Path: " + absPath;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool isSocketDead(const std::string& /*absPath*/)
|
|
{
|
|
// Fail closed, matching the documented contract: never report an endpoint
|
|
// dead unless certain. A named pipe that still exists still has an owner.
|
|
return false;
|
|
}
|
|
|
|
std::size_t reapStaleSockets(const std::string& /*dir*/, const std::string& /*prefix*/)
|
|
{
|
|
return 0; // named pipes leave nothing behind to reap
|
|
}
|
|
|
|
#else
|
|
|
|
namespace {
|
|
|
|
// Resolve a "group" env value to a gid. Accepts an all-digits string as a
|
|
// numeric gid directly, otherwise looks the name up in the group database.
|
|
bool resolveGid(const std::string& spec, gid_t& out)
|
|
{
|
|
if (!spec.empty() &&
|
|
spec.find_first_not_of("0123456789") == std::string::npos) {
|
|
errno = 0;
|
|
char* end = nullptr;
|
|
const unsigned long v = std::strtoul(spec.c_str(), &end, 10);
|
|
// Reject overflow and any value that doesn't fit gid_t — a truncated
|
|
// gid would silently chgrp to the wrong group.
|
|
if (errno != 0 || end == spec.c_str() || *end != '\0' ||
|
|
v > static_cast<unsigned long>(std::numeric_limits<gid_t>::max()))
|
|
return false;
|
|
out = static_cast<gid_t>(v);
|
|
return true;
|
|
}
|
|
|
|
// getgrnam_r with a growing buffer — thread-safe, unlike getgrnam.
|
|
std::vector<char> buf(1024);
|
|
struct group grp;
|
|
struct group* result = nullptr;
|
|
for (;;) {
|
|
int rc = ::getgrnam_r(spec.c_str(), &grp, buf.data(), buf.size(), &result);
|
|
if (rc == ERANGE && buf.size() < (1u << 20)) {
|
|
buf.resize(buf.size() * 2);
|
|
continue;
|
|
}
|
|
if (rc != 0 || result == nullptr) return false;
|
|
out = grp.gr_gid;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Parse an octal mode like "0660" / "660". Rejects garbage and anything wider
|
|
// than the low 12 bits (setuid/setgid/sticky + rwx triplets).
|
|
bool parseOctalMode(const std::string& spec, mode_t& out)
|
|
{
|
|
if (spec.empty()) return false;
|
|
for (char c : spec) {
|
|
if (c < '0' || c > '7') return false;
|
|
}
|
|
errno = 0;
|
|
char* end = nullptr;
|
|
unsigned long v = std::strtoul(spec.c_str(), &end, 8);
|
|
if (errno != 0 || end == spec.c_str() || *end != '\0' || v > 07777) return false;
|
|
out = static_cast<mode_t>(v);
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool applySocketPerms(const std::string& absPath, std::string* errOut)
|
|
{
|
|
auto fail = [&](const std::string& msg) {
|
|
if (errOut) *errOut = msg;
|
|
return false;
|
|
};
|
|
|
|
const char* grpEnv = std::getenv("LOGOS_SOCKET_GROUP");
|
|
const char* modeEnv = std::getenv("LOGOS_SOCKET_MODE");
|
|
const bool wantGroup = grpEnv && *grpEnv;
|
|
const bool wantMode = modeEnv && *modeEnv;
|
|
if (!wantGroup && !wantMode) return true; // policy unset: no-op, touch nothing
|
|
|
|
// Only ever change a socket we own. If a malformed URL produced a path that
|
|
// isn't the socket we just bound, refuse rather than chmod/chown a stray
|
|
// file. (Mirrors isSocketDead's owner check.)
|
|
struct stat st;
|
|
if (::lstat(absPath.c_str(), &st) != 0)
|
|
return fail("stat(" + absPath + ") failed: " + std::strerror(errno));
|
|
if (!S_ISSOCK(st.st_mode))
|
|
return fail(absPath + " is not a socket — refusing to change perms");
|
|
if (st.st_uid != ::geteuid())
|
|
return fail(absPath + " is not owned by us — refusing to change perms");
|
|
|
|
if (wantGroup) {
|
|
gid_t gid = 0;
|
|
if (!resolveGid(grpEnv, gid))
|
|
return fail(std::string("unknown group '") + grpEnv + "'");
|
|
// Non-root may chgrp a file it owns to any group it belongs to.
|
|
if (::chown(absPath.c_str(), static_cast<uid_t>(-1), gid) != 0)
|
|
return fail("chown(" + absPath + ") failed: " + std::strerror(errno));
|
|
}
|
|
|
|
if (wantMode) {
|
|
mode_t mode = 0;
|
|
if (!parseOctalMode(modeEnv, mode))
|
|
return fail(std::string("invalid LOGOS_SOCKET_MODE '") + modeEnv + "'");
|
|
if (::chmod(absPath.c_str(), mode) != 0)
|
|
return fail("chmod(" + absPath + ") failed: " + std::strerror(errno));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool isSocketDead(const std::string& absPath)
|
|
{
|
|
struct stat st;
|
|
if (::lstat(absPath.c_str(), &st) != 0) return false; // gone / unreadable
|
|
if (!S_ISSOCK(st.st_mode)) return false; // regular file, dir, ...
|
|
if (st.st_uid != ::geteuid()) return false; // not ours to reap
|
|
|
|
struct sockaddr_un addr;
|
|
std::memset(&addr, 0, sizeof(addr));
|
|
addr.sun_family = AF_UNIX;
|
|
if (absPath.size() >= sizeof(addr.sun_path)) return false; // can't probe -> assume alive
|
|
std::memcpy(addr.sun_path, absPath.c_str(), absPath.size());
|
|
|
|
const int fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
|
|
if (fd < 0) return false;
|
|
const int flags = ::fcntl(fd, F_GETFL, 0);
|
|
if (flags >= 0) ::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
|
|
|
const int rc = ::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
|
|
const int err = errno;
|
|
::close(fd);
|
|
|
|
if (rc == 0) return false; // a listener answered -> alive
|
|
// ECONNREFUSED: bound but nobody listening. ENOENT: vanished mid-probe.
|
|
// Everything else (EAGAIN/EINPROGRESS backlog full, EACCES, ETIMEDOUT, ...)
|
|
// is treated as alive so we never unlink a socket that might be in use.
|
|
return err == ECONNREFUSED || err == ENOENT;
|
|
}
|
|
|
|
std::size_t reapStaleSockets(const std::string& dir, const std::string& prefix)
|
|
{
|
|
// Refuse an empty prefix: it would make every dead socket the process owns
|
|
// (anywhere in `dir`) a deletion candidate. Callers always know the family
|
|
// of sockets they created ("logos_"), so this is misuse, not a valid sweep.
|
|
if (prefix.empty()) return 0;
|
|
|
|
DIR* d = ::opendir(dir.c_str());
|
|
if (!d) return 0;
|
|
|
|
std::size_t removed = 0;
|
|
while (struct dirent* ent = ::readdir(d)) {
|
|
const std::string name = ent->d_name;
|
|
if (name.size() < prefix.size() || name.compare(0, prefix.size(), prefix) != 0)
|
|
continue;
|
|
const std::string full = dir + "/" + name;
|
|
if (isSocketDead(full) && ::unlink(full.c_str()) == 0)
|
|
++removed;
|
|
}
|
|
::closedir(d);
|
|
return removed;
|
|
}
|
|
|
|
#endif // _WIN32
|
|
|
|
} // namespace logos
|