A read-only watcher that catches filenames which will silently break your cloud/NAS sync — before they do.
pathguard scans a synced folder (Synology Drive · Dropbox · Nextcloud · OneDrive · iCloud Drive,
etc.) and flags files whose name or path byte length may exceed the destination's NAME_MAX /
PATH_MAX once Unicode is decomposed to NFD — a sync failure waiting to happen. It only reads;
it never touches your files.
Filesystem limits NAME_MAX 255 / PATH_MAX 4096 count UTF-8 bytes, not characters. And the
byte length of the same text depends on its Unicode normalization form. Korean is the worst offender:
| Form | 보고서 ("report") |
UTF-8 bytes |
|---|---|---|
| NFC (precomposed) | 3 code points 보·고·서 |
9 bytes (3B each) |
| NFD (decomposed) | 6 jamo ㅂㅗ ㄱㅗ ㅅㅓ |
18 bytes (6B each, 9B with a final consonant) |
→ NFD is 2–3× larger than NFC (3× for syllables with a final consonant). For a name of Korean syllables that all carry a final consonant:
- NFC 28 chars = 84 B (plenty of room)
- NFD 28 chars = 252 B (right at the 255 edge)
The "NFD makes it bigger" effect applies to any script with combining characters — Korean is merely the most extreme:
| Char | Language | NFC → NFD | Ratio |
|---|---|---|---|
강 |
Korean | 3 → 9 B | 3.0× |
が |
Japanese (voiced kana) | 3 → 6 B | 2.0× |
ệ |
Vietnamese | 3 → 5 B | 1.67× |
й |
Russian | 2 → 4 B | 2.0× |
é |
French / German / Spanish… | 2 → 3 B | 1.5× |
中 · A · ก |
Han / Latin / Thai | no change | 1.0× |
The engine is language-agnostic — it just measures NFD bytes — so Vietnamese and French filenames are caught the same way.
- Say you sync a local folder ↔ a remote (e.g.
remote:/volume1/homes/johndoe/MyDocuments). - The remote filesystem (a NAS's btrfs/ext4, a Linux server, …) typically enforces
NAME_MAX 255,PATH_MAX 4096(bytes). - macOS stores new Korean filenames as NFD, and even NFC files can decompose to NFD on edit.
- Mobile viewers and sync clients may each normalize differently.
So even if it looks short on Windows today (NFC), the moment it decomposes to NFD somewhere in the
pipeline, the byte length spikes past NAME_MAX and the sync errors out. That's why this tool always
measures the NFD (worst-case) byte length.
If an over-limit file is already NFC, its NFD-equivalent length still exceeds the limit — so converting to NFC does nothing. The only real fix is to rename it shorter (trimming ~7 Korean chars brings NFD back under 255).
- 🔎 NFD worst-case — judges every name and full path by its NFD-normalized UTF-8 byte length
- 🌐 Language-agnostic — Korean, Vietnamese, accented Latin, and any combining-mark script
- 🧩 Sync-app agnostic — Synology Drive, Dropbox, whatever; just point it at the remote path prefix
- 🚫 Exclusions — noise dirs like
.gitand@eaDir(Synology cache) are skipped by default, configurable - 🛟 Read-only — never modifies, moves, or deletes a file
- 📋 Report + JSON — human-readable output plus a summary JSON for scripting
- 🧭 Roadmap — resident macOS/Windows tray & menu-bar app, YAML config, icon-state alerts (see Roadmap)
Prebuilt binaries are on the Releases page. They are currently
unsigned, so macOS and Windows warn on first launch — steps below. On first
run the app writes a default config (Settings… in the menu opens it); point
watch at your synced folder, then use Scan now. The icon color shows the
result and clicking an over-limit file reveals it in Finder/Explorer. It's a
menu-bar agent — look for the ruler icon, there's no Dock icon.
macOS — Homebrew (via the tap):
brew tap msjang/tap
brew install --cask pathguard # clears the quarantine flag for youmacOS — manual: because the app is unsigned, macOS blocks it with "can't be opened" until you remove the quarantine flag.
-
From Releases, download
Pathguard-macos-universal.zip(one build for both Apple Silicon and Intel) and unzip it. -
Move Pathguard.app to
/Applications. -
Clear quarantine and open:
xattr -dr com.apple.quarantine "/Applications/Pathguard.app" open "/Applications/Pathguard.app"
Or without Terminal: right-click the app → Open → Open; on recent macOS go to System Settings → Privacy & Security → Open Anyway.
Windows — manual: download Pathguard-windows-amd64.zip, unzip, run
Pathguard.exe. If SmartScreen warns: More info → Run anyway.
Build from source (any OS, requires Go 1.25+):
git clone https://github.com/msjang/pathguard.git
cd pathguard
go run ./cmd/pathguard-gui # or: scripts/build.sh → bin/pathguardLaunch at login (macOS): open the menu and toggle Launch at login. It
installs a per-user LaunchAgent at
~/Library/LaunchAgents/io.github.msjang.pathguard.plist that starts pathguard
at each login; toggling it off removes the file. It starts the app once at login
(no auto-restart), so Quit keeps working. Nothing else — no brew step and
no Login Item — is needed; Homebrew intentionally can't register autostart.
(macOS only for now.)
Config file location — the first match wins:
$PATHGUARD_CONFIG— an explicit file path (a leading~is expanded)$XDG_CONFIG_HOME/pathguard/config.yml— if that variable is set~/.config/pathguard/config.yml— only if it already exists- the OS convention, otherwise:
- macOS:
~/Library/Application Support/pathguard/config.yml - Windows:
%APPDATA%\pathguard\config.yml - Linux:
~/.config/pathguard/config.yml
- macOS:
So if you prefer the dotfile layout on macOS or Windows, just move the file:
mkdir -p ~/.config/pathguard
mv "~/Library/Application Support/pathguard/config.yml" ~/.config/pathguard/config.ymlRule 3 checks existence rather than winning outright, so configs already living in the OS location keep working untouched.
A pure-Go, dependency-free binary — handy for one-off checks, scripts, or CI (it exits non-zero when anything is over the limit).
go run ./cmd/pathguard # scan configured watches, print a report
go run ./cmd/pathguard --json # summary JSON
go run ./cmd/pathguard --root ~/Docs --remote-prefix /volume1/homes/johndoe/MyDocumentsBoth the app and the CLI read the same YAML config (full schema in
prj/ADR.md, ADR-0003):
watch:
- root: ~/Documents
remote_prefix: /volume1/homes/johndoe/MyDocuments # remote absolute root, for PATH_MAX
limits: { name_max: 255, path_max: 4096, warn_ratio: 0.80 } # 0.80 = warn from 80%
exclude: [.git, node_modules, "@eaDir", "#recycle"] # noise dirs to skip
notify:
thresholds: { yellow: 1, red: 10, warn: 1 } # icon color by over/warn counts
menu: { max_inline: 10 } # worst-N shown inline in the menu
ui: { language: auto } # auto (system locale) | en | koDefault exclusions: .git, node_modules, @eaDir, #recycle, #snapshot,
.DS_Store, .Trashes, .Spotlight-V100, .fseventsd, $RECYCLE.BIN, System Volume Information
CLI flags: --config <path>, --root <dir>, --remote-prefix <p>, --json.
The remote path length is the bottleneck — set
remote_prefixto your actual sync target.
- Measures every name and full path as UTF-8 bytes after NFD normalization
- Name NFD >
NAME_MAX→ over;WARN…limit → warning - Full path (remote absolute) NFD >
PATH_MAX→ over/warning - Excluded folders are not descended into; excluded files are skipped
Growing into a resident tray / menu-bar app for macOS (Intel & Apple Silicon) and Windows:
- Single-binary distribution — Go rewrite, one dependency-free executable (macOS arm64/amd64, Windows amd64)
- Resident tray / menu bar — click to configure watched folders, interval, schedule, exclusions
- YAML config file — multiple watch folders, limits/warn ratio/exclusions/alert style
- Icon-state alerts — icon color reflects state: gray (idle), blue (scanning), green (clean), yellow (warnings or some over), red (many over) — all thresholds configurable
- Localized UI — follows the system locale by default; English and Korean selectable
Design docs live in prj/ (PRD · ADR · TASKS · NOTES).