Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ The openvfs binary accepts the following command-line parameters:

The FUSE layer must be unmounted when the desktop client shuts down or when a sync connection is removed.

## Configuration File

The file passed via `-i` is JSON. All keys are optional; a missing key falls back to the built-in default.

* `ignoreApps.byName`: List of absolute executable paths that must not trigger a hydration. A matching caller receives `EPERM` from `open()` instead of the file being downloaded.
* `ignoreApps.endsWith`: List of suffixes matched against the calling executable's path, for the same purpose. This is the distribution-independent way to name a binary, and `-thumbnailer` catches the freedesktop thumbnailer convention in one entry.
* `hydrationTimeoutSeconds`: How long `open()` blocks waiting for the client to hydrate a file before giving up and returning `ETIMEDOUT`. Defaults to `300`. Raise it if large files are legitimately transferred over slow links; the point of the bound is to keep an unresponsive client from wedging the calling application indefinitely.

The shipped `config.json` is a cross-desktop starting point, not an exhaustive list. Any indexer, thumbnailer, or antivirus that opens files as a matter of routine will otherwise download the whole sync root.

## Socket API Communication

OpenVFS communicates with the desktop client over a local, unauthenticated Unix socket — the so-called socket API. This bidirectional channel is already used by both OpenCloud and Nextcloud clients for file manager integration.
Expand Down Expand Up @@ -49,3 +59,7 @@ Expected response fields:

On a successful response, the file is considered hydrated and its content is available locally.

Messages are newline-delimited on a stream socket, so a reply may be split across several writes and several replies may be batched into one. openVFS reassembles them; the client need not size its replies to any particular limit.

If the client reports an error, `open()` fails with `EIO`. If it does not answer within `hydrationTimeoutSeconds`, `open()` fails with `ETIMEDOUT`.

1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
include(CMakePackageConfigHelpers)
include(ECMMarkAsTest)
include(ECMMarkNonGuiExecutable)
include(ECMSetupVersion)

Expand Down
4 changes: 2 additions & 2 deletions src/openvfs/openvfconstants.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ namespace Constants {
{
switch (name) {
case PinStates::OnlineOnly:
return "hydrated";
return "onlineonly";
case PinStates::AlwaysLocal:
return "dehydrated";
return "alwayslocal";
case PinStates::Inherited:
Comment on lines +87 to 90

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change, without many benefits.

return "inherited";
case PinStates::Excluded:
Expand Down
2 changes: 1 addition & 1 deletion src/openvfs/openvfs.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Hannah von Reth <h.vonreth@opencloud.eu>

#include "openvfs/openvfs.h"
Expand Down
4 changes: 4 additions & 0 deletions src/openvfsfuse/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ target_link_libraries(openvfsfuse PRIVATE openvfs nlohmann_json::nlohmann_json F
# disable darwin extensions for now
target_compile_definitions(openvfsfuse PRIVATE FUSE_DARWIN_ENABLE_EXTENSIONS=0)

if (BUILD_TESTING)
add_subdirectory(autotests)
endif()

install(FILES config.json DESTINATION ${KDE_INSTALL_CONFDIR}/openvfs)
install(TARGETS openvfsfuse EXPORT OpenVFSConfig ${KDE_INSTALL_TARGETS_DEFAULT_ARGS} )
13 changes: 13 additions & 0 deletions src/openvfsfuse/autotests/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# The socket and job handling live in the openvfsfuse executable, so the test
# compiles those sources directly rather than linking a library.
add_executable(socketthreadtest
socketthreadtest.cpp
../socketthread.cpp
../sharedmap.cpp
../strtools.cpp
)
target_include_directories(socketthreadtest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/..)
target_link_libraries(socketthreadtest PRIVATE nlohmann_json::nlohmann_json Threads::Threads)
ecm_mark_as_test(socketthreadtest)

add_test(NAME socketthreadtest COMMAND socketthreadtest)
169 changes: 169 additions & 0 deletions src/openvfsfuse/autotests/socketthreadtest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 Klaas Freitag <k.freitag@opencloud.eu>

/*
* Drives SocketThread and SharedMap over a real AF_UNIX socket, standing in for
* the desktop client. Covers the stream framing and the hydration wait, both of
* which are timing dependent and regress silently.
*/

#include "sharedmap.h"
#include "socketthread.h"

#include <chrono>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <sys/socket.h>
#include <sys/un.h>
#include <thread>
#include <unistd.h>

using namespace std::chrono_literals;

namespace {

int failures = 0;

void check(bool ok, const std::string &what)
{
std::cerr << (ok ? "ok : " : "FAIL : ") << what << std::endl;
if (!ok) {
++failures;
}
}

/// A socket path has to fit into sockaddr_un::sun_path, so keep it short and
/// out of the (potentially deeply nested) build directory.
std::string makeSocketPath()
{
char tmpl[] = "/tmp/openvfs-test-XXXXXX";
const char *dir = mkdtemp(tmpl);
if (!dir) {
std::cerr << "Failed to create a temporary directory" << std::endl;
std::exit(2);
}
return std::string(dir) + "/s";
}

int listenOn(const std::string &path)
{
const int fd = socket(AF_UNIX, SOCK_STREAM, 0);
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1);
if (bind(fd, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) != 0 || listen(fd, 1) != 0) {
std::cerr << "Failed to listen on " << path << ": " << std::strerror(errno) << std::endl;
std::exit(2);
}
return fd;
}

void writeAll(int fd, const std::string &data)
{
size_t offset = 0;
while (offset < data.size()) {
const ssize_t n = write(fd, data.data() + offset, data.size() - offset);
if (n <= 0) {
return;
}
offset += static_cast<size_t>(n);
}
}

std::string hydrateResult(int id, const std::string &argumentsBody)
{
return "V2/HYDRATE_FILE_RESULT:{\"id\":\"" + std::to_string(id) + "\",\"arguments\":{" + argumentsBody + "}}\n";
}

}

int main()
{
const std::string socketPath = makeSocketPath();
const int server = listenOn(socketPath);

SharedMap jobs;
SocketThread socketThread("TestSocketThread", jobs);
socketThread.CreateThread(socketPath);

const int client = accept(server, nullptr, nullptr);
if (client < 0) {
std::cerr << "SocketThread did not connect" << std::endl;
return 2;
}

// Register a job the way openVFSfuse_open() does, before posting it.
const auto post = [&](int id) {
auto data = std::make_shared<MsgData>();
data->msg = "V2/HYDRATE_FILE";
data->file = "/tmp/some/file";
data->id = id;
jobs.insert(id, HydJob{.state = HydJobState::Running});
socketThread.PostMsg(data);
};

// A message split across two writes must not be acted on until it is complete.
writeAll(client, "VERSION:1.2.3:2");
std::this_thread::sleep_for(750ms);
check(jobs.desktopClientPid() == 0, "an incomplete message is not dispatched");
writeAll(client, ":4242\n");
std::this_thread::sleep_for(750ms);
check(jobs.desktopClientPid() == 4242, "a message split across two writes is reassembled");

// A reply well past any single read buffer must arrive as one message. An
// error string carrying a path and a description clears 1 KB easily.
post(101);
writeAll(client, hydrateResult(101, "\"error\":\"" + std::string(8000, 'E') + "\""));
check(jobs.waitForJob(101, 10s) == HydJobResult::Failed, "an 8 KB reply is parsed as a single message");

// Several replies batched into one write must all be dispatched.
post(102);
post(103);
writeAll(client, hydrateResult(102, "\"status\":\"OK\"") + hydrateResult(103, "\"status\":\"FAILED\""));
check(jobs.waitForJob(102, 10s) == HydJobResult::Succeeded, "first of two batched replies is dispatched");
check(jobs.waitForJob(103, 10s) == HydJobResult::Failed, "second of two batched replies is dispatched");

// ... and the stream is still in sync after all of the above.
post(104);
writeAll(client, hydrateResult(104, "\"status\":\"OK\""));
check(jobs.waitForJob(104, 10s) == HydJobResult::Succeeded, "the stream stays in sync");

// A silent client must time out, bounded by wall-clock time.
post(105);
const auto beforeTimeout = std::chrono::steady_clock::now();
const auto timedOut = jobs.waitForJob(105, 500ms);
const auto waited = std::chrono::steady_clock::now() - beforeTimeout;
check(timedOut == HydJobResult::TimedOut, "a silent client is reported as a timeout");
check(waited >= 450ms && waited < 30s, "the timeout is bounded by wall-clock time");

// A reply arriving later must be observed without waiting out a long backoff.
post(106);
std::thread late([&] {
std::this_thread::sleep_for(300ms);
writeAll(client, hydrateResult(106, "\"status\":\"OK\""));
});
const auto beforeLate = std::chrono::steady_clock::now();
const auto lateResult = jobs.waitForJob(106, 60s);
const auto latency = std::chrono::steady_clock::now() - beforeLate;
late.join();
check(lateResult == HydJobResult::Succeeded, "a late reply is picked up");
check(latency < 10s, "a late reply is not delayed by a growing backoff");

// A caller must never be left waiting for a message that was never queued.
socketThread.ExitThread();
auto dropped = std::make_shared<MsgData>();
dropped->msg = "V2/HYDRATE_FILE";
dropped->id = 107;
check(!socketThread.PostMsg(dropped), "PostMsg reports messages dropped during shutdown");

close(client);
close(server);
std::error_code ec;
std::filesystem::remove_all(std::filesystem::path(socketPath).parent_path(), ec);

std::cerr << (failures == 0 ? "All checks passed" : std::to_string(failures) + " check(s) failed") << std::endl;
return failures == 0 ? 0 : 1;
}
33 changes: 30 additions & 3 deletions src/openvfsfuse/config.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
{
"ignoreApps": {
"byName": [
"/usr/bin/dolphin"
],
"endsWith": [
"dolphin",
"kioworker",
"baloo_file"
"baloo_file",
"baloo_file_extractor",
"nautilus",
"nemo",
"caja",
"thunar",
"pcmanfm",
"pcmanfm-qt",
"thumbnailer",
"gnome-thumbnail-font",
"gnome-thumbnail-factory",
"tumblerd",
"tracker-miner-fs",
"tracker-miner-fs-3",
"tracker-extract",
"tracker-extract-3",
"localsearch-3",
"localsearch-extractor-3",
"gvfsd",
"gvfsd-metadata",
"gvfsd-trash",
"updatedb",
"updatedb.mlocate",
"updatedb.plocate",
"clamd",
"clamscan",
"freshclam"
]
}
},
"hydrationTimeoutSeconds": 300
}
34 changes: 30 additions & 4 deletions src/openvfsfuse/main.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Hannah von Reth <h.vonreth@opencloud.eu>
// SPDX-FileCopyrightText: 2025 Klaas Freitag <k.freitag@opencloud.eu>

#include "openvfsfuse.h"

#include <cassert>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <getopt.h>
Expand Down Expand Up @@ -37,6 +42,7 @@ namespace {
const std::string ConfigIgnoreAppsStr = "ignoreApps";
const std::string ConfigByNameStr = "byName";
const std::string ConfigEndsWith = "endsWith";
const std::string ConfigHydrationTimeoutSecondsStr = "hydrationTimeoutSeconds";
}

void usage(char *name)
Expand Down Expand Up @@ -82,10 +88,30 @@ std::optional<openVFSfuse_Args> processArgs(int argc, char *argv[])
break;
case 'i': {
std::ifstream ifs(optarg);
json data = json::parse(ifs);

out.appsNoHydrateFull = data[ConfigIgnoreAppsStr][ConfigByNameStr].get<std::vector<std::string>>();
out.appsNoHydrateEndsWith = data[ConfigIgnoreAppsStr][ConfigEndsWith].get<std::vector<std::string>>();
if (!ifs) {
std::cerr << "Failed to open config file " << optarg << std::endl;
return {};
}
json data;
try {
data = json::parse(ifs);
} catch (const json::exception &e) {
std::cerr << "Failed to parse config file " << optarg << ": " << e.what() << std::endl;
return {};
}

// Every setting is optional so that a config file written for an
// older version keeps working.
const auto ignoreApps = data.value(ConfigIgnoreAppsStr, json::object());
out.appsNoHydrateFull = ignoreApps.value(ConfigByNameStr, std::vector<std::string>{});
out.appsNoHydrateEndsWith = ignoreApps.value(ConfigEndsWith, std::vector<std::string>{});

const auto timeoutSeconds = data.value(ConfigHydrationTimeoutSecondsStr, 0);
if (timeoutSeconds > 0) {
out.hydrationTimeout = std::chrono::seconds(timeoutSeconds);
} else if (timeoutSeconds < 0) {
std::cerr << ConfigHydrationTimeoutSecondsStr << " must be positive, keeping the default" << std::endl;
}
break;
}
case 'o':
Expand Down
Loading