rclone_cpp
Voir sur GitHubBibliotheque C++ encapsulant rclone en tant que sous-processus, exposant une API orientee objet pour le stockage cloud.
rclone_cpp | Rapport technique
En bref
- Bibliothèque C++ (C++17/23) encapsulant rclone en tant que sous-processus, exposant une API orientée objet pour piloter le stockage cloud (Google Drive, SFTP, OneDrive, Dropbox, etc.).
- Fournit une couche d'abstraction complète : exécution asynchrone des commandes, parsing structuré des sorties JSON/texte, arbre de fichiers en mémoire, pool de processus avec priorité.
- Technologies clés : C++ moderne (concepts,
std::variant,requires), Boost (Process, JSON, Signals2, Thread), CMake + Conan 2, CI GitHub Actions. - Architecturé autour de trois piliers extensibles — entities, parsers, options — chacun héritable par l'utilisateur final.
- Pattern Builder/Fluent sur la classe
processpermettant de chaîner commande, options, callbacks et exécution en un seul pipeline lisible. - Distribué comme paquet Conan (v0.6.2) avec un suite de tests Boost.Test couvrant entités, parsers, processus et pool.
Contexte et objectif
rclone est un outil CLI en Go, considéré comme le « rsync du cloud ». Il gère plus de 70 backends de stockage (Google Drive, S3, SFTP, Dropbox, OneDrive, etc.) via une interface en ligne de commande. Cependant, intégrer rclone dans une application C++ nécessite de gérer manuellement les sous-processus, le parsing des sorties, la construction des arguments et la gestion asynchrone.
rclone_cpp comble ce gap en offrant une bibliothèque C++ header-friendly qui :
- Localise et lance le binaire
rcloneviaboost::process. - Expose chaque commande rclone (
lsjson,copyto,sync,bisync,tree, etc.) comme méthode C++ typée. - Parse automatiquement les sorties (JSON, texte structuré) en objets C++ (
file,remote,json_log,about,size,version). - Permet de contrôler finement les options rclone via un système d'options typées et extensibles.
- Offre un
process_poolavec file de priorité pour paralléliser les opérations de stockage.
La bibliothèque s'adresse aux développeurs C++ souhaitant intégrer des capacités de synchronisation/gestion cloud dans leurs applications — clients de bureau, outils de backup, gestionnaires de fichiers — sans réimplémenter les protocoles de chaque provider.
Fonctionnalités
| Catégorie | Commandes / Fonctionnalités |
|---|---|
| Info & config | version, listremotes, config, config create, delete remote |
| Listing | lsjson, ls, lsl, lsd, lsf, tree |
| Transfert | copyto, moveto, sync, bisync, copyurl |
| Manipulation | mkdir, rmdir, rmdirs, delete, purge, touch, cat |
| Métadonnées | about, size, check |
| Maintenance | cleanup |
| Options globales/locales | Filtres (--include, --exclude, --max-depth), performance (--transfers, --checkers, --buffer-size), logging (--use-json-log, --verbose, niveaux), listing (--fast-list) |
| Process pool | Exécution parallèle avec priorité (low/normal/high/max), configuration du nombre de slots simultanés |
| Parsing | JSON (lsjson), LSL (listing long), remote, version, about, size, json_log |
Architecture (vue d'ensemble)
include/iridium/
├── rclone.hpp # Header agrégateur + alias de namespace
├── process.hpp # Point d'entrée : process_pool + config_create
├── entities.hpp # Header agrégateur d'entités
├── parsers.hpp # Header agrégateur de parsers + export DLL
├── options.hpp # Header agrégateur d'options
├── process/
│ ├── process.hpp # Classe process (API publique)
│ ├── process_pool.hpp # Pool avec priorité
│ └── config_create.hpp # Builder fluent pour config create
├── entities/
│ ├── entity.hpp # Classe de base (polymorphisme)
│ ├── file.hpp # Arborescence de fichiers in-memory
│ ├── remote.hpp # Représentation d'un remote (13+ types)
│ ├── json_log.hpp # Log structuré + stats de transfert
│ ├── about.hpp / size.hpp / version.hpp
├── parsers/
│ ├── basic_parser.hpp # Template abstrait avec callback
│ ├── file_parser.hpp # JSON + LSL → arbre de fichiers
│ ├── json_log_parser.hpp # JSON → json_log (avec stats/transfers)
│ ├── remote_parser.hpp / version_parser.hpp / about_parser.hpp / size_parser.hpp
├── options/
│ ├── basic_option.hpp # Option CLI (flag ou clé=valeur)
│ ├── filter.hpp # --include, --exclude, --max-depth, etc.
│ ├── listing.hpp # --fast-list, --default-time
│ ├── performance.hpp # --transfers, --checkers, --buffer-size
│ ├── logging.hpp # --use-json-log, --verbose, niveaux
│ └── tree.hpp # Options spécifiques à tree
Flux de données typique :
Utilisateur C++
│
▼
process::initialize() → localise le binaire rclone
│
▼
process p;
p.lsjson(file) → construit les args ["lsjson", "remote:path"]
.add_option(...) → injecte des options CLI dans les args
.every_line_parser(parser) → connecte un callback via boost::signals2
.execute() → lance boost::process::child + threads de lecture
.wait_for_finish() → join des threads
│
▼
_process_impl_ → 4 threads : lecture stdout, lecture stderr,
wait du child, dispatch signaux
│
▼
_stdout_ ──► file_parser::parse() ──► boost::json::parse()
│
▼
entities::file (arbre parent/enfants)
│
▼
callback utilisateur
Patterns identifiés
- Builder fluent :
processretourneprocess&sur chaque méthode, permettant le chaînagep.lsjson(f).execute().wait_for_finish(). - Strategy (parsers) :
basic_parser<T>est un template abstrait ; chaque parser concret (JSON, LSL, etc.) implémenteparse(). - Signal/Slot :
boost::signals2pour les événementson_start,on_finish,on_stop,every_line. - Pimpl :
_process_impl_et_process_pool_impl_cachent les détails de threading et de gestion processus. - Factory method :
remote::create_shared_ptr(),file::create_shared_ptr(),basic_option::uptr().
Choix techniques et raisons
1. Encapsulation de rclone en sous-processus plutôt que liaison FFI/C
Contrairement à une approche CGO ou FFI, rclone_cpp communique avec rclone via boost::process en lançant un processus enfant avec pipes stdout/stderr/stdin. Ce choix évite toute dépendance au runtime Go, permet de cibler n'importe quel binaire rclone (y compris les versions custom), et isole complètement les crashes du processus rclone.
2. C++17/23 avec concepts pour la sécurité à la compilation
L'utilisation de requires (C++20 concepts) sur les templates garantit que seuls des types dérivant d'entity peuvent être passés aux parsers, et que les options acceptées par add_option sont bien des basic_opt_uptr. Le std::variant pour les callbacks (on_finish, on_stop, on_start) permet d'accepter plusieurs signatures sans surcharge manuelle.
3. Boost comme unique dépendance externe
Boost fournit à la fois la gestion de processus (boost::process), le parsing JSON (boost::json), le système de signaux (boost::signals2), le threading (boost::thread avec interruption points), et la manipulation de dates (boost::posix_time). Ce choix unifié réduit la surface de dépendances tout en offrant des primitives robustes et cross-platform.
4. Pimpl pour l'isolation de l'implémentation
Les classes process et process_pool utilisent le pattern Pimpl (_process_impl_* / _process_pool_impl_*) déclaré dans les .cpp, pas dans les headers. Cela accélère la compilation pour les consommateurs et permet de modifier l'implémentation interne sans casser l'ABI.
5. Threading non-bloquant avec lecture streaming
Chaque exécution de commande lance 4 threads : lecture stdout, lecture stderr, attente du child, et dispatch du signal de démarrage. Les données sont lues ligne par ligne et distribuées au fur et à mesure via boost::signals2, permettant un traitement en temps réel (parsing de fichiers au fil de l'eau, suivi de progression de transfert).
6. Process pool avec file de priorité
Le process_pool utilise un std::map<priority, vector<process>> trié par priorité décroissante. Un thread dispatcher consomme les processus en attente dès qu'un slot se libère. Les variables de condition synchronisent le dispatcher avec les événements de fin de processus.
7. Distribution via Conan 2
La bibliothèque est conçue pour être intégrée via conan create, avec un conanfile.py complet (build CMake, settings, options shared/fPIC, validation C++17). Un test_package autonome vérifie l'intégration de bout en bout.
8. Entité file comme arbre in-memory thread-safe
La classe file maintient une arborescence parent/enfants avec un std::shared_ptr<std::mutex> pour protéger les modifications concurrentes (ajout/suppression d'enfants). Le parser JSON construit récursivement l'arbre à partir du champ Path de chaque objet JSON retourné par lsjson.
Extraits de code remarquables
Extrait 1 — API fluente de process : chaînage type-safe
Fichier : include/iridium/process/process.hpp (lignes 44-108)
class process
{
public:
static auto initialize(const std::string &path_rclone = "") -> bool;
auto wait_for_start() -> process&;
auto wait_for_finish() -> process&;
auto execute(bool with_global_opt = false) -> process&;
auto every_line(std::function<void(const std::string &)> &&callback) -> process&;
template<class T>
auto every_line_parser(std::shared_ptr<parser::basic_parser<T>> parser) -> process&
{
every_line([this, parser = std::move(parser)](const std::string &line) {
parser->parse(line);
});
return *this;
}
using on_finish_callback = std::variant<
std::function<void()>,
std::function<void(int)>,
std::function<void(int, process *)>
>;
auto on_finish(on_finish_callback &&callback) -> process&;
auto lsjson(const entities::file &file) -> process&;
auto copy_to(const entities::file &source, const entities::file &destination) -> process&;
auto sync(const entities::file &source, const entities::file &destination) -> process&;
// ... 20+ commandes
};
Pourquoi c'est intéressant : Chaque méthode retourne process&, ce qui permet d'écrire p.lsjson(f).every_line_parser(parser).execute().wait_for_finish() — un pipeline expressif qui reste lisible. Le std::variant sur on_finish_callback accepte trois signatures différentes, résolues via std::visit et un helper overloaded. Le template every_line_parser utilise un concept implicite : basic_parser<T> impose que T dérive d'entity.
Extrait 2 — Parsing JSON d'arborescence de fichiers avec construction d'arbre
Fichier : src/parsers/file_parser.cpp (lignes 53-103)
auto file_parser::json_parse(const std::string &data) const -> void
{
if (_parent == nullptr) return;
auto regex = std::regex(R"(\{.*\})");
std::smatch match;
if (std::regex_search(data, match, regex))
{
try
{
auto json = boost::json::parse(match[0].str());
if (json.is_object())
{
if (not(json.as_object().contains("Name") and
json.as_object().contains("Path") and
json.as_object().contains("Size") and
json.as_object().contains("IsDir") and
json.as_object().contains("ModTime"))) { return; }
std::string path = json.at("Path").as_string().c_str();
file *parent = _parent;
// Reconstituer l'arborescence à partir du champ Path
while (path.find_first_of('/') not_eq std::string::npos)
{
auto file = std::make_shared<::file>(parent,
path.substr(0, path.find_first_of('/')), -1, true,
string_to_mode_time(json.at("ModTime").as_string().c_str()),
parent->remote());
::file *dir = dir_is_in_parent(file.get(), parent,
[](const ::file &f1, const ::file &f2) {
return f1.name() == f2.name() and f1.is_dir() == f2.is_dir();
});
if (dir not_eq nullptr)
parent = dir;
path = path.substr(path.find_first_of('/') + 1);
}
file file = ::file(parent, json.at("Name").as_string().c_str(),
json.at("IsDir").as_bool(), string_to_mode_time(...),
parent->remote());
parent->add_child(std::make_shared<::file>(file));
callback(file);
}
}
catch (const std::exception &e) { std::cerr << "Error: " << e.what() << std::endl; }
}
}
Pourquoi c'est intéressant : Ce parser résout un problème non trivial : rclone retourne une liste plate d'objets JSON (un par fichier), chacun avec un champ Path relatif (ex : Photos/2024/img.jpg). Le parser reconstitue l'arborescence in-memory en découpant ce chemin et en vérifiant si chaque répertoire intermédiaire existe déjà dans le parent — ce qui permet de construire un arbre correct sans dupliquer les nœuds. La fonction dir_is_in_parent avec prédicat personnalisé illustre une utilisation flexible de la comparaison.
Extrait 3 — Gestion asynchrone des sous-processus (Pimpl)
Fichier : src/process/process_impl.cpp (lignes 141-211)
auto execute() -> void
{
if (_state != state::not_launched)
throw std::runtime_error("process already started");
if (use_global_options)
option::basic_option::add_options_to_vector(_global_options, _args);
option::basic_option::add_options_to_vector(_local_options, _args);
try
{
_in = std::make_unique<bp::opstream>();
_out = std::make_unique<bp::ipstream>();
_err = std::make_unique<bp::ipstream>();
_child = bp::child(
bp::exe(_path_rclone),
bp::args(_args),
bp::std_in < *_in, bp::std_out > *_out,
bp::std_err > *_err
);
}
catch (boost::wrapexcept<bp::process_error> &e) {
std::cerr << e.what() << std::endl;
exit(1);
}
_state = state::running;
// Thread 1 : signal de démarrage
_threads.push_back(boost::thread([this] {
if (_signal_start) _signal_start->operator()();
}));
_cv.notify_all();
// Thread 2 : lecture stdout
_threads.push_back(boost::thread([this] {
read_output();
_counter_read++;
}));
// Thread 3 : lecture stderr
_threads.push_back(boost::thread([this] {
read_error();
_counter_read++;
}));
// Thread 4 : attente de la fin du child + dispatch signal finish
_threads.push_back(boost::thread([this] {
try {
if (_child.running()) _child.wait();
} catch (boost::wrapexcept<bp::process_error> &e) { ... }
if (_state not_eq state::stopped) {
_state = (_child.exit_code() == 0) ? state::finished : state::error;
// S'assurer que les lectures sont terminées avant de dispatcher
while (_counter_read < 2)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (_signal_finish)
_signal_finish->operator()(_child.exit_code());
}
}));
}
Pourquoi c'est intéressant : L'architecture à 4 threads par processus est conçue pour ne jamais bloquer l'utilisateur. stdout et stderr sont lus en parallèle et leurs lignes sont immédiatement dispatchées via boost::signals2. Le thread de wait du child utilise un compteur atomique (_counter_read) pour s'assurer que toutes les données ont été lues avant de déclencher le callback on_finish — cela évite les race conditions où le signal de fin arriverait avant que les dernières lignes n'aient été parsées.
Extrait 4 — Process pool avec file de priorité et synchronisation
Fichier : src/process/process_pool_impl.cpp (lignes 39-93)
void start_thread() {
_thread = std::thread([this] {
_state = running;
while (_state == running)
{
{
std::unique_lock lock(_process_mutex);
_cv_process.wait(lock, [this] {
if (_state == stopped) return true;
std::lock_guard lock(_mutex);
return (_running_processes < _simultaneous_processes
&& get_process() != nullptr) || _state == stopped;
});
}
if (_state == stopped) break;
std::lock_guard lock(_mutex);
auto* process = get_process();
if (process == nullptr) { continue; }
process->on_finish([this] {
_running_processes--;
_executed_processes++;
_cv_process.notify_one();
_wait_cv.notify_one();
});
process->on_stop([this] {
_running_processes--;
_executed_processes++;
_cv_process.notify_one();
_wait_cv.notify_one();
});
process->execute();
_running_processes++;
}
});
// Attendre que le thread dispatcher soit prêt
while (_state != running)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
auto get_process() -> process *
{
// std::map trié par priorité décroissante (std::greater<>)
for (const auto &pair: _processes)
for (const auto &process: pair.second)
if (process->get_state() == process::state::not_launched)
return process.get();
return nullptr;
}
Pourquoi c'est intéressant : Le pool utilise un std::map<priority, vector<process>, std::greater<>> — la clé std::greater<> garantit que les processus haute priorité sont toujours servis en premier. Le dispatcher tourne en boucle, réveillé par condition variable quand un nouveau processus est ajouté ou quand un slot se libère. La méthode wait() bloque l'appelant jusqu'à ce que tous les processus soient exécutés, en comparant _executed_processes au nombre total de processus dans la map.
Extrait 5 — Système d'options typées et extensibles
Fichier : include/iridium/options/filter.hpp (lignes 68-104)
class filter_file : public basic_option
{
public:
[[nodiscard]] auto get() -> std::vector<std::string> override { return _files; }
template<typename ...Args>
static auto uptr(Args && ...args) -> std::unique_ptr<filter_file>
requires (std::conjunction_v<std::is_convertible<Args, std::string> ...>)
{
return std::make_unique<filter_file>(std::forward<std::string>(args) ...);
}
template<typename ...Args>
auto add_filter(Args && ...args) requires (std::conjunction_v<
std::is_convertible<Args, std::string> ...>)
{
for (const auto &arg: {std::forward<Args>(args) ...})
_files.push_back("--filter=" + arg);
}
auto copy_uptr() -> basic_opt_uptr override {
return std::make_unique<filter_file>(*this);
}
private:
std::vector<std::string> _files;
};
Pourquoi c'est intéressant : filter_file est un cas d'option multi-valeurs : elle redéfinit get() pour retourner plusieurs flags --filter=... au lieu d'un seul. L'utilisation de variadic templates avec C++20 requires contraint les arguments à être convertibles en std::string, fournissant un message d'erreur de compilation clair en cas de mauvais type. Le pattern copy_uptr() est un clone polymorphique nécessaire car les options sont stockées comme unique_ptr<basic_option> mais doivent être copiables pour les options globales réutilisées entre les processus.
Extrait 6 — Template de parser abstrait avec contrainte de concept
Fichier : include/iridium/parsers/basic_parser.hpp
namespace iridium::rclone::parser
{
template<class T> requires (std::is_base_of_v<entity, T>)
class basic_parser
{
std::function<void(const T&)> _callback;
protected:
explicit basic_parser(std::function<void(const T&)> callback)
: _callback(std::move(callback)) {}
void callback(const T& data) const { _callback(std::move(data)); }
public:
virtual void parse(const std::string& data) const = 0;
virtual ~basic_parser() = default;
static auto create(basic_parser* parser) -> std::shared_ptr<basic_parser> {
return std::shared_ptr<basic_parser>(parser);
}
};
}
Pourquoi c'est intéressant : Le requires (std::is_base_of_v<entity, T>) est un concept C++20 qui garantit à la compilation que tout type T utilisé comme entité de parsing hérite bien de la classe de base entity. Cela crée un contrat explicite et empêche les erreurs silencieuses. Le callback stocké comme std::function<void(const T&)> permet à l'utilisateur d'injecter n'importe quel traitement (affichage, accumulation, transformation) sans sous-classer davantage.
Extrait 7 — Utilitaires de parsing JSON avec gestion des optionnels
Fichier : src/parsers/utils.hpp
template<class T>
auto get_from_obj(const boost::json::object &obj, const std::string &key) -> T
{
const auto *it = obj.if_contains(key);
if (it)
return boost::json::value_to<T>(obj.at(key));
return T();
}
template<class T>
auto get_from_obj_optional(const boost::json::object &obj, const std::string &key)
-> std::optional<T>
{
const auto *it = obj.if_contains(key);
if (it)
{
auto val = boost::json::try_value_to<T>(obj.at(key));
if (val)
return *val;
}
return std::nullopt;
}
auto string_to_mode_time(const std::string &time) -> system_clock::time_point
{
auto tif = new boost::posix_time::time_input_facet();
tif->set_iso_extended_format();
std::istringstream iss(time);
iss.imbue(std::locale(std::locale::classic(), tif));
boost::posix_time::ptime abs_time;
iss >> abs_time;
boost::posix_time::ptime epoch(boost::gregorian::date(1970, 1, 1));
boost::posix_time::time_duration diff = abs_time - epoch;
return system_clock::from_time_t(diff.total_seconds());
}
Pourquoi c'est intéressant : Ces deux templates illustrent la distinction entre champs obligatoires et optionnels dans le parsing JSON de rclone. get_from_obj retourne une valeur default-constructed si la clé est absente (pour les champs structurels comme Name, Size). get_from_obj_optional utilise try_value_to et retourne std::optional<T>, ce qui correspond aux champs rclone qui ne sont pas toujours présents (ex : eta, serverSideCopies). La fonction string_to_mode_time convertit les timestamps ISO 8601 de rclone en std::chrono::system_clock::time_point via boost::posix_time.
Qualité, sécurité, maintenance
Tests
La bibliothèque est livrée avec un package de tests Conan (test_package/) utilisant Boost.Test et comprenant 5 exécutables :
| Binaire | Couverture |
|---|---|
rclone_file_test |
Constructeurs (copy/move), opérateurs, arborescence parent/enfants, chemins absolus, add_child_if_not_exist, remove_child |
rclone_remote_test |
Création, chemins, types, factory methods |
rclone_json_log_test |
Setters, copy/move, conversion niveaux de log, stats |
rclone_process_test |
Initialisation, exécution asynchrone, options globales/locales, stop, config_create, lsjson, touch, mkdir, copy_to, move_to, delete, rmdir, purge, cat, about, size, tree, check |
rclone_process_pool_test |
Initialisation, ajout de processus, wait, stop, stop_all, clear, priorités |
Les tests d'intégration (rclone_process_test) exécutent de vraies commandes rclone contre le filesystem local, validant le pipeline complet (commande → exécution → parsing → exit code).
CI
Un workflow GitHub Actions (build_and_test.yml) s'exécute à chaque push/pull_request :
- Installation de rclone via le script officiel.
- Installation de Conan via pip.
- Détection du profil Conan, build des dépendances,
conan create .(build + test_package).
Gestion d'erreurs
- Exception dédiée :
initialize_errorhérite destd::runtime_erroret est levée si rclone n'est pas trouvé. - Validation systématique : chaque commande de listing vérifie que le fichier est un répertoire (
if (not file.is_dir()) throw),config_createvérifie la présence denameettype. - Callbacks d'erreur :
on_finish_error()déclenche un callback si le code de sortie est non-nul. - Nettoyage automatique : le destructeur de
_process_impl_arrête le processus s'il est encore en cours d'exécution, évitant les orphelins.
Limites de sécurité
- Les credentials FTP sont visibles en clair dans les tests (
config_createTest) — à ne pas reproduire en production. - La bibliothèque exécute un binaire externe (
rclone) dont la sécurité dépend de l'environnement de déploiement (vérification du chemin, permissions).
Installation et exécution (local)
Prérequis
- C++17 minimum (C++23 recommandé, utilisé dans le CMakeLists)
- CMake ≥ 3.25
- Conan ≥ 2.0
- rclone installé et accessible dans le
$PATH(ou chemin explicite passé àinitialize()) - Boost ≥ 1.80 (géré par Conan)
Build et installation via Conan
git clone https://github.com/Sudo-Rahman/rclone_cpp.git
cd rclone_cpp
conan profile detect --force # si premier usage
conan install . --build=missing # résout Boost, CMake
conan create . # build la lib + lance les tests
Utilisation dans un projet tiers
conanfile.txt :
[requires]
rclone_cpp/[>=0.1.0]
[generators]
CMakeDeps
CMakeToolchain
CMakeLists.txt :
find_package(rclone_cpp)
target_link_libraries(your_target PRIVATE rclone_cpp::rclone_cpp)
main.cpp (exemple minimal) :
#include <iridium/rclone.hpp>
auto main() -> int
{
process::initialize(); // localise rclone
auto remote = ire::remote::create_shared_ptr(
"my_drive", ire::remote::remote_type::google_drive, "");
auto root = ire::file{nullptr, "/", 0, true,
std::chrono::system_clock::now(), remote};
auto parser = irp::file_parser::ptr(&root,
[](const ire::file &file) {
std::cout << file << std::endl;
}, irp::file_parser::json);
ir::process p;
p.lsjson(root)
.every_line_parser(parser)
.execute()
.wait_for_finish();
return 0;
}
Liens
- GitHub : https://github.com/Sudo-Rahman/rclone_cpp
- Licence : MIT © 2024 Rahman Yilmaz
- Version : 0.6.2