Skip to content
Merged
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [1.19.3] — 2026-09-17

> Bugfix-only release on the 1.19.0 stable line. Closes the root cause
> behind #870, reproduced and confirmed on real Mac hardware.

### Fixed

- **macOS: `opensak.json` could be silently lost during the #825
migration instead of being moved (fixes #870)** — Reproducing the
original report on real Mac hardware showed that `shutil.move()` can
apparently fail for a specific file during the one-time macOS path
migration without ever raising an exception — evidenced by the
"migrated" `opensak.json` carrying the migration's own timestamp
rather than its original one, meaning it had been freshly created by
the app finding nothing at the destination, not actually moved.
Meanwhile other files in the very same migration run (e.g.
`Default.db`) moved correctly, ruling out a simple permissions
problem. The exact underlying trigger is still unconfirmed, but the
migration no longer depends on `shutil.move()` behaving correctly to
avoid data loss: each file is now copied, verified to match the
original by size, and only then is the source removed. If
verification ever fails, the original file is left completely
untouched (and the incomplete copy is cleaned up) instead of quietly
vanishing.

---

## [1.19.2] — 2026-09-16

> Bugfix-only release on the 1.19.0 stable line, following directly on
Expand Down
Binary file modified site/assets/screenshots/main-window.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/screenshots/map-maximized.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/assets/screenshots/map-popout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions site/user-guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenSAK User Guide — v1.19.2</title>
<title>OpenSAK User Guide — v1.19.3</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Source+Serif+4:ital,wght@0,300;0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap');

Expand Down Expand Up @@ -473,7 +473,7 @@
<nav>
<div class="nav-header">
<span class="nav-logo">OpenSAK</span>
<div class="nav-version">User Guide · v1.19.2</div>
<div class="nav-version">User Guide · v1.19.3</div>
</div>

<div class="nav-section-heading">Getting Started</div>
Expand Down Expand Up @@ -535,7 +535,7 @@
<div class="hero-eyebrow">Complete User Guide</div>
<h1>OpenSAK</h1>
<p class="hero-sub">The open-source geocache management tool for Windows, Linux, and macOS.</p>
<div class="hero-meta">Version 1.19.2 &nbsp;·&nbsp; MIT Licence &nbsp;·&nbsp; <a href="https://github.com/OpenSAK-Org/opensak">github.com/OpenSAK-Org/opensak</a></div>
<div class="hero-meta">Version 1.19.3 &nbsp;·&nbsp; MIT Licence &nbsp;·&nbsp; <a href="https://github.com/OpenSAK-Org/opensak">github.com/OpenSAK-Org/opensak</a></div>
</div>

<figure class="screenshot" style="margin-top:-0.5rem;">
Expand Down Expand Up @@ -1463,7 +1463,7 @@ <h3>Debug Log</h3>
<tr><td>Bug reports &amp; feature requests</td><td><a href="https://github.com/OpenSAK-Org/opensak/issues">github.com/OpenSAK-Org/opensak/issues</a></td></tr>
<tr><td>Community discussion</td><td><a href="https://www.facebook.com/groups/opensak">Facebook group: OpenSAK</a></td></tr>
<tr><td>Releases &amp; downloads</td><td><a href="https://github.com/OpenSAK-Org/opensak/releases">github.com/OpenSAK-Org/opensak/releases</a></td></tr>
<tr><td>Changelog</td><td><a href="https://github.com/OpenSAK-Org/opensak/blob/v1.19.2/CHANGELOG.md">CHANGELOG.md on GitHub</a></td></tr>
<tr><td>Changelog</td><td><a href="https://github.com/OpenSAK-Org/opensak/blob/v1.19.3/CHANGELOG.md">CHANGELOG.md on GitHub</a></td></tr>
<tr><td>Contributing</td><td><a href="https://github.com/OpenSAK-Org/opensak/blob/main/CONTRIBUTING.md">CONTRIBUTING.md on GitHub</a></td></tr>
<tr><td>Support the project</td><td><a href="https://opencollective.com/opensak">opencollective.com/opensak</a></td></tr>
<tr><td>Website</td><td><a href="https://opensak.com">opensak.com</a></td></tr>
Expand All @@ -1473,7 +1473,7 @@ <h3>Debug Log</h3>
<div class="callout tip"><div class="callout-icon">💡</div><div>OpenSAK is free and open-source software released under the MIT licence. Contributions of any kind — code, translations, documentation, or testing — are very welcome.</div></div>

<p style="margin-top:2rem;color:var(--ink-light);font-size:0.85rem;font-style:italic;">
This guide was generated from the OpenSAK source code (v1.19.2). Last updated August 2026.
This guide was generated from the OpenSAK source code (v1.19.3). Last updated August 2026.
</p>
</section>

Expand Down
2 changes: 1 addition & 1 deletion src/opensak/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""OpenSAK — cross-platform geocache management tool."""
__version__ = "1.19.2"
__version__ = "1.19.3"
__author__ = "OpenSAK Contributors"
__license__ = "MIT"
96 changes: 88 additions & 8 deletions src/opensak/settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,87 @@ def _backup_opensak_json_in_place(source_dir: Path) -> None:
source, exc)


def _move_verified(entry: Path, target: Path) -> bool:
"""
Robust erstatning for shutil.move() til brug i
migrate_macos_default_paths(): kopiér, verificér at kopien reelt
matcher kilden, og slet FØRST kildefilen når det er bekræftet.

Issue #870: reproduceret på rigtig Mac-hardware, at shutil.move()
tilsyneladende kan fejle stille for netop opensak.json under denne
migrering, mens andre filer i samme løkke (fx Default.db) flyttes
korrekt — uden at nogen exception nogensinde ramte den daværende
except-gren. Timestamp-bevis: den "migrerede" opensak.json havde
migreringstidspunktet som mtime, ikke det oprindelige, bekræftende
at filen blev nyoprettet af SettingsStore (fordi intet fandtes på
destinationen), ikke flyttet.

Den præcise bagvedliggende årsag (race condition, macOS-specifik
I/O-kvirk, andet) er stadig ukendt — denne funktion løser symptomet
uafhængigt af årsagen: kildefilen fjernes ALDRIG, medmindre
destinationen beviseligt indeholder identisk indhold bagefter.
Undlader bevidst en indholds-hash for at holde det billigt for store
databasefiler — filstørrelse er tilstrækkeligt til at opdage den
"tom/delvis fil" fejlklasse, vi faktisk har observeret.

Kataloger (bør ikke forekomme i praksis i denne installations-mappe,
men for en sikkerheds skyld) falder tilbage til almindelig
shutil.move(), da størrelses-verifikation ikke giver mening for dem.

Returnerer True hvis flytningen lykkedes og blev verificeret.
"""
if entry.is_dir():
try:
shutil.move(str(entry), str(target))
return True
except OSError as exc:
_log.warning("migrate_macos_default_paths: flytning af mappen "
"%s -> %s fejlede: %s", entry, target, exc)
return False

try:
shutil.copy2(str(entry), str(target))
except OSError as exc:
_log.warning("migrate_macos_default_paths: kopiering af %s -> %s "
"fejlede: %s", entry, target, exc)
return False

try:
source_size = entry.stat().st_size
target_size = target.stat().st_size
except OSError as exc:
_log.warning("migrate_macos_default_paths: kunne ikke verificere "
"%s efter kopiering fra %s: %s", target, entry, exc)
return False

if source_size != target_size:
_log.warning(
"migrate_macos_default_paths: verifikation fejlede for %s -> "
"%s (kilde=%d bytes, kopi=%d bytes) — kildefilen BEVARES "
"urørt, kopien slettes igen", entry, target, source_size,
target_size,
)
try:
target.unlink()
except OSError:
pass
return False

try:
entry.unlink()
except OSError as exc:
# Kopien er verificeret identisk, men kilden kunne ikke fjernes
# (fx en fil der er låst). Ufarligt at have begge liggende —
# betragt selve migreringen af DENNE fil som lykkedes.
_log.warning(
"migrate_macos_default_paths: kunne ikke slette kildefilen %s "
"efter bekræftet kopiering til %s: %s (begge steder har nu "
"identisk indhold — ufarligt, men ryd op manuelt om ønsket)",
entry, target, exc,
)
return True


def migrate_macos_default_paths() -> bool:
"""
Én-gangs migration af eksisterende macOS-brugeres data fra den
Expand Down Expand Up @@ -703,16 +784,15 @@ def migrate_macos_default_paths() -> bool:
_log.debug("migrate_macos_default_paths: kollision på %s "
"— springer over, rører intet", target)
continue # kollision — rør det ikke, behold begge som de er
try:
shutil.move(str(entry), str(target))
if _move_verified(entry, target):
migrated_something = True
_log.debug("migrate_macos_default_paths: flyttede %s -> %s",
entry, target)
except OSError as exc:
print(f"[settings] macOS-migration: kunne ikke flytte "
f"{entry} → {target}: {exc}")
_log.debug("migrate_macos_default_paths: flyttede %s -> %s "
"(verificeret)", entry, target)
else:
_log.debug("migrate_macos_default_paths: flytning af %s "
"-> %s fejlede: %s", entry, target, exc)
"-> %s fejlede eller kunne ikke verificeres "
"(se warning ovenfor) — kildefil bevaret",
entry, target)
try:
if not any(old_default_install.iterdir()):
old_default_install.rmdir()
Expand Down
142 changes: 142 additions & 0 deletions tests/unit-tests/test_settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,148 @@ def test_rewrite_helper_invalid_json_is_safe(self, tmp_path):
ss._rewrite_stale_install_dir_paths(bad, tmp_path / "old", tmp_path / "new")
assert bad.read_text(encoding="utf-8") == "{not valid json" # left untouched

@posix_only
def test_end_to_end_survives_silent_copy_corruption_of_settings(
self, monkeypatch, tmp_path
):
"""
Full-migration regression test for #870: simulates the exact
symptom reproduced on real hardware — copying opensak.json
"succeeds" with no exception, but the destination ends up empty
instead of matching the source (Default.db, in the same run,
copies correctly). Before the _move_verified() fix, this meant
the real settings were silently lost, and the app fell back to
a fresh-looking install (missing username/home location) on
next launch. After the fix, the real opensak.json must survive
in the OLD directory rather than being replaced by an empty one.
"""
self._patch_platform(monkeypatch, tmp_path)
old_dir = ss._legacy_macos_default_install_dir()
old_dir.mkdir(parents=True)
real_settings = json.dumps({"user.gc_username": "Allan", "user.home_lat": 55.6})
(old_dir / "opensak.json").write_text(real_settings, encoding="utf-8")
(old_dir / "Default.db").write_text("real-cache-data", encoding="utf-8")

original_copy2 = ss.shutil.copy2

def _corrupt_only_settings_json(src, dst, *a, **kw):
if Path(src).name == "opensak.json":
Path(dst).write_text("{}", encoding="utf-8") # silent corruption
else:
original_copy2(src, dst, *a, **kw) # everything else moves fine
monkeypatch.setattr(ss.shutil, "copy2", _corrupt_only_settings_json)

ss.migrate_macos_default_paths()

new_dir = ss._default_install_dir()
# The database migrated fine, same as the real hardware reproduction.
assert (new_dir / "Default.db").read_text(encoding="utf-8") == "real-cache-data"
# The settings file must NOT have been silently replaced by an
# empty one — the real data must still be found, in the old dir,
# not lost.
assert not (new_dir / "opensak.json").exists()
assert (old_dir / "opensak.json").read_text(encoding="utf-8") == real_settings


class TestMoveVerified:
"""
Issue #870: reproduced on real Mac hardware that shutil.move() can
apparently fail silently for a specific file during the macOS #825
migration, with no exception ever raised — evidenced by a "migrated"
opensak.json carrying the migration run's own timestamp instead of
its original one, meaning it was freshly created (nothing found at
the destination), not actually moved.

_move_verified() replaces the raw shutil.move() call with a
copy-then-verify-then-delete sequence, so the source file is never
lost regardless of the still-unconfirmed underlying cause.
"""

def test_successful_move_copies_content_and_removes_source(self, tmp_path):
entry = tmp_path / "opensak.json"
entry.write_text('{"real": "data"}', encoding="utf-8")
target = tmp_path / "dest" / "opensak.json"
target.parent.mkdir()

assert ss._move_verified(entry, target) is True
assert not entry.exists()
assert target.read_text(encoding="utf-8") == '{"real": "data"}'

def test_directory_falls_back_to_plain_move(self, tmp_path):
entry = tmp_path / "somedir"
entry.mkdir()
(entry / "inner.txt").write_text("x", encoding="utf-8")
target = tmp_path / "dest" / "somedir"
target.parent.mkdir()

assert ss._move_verified(entry, target) is True
assert not entry.exists()
assert (target / "inner.txt").read_text(encoding="utf-8") == "x"

def test_copy_failure_preserves_source(self, monkeypatch, tmp_path):
entry = tmp_path / "opensak.json"
entry.write_text('{"real": "data"}', encoding="utf-8")
target = tmp_path / "dest" / "opensak.json"
target.parent.mkdir()

def _boom(*a, **kw):
raise OSError("simulated I/O failure")
monkeypatch.setattr(ss.shutil, "copy2", _boom)

assert ss._move_verified(entry, target) is False
# Source must survive untouched — this is the core guarantee.
assert entry.read_text(encoding="utf-8") == '{"real": "data"}'
assert not target.exists()

def test_size_mismatch_preserves_source_and_cleans_up_partial_copy(
self, monkeypatch, tmp_path
):
"""
Directly reproduces the observed #870 symptom: the copy
"succeeds" (no exception) but the destination doesn't actually
match the source — e.g. an empty/truncated file appears there
instead. Must be treated as a failed move, not a successful one.
"""
entry = tmp_path / "opensak.json"
entry.write_text('{"user.gc_username": "MikeWood"}', encoding="utf-8")
target = tmp_path / "dest" / "opensak.json"
target.parent.mkdir()

def _write_empty_instead(src, dst, *a, **kw):
Path(dst).write_text("{}", encoding="utf-8")
monkeypatch.setattr(ss.shutil, "copy2", _write_empty_instead)

assert ss._move_verified(entry, target) is False
# Source must survive untouched — this is the whole point of the fix.
assert entry.read_text(encoding="utf-8") == '{"user.gc_username": "MikeWood"}'
# The bogus partial copy must be cleaned up, not left behind
# looking like a (wrong) successful migration.
assert not target.exists()

def test_source_unlink_failure_still_counts_as_success(self, monkeypatch, tmp_path):
"""
If the verified-identical copy exists but the source can't be
deleted (e.g. a locked file), the migration of THIS file should
still be considered successful — having both is harmless, unlike
losing the data.
"""
entry = tmp_path / "opensak.json"
entry.write_text('{"real": "data"}', encoding="utf-8")
target = tmp_path / "dest" / "opensak.json"
target.parent.mkdir()

original_unlink = Path.unlink

def _boom_unlink(self, *a, **kw):
if self == entry:
raise OSError("simulated: file locked")
return original_unlink(self, *a, **kw)
monkeypatch.setattr(Path, "unlink", _boom_unlink)

assert ss._move_verified(entry, target) is True
assert entry.exists() # left behind, but harmless
assert target.read_text(encoding="utf-8") == '{"real": "data"}'


class TestBackupOpensakJsonInPlace:
"""
Expand Down