Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

git clone https://github.com/equwal/sbm

Log | Files | Refs


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'