- C++ 79.2%
- Shell 11.1%
- Roff 8.3%
- Makefile 1.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| service | ||
| src | ||
| tests | ||
| .gitignore | ||
| .tideignore.example | ||
| deploy.sh | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
| tide.1 | ||
tide
A small two-way file synchroniser in C++17, in the spirit of MegaSync or Dropbox: point two or more machines at the same directory and they converge through a server you control. No libraries, no database, no daemon to babysit — a single ~1800-line binary that is both the client and the server.
machine A ──┐ ┌── machine B
├── your server ─────┤
machine C ──┘ ~/tide/<share> └── machine D
Changes, deletions and conflicts propagate in both directions, files are compared by SHA-256 rather than timestamps, and no version is ever silently discarded.
Build
make # -> ./tide
make install # -> ~/.local/bin + man page (sudo make install PREFIX=/usr/local for system-wide)
make test # end-to-end suite: two machines, conflicts, deletes, auth
Requires only a C++17 compiler and its standard library. Tested with GCC on
glibc and Clang/libc++ on musl. make static produces a single file you can
copy to any x86-64 Linux box.
Quick start
1. Put tide on the server. It needs no root, no open port and no service:
./deploy.sh myserver # [user@]host, or a Host alias from ~/.ssh/config
./deploy.sh myserver 2222 # ...with a non-default SSH port
This builds tide on the server (or copies a static binary if it has no
compiler), installs it to ~/bin/tide and creates ~/tide.
2. Sync, from each machine:
# one pass
tide sync ~/Documents --ssh myserver --share docs --remote-bin '~/bin/tide'
# stay running, re-checking every 5 seconds
tide sync ~/Documents --ssh myserver --share docs --remote-bin '~/bin/tide' --watch
Run the same command on every machine with the same --share. Each share is a
separate directory on the server, created on first use. Keep --remote-bin in
single quotes so the tilde is expanded by the server's shell, not yours.
Useful flags: --interval N (seconds between passes), -n (dry run: report what
would move, touch nothing), -v, --remote-root DIR (server-side share root,
default ~/tide), --ssh-port N.
See man tide for the full reference.
How it decides what to do
Every pass compares three things: the local directory, the server's directory,
and a record of how they looked when the last pass finished (kept in
.tide/state-<share> inside the synced directory). That third input is what
lets it tell "you created this file" apart from "someone else deleted it", so
creates, edits and deletes all travel in both directions instead of deleted
files coming back from the dead.
| situation | result |
|---|---|
| changed on one side | copied to the other |
| deleted on one side | deleted on the other |
| deleted on one side, edited on the other | the edit wins — a delete never beats real work |
| edited on both sides | conflict: both versions kept (see below) |
| file replaced by a directory | the change propagates; the displaced version is kept |
On a conflict your copy is renamed to name.conflict-<host>-<timestamp>.ext
and uploaded as a new file, while the server's copy keeps the original name.
Both versions end up on every machine, and you resolve it by hand. Nothing is
ever overwritten without a copy surviving.
Files are compared by content hash, so touching a file transfers nothing.
Downloads land in a temp file inside .tide/tmp and are renamed into place,
so an interrupted transfer cannot leave a half-written file where your real one
was. Permission bits and modification times are preserved.
Not synced: symlinks, and anything matching a glob in .tideignore at the
root of the synced directory. See .tideignore.example.
Performance
Hashes are cached by (size, mtime, nanoseconds), so an unchanged tree costs a stat per file and no reads. On a 2000-file, 24 MB tree over a local transport:
| time | |
|---|---|
| first full sync | 1.8 s |
| idle pass, nothing to do | 0.03 s |
| propagate one changed file | 0.03 s |
Over SSH each pass additionally pays the connection handshake, so --watch —
which opens one connection and keeps it for every pass — is much cheaper than
repeated one-shot runs. If you do run one-shot syncs often, let SSH multiplex
them:
Host myserver
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
Running it at login
Run it as your normal user, never as root. tide needs no privileges: it
reads and writes files in your own directory and authenticates with your own
~/.ssh key. As root it would create root-owned files inside your home and look
for the key in /root/.ssh, where it is not.
The only environment it needs is HOME, so ssh can find your config and key.
Forgetting that is the usual reason a service crash-loops.
Without a supervisor, a user crontab is enough — see service/crontab-line.txt:
crontab -e # as your normal user, not root
With runit, service/sv/tide-docs/ is a ready service that drops privileges
with chpst and sets HOME:
sudo cp -r service/sv/tide-docs /etc/sv/
sudo ln -s /etc/sv/tide-docs /etc/service/
sv status tide-docs
Edit the settings at the top of the run script, and copy the directory once
per share. Note that --watch already retries on its own when the network
drops, so a supervisor only buys start-at-boot and restart-after-crash.
Transports
SSH (recommended). --ssh HOST runs ssh HOST tide serve --stdio and
talks over the pipes, the way git and rsync do. Encrypted and authenticated by
SSH itself: no listening port, no service to supervise, and no credentials of
tide's own. Anyone who could abuse it could already read those files over
sftp, so tide adds no attack surface.
Plain TCP. For a trusted LAN, or inside a VPN or an ssh -L tunnel:
tide keygen > ~/.tide.key # same file on server and clients, chmod 600
tide serve --root ~/tide --key-file ~/.tide.key # on the server
tide sync ~/Documents --host 192.168.1.10 --share docs --key-file ~/.tide.key
Clients prove they hold the key with an HMAC-SHA256 challenge-response, so it
never crosses the wire, and a server started without --key-file warns loudly.
The traffic itself is not encrypted. Do not expose this port to the
internet; use the SSH transport there.
Safety properties
- Share names and every path arriving from the network are validated: no
.., no absolute paths, no writing outside the share root, no touching.tide. - A share is locked with
flockfor the duration of a client's pass, so two machines syncing at the same moment queue instead of interleaving. - The server hashes files as it writes them and caches hashes by (size, mtime, nanoseconds), so a same-second rewrite of a same-size file is still detected — the trap that catches most naive syncers.
- Writes are atomic (temp file, fsync, rename) and deletions are ordered bottom-up, so an interrupted pass leaves a consistent tree.
Layout
| path | what |
|---|---|
src/common.cpp |
SHA-256/HMAC, framing, directory scanning, state files |
src/server.cpp |
share hosting, locking, TCP and stdio modes |
src/client.cpp |
transports, three-way reconciliation, conflict handling |
src/main.cpp |
CLI |
tide.1 |
manual page |
tests/run.sh |
end-to-end suite (29 checks) |
tests/hashtest.cpp |
SHA-256/HMAC vectors |
deploy.sh |
install onto a server |
service/ |
runit service and crontab templates |
License
MIT. See LICENSE.
Limitations
Whole files are transferred, not block deltas — changing one byte of a large file resends it. Changes are found by polling on an interval, not inotify. Renames are seen as a delete plus a create. No symlink support. Linux and macOS; no Windows.