Introduction
LinkMarks is a local-first bookmark manager with deterministic
deduplication, multi-device sync over CRDT, and an interactive TUI
browser. It imports what you already have (Chromium, Firefox,
Netscape HTML exports), deduplicates by canonical URL, keeps
everything in a single SQLite store under ~/.local/share/linkmarks/,
and optionally syncs across your devices through a self-hosted
yrs-based relay.
TL;DR
# Install
cargo install linkmarks-cli --path crates/linkmarks-cli --bin linkmarks --locked
# Import from a browser export
linkmarks init
linkmarks import chromium ~/.cache/chromium/Default/Bookmarks
# Browse interactively
linkmarks tui
# Or script it
linkmarks list --format json | jq '.[] | select(.tags | index("rust"))'
What it is
LinkMarks treats bookmarks as first-class Rust data with five properties that matter:
- Local-first. The SQLite store is the source of truth. Sync is
additive, never required for correctness. A user who never runs
linkmarks synchas the same data as a user with three devices. - Deterministic. Dedup is by canonical URL (sorted query
params, lowercased host, no fragment, no trailing slash variance).
Re-running
linkmarks dedupeagainst the same input produces byte-identical output. - Auditable. The codebase is 8 crates in a Cargo workspace. The
core (
linkmarks-core) is a library with no I/O outside the SQLite handle the caller hands it. The CLI (linkmarks-cli) is a thin clap wrapper. There is no telemetry, no analytics, no automatic network call. - Multi-format import. Chromium's
BookmarksJSON, Firefox'splaces.sqlite, and Netscape-style HTML exports are all parsed natively. Round-trip fidelity is verified by unit tests for each bridge. - Multi-device via yrs CRDT. When the operator chooses to run the self-hosted relay (preview), changes propagate as per-collection yrs sub-documents. Conflicts merge without operator action; the relay sees opaque bytes, not plaintext bookmarks.
What it is not
- A bookmarking service. There is no central account. There is no SaaS. There is no "free tier" that costs you your privacy.
- A web clipper. LinkMarks is for URLs, titles, tags, and notes. Not for full-page archives, screenshots, or rich text.
- A read-it-later service. The TUI is a browser, not a reader. It does not download page contents.
Who it's for
LinkMarks is for operators who:
- Care about the difference between
m.example.comandexample.com(canonical URL dedup handles this) - Run multiple devices and want bookmarks synced without trusting a third-party SaaS with the data
- Like a TUI as their primary surface (ratatui + nucleo fuzzy search)
- Want to be able to read every line of code that touches their
bookmarks (the core is ~3,500 LOC, the whole workspace is ~12,000
LOC excluding
linkmarks-bench-crdt)
Crate map
The workspace is 8 crates; the umbrella linkmarks is the
canonical binary:
| Crate | Visibility | Description |
|---|---|---|
linkmarks | public (umbrella) | Single-binary that re-exports the CLI |
linkmarks-cli | public | The clap-based command dispatch |
linkmarks-core | public | Library: SQLite schema, dedup, sort, filter |
linkmarks-tui | public | Interactive ratatui browser |
linkmarks-bridge-chromium | public | Chromium Bookmarks JSON parser |
linkmarks-bridge-firefox | public | Firefox places.sqlite parser |
linkmarks-bridge-netscape | public | Netscape HTML parser/serializer |
linkmarks-bench-crdt | private | Benchmark harness (not published) |
Every public crate ships to crates.io
with the same Cargo.lock pinned via --locked install. The umbrella
binary is documented at
docs.rs/linkmarks; the library API
is documented at the per-crate docs.rs page.
At a glance
| Property | Value |
|---|---|
| Language | Rust (edition 2021, MSRV 1.78) |
| License | AGPL-3.0-or-later OR LicenseRef-Commercial |
| Storage | SQLite (single file under XDG) |
| Sync | yrs CRDT, optional self-hosted relay (preview) |
| Bridges | Chromium JSON, Firefox places.sqlite, Netscape HTML |
| CLI | linkmarks (single binary) |
| TUI | ratatui + crossterm + nucleo |
| Runtime deps | 16 per crate (workspace-shared Cargo.lock) |
| Test count | 286 (workspace, release mode) |
| Headline LOC | ~12,000 across 8 crates (excluding bench) |
Where to next
- Getting started —
cargo installand the firstlinkmarks import. - Concepts — the canonical URL model, the SQLite schema, the dedup algorithm.
- CLI reference — every subcommand with flags.
- TUI browser — keys, sort modes, filter modes.
- Bridge formats — what's preserved per import.
- Sync model — how the relay sees your bookmarks.
- Architecture — the 8-crate workspace breakdown.
- Hardening — operational hardening the reference deployment applies.
- Reference — env vars, exit codes, file layout.
- License — AGPL-3.0-or-later OR LicenseRef-Commercial.
Getting started
This page covers installing the linkmarks binary, performing the
first-time store initialisation, and importing your first batch of
bookmarks.
Install via cargo
cargo install linkmarks-cli \
--git https://github.com/LOUST-PRO/LinkMarks \
--tag v2.2.0 \
--path crates/linkmarks-cli \
--bin linkmarks \
--locked
This places the binary at ~/.cargo/bin/linkmarks. The --locked
flag pins the install to the lockfile shipped in the published
crate, ensuring the same dependency graph that CI tests. The
--path flag tells cargo which workspace member to build (the
repo is a Cargo workspace with 8 crates).
Install from a package manager
Arch Linux
pacman -U linkmarks-2.2.0-1-x86_64.pkg.tar.zst
The PKGBUILD lives at arch/PKGBUILD in the repo and ships a
linkmarks.install script with pre_upgrade and post_install
hooks for migrating the SQLite store on version bumps.
Debian / Ubuntu
sudo dpkg -i linkmarks_2.2.0-1_amd64.deb
The Debian rules at debian/rules use dh $@ --buildsystem cargo
with a dh-cargo integration, and respect DEB_BUILD_OPTIONS=nocheck
in the test override.
Fedora / RHEL
sudo dnf install ./linkmarks-2.2.0-1.fc42.x86_64.rpm
The spec at rpm/linkmarks.spec uses rpmlint-clean license
metadata (License: AGPL-3.0-or-later).
Homebrew
brew install LOUST-PRO/tap/linkmarks
The Formula at homebrew/Formula/linkmarks.rb uses
brew audit --strict-clean metadata.
First-time init
The first run creates the XDG store directory and a default config:
linkmarks init
# expected output: "Initialised linkmarks store at ~/.local/share/linkmarks/linkmarks.db"
# "Wrote default config to ~/.config/linkmarks/config.toml"
The default config (config.toml.example shows every field with
comments) covers:
- The SQLite path (default: XDG-resolved)
- The default sort mode (one of
updated,title,canonical-url,created) - The default filter mode (1 of 3 — see Concepts)
- The sync relay URL (only used when
linkmarks syncruns)
linkmarks init is idempotent — re-running it is safe.
Importing your first bookmarks
From Chromium (Brave, Edge, Opera, etc.)
Chromium-family browsers store bookmarks at
<profile>/Bookmarks as a JSON file. Find your profile with
chrome://version (the "Profile Path" line).
linkmarks import chromium ~/.config/chromium/Default/Bookmarks
# expected output: "Imported 1247 bookmarks from chromium"
# " - 1189 kept after canonical-URL dedupe"
# " - 58 duplicates suppressed"
The importer:
- Parses Chromium's
bookmark_bar,other, andsyncedfolders. - Resolves Chromium's internal node IDs to deterministic ULIDs.
- Canonicalises every URL (sorted query params, lowercased host, no fragment).
- Deduplicates by canonical URL — last write wins per URL.
- Imports folder structure as tag-prefix tags (e.g.
bar/Tech/Rustbecomes["bar", "Tech", "Rust"]).
From Firefox
Firefox stores bookmarks at <profile>/places.sqlite. Find your
profile with about:profiles.
linkmarks import firefox ~/.mozilla/firefox/abc123.default-release/places.sqlite
The Firefox bridge reads moz_bookmarks (folder structure) and
moz_places (URL/title/timestamp). It does NOT import moz_annos
(annotations) by default; pass --with-annotations to include
them.
From Netscape HTML (Pocket, Raindrop, Pinboard exports)
linkmarks import netscape ~/Downloads/pocket-export.html
The Netscape bridge parses the standard <DL> / <A HREF>
hierarchy. Tags from <DD> comments are preserved.
Verify the install
Two sanity checks confirm the store is alive:
linkmarks --version
# expected: linkmarks 2.2.0
linkmarks list --limit 5
# expected: table with the 5 most-recently-updated bookmarks
If linkmarks list returns Error::StoreNotInitialised, run
linkmarks init first.
Uninstalling
cargo uninstall linkmarks-cli
rm -rf ~/.local/share/linkmarks
rm -rf ~/.config/linkmarks
The SQLite store is the only on-disk state. Removal is fully reversible.
Concepts
This page explains the four concepts the rest of LinkMarks builds on: canonical URL, the SQLite schema, the dedup algorithm, and the sort/filter modes. Understanding these makes the CLI reference and the TUI browser feel like obvious consequences.
Canonical URL
The single most important concept in LinkMarks is the canonical URL: a normalised form of a URL where two URLs that "look the same" to a human are byte-identical after canonicalisation.
A canonical URL is computed by applying these rules, in order:
- Lowercase the scheme (
https://stayshttps://). - Lowercase the host (
Example.COMbecomesexample.com). - Remove the default port for the scheme (
:443forhttps,:80forhttp). - Remove the fragment (
#section-2is gone). - Remove trailing slash on the path (except for the empty path
itself, which stays
/). - Sort query parameters lexicographically by name, then by value.
- Recode percent-encoding to NFC unicode normalisation.
After canonicalisation, the following URLs are all byte-identical:
https://Example.com/foo?b=2&a=1
https://example.com/foo?a=1&b=2
HTTPS://example.com:443/foo/?b=2&a=1#section-2
This matters because two bookmark managers will routinely create the "same" bookmark under different strings. Without canonical URLs, dedup is a heuristic; with canonical URLs, dedup is a lookup.
SQLite schema
The store is a single SQLite file. The schema (v2.2.0) has 7 tables:
| Table | Purpose |
|---|---|
bookmarks | The bookmark records themselves |
tags | Tag definitions (id + canonical name) |
bookmark_tags | Many-to-many bookmark ↔ tag |
folders | Folder hierarchy (for Chromium-style import) |
bookmark_folders | Many-to-many bookmark ↔ folder |
sync_log | Sync metadata (last seen, last pushed) |
schema_version | Single-row schema version table |
bookmarks columns
id ULID primary key
canonical_url TEXT NOT NULL -- the canonical form (unique index)
original_url TEXT NOT NULL -- the first-seen raw URL
title TEXT NOT NULL
notes TEXT NULL
created_at TEXT NOT NULL -- RFC3339 timestamp
updated_at TEXT NOT NULL
last_visit_at TEXT NULL -- null if never visited
visit_count INTEGER NOT NULL DEFAULT 0
The unique index on canonical_url enforces dedup at the storage
layer. An INSERT of a duplicate canonical URL fails with
SQLITE_CONSTRAINT_UNIQUE, which the importer treats as
"already-imported, skip".
The dedup algorithm
linkmarks dedupe walks the entire bookmarks table, groups by
canonical_url, and for each group keeps one record per the
following tie-breakers (in order):
- Most recent
updated_at— the most-recently-touched record wins. - Most recent
last_visit_at— among records with the sameupdated_at, the most-recently-visited wins. - Highest
visit_count— among ties on timestamps, the most-visited wins. - Lexicographically smallest
id— final deterministic fallback.
The losing records are deleted; their bookmark_tags and
bookmark_folders rows are re-parented to the winner before the
delete. The whole operation is a single transaction with
PRAGMA foreign_keys = ON (cascading deletes).
Re-running linkmarks dedupe against the same store produces
byte-identical output. This is verified by the property tests in
linkmarks-core/src/dedupe.rs.
Sort modes
The TUI browser and the linkmarks list command both support four
sort modes:
| Mode | Comparator |
|---|---|
updated | updated_at DESC (default) |
title | title COLLATE NOCASE ASC |
canonical-url | canonical_url ASC |
created | created_at DESC |
The sort modes are enumerated as SortMode in linkmarks-core.
The TUI cycles through them with the s key; the CLI accepts
--sort as a flag.
Filter modes
The TUI's filter input (/ key) supports three filter modes:
| Mode | Description |
|---|---|
Substring | Default. The query is matched as a substring against title + URL + tags. Case-insensitive. |
Tag | The query is matched as a tag prefix (e.g. rus matches rust, rust-cli, rustr but not cru). |
Fuzzy | The query is matched using nucleo's fuzzy matcher. rs tmpl matches ratatui-template. |
The filter modes are enumerated as FilterMode in
linkmarks-tui. The TUI cycles through them with Ctrl+F.
Sync model (preview)
The self-hosted relay is preview-stage in v2.2.0. When enabled:
- Each device writes to its local SQLite store.
linkmarks sync pushserialises the changed rows into a per-collection yrs sub-document.- The relay receives opaque yrs bytes + an HTTP path; it does not see plaintext bookmarks.
- Each devices pull merges the remote yrs bytes into its local store, applying the canonical-URL dedup on receive.
The relay itself is preview; the linkmarks sync --remote CLI is
fully wired and tested but the linkmarks-relay binary is in a
future release. The architecture is documented in
Architecture.
CLI reference
The linkmarks binary exposes 13 subcommands. Every subcommand
supports --help, accepts -h as shorthand, and exits 0 on
success, 1 on user error, 2 on store error, 3 on import/parse
error.
Global flags
linkmarks [OPTIONS] <COMMAND>
Options:
-C, --config <PATH> Override config file path (default: XDG)
-v, --verbose Increase log verbosity (-v, -vv, -vvv)
-q, --quiet Suppress non-error output
-V, --version Print version and exit
-h, --help Print help and exit
Subcommands
init
Initialise the store and config file.
Usage: linkmarks init [OPTIONS]
Options:
--force Reinitialise even if store exists
--store-dir <PATH> Override the XDG store directory
import
Import bookmarks from a browser export.
Usage: linkmarks import <FORMAT> <SOURCE>
Arguments:
<FORMAT> One of: chromium, firefox, netscape
<SOURCE> Path to the export (file or directory depending on format)
Options:
--dry-run Show what would be imported without writing
--limit <N> Cap the number of records imported
--with-annotations Firefox only: include moz_annos
Examples:
linkmarks import chromium ~/.config/chromium/Default/Bookmarks
linkmarks import firefox ~/.mozilla/firefox/abc.default/places.sqlite --with-annotations
linkmarks import netscape ~/Downloads/pocket-export.html --dry-run
list
List bookmarks.
Usage: linkmarks list [OPTIONS]
Options:
--limit <N> Cap the number of records shown
--sort <MODE> One of: updated, title, canonical-url, created
--filter <MODE> One of: substring, tag, fuzzy
--query <Q> Pre-populate the filter query
--format <FMT> One of: table, json, csv, plain
--tag <TAG> Filter to records carrying <TAG> (repeatable)
--folder <PATH> Filter to records in <PATH> (repeatable)
--no-header Omit header row (table, csv formats)
Examples:
linkmarks list --limit 10
linkmarks list --tag rust --format json
linkmarks list --query ratatui --filter fuzzy
linkmarks list --folder "Tech/Rust" --folder "Tech/Go"
add
Add a single bookmark.
Usage: linkmarks add <URL> [OPTIONS]
Arguments:
<URL> The URL to bookmark
Options:
-t, --title <TITLE> Bookmark title (defaults to <URL>)
-T, --tag <TAG> Tag (repeatable)
-n, --notes <NOTES> Free-form notes
--folder <PATH> Place in folder hierarchy
delete
Delete a bookmark by ID or canonical URL.
Usage: linkmarks delete <TARGET>
Arguments:
<TARGET> ULID or canonical URL
Options:
--dry-run Show what would be deleted
--force Skip confirmation prompt
update
Update bookmark fields.
Usage: linkmarks update <TARGET> [OPTIONS]
Arguments:
<TARGET> ULID or canonical URL
Options:
--title <TITLE> Set title
--notes <NOTES> Set notes
--add-tag <TAG> Add tag (repeatable)
--rm-tag <TAG> Remove tag (repeatable)
--folder <PATH> Set folder
dedupe
Run the canonical-URL dedupe pass.
Usage: linkmarks dedupe [OPTIONS]
Options:
--dry-run Show the dedupe plan without executing
--limit <N> Cap the number of groups processed
--verbose Print per-group winners and losers
export
Export the store to another format.
Usage: linkmarks export <FORMAT> [OPTIONS]
Arguments:
<FORMAT> One of: netscape, json, csv
Options:
-o, --output <PATH> Output path (default: stdout)
--limit <N> Cap records exported
--query <Q> Filter query
sync
Push or pull from the self-hosted relay (preview).
Usage: linkmarks sync <DIRECTION> [OPTIONS]
Arguments:
<DIRECTION> One of: push, pull, status
Options:
--remote <URL> Override the relay URL from config
--dry-run Show what would be sent/received
--limit <N> Cap the number of records
--since <RFC3339> Only push/pull records updated since
tui
Launch the interactive TUI browser.
Usage: linkmarks tui [OPTIONS]
Options:
--sort <MODE> Initial sort mode
--filter <MODE> Initial filter mode
--query <Q> Pre-populate filter query
--theme <NAME> Color theme: rust, light, dark, ayu
completions
Generate shell completions.
Usage: linkmarks completions <SHELL>
Arguments:
<SHELL> One of: bash, zsh, fish, elvish, powershell
Options:
-o, --output <PATH> Output path (default: stdout)
doctor
Diagnose the install.
Usage: linkmarks doctor
Options:
--check <NAME> Run a single check (one of: store, config, indices, sync)
Checks:
store Verifies the SQLite file exists and is readable
config Validates config.toml against the schema
indices Confirms unique indexes on canonical_url exist
sync Tests connectivity to the configured relay
version
Print version, build profile, and feature flags.
Usage: linkmarks version
Output:
linkmarks 2.2.0
profile: release
features: default
sqlite: 3.46.1
yrs: 0.18.5
cargo: 1.84.0
rustc: 1.84.0 (edition 2021)
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | User error (bad flags, missing arguments) |
| 2 | Store error (SQLite open, schema mismatch) |
| 3 | Import/parse error (malformed input) |
| 4 | Sync error (relay unreachable, conflict) |
| 5 | Permission error (cannot read source, cannot write store) |
| 64 | Configuration error (config.toml invalid) |
The exit codes are stable across releases; downstream tooling can rely on them.
TUI browser
The TUI browser (linkmarks tui) is the primary interactive
surface. It runs in any terminal, uses ratatui for rendering and
crossterm for input, and indexes the store into an in-memory
nucleo matcher for sub-millisecond filter feedback.
Layout
┌──────────────────────────────────────────────────────────────────────┐
│ LinkMarks ~/local/share/linkmarks/linkmarks.db │
├──────────────────────────────────────────────────────────────────────┤
│ / rust tmpl ▏ │
├──────────────────────────────────────────────────────────────────────┤
│ ▎ Updated 2026-08-12 rust-template https://github.com/.../tmpl │
│ ▎ Updated 2026-08-10 rust-analyzer https://rust-analyzer.github.io │
│ Updated 2026-08-05 rust-2024 https://doc.rust-lang.org/... │
│ ... │
├──────────────────────────────────────────────────────────────────────┤
│ 1/1247 updated:rust,cli s:sort Ctrl+F:filter ?:help q:quit │
└──────────────────────────────────────────────────────────────────────┘
The TUI has 5 regions: header (store path), filter bar, list,
status bar. The list selection cursor (▎) can be moved with
j/k or arrow keys.
Keymap
| Key | Action |
|---|---|
j / ↓ | Move cursor down |
k / ↑ | Move cursor up |
g | Jump to first record |
G | Jump to last record |
/ | Enter filter mode |
Esc | Clear filter, exit help/menu |
Enter | Open the highlighted URL in the default browser |
Tab | Toggle tag picker |
s | Cycle sort mode |
Ctrl+F | Cycle filter mode |
a | Add a bookmark (prompts for URL + title) |
e | Edit the highlighted bookmark |
d | Delete the highlighted bookmark (with confirmation) |
r | Refresh from disk |
? | Toggle help overlay |
q / Ctrl+C | Quit |
Filter modes
Pressing Ctrl+F cycles through three modes; the active mode is
shown in the status bar.
Substring (default)
The query is matched as a substring against title, URL, and tags. Case-insensitive. Use this for direct keyword search.
Examples:
rustmatches every bookmark whose title, URL, or tags contain "rust" (case-insensitive)cli templatematches records containing both substrings (in any field)
Tag
The query is matched as a tag prefix. Useful for narrowing by categorisation without typing the full tag.
Examples:
rumatches tagsrust,rust-cli,rustrbut NOTcrutech/matches tagstech/rust,tech/go(forward-slash is literal)
Fuzzy
The query is matched using nucleo's fuzzy matcher. Allows out-of-order tokens and small typos.
Examples:
rs tmplmatchesrust-template,ratatui-template,crate-template-rslinkmarkmatcheslinkmarks,link-marker,l-i-n-k-marks
Sort modes
Pressing s cycles through four modes; the active mode is shown
in the status bar.
| Mode | Comparator |
|---|---|
updated | updated_at DESC (default) |
title | title COLLATE NOCASE ASC |
canonical-url | canonical_url ASC |
created | created_at DESC |
The sort persists across filter changes within the same session.
Actions
Open URL
Enter opens the highlighted URL via xdg-open (Linux),
open (macOS), or start (Windows). If the URL is malformed,
the TUI shows a notification and continues.
Add a bookmark
a opens an input prompt for URL; pressing Enter opens a
second prompt for title (with the URL as default). Tags are added
by Tab-completion against existing tags.
Edit a bookmark
e opens an edit form for the highlighted record: title, notes,
tags. Save with Enter; cancel with Esc.
Delete a bookmark
d opens a confirmation prompt. Default is No (cursor on No).
Press Tab to switch to Yes, then Enter to delete. The
delete is reversible: u (undo) restores the last 10 deletes
within the session.
Themes
The TUI supports 4 themes, switchable with :theme <name> from
the help overlay:
rust(default): the rust-lang.org palettelight: high-contrast light themedark: low-contrast dark themeayu: Ayu-inspired theme
Configuration
The TUI reads its keymap and theme from the global config. To
customise the keymap, write a ~/.config/linkmarks/keymap.toml:
[keymap]
quit = ["q", "Ctrl+C", "Esc"]
open = ["Enter", "o"]
delete = ["d", "Backspace"]
The default keymap is documented in linkmarks-tui/src/input.rs.
Bridge formats
LinkMarks imports bookmarks from 3 source formats and exports to
2. Each bridge is a separate crate (linkmarks-bridge-*) with
its own dependency tree, its own test fixture, and its own
round-trip fidelity test.
Chromium / Brave / Edge / Opera
The Chromium bridge (linkmarks-bridge-chromium) parses the
Bookmarks JSON file produced by Chromium-family browsers.
File location
Linux: ~/.config/<browser>/Default/Bookmarks
macOS: ~/Library/Application Support/<browser>/Default/Bookmarks
Windows: %LOCALAPPDATA%\<browser>\User Data\Default\Bookmarks
The file is written by the browser on every bookmark change; the importer reads it once and disconnects.
Schema mapping
| Chromium field | LinkMarks field |
|---|---|
bookmark_bar.children[] | records + folder bar/ |
other.children[] | records + folder other/ |
synced.children[] | records + folder synced/ |
children[].url | original_url (canonicalised on import) |
children[].name | title |
children[].date_added | created_at |
children[].date_last_used | last_visit_at (when present) |
children[].id | discarded (LinkMarks assigns its own ULID) |
children[].guid | discarded |
children[].meta_info | notes (when present) |
| Folder paths | bookmark_folders rows |
What's preserved
- URL (canonicalised)
- Title
- Date added →
created_at - Date last used →
last_visit_at(when non-zero) - Folder hierarchy (the importer splits
/-separated folder paths into ancestor folders) - Tags from
meta_info(when present)
What's NOT preserved
- Favicon (not stored in the Bookmarks JSON)
- Visit count (Chromium does not embed this; the importer starts from 0 and the user's browsing history is not accessible)
- Sync metadata (the
syncedfolder is preserved as a folder but the per-device attribution is not) - Internal IDs (LinkMarks assigns its own ULIDs)
Round-trip test
The Chromium bridge ships with a fixture
(fixtures/chromium-v123.bookmarks) and a round-trip test:
- Import fixture → store A
- Export store A as Netscape HTML
- Import the Netscape HTML → store B
- Compare A and B by canonical URL
The test passes when every record in A has a matching canonical URL in B with the same title, tags, and folder path.
Firefox
The Firefox bridge (linkmarks-bridge-firefox) reads a
places.sqlite SQLite file directly.
File location
Linux: ~/.mozilla/firefox/<profile>/places.sqlite
macOS: ~/Library/Application Support/Firefox/Profiles/<profile>/places.sqlite
Windows: %APPDATA%\Mozilla\Firefox\Profiles\<profile>\places.sqlite
The bridge reads the file read-only; the importer does not require Firefox to be closed.
Schema mapping
| Firefox table.column | LinkMarks field |
|---|---|
moz_places.url | original_url |
moz_places.title | title |
moz_places.last_visit_date | last_visit_at |
moz_bookmarks.dateAdded | created_at |
moz_bookmarks.lastModified | updated_at |
moz_bookmarks.title | folder title (when type = 2) |
moz_bookmarks.type = 1 | bookmark record |
moz_bookmarks.type = 2 | folder record |
moz_bookmarks.type = 3 | separator (discarded) |
moz_annos.content (when --with-annotations) | notes |
What's preserved
- URL (canonicalised)
- Title
- Folder hierarchy
- Tags from
moz_tags(when present; Firefox's tagging was introduced in Firefox 96) - Annotations (when
--with-annotations) - Date added / date modified / date last visited
What's NOT preserved
- Favicons (stored in
favicons.sqlite, not inplaces.sqlite) - Tags from
places.sqlitewithout the--with-annotationsflag (the import is much faster without annotations; the SQLite ATTACH is the expensive part) - History (only bookmarks are imported; the importer filters by
moz_bookmarks.type IN (1, 2)) - Visit counts (not stored in
places.sqlite; Firefox tracks visits separately inmoz_historyvisits)
Live Firefox warning
If Firefox is running while linkmarks import firefox executes,
the bridge reads the file in read-only mode. SQLite WAL mode is
honored; the importer does not block Firefox's writes.
Netscape HTML
The Netscape bridge (linkmarks-bridge-netscape) parses the
canonical <DL><DT><A HREF> HTML format used by every browser
export ever and by Pocket / Raindrop / Pinboard exports.
Example input
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><H3 ADD_DATE="1723500000">Tech</H3>
<DL><p>
<DT><A HREF="https://example.com/" ADD_DATE="1723500001">Example</A>
<DD>example-tag
<DT><A HREF="https://github.com/" ADD_DATE="1723500002">GitHub</A>
</DL><p>
</DL><p>
Schema mapping
| Netscape attribute | LinkMarks field |
|---|---|
HREF | original_url |
Text content of <A> | title |
ADD_DATE (epoch seconds) | created_at |
LAST_MODIFIED (epoch seconds) | updated_at (when present) |
TAGS (comma-separated, post-2008) | tags |
Text content of <DD> (Pocket style) | tags (split by ,) |
<H3> headings | folder titles |
Nested <DL> | folder hierarchy |
What's preserved
- URL (canonicalised)
- Title
- Tags (from
TAGSattribute or<DD>comment) - Folder hierarchy (from
<H3>headings) - Date added / date modified
What's NOT preserved
- Favicons (not in the Netscape format)
- Visit history (not in the Netscape format)
- Per-record GUIDs (LinkMarks assigns its own ULIDs)
Round-trip test
The Netscape bridge's round-trip test is identical to Chromium's: import → export → re-import → compare by canonical URL.
Export formats
linkmarks export <FORMAT> writes the store in the chosen
format. Two formats are supported:
netscape— produces a<DL>HTML file, ready for import into another browser or servicejson— produces a per-record JSON array, suitable for piping intojqcsv— produces a CSV with columns matching thelinkmarks list --format csvschema
Choosing a bridge
If you have multiple source formats, import each one in order of size:
- Firefox (most complete: tags + annotations)
- Chromium (most universally available)
- Netscape HTML (least information, but the most portable)
The canonical-URL dedupe is idempotent, so importing multiple sources is safe.
Sync model
This page documents the multi-device sync model used by LinkMarks v2.2.0 and later. The sync layer is preview in v2.2.0 — the CLI is fully wired and tested, but the relay binary is in a future release. The data model and conflict-resolution rules are stable.
Design goals
- Local-first. A device that has never run
linkmarks syncis functionally identical to one that syncs daily. Sync is additive, never required. - Operator-controlled. The relay is self-hosted; the operator controls the server, the storage, the retention, and the access logs.
- Privacy by design. The relay sees opaque yrs bytes, not plaintext bookmarks. The relay cannot decrypt without the device-shared key, which is configured out-of-band.
- Conflict-free. Two devices editing the same bookmark concurrently converge to the same final state without operator intervention.
- Auditable. The sync layer is ~600 LOC and lives in
linkmarks-core/src/sync/; every merge operation has unit tests with deterministic fixtures.
Architecture
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Device A │ │ Relay │ │ Device B │
│ SQLite │ │ (yrs │ │ SQLite │
│ + yrs │ ──yrs bytes──▶ │ opaque │ ──yrs bytes──▶ │ + yrs │
│ snapshot│ │ storage)│ │ snapshot│
└──────────┘ └──────────┘ └──────────┘
Each device has a local SQLite store. Sync serialises the
changed rows into a per-collection yrs sub-document and pushes
the opaque bytes to the relay. The relay stores the bytes keyed
by a per-collection name (bookmarks, tags, folders) and a
per-device device_id.
On pull, the device fetches the latest remote bytes for each collection and merges them into its local yrs sub-document. The SQLite rows are then re-derivable from the merged yrs snapshot.
The relay
The relay (linkmarks-relay, future release) is a tiny HTTP
server with 4 endpoints:
POST /v1/push/{collection}
Headers: Authorization: Bearer <device-token>
Body: opaque yrs bytes
Response: 204 No Content
GET /v1/pull/{collection}
Headers: Authorization: Bearer <device-token>
Response: 200 OK with body = opaque yrs bytes
GET /v1/state/{collection}
Headers: Authorization: Bearer <device-token>
Response: { "version": <u64>, "device_count": <u32> }
GET /healthz
Response: 200 OK with body = { "version": "<relay-version>" }
The relay stores bytes in a per-collection file under
/var/lib/linkmarks-relay/. There is no index, no query layer,
no plaintext ever.
Conflict resolution
Conflicts arise when two devices edit the same bookmark concurrently. The yrs CRDT resolves them by Lamport timestamp order. Specifically:
- Each device tracks a monotonically-increasing
lamport_clockcounter. - Every mutation is stamped with
(device_id, lamport_clock). - On merge, the mutation with the higher
lamport_clockwins. - Ties are broken by
device_idlexicographic order.
Bookmark fields are merged per-field (not per-record), so two devices editing different fields of the same bookmark never overwrite each other.
Example:
Device A (clock=42): edit https://example.com -> title="Example"
Device B (clock=41): edit https://example.com -> tags=["rust"]
After merge, the bookmark has title="Example" (from A, clock 42)
and tags=["rust"] (from B, clock 41). The merge is automatic;
neither edit is lost.
What syncs
The sync layer covers 4 entity types:
| Entity | Per-collection name | Conflict policy |
|---|---|---|
bookmarks | bookmarks | field-level merge |
tags | tags | set union |
folders | folders | last-writer-wins (tree shape is single-writer in practice) |
sync_log | not synced | per-device, never shared |
The bookmark_tags and bookmark_folders join tables are
derived locally from bookmarks + tags + folders after each
pull.
What does NOT sync
- The local
config.toml— each device has its own configuration. - The local
keymap.toml— same. - The
last_visit_atandvisit_countfields — these are per-device browsing activity, not shared bookmarks. - The original URL — only the canonical URL is synced. The
first device to see a URL wins for
original_url.
Sync CLI
# Push local changes to the relay
linkmarks sync push
# Pull remote changes into local
linkmarks sync pull
# Show sync state
linkmarks sync status
# Push with custom relay URL
linkmarks sync push --remote https://relay.example.com
linkmarks sync is idempotent — pushing with no local changes
is a no-op; pulling with no remote changes is a no-op.
Sync timing
By default, linkmarks sync runs when invoked. There is no
built-in cron. Operators who want background sync typically
wrap it in a systemd timer:
# ~/.config/systemd/user/linkmarks-sync.service
[Unit]
Description=LinkMarks background sync
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/linkmarks sync push
ExecStart=/usr/bin/linkmarks sync pull
# ~/.config/systemd/user/linkmarks-sync.timer
[Unit]
Description=Run LinkMarks sync every 15 minutes
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
Threat model
The relay's threat model is documented in the Hardening page. The short version: the relay is untrusted from the perspective of the bookmark contents; it stores opaque bytes and cannot decrypt. The relay is trusted from the perspective of availability and storage integrity.
Architecture
The LinkMarks workspace is 8 Cargo crates organised in 4 layers: core library, bridges, TUI, CLI/umbrella. This page documents the dependency graph, the layer boundaries, and the where-to-find-what map for contributors.
Workspace layout
LinkMarks/
├── Cargo.toml # Workspace root
├── Cargo.lock # Shared lockfile
├── crates/
│ ├── linkmarks/ # Umbrella binary (re-exports CLI)
│ ├── linkmarks-cli/ # Command dispatch
│ ├── linkmarks-core/ # Library: SQLite, dedup, sort, filter, sync
│ ├── linkmarks-tui/ # Interactive ratatui browser
│ ├── linkmarks-bridge-chromium/
│ ├── linkmarks-bridge-firefox/
│ ├── linkmarks-bridge-netscape/
│ └── linkmarks-bench-crdt/ # Private benchmark harness
├── docs/
│ ├── man/linkmarks.1 # Canonical man page
│ └── relay-deployment.md # Self-hosted relay guide (preview)
├── book/ # This mdbook site
├── arch/PKGBUILD
├── debian/
├── rpm/
└── homebrew/Formula/
Dependency graph
┌────────────────────┐
│ linkmarks │ (umbrella binary)
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ linkmarks-cli │
└─────────┬──────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ linkmarks- │ │ linkmarks- │ │ linkmarks- │
│ core │ │ tui │ │ bridges │
└─────────────┘ └──────┬───────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌──────────────────────┐
│ linkmarks- │ │ linkmarks-bridge-* │
│ core │ │ (chromium/firefox/ │
│ │ │ netscape) │
└─────────────┘ └──────────────────────┘
The bridges depend on linkmarks-core for the canonical-URL
helper. The TUI depends on linkmarks-core for the SQLite
reader. The CLI depends on all of them.
Layer responsibilities
linkmarks-core (~3,500 LOC)
The pure-library layer. Has zero network I/O, zero CLI integration, zero TUI integration. Public API surface:
| Module | Purpose |
|---|---|
canonical | Canonical URL computation |
schema | SQLite schema + migrations |
store | CRUD over the SQLite store |
dedupe | Deduplication pass |
sort | Sort mode enum + comparators |
filter | Filter mode enum + matchers |
bridge | Bridge trait + shared types |
sync | yrs sub-document merge |
linkmarks-bridge-* (~400 LOC each)
Format-specific parsers. Each bridge:
- Implements the
Bridgetrait fromlinkmarks-core. - Has its own dependency tree (e.g.
linkmarks-bridge-firefoxusesrusqliteto readplaces.sqlite; the others don't). - Has its own fixture under
tests/fixtures/. - Has a round-trip test that imports → exports → re-imports.
The three bridges share no code beyond the Bridge trait.
linkmarks-tui (~2,200 LOC)
The interactive ratatui browser. Depends on linkmarks-core
for the read-only store, on nucleo for fuzzy matching, and on
crossterm for terminal I/O.
Public API surface:
| Module | Purpose |
|---|---|
app | The App struct (state machine) |
input | Key event → action dispatcher |
render | ratatui render loop |
theme | Color theme registry |
The TUI does not depend on linkmarks-cli. It is a separate
binary that ships alongside the CLI.
linkmarks-cli (~1,800 LOC)
The clap-based command dispatcher. Public API surface:
| Module | Purpose |
|---|---|
main | clap parser + dispatch |
cmd | Per-subcommand implementations |
config | Config file loader |
output | Output formatting (table, json, csv) |
The CLI is the only crate that wires linkmarks-core,
linkmarks-tui, and the bridges together.
linkmarks (umbrella, ~50 LOC)
The single-binary umbrella that re-exports the CLI's main:
fn main() { linkmarks_cli::run(); }
The umbrella exists so users can cargo install linkmarks and
get a single canonical binary, while library users can depend
on linkmarks-core directly.
linkmarks-bench-crdt (private)
Benchmark harness for the yrs CRDT merge path. NOT published to crates.io. Not in the umbrella binary. The CI runs the benchmarks nightly and posts results to a private dashboard.
Public API contracts
Every public crate ships an API.toml (a small TOML manifest)
documenting the public surface. The CI enforces that no
pub symbol is added without an entry in API.toml.
This is the guarantee that lets LinkMarks ship semver-meaningful minor releases without breaking downstream library users.
Where to find what
| If you want to... | Look at... |
|---|---|
| Add a new sort mode | linkmarks-core/src/sort.rs |
| Add a new filter mode | linkmarks-core/src/filter.rs |
| Add a new bridge | copy linkmarks-bridge-netscape/, edit the parser |
| Add a new CLI subcommand | linkmarks-cli/src/cmd/ |
| Add a new TUI keybinding | linkmarks-tui/src/input.rs |
| Add a new SQLite column | linkmarks-core/src/schema.rs + write a migration |
| Change the canonical URL rules | linkmarks-core/src/canonical.rs |
| Change the yrs merge semantics | linkmarks-core/src/sync/ |
| Add a new export format | linkmarks-cli/src/cmd/export.rs |
| Add a new theme | linkmarks-tui/src/theme/ |
Build flags
| Feature | Purpose |
|---|---|
default | Stable features |
sync | yrs CRDT sync layer |
bench | Benchmark harness (private only) |
static-sqlite | Bundled SQLite (no system dep) |
The default feature set is ["sync"]. Disabling sync removes
the linkmarks sync subcommand and the yrs dependency from the
core library.
Hardening
This page documents the operational hardening applied to the
reference LinkMarks deployment. It is the documentation of
docs/relay-deployment.md extended with the threat model and
the day-2 operations procedures.
Threat model
LinkMarks has three trust boundaries:
-
Local device. The device is fully trusted. The operator owns the SQLite store; the TUI is a normal user-space application. No sandboxing is needed.
-
Relay. The relay is untrusted from the perspective of bookmark contents (it sees opaque yrs bytes). It is trusted from the perspective of availability and storage integrity: the operator runs the relay on infrastructure they control.
-
Network. The network is untrusted. All transport is HTTPS + bearer-token authentication. The relay TLS cert is operator-managed (Let's Encrypt via certbot).
What an attacker can do
| Attacker | Capability | Mitigation |
|---|---|---|
| Network observer | Sees TLS-encrypted HTTP traffic | TLS 1.3 + HSTS |
| Network MITM | Can break TLS only with operator's CA | cert pinning (optional, off by default) |
| Relay operator (or relay attacker) | Sees opaque yrs bytes, version vectors | Cannot decrypt without device-shared key |
| Compromised device | Sees plaintext bookmarks | Not in scope (device is fully trusted) |
| Compromised SQLite file | Sees plaintext bookmarks | Use FDE + dm-crypt on the device |
What an attacker cannot do
- Read plaintext bookmarks by compromising the relay alone.
- Forge mutations from another device (bearer-token auth + yrs lamport ordering).
- Roll back to a previous state without consent (yrs vectors are append-only; deletion requires explicit operator action).
- Cause data loss across devices by compromising a single device (mutations are field-merged, not record-replaced).
Reference deployment
The reference deployment uses:
- A single VPS running the relay behind a reverse proxy
- A reverse proxy (nginx) terminating TLS + serving ACME
- A separate storage volume for the relay's opaque bytes
- systemd unit for the relay, enabled at boot
nginx configuration
server {
listen 443 ssl;
server_name relay.example.com;
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
ssl_protocols TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /healthz {
proxy_pass http://127.0.0.1:8787/healthz;
access_log off;
}
}
The relay listens on 127.0.0.1:8787 and is unreachable from
the network directly.
systemd unit
[Unit]
Description=LinkMarks relay (preview)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=linkmarks-relay
Group=linkmarks-relay
WorkingDirectory=/var/lib/linkmarks-relay
Environment=LM_RELAY_LISTEN=127.0.0.1:8787
Environment=LM_RELAY_DATA_DIR=/var/lib/linkmarks-relay/collections
Environment=LM_RELAY_TOKEN=<32-byte hex token>
ExecStart=/usr/local/bin/linkmarks-relay
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/linkmarks-relay
PrivateTmp=true
MemoryMax=64M
MemoryHigh=48M
CPUQuota=50%
[Install]
WantedBy=multi-user.target
The relay runs under a dedicated user, with ProtectSystem=strict
read-only system access, and a 64 MiB RSS cap.
Backup
The relay's data directory (/var/lib/linkmarks-relay/) should
be backed up nightly. The opaque bytes are:
- Per-collection files (
bookmarks,tags,folders) - A small
metadata.jsonwith version vectors
A 30-day retention is sufficient: the operator can always
reconstruct the state from any device's local SQLite store by
running linkmarks sync push, so the relay is a coordination
service, not the source of truth.
# /etc/cron.daily/linkmarks-relay-backup
tar czf /var/backups/linkmarks-relay-$(date +%F).tar.gz \
-C /var/lib linkmarks-relay
find /var/backups -name 'linkmarks-relay-*.tar.gz' -mtime +30 -delete
Monitoring
Three signals matter:
- Relay availability —
GET /healthzreturns 200. - Relay storage —
du -sh /var/lib/linkmarks-relay. - Sync activity — per-device, the number of records pushed and pulled.
A simple Prometheus exporter is documented in the
linkmarks-relay repo (future).
Incident response
Stolen device
If a device is stolen:
- On every other device, run
linkmarks sync pushto flush any pending mutations. - On the relay, rotate the bearer token for the stolen device (this invalidates its bearer token).
- The stolen device can no longer push or pull.
- The bookmarks are unaffected; the relay still has the latest bytes.
Relay compromise
If the relay is compromised:
- The attacker has opaque yrs bytes. Without the device-shared key (configured out-of-band), the bytes are unreadable.
- Rotate the bearer tokens on all devices.
- Provision a fresh relay (the bytes are reproducible from any device's local store).
- The compromise is contained.
Storage corruption
If the relay's storage is corrupted:
- Stop the relay.
- Restore from the most recent backup.
- Run
linkmarks sync pushfrom every device to reconcile.
Storage corruption on a single device does not propagate: the yrs merge applies only the diff, not a full state replacement.
Future hardening
- TLS cert pinning for the client side, off by default in v2.2.0 (turning it on requires a one-time cert hash configuration).
- Audit log at the relay level (who pushed what, when). Currently logged to stdout; the relay does not persist.
- Rate limiting at the reverse proxy level. nginx
limit_reqis recommended for production deployments.
Reference
This page is the technical reference: env vars, file layout, config schema, exit codes, signal handling.
Environment variables
| Variable | Default | Description |
|---|---|---|
LINKMARKS_CONFIG | $XDG_CONFIG_HOME/linkmarks/config.toml | Path to the config file |
LINKMARKS_STORE | $XDG_DATA_HOME/linkmarks/linkmarks.db | Path to the SQLite store |
LINKMARKS_RELAY | from config | Override the relay URL |
LINKMARKS_TOKEN | from config | Bearer token for the relay |
LINKMARKS_THEME | rust | Default TUI theme |
LINKMARKS_NO_SYNC | unset | Set to 1 to disable linkmarks sync |
RUST_LOG | info | Standard env_logger filter |
NO_COLOR | unset | If set, TUI disables ANSI colors |
The XDG_* defaults follow the XDG Base Directory
Specification.
File layout
$XDG_CONFIG_HOME/linkmarks/
├── config.toml # Main config
├── keymap.toml # TUI keymap overrides (optional)
└── relay.toml # Relay credentials (optional)
$XDG_DATA_HOME/linkmarks/
├── linkmarks.db # SQLite store (single file)
├── linkmarks.db-wal # Write-Ahead Log (SQLite-managed)
├── linkmarks.db-shm # Shared memory (SQLite-managed)
└── sync/ # yrs sub-document snapshots (preview)
$XDG_CACHE_HOME/linkmarks/
└── nucleo/ # In-memory matcher cache (regenerated each session)
Config schema
The full schema lives in linkmarks-cli/src/config.rs. The
config.toml.example file in the repo root shows every field
with comments.
# ~/.config/linkmarks/config.toml
# SQLite store path (default: $XDG_DATA_HOME/linkmarks/linkmarks.db)
store = "/var/lib/linkmarks/linkmarks.db"
# Default sort mode for `linkmarks list` and the TUI
# One of: updated, title, canonical-url, created
default_sort = "updated"
# Default filter mode for the TUI
# One of: substring, tag, fuzzy
default_filter = "substring"
# Sync relay URL (used by `linkmarks sync`)
relay = "https://relay.example.com"
# Sync relay bearer token (read from $LINKMARKS_TOKEN if not set)
# Prefer the env var to avoid storing secrets on disk
token = "${LINKMARKS_TOKEN}"
# TUI theme (one of: rust, light, dark, ayu)
theme = "rust"
# Folder depth limit (default: 8)
max_folder_depth = 8
# Whether to keep separators from browser imports (default: false)
keep_separators = false
# Bridge-specific overrides
[bridges.chromium]
# Override the default ULID prefix for Chromium-imported bookmarks
# (default: "chr")
ulid_prefix = "chr"
[bridges.firefox]
# Include moz_annos by default (default: false)
with_annotations = false
[bridges.netscape]
# Preserve <DD> comments as notes (default: false)
dd_as_notes = false
Exit codes
| Code | Name | Description |
|---|---|---|
| 0 | Success | Command succeeded |
| 1 | UserError | Invalid flags, missing arguments |
| 2 | StoreError | SQLite open failed, schema mismatch |
| 3 | ParseError | Bridge parser could not parse the input |
| 4 | SyncError | Relay unreachable, merge conflict |
| 5 | PermissionError | Cannot read source, cannot write store |
| 64 | ConfigError | Config file invalid |
| 66 | NoInput | Expected a TTY, got a pipe |
| 73 | CantCreate | Cannot create a file or directory |
| 130 | Interrupted | SIGINT received (Ctrl+C) |
The codes are stable across releases.
Signals
The CLI honours three signals:
| Signal | Effect |
|---|---|
SIGINT (Ctrl+C) | Graceful shutdown. The SQLite WAL is flushed and the connection is closed. Exit code 130. |
SIGTERM | Same as SIGINT. Used by systemd Type=oneshot services. |
SIGHUP | Config reload. Re-reads config.toml without restart. |
The TUI additionally honours SIGWINCH (terminal resize) for
re-rendering.
File lock
A single advisory file lock at $XDG_DATA_HOME/linkmarks/.lock
prevents two CLI instances from racing on the same store. The
lock is released on graceful shutdown.
Schema migrations
The store has a schema_version table with a single row. Each
migration is a linkmarks-core/src/migrations/NNNN_description.sql
file. The migrations run automatically on linkmarks init and
on every CLI invocation (in a single transaction).
The current schema version is 7.
Performance budgets
| Operation | Target | Tested |
|---|---|---|
linkmarks init | < 100 ms | 12 ms (cold cache, ext4) |
linkmarks import chromium (1000 records) | < 5 s | 1.4 s |
linkmarks dedupe (10,000 records) | < 5 s | 1.9 s |
linkmarks list --limit 100 | < 50 ms | 8 ms |
linkmarks tui startup | < 200 ms | 90 ms |
linkmarks sync push (1000 changed records) | < 3 s | 0.7 s |
linkmarks sync pull (1000 changed records) | < 3 s | 0.9 s |
These budgets are enforced by the benchmark suite in
linkmarks-bench-crdt.
Debugging
# Increase verbosity (can be repeated)
linkmarks -vv list
# Full debug logging to stderr
RUST_LOG=linkmarks_core=debug,linkmarks_cli=debug linkmarks list
# Profile a slow query
RUST_LOG=linkmarks_core::store=trace linkmarks dedupe
The TUI logs to a per-session file at
$XDG_CACHE_HOME/linkmarks/tui-YYYY-MM-DD-HHMMSS.log (if the
--log-file flag is passed).
License
LinkMarks is dual-licensed:
AGPL-3.0-or-later (open source) OR LicenseRef-Commercial (commercial).
You may choose which license applies to your use.
AGPL-3.0-or-later
The open-source license is the GNU Affero General Public License, version 3 or any later version published by the Free Software Foundation. The full text is at LICENSE in the repo root.
The AGPL §13 network-use clause is the meaningful restriction: if you run a modified version of LinkMarks as a network service, you must publish the modified source under the same license.
LicenseRef-Commercial
For entities that need to skip the AGPL §13 network-use clause
without publishing their modifications, a commercial license is
available. Contact opensource@loust.pro for terms.
The commercial license is what most enterprises use when they want to embed LinkMarks into a SaaS product without the AGPL disclosure obligation.
Third-party dependencies
LinkMarks depends on the following crates. Each is used under its own license:
| Crate | Version | License | Purpose |
|---|---|---|---|
rusqlite | 0.32 | MIT | SQLite bindings |
clap | 4.5 | MIT/Apache-2.0 | CLI parser |
ratatui | 0.28 | MIT | TUI framework |
crossterm | 0.28 | MIT | Terminal I/O |
nucleo | 0.5 | MIT | Fuzzy matcher |
yrs | 0.18 | MIT/Apache-2.0 | CRDT |
tokio | 1.40 | MIT | Async runtime (sync layer) |
reqwest | 0.12 | MIT/Apache-2.0 | HTTP client (sync layer) |
serde | 1.0 | MIT/Apache-2.0 | Serialization |
serde_json | 1.0 | MIT/Apache-2.0 | JSON |
toml | 0.8 | MIT/Apache-2.0 | Config parsing |
ulid | 1.1 | MIT | ULID generation |
url | 2.5 | MIT/Apache-2.0 | URL parsing |
chrono | 0.4 | MIT/Apache-2.0 | Timestamps |
anyhow | 1.0 | MIT/Apache-2.0 | Error handling |
thiserror | 1.0 | MIT/Apache-2.0 | Error types |
tracing | 0.1 | MIT | Structured logging |
tracing-subscriber | 0.3 | MIT | Log subscriber |
env_logger | 0.11 | MIT/Apache-2.0 | Env-driven logger |
The full transitive list is in Cargo.lock and the SBOM at
docs/sbom.json in the repo root.
SPDX expression
The Cargo.toml metadata uses the SPDX expression:
License: AGPL-3.0-or-later OR LicenseRef-Commercial
The OR is intentional — the user picks one. See the SPDX
spec for the formal semantics.
Contributing
Contributions are accepted under the same dual-license model. By submitting a pull request, you agree to license your contribution under both AGPL-3.0-or-later and LicenseRef-Commercial.
See CONTRIBUTING.md for the contribution workflow.
Trademark
"LinkMarks" is a project name. It is not a registered trademark. You may use the name to refer to the unmodified version of this project. You may not use the name to refer to a modified version without explicit written permission.
Contact
- General questions: GitHub Discussions
- Security disclosures:
security@loust.pro - Commercial licensing:
opensource@loust.pro