commit 850dc47b1ecfa0ce8f9e47f99a052b2bc9431a89 Spenser Truex <truex@equwal.com> 2026-09-21 12:41:57 -0700 bm-sync: three guards against data loss An empty or missing bookmark file now goes to the server without a version. With the version of the last sync, an empty file told the server to delete all bookmarks on all devices. Without a version, the server deletes nothing and sends all bookmarks back. bm-sync now removes the version before it writes the merged file. It writes the new version only after the write succeeds. Before, a write that stopped part way (for example on a full disk) kept the old version. The next sync then deleted the lost lines on the server. bm-sync login now sets mode 600 on a config file that exists already. Before, only the umask set the mode, and the umask acts only on a new file. Under Cygwin, in a directory with inherited Windows ACLs, a new file also got read access for other users. A comment in bm-sync describes a race that stays. bm and bm-sync do not lock the file against each other. When bm writes the file in the moment before bm-sync writes the merged file, one of the two writes is lost. A fix needs a lock in bm too. The stand-in server in test/run.sh now deletes nothing when a device sends no version, as the real server does. It can also give an answer that bm-sync cannot write. Five new tests cover the guards. Each one fails with the bm-sync of the last commit.
README | 3 +++ bm-sync | 20 ++++++++++++++++++-- test/run.sh | 44 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-)
diff --git a/README b/README index 1ed1356..86b5931 100644 --- a/README +++ b/README @@ -202,6 +202,9 @@ background. Bookmarks that you add or remove on one device are added or removed on the others. The file stays a plain file, and bm works without a network. The sign-in token is kept in ~/.config/sbm/sync. +An empty or missing bookmark file removes nothing on the other devices: +bm-sync gets all the bookmarks back from the server. + The server is sbm-sync (https://github.com/equwal/sbm-sync). bm-sync uses https://sbm.subread.space unless you give another server: create an account there. The server is free software, so you can run your own: diff --git a/bm-sync b/bm-sync index a12b86c..8fdb56e 100755 --- a/bm-sync +++ b/bm-sync @@ -41,9 +41,11 @@ setting () { sed -n "s/^$1=//p" "$CONFIG" 2>/dev/null | head -n 1 } -# private <file>: create an empty file that only you can read. +# private <file>: make <file> an empty file that only you can read. umask +# sets the mode of a new file. chmod sets the mode of a file that exists +# already, for example the config file of an earlier login. private () { - (umask 077; : > "$1") + (umask 077; : > "$1") && chmod 600 "$1" } cleanup () { @@ -136,6 +138,10 @@ sync () { while :; do rm -f "$LOCK/again" cat "$BOOKMARKS" > "$work/sent" + # An empty file with a version would delete all bookmarks on the + # server, and then on all devices. Without a version, the server + # deletes nothing, and sends all bookmarks back. + [ -s "$work/sent" ] || rm -f "$STATE" base=$(cat "$STATE" 2>/dev/null) code=$(curl -sS --max-time 60 -o "$work/out" -D "$work/head" -w '%{http_code}' \ -H "@$work/auth" -H 'Content-Type: text/plain; charset=utf-8' \ @@ -156,7 +162,17 @@ sync () { [ $tries -lt 5 ] || die 'the file changes all the time: try again later' continue fi + # A known race: bm and bm-sync do not lock the file against each + # other. If bm adds a line after the cmp above and before the write + # below, the write removes that line. If bm reads the file for a + # delete or an edit before the write and writes it after the write, + # bm removes the merged lines. The next sync then deletes them on the + # server. A fix needs a lock that bm and bm-sync take for each write. if ! cmp -s "$work/out" "$BOOKMARKS"; then + # Forget the version first, and keep the new one only after the + # write. If the write stops part way (a full disk), the next sync + # sends no version, so the server deletes none of the lost lines. + rm -f "$STATE" # Through cat, not mv, as in bm: a symlinked file stays a symlink. cat "$work/out" > "$BOOKMARKS" || die "cannot write $BOOKMARKS" if command -v bm-commit >/dev/null 2>&1; then bm-commit 'bm-sync: merge'; fi diff --git a/test/run.sh b/test/run.sh index 771c129..ede7d26 100755 --- a/test/run.sh +++ b/test/run.sh @@ -945,7 +945,9 @@ fi # A stand-in for curl that plays an sbm-sync server. The file of the server # is $srv/file. Lines in $srv/other come from another device, once. With # $srv/code, the server refuses with that status. With $srv/touch, bm adds a -# bookmark while the request runs. +# bookmark while the request runs. Without a version, the server deletes +# nothing, as the real one: it keeps its file and adds the new lines. With +# $srv/broken, the answer is a directory, so bm-sync cannot write it. srv="$work/srv" mkdir "$srv" "$work/syncnet" cat > "$work/syncnet/curl" <<'FAKE' @@ -976,12 +978,18 @@ case $url in esac [ "$auth" = 'Authorization: Bearer tok123' ] || answer 401 'not signed in' [ ! -e "$SBM_TEST_SRV/code" ] || answer "$(cat "$SBM_TEST_SRV/code")" 'sync is paused' -printf '%s\n' "${url#*base=}" >> "$SBM_TEST_SRV/bases" +base=${url#*base=} +printf '%s\n' "$base" >> "$SBM_TEST_SRV/bases" +if [ -z "$base" ]; then + cat "$SBM_TEST_SRV/file" "$data" 2>/dev/null | awk '!seen[$0]++' > "$SBM_TEST_SRV/union" + data=$SBM_TEST_SRV/union +fi cat "$data" "$SBM_TEST_SRV/other" > "$SBM_TEST_SRV/file" 2>/dev/null rm -f "$SBM_TEST_SRV/other" version=v$(cksum < "$SBM_TEST_SRV/file" | cut -d' ' -f1) echo "$version" >> "$SBM_TEST_SRV/versions" cp "$SBM_TEST_SRV/file" "$out" +[ ! -e "$SBM_TEST_SRV/broken" ] || { rm -f "$out"; mkdir "$out"; } printf 'HTTP/1.1 200 OK\r\nsbm-version: %s\r\n\r\n' "$version" > "$head" if [ -e "$SBM_TEST_SRV/touch" ]; then rm -f "$SBM_TEST_SRV/touch" @@ -1028,6 +1036,38 @@ eq 'bm-sync shows why the server refused, and keeps the file' \ '1:bm-sync: sync is paused:same' rm -f "$srv/code" +chmod 644 "$SBM_SYNC_CONFIG" +printf 'me@example.org\nsecret pass \n' | bmsync login https://sync.example/ >/dev/null 2>&1 +eq 'bm-sync login makes a config file that exists already private too' \ + "$(ls -l "$SBM_SYNC_CONFIG" | cut -c1-10)" '-rw-------' + +# kept: the version that bm-sync sent last, then "kept" if the server still +# has all bookmarks, then "back" if the file has all of them again. +cp "$srv/file" "$work/before" +kept () { + printf '%s:%s:%s' "$(sed -n '$p' "$srv/bases")" \ + "$(cmp -s "$work/before" "$srv/file" && echo kept)" \ + "$(cmp -s "$work/before" "$BOOKMARKS" && echo back)" +} +: > "$BOOKMARKS" +bmsync -q +eq 'bm-sync sends an empty file without a version, so the server deletes nothing' \ + "$(kept)" ':kept:back' +rm -f "$BOOKMARKS" +bmsync -q +eq 'bm-sync sends a missing file without a version too' "$(kept)" ':kept:back' + +: > "$srv/broken" +bmsync -q 2>"$work/err" +eq 'when bm-sync cannot write the file, it says so and forgets the version' \ + "$?:$(grep -c 'cannot write' "$work/err"):$([ -e "$BOOKMARKS.sync" ] || echo none)" '1:1:none' +rm -f "$srv/broken" +# The write stopped after the first line. +sed -n 1p "$work/before" > "$BOOKMARKS" +bmsync -q +eq 'after a failed write, the next sync has no version, so the server deletes nothing' \ + "$(kept)" ':kept:back' + bmsync -q logout eq 'bm-sync logout forgets the token and signs out on the server' \ "$([ -e "$SBM_SYNC_CONFIG" ] || echo gone) $(cat "$srv/log")" 'gone logout'