Compare commits

..

No commits in common. "rad-lfs-ipfs" and "master" have entirely different histories.

30 changed files with 4 additions and 3204 deletions

11
.github/README.md vendored
View File

@ -1,16 +1,5 @@
# ❤️🪵
> ## Fork: Git LFS support, backed by IPFS
>
> This is a fork of upstream [`radicle-dev/heartwood`](https://github.com/radicle-dev/heartwood)
> adding Git LFS (large file) support, with large file content stored on each contributor's own
> local IPFS node rather than a central server. Everything below is upstream's own text,
> unchanged, and describes upstream `heartwood` in general, not this fork specifically.
>
> This repository is developed on a self-hosted Forgejo instance, not GitHub — see
> [the main `README.md`](../README.md) for the fork's own quickstart, and
> [`LFS-IPFS.md`](../LFS-IPFS.md) for the full design, encryption details, and troubleshooting.
*Radicle Heartwood Protocol & Stack*
Heartwood is the third iteration of the Radicle Protocol, a powerful

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "radicle-lfs-transfer"]
path = radicle-lfs-transfer
url = ../radicle-lfs-transfer.git

87
Cargo.lock generated
View File

@ -1837,15 +1837,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
@ -1855,22 +1846,6 @@ dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "human-panic"
version = "2.0.6"
@ -2352,22 +2327,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -2993,14 +2952,10 @@ version = "0.21.0"
dependencies = [
"anyhow",
"backtrace",
"base64 0.21.7",
"chacha20poly1305",
"chrono",
"clap",
"clap_complete",
"cyphernet",
"dunce",
"hkdf",
"human-panic",
"humantime",
"itertools",
@ -3019,7 +2974,6 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"sha2",
"shlex",
"tempfile",
"thiserror 2.0.18",
@ -3038,9 +2992,7 @@ dependencies = [
"tree-sitter-rust",
"tree-sitter-toml-ng",
"tree-sitter-typescript",
"ureq",
"winsplit",
"zeroize",
]
[[package]]
@ -4679,12 +4631,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-display-width"
version = "0.3.0"
@ -4743,33 +4689,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"getrandom 0.4.2",
"log",
"mime_guess",
"percent-encoding",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@ -4782,12 +4701,6 @@ dependencies = [
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View File

@ -1,6 +1,5 @@
[workspace]
members = ["crates/*"]
exclude = ["radicle-lfs-transfer"]
resolver = "2"
[workspace.package]
@ -22,10 +21,8 @@ rust-version = "1.88.0"
[workspace.dependencies]
amplify = { version = "4.0.0", default-features = false }
backtrace = "0.3.75"
base64 = "0.21.3"
bstr = "1.3"
bytes = "1.11.1"
chacha20poly1305 = "0.10"
chrono = { version = "0.4.26", default-features = false }
colored = "2.1.0"
crossbeam-channel = "0.5.6"
@ -36,7 +33,6 @@ fastrand = { version = "2.0.0", default-features = false }
git2 = { version = "0.20.4", default-features = false, features = ["vendored-libgit2"] }
gix-hash = { version = "0.25.0", default-features = false, features = ["sha1"] }
gix-packetline = { version = "0.21.1", default-features = false }
hkdf = "0.12"
human-panic = "2.0.6"
humantime = "2.3"
indexmap = { version = "2", default-features = false }
@ -72,7 +68,6 @@ radicle-windows = { version = "0.1", path = "crates/radicle-windows" }
schemars = { version = "1.0.4", default-features = false }
serde = { version = "1.0", default-features = false }
serde_json = "1.0"
sha2 = "0.10.9"
shlex = "1.1.0"
signature = "2.2"
snapbox = "1.2"
@ -80,7 +75,6 @@ sqlite = "0.37.0"
tempfile = "3.3.0"
thiserror = { version = "2", default-features = false }
uds_windows = "1.1.0"
ureq = { version = "3", default-features = false }
windows = "0.62"
winpipe = "0.1.1"
winsplit = "0.1.0"

View File

@ -1,261 +0,0 @@
# Git LFS support, backed by IPFS
This is a fork of [Radicle's `heartwood`][heartwood] adding Git LFS (large file) support,
with large file content stored on each contributor's own local IPFS node rather than on a
central server.
[heartwood]: https://github.com/radicle-dev/heartwood
## Why not a server?
Radicle's own FAQ notes that its first-generation protocol was built on IPFS and moved away
from it — IPFS is a content-addressed store, and repositories are mutable, so it wasn't a fit
for Radicle's core storage. That objection doesn't apply here: Git LFS's own pattern already
separates a small, mutable pointer file (which Radicle replicates exactly as it replicates any
other git object) from immutable large-file content addressed by hash — which is precisely
what IPFS is good at. This fork only uses IPFS for that second part.
There's no server anywhere in this design: every peer uses their own local IPFS node, and the
file-CID mapping travels with the repository itself, avoiding a single point of failure.
## How it works
- **The mapping**: a JSON git note, attached to each LFS pointer file's own git blob hash (not
the LFS oid — a different value), e.g. `{"v":1,"cid":"Qm..."}` for a public-repo object.
Written locally to `refs/notes/rad-lfs/local` rather than the bare `refs/notes/rad-lfs`
(writing to the bare ref directly causes a git ref D/F conflict once a peer's note has been
fetched — see the `rad lfs init` push/fetch refspecs below); `rad lfs init`'s push refspec
maps that local ref to the bare `refs/notes/rad-lfs` name on the remote, which is what other
peers then fetch as `refs/notes/rad-lfs/<your-peer-id>`. Verified against this repo's actual
replication code (`references_of` in `crates/radicle/src/storage/git.rs`) that Radicle
replicates every ref under a peer's namespace except `refs/tmp/heads/*` — there's no fixed
allowlist, so these notes refs replicate the same way `refs/heads`/`refs/tags`/`refs/cobs` do,
once the push/fetch refspecs below are configured.
- **On commit**: a pre-commit hook (installed by `rad lfs init`) computes every staged
LFS-tracked file's oid/size and passes them all to a single `rad lfs precommit` call, which
adds each one to your local IPFS daemon, pins it, and writes the note above — encrypting the
content first if the repository is private (see below). Batched into one call (rather than one
`rad lfs store` call per file) specifically so a private repo's passphrase is only prompted
for once per commit, not once per file.
- **On push**: `rad lfs init` configures `remote.rad.push` with *two* refspecs -- one for your
branches (`+refs/heads/*:refs/heads/*`), one for the notes mapping
(`+refs/notes/rad-lfs/local:refs/notes/rad-lfs`) -- so a single bare `git push rad` pushes
your commit and its notes mapping together. This wasn't always true: earlier builds only configured the notes
refspec, so a bare `git push rad` silently pushed *only* the mapping, leaving the commit
itself unpushed -- the single most common way to end up with content that looks committed but
that a collaborator can't fetch (`no CID recorded for oid ...`). Fixed, but only takes effect
once `rad lfs init` has been (re-)run with a build that includes the fix -- see
[Troubleshooting](#troubleshooting) below if you're still seeing the old behavior.
- **The actual bytes** move through [`rad-lfs-transfer`][rad-lfs-transfer], a Git LFS custom
transfer agent invoked automatically by `git`/`git-lfs` during a push or checkout — see that
repo for details. It's a separate binary/repository since it has nothing Radicle-specific in
it (no identity, no keys, no encryption). Uploads shell out to `rad lfs store` per object
(a no-op re-upload if the object already has a note, e.g. one your own pre-commit hook already
wrote). Downloads go through `rad lfs fetch-batch` instead of `rad lfs fetch` directly: since
git-lfs's custom-transfer protocol requests objects one at a time rather than all upfront,
`rad-lfs-transfer` spawns `fetch-batch` once per `git lfs pull`/checkout session and keeps it
alive for every object in that session, so a private repo's passphrase is prompted for once
per session rather than once per file.
- **Seeding**: `rad seed` now also pins a repository's known LFS objects in your local IPFS
node (mirroring "seeding = keep a full copy"); `rad unseed` unpins them, except any CID also
referenced by another currently-seeded repository's own LFS notes (e.g. two repositories that
happen to track byte-identical content, which hashes to the same CID) -- unpinning those would
silently break the other repository. There's no persistent pin refcount kept anywhere; this is
recomputed from every other seeded repository's notes each time `rad unseed` runs.
- **Recovering from a skipped pin**: if the pre-commit hook didn't run (`git commit --no-verify`)
or soft-skipped because `ipfs`/`rad` weren't on `PATH` yet, a file can end up committed as a
valid LFS pointer with no corresponding note -- nothing pins it anywhere. Run `rad lfs
backfill` to retroactively pin every LFS-tracked file at HEAD that's missing one, using
content from Git LFS's own local object cache (or the working-tree file, as a fallback).
## Encryption for private repositories
Radicle's `Visibility::Private` is enforced entirely by access control at the replication
layer — git objects for a private repo are plain, unencrypted objects, protected only by
"unauthorized peers are never given them" (see `crates/radicle/src/identity/doc.rs`). That
protection has no jurisdiction over IPFS's own block-exchange layer: once a legitimate
collaborator's IPFS daemon pins content and joins the network, anyone who obtains the CID by
any means can fetch the plaintext directly. So for private repositories, `rad lfs store`
encrypts content before it ever reaches IPFS:
- A random per-object content key (CEK) encrypts the file (XChaCha20-Poly1305).
- The CEK is wrapped once per authorized recipient (repo delegates + the private allow-list),
using ECDH between the committer's and each recipient's Radicle identity key (Ed25519,
directly on the curve — no separate encryption keypair to manage) plus HKDF, so only
identities Radicle already considers authorized for the repo can decrypt.
- The wrapped keys travel in the same git note as the CID — no separate key-distribution
mechanism, no server.
**This requires a signer capable of key agreement (ECDH), not just signing.** ssh-agent — the
normal day-to-day flow after `rad auth` — can sign but can't expose the key material ECDH
needs; that's a hard limitation of the standard ssh-agent protocol (it only implements
signing, there's no request type for general key agreement), not something to configure
around. `rad lfs store`/`rad lfs fetch`/`rad lfs rekey` on a *private* repository fall back to
whichever of these applies, in order:
1. An unencrypted keystore, if that's how you've set things up.
2. `RAD_PASSPHRASE`, if set in the environment.
3. **An interactive passphrase prompt**, if connected to a terminal — the same fallback
`rad`'s other commands already use when ssh-agent isn't available or the key isn't
registered with it. This covers the common case (an interactive `git commit` triggering
the pre-commit hook, which inherits the terminal) with no env var needed at all.
Only a genuinely non-interactive context (CI, a script with no TTY, `RAD_PASSPHRASE` unset)
hits a hard failure — and it fails fast with a clear message, rather than hanging waiting on a
prompt that can't be answered. Public repositories are entirely unaffected — no keys, no ECDH,
same plaintext behavior as before.
### Granting access to existing objects: `rad lfs rekey`
Radicle's access control is retroactive: once a DID is authorized, it can replicate a
repository's entire history, including everything from before it was added. Without extra
handling, this encryption design would *not* match that: a newly-authorized collaborator could
see a private repo's full git history but be permanently unable to decrypt any LFS object
committed before they were granted access, since those objects' wrapped-key lists only include
whoever was authorized at the time.
`rad lfs rekey` closes that gap. Run by anyone *already* authorized (it can't be self-served by
the new collaborator — they have nothing to decrypt yet), it walks every encrypted object,
recovers each one's content key using the runner's own existing access, and adds a wrapped-key
entry for any currently-authorized DID that's missing one. It never re-encrypts content or
touches IPFS — it's a pure git-notes operation, so it's fast and doesn't need the original
file. Run it after adding a new delegate or allow-listing a new DID on a private repository, if
you want them to have access to previously-committed LFS objects (not automatic — see
[Prerequisites](#prerequisites)/[Use](#use) below for the full flow).
**Still explicitly not solved**: revocation. A formerly-authorized peer who already decrypted
an object can't be made to un-know its content key — the same fundamental limitation as
Radicle's own replication-based access control (already-replicated git objects can't be
un-seen either), not a regression introduced by this design.
[rad-lfs-transfer]: https://git.hswro.org/mab122/radicle-lfs-transfer
## Prerequisites
- A [Rust toolchain](https://rustup.rs) (stable; see `rust-toolchain.toml` for the exact
version this workspace pins), Git, and OpenSSH.
- [Git LFS](https://git-lfs.com) (`git-lfs` on your `PATH`) — only needed to *use* `rad lfs`,
not to build or install anything.
- A local IPFS (Kubo) daemon — also only needed to *use* `rad lfs`, not to build/install.
Install [Kubo](https://docs.ipfs.tech/install/) and make sure `ipfs` is on your `PATH`.
On Arch Linux:
```sh
sudo pacman -S --needed rust git openssh base-devel # build
sudo pacman -S --needed git-lfs kubo # use `rad lfs`
```
## Build & install
Follows upstream `heartwood`'s own install convention (`cargo install --root ~/.radicle`, one
shared install root for the whole stack), with `radicle-lfs-transfer` added the same way:
```sh
git clone --branch rad-lfs-ipfs --recurse-submodules \
ssh://git@git.hswro.org:9022/mab122/radicle-heartwood-lfs.git
cd radicle-heartwood-lfs
cargo install --path crates/radicle-cli --force --locked --root ~/.radicle
cargo install --path crates/radicle-node --force --locked --root ~/.radicle
cargo install --path crates/radicle-remote-helper --force --locked --root ~/.radicle
cargo install --path radicle-lfs-transfer --force --locked --root ~/.radicle
```
Add `~/.radicle/bin` to your `PATH` (e.g. in `~/.bashrc`/`~/.zshrc`):
```sh
export PATH="$HOME/.radicle/bin:$PATH"
```
Confirm everything landed correctly:
```sh
rad --version
command -v rad-lfs-transfer # just needs to resolve; it only speaks git-lfs's stdin/stdout protocol, no --help output
```
If you already cloned without `--recurse-submodules`, run `git submodule update --init` before
building — otherwise `radicle-lfs-transfer/` will be an empty directory and that last
`cargo install` step will fail.
## Run
Nothing extra to run for `rad`/`radicle-node` themselves — use them exactly as upstream
(`rad auth`, `radicle-node`, etc.; see the main [README](README.md) and
[`docs/`](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:zzz)). The only new moving part is
the IPFS daemon, needed only when you use `rad lfs`:
```sh
ipfs init # first time only, creates ~/.ipfs
ipfs daemon # leave running in the background while you use `rad lfs`
```
## Use
Inside a Radicle repository you're a contributor on (i.e. it already has a `rad` remote from
`rad init`/`rad clone`):
```sh
# One-time per repository (with the IPFS daemon above already running)
rad lfs init
# Then it's just Git LFS, as normal
git lfs track "*.psd" # or whatever large-file patterns you need
git add .gitattributes
git add my-large-file.psd
git commit -m "Add asset" # pre-commit hook pins it to IPFS and records the CID here
git push rad # pushes your commit(s) and the refs/notes/rad-lfs mapping together
```
Someone else cloning the same repository:
```sh
rad clone rad:<repo-id>
cd <repo>
rad lfs init # one-time, same as above (needs their own IPFS daemon running)
git lfs pull # fetches large files via IPFS instead of git
```
`rad seed`/`rad unseed` additionally pin/unpin a repository's known LFS objects in your local
IPFS node, so seeding a repository keeps a full copy of its large files too, not just its git
history.
**On a private repository**, committing from a terminal you'll be prompted for your passphrase
if needed (see [Encryption for private repositories](#encryption-for-private-repositories)
above) — no extra setup required. After granting a new collaborator access
(`rad id update --allow <did>` or adding them as a delegate), have an already-authorized
collaborator run `rad lfs rekey` and push, so the new collaborator can decrypt
previously-committed LFS objects too, not just ones committed after they were added.
### What happens without an IPFS daemon running
- `rad lfs init` checks upfront and refuses with a clear message
(`no IPFS (Kubo) daemon reachable at http://127.0.0.1:5001 — start one with 'ipfs daemon'`)
rather than silently continuing.
- `rad seed`/`rad unseed` print a warning and skip pinning/unpinning — seeding/unseeding itself
still succeeds.
- **Nothing else in this fork needs IPFS at all.** Building, installing, and using `rad` for
anything other than `rad lfs`/pinning works exactly like upstream `heartwood`, with zero IPFS
dependency.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `` `rad lfs init` must be run inside a Radicle repository working copy `` | Run it inside a directory that already has a `rad` remote (from `rad init` or `rad clone`), not a plain git repo. |
| `Git LFS is not installed (the 'git-lfs' binary was not found on your PATH)` | Install [Git LFS](https://git-lfs.com). |
| `no IPFS (Kubo) daemon reachable at ...` | Start one: `ipfs daemon` (see [Run](#run) above). |
| `rad-lfs-transfer: command not found` during `git lfs push`/`pull` | `cargo install --path radicle-lfs-transfer` didn't complete, or `~/.radicle/bin` isn't on `PATH`. |
| Large file didn't get pinned (commit went through, but no `rad-lfs` note) | The pre-commit hook silently skips pinning if the `ipfs`/`rad` CLIs aren't on `PATH` — check `command -v ipfs` / `command -v rad`. Once available, run `rad lfs backfill` to pin it retroactively rather than needing to re-commit. |
| Committed with `git commit --no-verify` (or the pre-commit hook was skipped some other way) and now nothing's pinned | Run `rad lfs backfill` — it walks every LFS-tracked file at HEAD and pins whichever ones are missing a note, without needing a new commit. |
| `private-repo LFS operations need to perform a key-agreement (ECDH) operation ...` | Your keystore is encrypted, ssh-agent can't do key agreement (protocol limitation), and no passphrase is available — normally you'd just be prompted interactively; this only happens in a non-interactive context (no TTY, e.g. CI). Set `RAD_PASSPHRASE`, or use an unencrypted keystore, or run it from a terminal. |
| `you are not an authorized recipient of this object` | Either genuinely unauthorized (not a delegate/allow-listed DID for this private repo), or authorized *after* this specific object was committed and nobody has run `rad lfs rekey` since — ask an already-authorized collaborator to run it and push. |
| `fatal: couldn't find remote ref refs/notes/rad-lfs` on `git fetch`/`git pull` (breaks *all* fetching, not just LFS) | Fixed — but if this repo had `rad lfs init` run against an older build, re-run `rad lfs init` once to replace the stale, now-invalid fetch refspec it configured (see `NOTES-lfs-notes-fetch-bug.md` for the full story). |
| Plain `git push rad` (no branch name) doesn't push your commits, only the notes ref | Your repository was set up with an older build: `rad lfs init` used to only configure a push refspec for `refs/notes/rad-lfs`, not the branch, so a bare push covered only that. Fixed — re-run `rad lfs init` once (harmless, idempotent) to add the missing `+refs/heads/*:refs/heads/*` push refspec, then a bare `git push rad` pushes both together. Until then, push both explicitly: `git push rad <branch>` for the commit, then `git push rad` for the notes mapping. |
| Collaborator gets `no CID recorded for oid ...; the peer that has this object needs to push its refs/notes/rad-lfs ref` even though they can see your commit | You (or whoever committed the LFS file) pushed from a repository still missing the branch push refspec — see the row above. Re-run `rad lfs init`, push again, then have them `git fetch rad` (or re-clone/re-pull). |
## Status
This is a prototype, not upstream Radicle functionality. See the commits on the
`rad-lfs-ipfs` branch for the full change set relative to upstream `heartwood`.

View File

@ -1,362 +0,0 @@
# Bug: `git fetch rad` can never fetch `refs/notes/rad-lfs`
**RESOLVED** (2026-07-14). Fix: `refs/notes/rad-lfs` is now exposed per-peer
(`refs/notes/rad-lfs/<peer-id>`) instead of expecting one canonical bare ref, matching how
patch refs already work — see "Fix actually shipped" for the full writeup and verification.
**Follow-up "Bug #2" also RESOLVED** (2026-07-14, same day) — turned out to be two separate
things, neither a bug in the fix above: a stale, unrelated system-wide `git-remote-rad`
install being silently used in manual ad-hoc testing (not a real pipeline issue), plus one
genuinely new bug (a cross-device `rename()` failure on LFS download) found only once the
real pipeline actually ran with the fix. See "Root cause of Bug #2, actually found" at the
very bottom for the full story — verified live against the production deploy pipeline on
mbator.pl, which completed successfully end-to-end.
Kept the original report and the mid-investigation "Follow-up" section below unedited, for
context on how the debugging actually went.
Found while deploying `rad-lfs-ipfs` to mbator.pl for the blog pipeline (2026-07-14).
Blocks the whole LFS-over-IPFS flow end-to-end, not just a cold-start edge case.
## Symptom
After `rad lfs init` in any repo, `git fetch rad` (and therefore `git pull`, and anything
that does the equivalent, e.g. `deploy.sh`'s `git fetch rad && git reset --hard rad/master`)
fails every time with:
```
fatal: couldn't find remote ref refs/notes/rad-lfs
```
This isn't specific to a fresh repo where nobody has pushed an LFS object yet — it
reproduces even after a real push has created the note and it's fully present in the
receiving node's storage.
## Root cause
Two places are involved:
1. **`crates/radicle-cli/src/commands/lfs/init.rs:134`** — `rad lfs init` configures the
client-side fetch/push refspec as a bare, non-namespaced ref:
```rust
let notes_refspec = format!("+{NOTES_REF}:{NOTES_REF}"); // +refs/notes/rad-lfs:refs/notes/rad-lfs
```
2. **`crates/radicle-remote-helper/src/list.rs`, `for_fetch()`** (lines 38-79) — this is
what actually determines which refs `git-remote-rad` advertises as fetchable for a
plain (non-namespaced) `git fetch rad`. It only ever lists:
- `HEAD`
- `refs/heads/*` and `refs/tags/*` (canonicalized across delegates)
- patch refs (via `patch_refs()`)
It has **no knowledge of `refs/notes/rad-lfs` at all**. Notes only exist per-peer,
under `refs/namespaces/<peer-id>/refs/notes/rad-lfs` (confirmed directly on a live
node: `git for-each-ref` in the node's storage shows
`refs/namespaces/z6Mkkph.../refs/notes/rad-lfs`, never a bare `refs/notes/rad-lfs`).
`refs/heads/*` gets a canonical, non-namespaced view via delegate-threshold
resolution; `refs/notes/rad-lfs` has no equivalent resolution step, so it's simply
never in the list the remote helper offers — regardless of what refspec the client
has configured in `.git/config`.
So the client asks for a ref the server-side listing never advertises, and `git fetch`
(which validates requested refspecs against the advertised ref list) fails hard for the
*entire* fetch, not just that one ref — meaning a repo with `rad lfs init` run can't
`git fetch`/`git pull` *anything* anymore, heads included, until this is fixed.
## Suggested direction for a fix
`for_fetch()` needs to expose some resolved form of the LFS notes ref for the
non-namespaced case, analogous to how `refs/heads/*` gets canonicalized. Options,
roughly in order of how much they change the design:
- **Simplest**: expose *all* peers' notes refs individually under some fetchable name
(e.g. `refs/notes/rad-lfs/<peer-id>`), and change the LFS tooling (`rad lfs
store`/`fetch`/`rekey`) to read/merge across all of them instead of expecting one
canonical ref. Matches how git notes are designed to be merged anyway.
- **Closer to current design**: pick one canonical note per commit the same way heads
get a canonical value (e.g. delegate-threshold agreement, or "the delegate's own
namespace" the way `for_push` already restricts pushes to `profile.id()`'s own
namespace) and expose that as the bare `refs/notes/rad-lfs`.
- Either way, `init.rs`'s refspec (`+refs/notes/rad-lfs:refs/notes/rad-lfs`) is probably
fine as-is *once* the server side actually advertises something at that path — the
bug is really in `list.rs`, not `init.rs`.
## Confirmed working around this bug (verified live on mbator.pl, 2026-07-14)
Everything upstream and downstream of this one step works:
- Fork built clean (`cargo install` for `radicle-cli`, `radicle-node`,
`radicle-remote-helper`, `radicle-lfs-transfer`) at commit `33ffa122`.
- `rad lfs init` succeeds and configures the pre-commit hook, transfer agent, and (broken)
refspec correctly per its own logic.
- A real private-repo LFS push (`git push rad master`, encrypted via ECDH, `RAD_PASSPHRASE`
from an ssh-agent-backed key) succeeded and replicated to a seed node.
- Node-to-node replication of the note itself works fine at the storage layer — the
receiving node's `refs/namespaces/<pusher>/refs/notes/rad-lfs` is populated correctly.
- The failure is *only* in the working-copy-level `git fetch rad` being unable to see it.
## Also found along the way (separate, minor)
Two `radicle-cli` crashes (not yet investigated, crash reports written to `/tmp/report-*.toml`
on the VPS at the time):
- `rad node status` crashed once (worked on retry).
- `rad seed` (no args, to check a repo's local seeding policy) crashed consistently.
## Deployment state left in place (mbator.pl)
- `blog` user has a working IPFS (Kubo) container, the fork built at `~/.radicle/bin`,
`radicle-node-blog.service`/`blog-radicle-watch.service`/`blog-deploy.service` all
pointed at the new binaries with `PATH`/`KUBO_API_URL` set correctly.
- Added an explicit `connect` entry for the local `seed` node
(`z6MkmUF9KWFBQzYXBeNJk1bXXQGzaATnEsWxmbUYt54fyEC6@127.0.0.1:8776`) to
`/home/blog/.radicle/config.json` — the private `blog` node had no route to the local
public seed by default (`connect: []`, generic `preferredSeeds`), so `rad sync` timed
out until this was added. Worth keeping regardless of the notes bug.
- Public `seed` node/`/usr/bin/radicle-node`/`radicle-httpd` untouched throughout.
- A harmless test commit (`3f650d2`, "infra: LFS-over-IPFS smoke test (safe to remove)")
is sitting on top of the blog repo's real history in `~/Blog/Blog` and pushed to the
network — adds `assets/_infra-test/lfs-ipfs-smoketest.jpg` (LFS-tracked, not referenced
from any post, won't appear anywhere on the live site). Safe to leave or revert
whenever convenient.
## Next step once fixed
Re-run `git fetch rad` in `/home/blog/repo` on the VPS (as `blog`) — if the fix resolves
the notes ref, that fetch (and the LFS smudge it triggers) is the last remaining piece to
confirm the whole IPFS content-fetch path end-to-end.
## Follow-up: fix deployed, uncovered a second, more specific bug (2026-07-14, same day)
Rebuilt both the local (`~/Blog/Blog`) and VPS (`/home/blog/repo`) checkouts against
`ff762ef8` (the fix + the interactive-passphrase commit) and re-ran `rad lfs init` to pick
up the migrated wildcard refspec (`+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*`; the old
exact-match entry needed manual removal once — `git update-ref -d refs/notes/rad-lfs` — on
the author's machine where a stale *local* plain note ref pre-existed and D/F-conflicted
with the incoming namespaced one; the `remove_refspec_if_present` migration in `init.rs`
correctly stripped the *refspec config*, but doesn't (and structurally can't, from
`init.rs`) also clean up a ref that already exists on disk from before).
**On the author's machine**: `git fetch rad` now genuinely works — pulls
`refs/notes/rad-lfs/<peer>` for real, exactly the repro case from the original bug.
Confirmed fixed there.
**On the VPS (`blog` user, second peer/node)**: same `rad lfs init` migration, but the
actual fetch behaves inconsistently:
- `git ls-remote rad` correctly lists the note:
`c4538c2d... refs/notes/rad-lfs/z6MkkphWkrBU3Buhh4ZN8Gtt9KLzn2DUsx3hZhhXirK7XAyn`
- `git fetch rad` (relying on the configured wildcard refspec) silently skips it — only
reports `master` as `[up to date]`, no attempt at the notes ref, no error.
- An **explicit** fetch of that exact literal ref name (copy-pasted straight from the
`ls-remote` output above) still fails outright:
```
$ git fetch rad refs/notes/rad-lfs/z6MkkphWkrBU3Buhh4ZN8Gtt9KLzn2DUsx3hZhhXirK7XAyn:refs/notes/rad-lfs/z6MkkphWkrBU3Buhh4ZN8Gtt9KLzn2DUsx3hZhhXirK7XAyn
fatal: couldn't find remote ref refs/notes/rad-lfs/z6MkkphWkrBU3Buhh4ZN8Gtt9KLzn2DUsx3hZhhXirK7XAyn
```
So `git-remote-rad`'s ref listing is inconsistent *between its own `list` output and what
`fetch` actually resolves against*, for the identical ref, in the identical process
invocation style — `ls-remote` and `fetch` are supposed to both go through the remote
helper's `list` verb (that's the whole point of `for_fetch()` being one shared function),
so seeing them disagree points at something conditional in how `list` is invoked (fetch
vs. ls-remote may pass different capabilities/args to the helper, e.g. a `list for-push`
vs. plain `list`, or some caching), rather than `for_fetch()`'s ref-selection logic itself
(which is presumably fine, given `ls-remote` gets it right).
Confirmed the underlying data is genuinely present and correct the whole time (verified
directly in `blog`'s node storage, bypassing git-remote-rad entirely):
```
$ git -C /home/blog/.radicle/storage/z2Z16c9C5BaqSQPfbVrGPGG68wAx3 for-each-ref | grep lfs
c4538c2d... commit refs/namespaces/z6MkkphW.../refs/notes/rad-lfs
```
and the note content itself has the right CID and correctly lists `blog`'s own node DID
(`z6Mkpqi56U6...`) as an authorized decrypt recipient. So this is purely a
`git-remote-rad` transport/listing bug, not a storage/replication/encryption issue.
**Not yet root-caused** — flagging with the concrete repro above rather than guessing
further at the Rust internals blind. Two machines, two outcomes, same binary version, same
refspec, same underlying data: something about the *fetch* invocation path differs from
the *ls-remote* invocation path in a way that changes what the helper's `list` returns or
how the client matches against it. Worth checking: does `list.rs` (or whatever calls
`for_fetch()`) get invoked with different arguments for `list` vs. `list for-push` vs. a
fetch-triggered `list`, and does the *local* `remote.<rad>.fetch` refspec (not just the
server-side listing) factor into which lines the helper actually returns — i.e. is there a
second, client-driven filtering step somewhere that behaves differently between the two
call sites?
## Fix actually shipped
Went with the "simplest" option from the list above, since it's also the semantically
correct one: `refs/heads`/`refs/tags` get a canonical, non-namespaced value because only
*delegates'* view matters for canonical history — a whole separate subsystem (canonical-ref
computation on push/sync) maintains that. Notes have no equivalent: any peer, not just
delegates, can commit an LFS-tracked file and write a note for it, so there's no single
"correct" value to resolve to. Building delegate-consensus canonicalization for notes would
also have been semantically wrong, not just more work — it would silently drop non-delegate
contributors' LFS objects from ever being discoverable by anyone else.
- `crates/radicle-remote-helper/src/list.rs`, `for_fetch()`: now also lists every peer's
`refs/notes/rad-lfs`, individually, as `refs/notes/rad-lfs/<peer-id>` (new `lfs_notes_refs`
helper, using `stored.remotes()` + `stored.reference_oid()`). `for_push()` needed no change
— turns out it's purely informational (used for the `list for-push` git protocol handshake,
i.e. fast-forward computation), not an enforcement gate; the actual `push_ref`/`send-pack`
call already accepts any refspec regardless of what `for_push()` lists, which is why notes
pushing already worked despite `for_push()` only ever listing `refs/heads`/`refs/tags`.
- `crates/radicle-cli/src/commands/lfs/init.rs`: fetch refspec becomes a wildcard
(`+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*`); push refspec is unchanged (`git push`
lands under the pusher's own namespace server-side regardless of the local ref name, same
as `refs/heads/*`, so no wildcard needed there). Added a migration step
(`remove_refspec_if_present`) that strips the old broken non-wildcard fetch refspec from
already-configured repos — otherwise re-running `rad lfs init` would've just added the
correct wildcard *alongside* the still-broken exact-match entry, which would still break
the whole fetch (git fails the entire fetch if *any* requested refspec isn't advertised).
- `crates/radicle-cli/src/lfs_crypto.rs`: new `find_envelopes`/`all_note_targets`/`find_cids`
read across every notes ref (local + every fetched peer), not just one. Notes sharing the
same `cid` (e.g. an original commit plus a later `rekey` that added recipients under a
*different* peer's namespace) have their recipient lists safely unioned. Notes with
*different* `cid`s for the same object (only possible if two peers independently encrypted
byte-identical content — same oid/size — producing different ciphertext each time, since
encryption uses a fresh random key/nonce) are kept as separate candidates; `rad lfs
fetch`/`rekey` try each in turn rather than assuming there's only ever one.
- `crates/radicle-cli/src/commands/lfs/fetch.rs`, `rekey.rs`, `ipfs.rs` (`lfs_cids`, used by
`seed`/`unseed` pinning): all switched from single-ref `repo.notes(Some(NOTES_REF))` /
`find_note` calls to the new multi-ref helpers above. `store.rs` needed no read-side change
(write-only, always writes to the local plain ref, which is correct as-is).
### Verified for real (not the storage-copy shortcut used for the base feature's original
tests — this bug lives specifically in the `git-remote-rad` transport, so the fix had to be
exercised through the actual thing)
Built fresh `rad`/`git-remote-rad`/`radicle-node`/`rad-lfs-transfer`, created two-then-three
real Radicle identities/profiles, a real private repo with an allow-list, and used the
**real** `rad://` remote (`git remote add rad rad://<rid>` for fetch, matching exactly what
`rad init` itself configures — an earlier test attempt using a peer-scoped URL for both fetch
and push accidentally bypassed the bug entirely by routing into `for_fetch()`'s *namespaced*
branch, which was never broken; had to redo it with the correct canonical URL to actually
exercise the bug/fix).
- `git ls-remote rad` on the canonical URL now advertises `refs/notes/rad-lfs/<peer>` instead
of nothing.
- A genuine `git fetch rad` (no explicit refspec workaround) succeeds and actually pulls the
peer's notes ref down locally — this is the exact command from the bug report that used to
fail with `fatal: couldn't find remote ref refs/notes/rad-lfs`.
- `git lfs pull` after that fetch correctly decrypts the private object end-to-end
(SHA-256-verified).
- Re-tested the rekey scenario (grant a new peer access after the object was already
committed) through the same real fetch path: denied before rekey, an authorized peer runs
`rad lfs rekey` + pushes, the new peer re-fetches and successfully decrypts.
### Also discovered along the way (separate, not fixed here — flagging, not scope-creeping)
Once `rad lfs init` adds *any* explicit `remote.<rad>.push` refspec (the notes one), a plain
`git push rad` (no branch argument) stops falling back to `push.default=simple` for the
current branch — git only pushes whatever's explicitly configured once any explicit refspec
exists. So after running `rad lfs init`, `git push rad` alone only pushes the notes ref;
`git push rad master` (explicit) is needed to also push the branch. This isn't new — it was
already true before this fix, and both the original bug report's own reproduction and this
fix's testing always used an explicit branch name when pushing, so it never surfaced as a
problem in practice. Worth a real fix at some point (e.g. `rad lfs init` also ensuring an
explicit `+refs/heads/*:refs/remotes/rad/*`-equivalent push default is present, or documenting
"always `git push rad <branch>` explicitly" as a hard requirement), but out of scope for this
specific fetch-bug fix.
## Post-verification cleanup (2026-07-14, later still)
VPS only needs the compiled binaries, not a persistent build environment. After confirming
`/home/blog/.radicle/bin/{rad,radicle-node,git-remote-rad,rad-lfs-transfer}` are correct and
working (live deploy already succeeded, see above), removed the source checkout and Rust
toolchain to reclaim disk: `rm -rf /home/blog/radicle-heartwood-lfs /home/blog/.rustup
/home/blog/.cargo`, plus dropped the now-dangling `. "$HOME/.cargo/env"` lines from
`blog`'s `.bashrc`/`.profile`. Reclaimed ~3.2GB (9.7G → 6.5G used on `/`). Confirmed no
systemd unit referenced the source dir or cargo/rustup, and the binaries are dynamically
linked only against standard system libs (`ldd` shows just `libgcc_s`/`libm`/`libc`) — fine
to run standalone.
**Implication for next time a rebuild is needed**: the VPS has no source checkout or Rust
toolchain anymore. Re-deploying a new fix means redoing the copy-source-and-build steps
from scratch (or, better, sorting out `blog`'s lack of Forgejo SSH credentials so it can
`git clone`/`pull` properly instead of the source being hand-copied in — see Part 1 of Bug
#2 below for why that copy-instead-of-clone approach already caused one bit of git-history
drift).
## Root cause of Bug #2, actually found (2026-07-14, same day, later)
Got shell access to the VPS (`ssh mbator.pl`, passwordless sudo in a real tty) and reproduced
directly rather than guessing further at the Rust internals. It was **two separate things**,
not one — neither of them a bug in the fetch fix above.
### Part 1: not a code bug at all — a stale, unrelated binary
`sudo -u blog bash -c '...'` (a bare, non-login shell — the same style of ad-hoc command used
for all the manual reproduction so far) resolves `PATH` to the plain system default
(`/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`), which has **no**
`~/.radicle/bin` in it. `which rad` in that context returns `/usr/local/bin/rad` — version
`1.8.0`, a completely unrelated, root-owned, March-2026 system-wide install, nothing to do
with this fork. Confirmed directly:
- With `PATH=/home/blog/.radicle/bin:$PATH` explicitly set: `git ls-remote rad` *and*
`git fetch rad` both correctly see and fetch `refs/notes/rad-lfs/<peer>` — full
`GIT_TRANSPORT_HELPER_DEBUG=1` trace shows `list` correctly advertising it and git correctly
issuing `fetch <oid> <ref>` for it, ending in `[new ref] refs/notes/rad-lfs/<peer>`.
- With the bare/default `PATH` (resolving to `/usr/local/bin/git-remote-rad`, the unrelated old
binary): `git fetch rad` produces **zero output at all** — exactly the silent-skip symptom
originally reported, byte for byte.
So `list`/`for_fetch()` never actually disagreed between `ls-remote` and `fetch` for the same
binary — the two commands were, at some point during manual testing, silently invoking two
*different* binaries. The real automated pipeline was never actually at risk from this: all
three blog-owned systemd units (`radicle-node-blog`, `blog-radicle-watch`, `blog-deploy`)
already have `Environment=...PATH=/home/blog/.radicle/bin:...` correctly set. Confirmed via
`systemctl cat` on all three. This was purely a manual-testing artifact.
### Part 2: a real, new bug — but only surfaces once you get past Part 1
`blog-deploy.service`'s last run before the fork rebuild had failed with the *original*
Bug #1 error (timestamped *before* the 16:1016:14 UTC rebuild/restart) — meaning the real
pipeline, with its correct `PATH`, hadn't actually run even once against the fetch fix yet.
Triggering it manually (`sudo systemctl start blog-deploy.service`) surfaced a genuinely new
failure:
```
Downloading assets/_infra-test/lfs-ipfs-smoketest.jpg (692 B)
Error downloading object: ...: failed to copy downloaded file: cannot replace
".../ .git/lfs/objects/c5/e3/c5e3b0c1..." with "/tmp/rad-lfs-c5e3b0c1...":
rename /tmp/rad-lfs-c5e3b0c1... .../.git/lfs/objects/c5/e3/c5e3b0c1...: invalid cross-device link
```
The download and decryption had already succeeded by this point (692 B, matching exactly) —
this was purely the final handoff to git-lfs failing. Root cause:
`radicle-lfs-transfer/src/main.rs`'s `download_inner` reported a path under
`std::env::temp_dir()` (`/tmp`) back to git-lfs as the "complete" event's path. Git-lfs then
does an *atomic* `rename()` from that path into `.git/lfs/objects/...` — it does not fall back
to a copy if that fails. On this VPS, `/tmp` and the repository are on different
filesystems/mounts, so the rename hits `EXDEV` ("invalid cross-device link") and git-lfs just
errors out rather than retrying with a copy.
Fixed in `radicle-lfs-transfer` (commit `ac4a27c`): resolve `.git/lfs/tmp` (via
`git rev-parse --git-dir`, so this works from a subdirectory too, not just the repo root) and
download there instead, mirroring git-lfs's own existing convention for its own internal temp
downloads — guaranteed same-filesystem by construction, not by hoping `/tmp` happens to line
up. This does reintroduce a `git` binary runtime dependency for `rad-lfs-transfer` (previously
dropped once it stopped needing to read/write notes directly) — an acceptable tradeoff, since
anything using `rad`/git-lfs at all already requires git to be installed anyway. Submodule
pointer bumped in this repo at `a97484c9`.
### Verified live, for real, against production
Deployed the fix to the VPS (`blog` has no Forgejo SSH credentials, so copied the fixed
`main.rs` directly rather than provisioning new access; rebuilt in place with
`~/.cargo/bin`/`~/.radicle/bin` on `PATH`), then actually triggered `blog-deploy.service`:
```
● blog-deploy.service ... Active: inactive (dead) ...
Process: ExecStart=/home/blog/repo/deploy/deploy.sh (code=exited, status=0/SUCCESS)
[blog-deploy] deploying to /srv/mbator.pl...
[blog-deploy] deploy complete: 3f650d2d7e9accfba3468a885fc63984e18ceadb
```
Full success, end to end: `git fetch rad` pulled the notes ref for real, `git lfs pull`
downloaded and decrypted the private object, the site built, and it deployed. Confirmed the
checked-out asset's SHA-256 matches the original exactly
(`c5e3b0c1eb2d6e2f3ef98fe74e70fe431986dc2a401b1eae961e175b5a2164ca`). It doesn't appear under
`/srv/mbator.pl` because it's deliberately unreferenced by any post — expected, by the smoke
test's own design, not a bug.

View File

@ -1,198 +0,0 @@
# Bug: `rad lfs store` fails with "failed to write LFS note" — likely fallout from the `refs/notes/rad-lfs/*` migration
**RESOLVED** (2026-07-15). The leading hypothesis in this report — a git ref D/F conflict
between the bare `refs/notes/rad-lfs` (what `store.rs`/`rekey.rs` wrote to) and the namespaced
`refs/notes/rad-lfs/<peer>` (what fetch populates, including your own peer's copy fetched back
after a push) — was confirmed exactly, reproduced locally in the identical order that
triggers it (fetch a namespaced sibling in *first*, then attempt a local write). See
"Fix actually shipped" at the bottom for the full writeup, the fix, and local verification
(including the migration path for already-affected repos like this one).
`~/Blog/Blog` and the VPS checkout both need `rad lfs init` re-run once the fix is installed,
to run the migration and pick up the corrected push refspec — see the deployment steps at the
bottom before relying on `rad lfs store` there again.
## Repro
```
$ rad lfs store --oid <sha256 of a food jpg> --size <n> -- assets/food/boczekwziemniaczkach.jpg
✗ Error: failed to write LFS note
```
Exit code 1. No further detail is printed — `store.rs`'s only `.context("failed to write LFS
note")?` wraps the `repo.note(...)` call at the very end of `run()`
(`crates/radicle-cli/src/commands/lfs/store.rs:74`), and the CLI's top-level error printer only
shows the outermost context, not the full `anyhow` chain, so the actual libgit2/`radicle`
error underneath is not visible from the CLI alone.
Confirmed via `RUST_LOG=trace` that everything *before* the note write succeeds:
- the LFS pointer blob is written (`repo.blob(...)`, no error)
- `ipfs::check_daemon()` succeeds (local Kubo at `127.0.0.1:5001`, confirmed reachable)
- `ipfs::add()` succeeds — HTTP 200 from `/api/v0/add`, got back a CID
(`QmXshEtcDYxmXvpaF13otbysCeqzvhNS2U8GF1hr6qFxrc`)
- `ipfs::pin_all()` succeeds — HTTP 200 from `/api/v0/pin/add`
So the failure is isolated to the final `repo.note(&signature, &signature,
Some(ipfs::LFS_NOTES_REF), blob_oid, &message, true)` call. No network I/O happens after the
pin step (confirmed in the trace log — the error is printed immediately after the pin response,
with no further HTTP/socket activity), so this is a local storage-write failure, not a
network/IPFS-daemon issue. This also rules out "rad node isn't running" as the cause — it was
freshly restarted and confirmed connected to peers (`rad node status`) before this repro.
## Suspected root cause: `NOTES_REF` was never updated to match the per-peer ref migration
`crates/radicle-cli/src/lfs_crypto.rs:42`:
```rust
pub const NOTES_REF: &str = "refs/notes/rad-lfs";
```
This is the *unnamespaced* ref name, and it's what `store.rs` passes as the notes ref to write
into (`ipfs::LFS_NOTES_REF` re-exports this constant, see `ipfs.rs:28`).
But the fetch-side fix in `NOTES-lfs-notes-fetch-bug.md` (commit `1c65ee62`, "Fix: git fetch rad
can never fetch refs/notes/rad-lfs") deliberately moved away from one canonical bare ref to a
**per-peer** ref shape, `refs/notes/rad-lfs/<peer-id>`, and `rad lfs init` now configures the
remote fetch refspec accordingly:
```
+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*
```
Confirmed on this machine, in the blog repo's working copy, right now:
```
$ git config --get-all remote.rad.fetch
+refs/heads/*:refs/remotes/rad/*
+refs/tags/*:refs/remotes/rad/tags/*
+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*
$ git for-each-ref refs/notes/
c4538c2d... commit refs/notes/rad-lfs/z6MkkphWkrBU3Buhh4ZN8Gtt9KLzn2DUsx3hZhhXirK7XAyn
```
i.e. the **working copy** now has the note under the new namespaced shape (fetched down from
the remote, post-fix). But in the **storage repo** (`~/.radicle/storage/<rid>`, where
`store.rs`'s `repo.note()` call actually writes, via `NOTES_REF` = the old bare name):
```
$ git -C ~/.radicle/storage/z2Z16c9C5BaqSQPfbVrGPGG68wAx3 for-each-ref | grep notes
c4538c2d... commit refs/namespaces/z6MkkphW.../refs/notes/rad-lfs
```
— still the **old, unnamespaced** ref (`refs/notes/rad-lfs`, no peer suffix), holding the one
note that was successfully written earlier today (17:08:38, for the now-reverted
`_infra-test/lfs-ipfs-smoketest.jpg` smoke test — that first write succeeded because at that
point nothing had fetched/created the namespaced sibling ref yet).
So the repo now has **two differently-shaped note refs pointing at overlapping data**: the
legacy bare `refs/notes/rad-lfs` (what `store.rs` still writes to) in storage, and the new
namespaced `refs/notes/rad-lfs/<peer>` (what fetch/init now expect and manage) in the working
copy and presumably also fetchable from storage once something writes it there. This is the
same shape of problem already diagnosed once in `NOTES-lfs-notes-fetch-bug.md` — "a stale local
plain note ref pre-existed and D/F-conflicted with the incoming namespaced one" — just hit from
the **write** side (`store.rs`) this time instead of the fetch side. Given git's notes trees use
fanout-by-target-oid internally, and notes refs are just regular refs, having both a bare
`refs/notes/rad-lfs` and a `refs/notes/rad-lfs/<peer>` **coexist** is a classic D/F (file vs.
directory) ref conflict: `refs/notes/rad-lfs` can't simultaneously be a leaf ref and a directory
prefix for `refs/notes/rad-lfs/<peer>` in the same ref namespace. That would explain why the
very first note write (before any namespaced sibling existed) succeeded, and why the second one
(now that `rad lfs init` has created the namespaced ref shape) fails outright, silently, at
exactly the ref-write step.
**Not yet confirmed against the actual libgit2 error text** (the CLI swallows it — see above),
so treat the D/F-conflict theory as the leading hypothesis, not a confirmed fix target. Worth
checking next:
1. Add temporary `eprintln!("{:#}", e)` (or similar) around the `repo.note(...)` call in
`store.rs` to print the full `anyhow` chain instead of just the top context, to get the real
libgit2 error text.
2. If it is a D/F conflict: `NOTES_REF` in `lfs_crypto.rs:42` needs to move to the same
per-peer-namespaced shape `store.rs` writes with (probably `refs/notes/rad-lfs/<own-peer-id>`,
matching what `init.rs`'s migrated refspec expects), not just the fetch/init side.
3. Given the write already touched storage in a bad way, may need a one-off manual cleanup on
this machine (`git update-ref -d refs/namespaces/<peer>/refs/notes/rad-lfs` inside
`~/.radicle/storage/<rid>`, analogous to the working-copy cleanup already documented for the
fetch bug) once the write-path fix lands, to avoid the same ref existing in both old and new
shape indefinitely.
## Impact
Blocks committing any new file into an LFS-tracked path (`assets/food/*.jpg`,
`assets/_infra-test/*.jpg`) in the blog repo via the `rad-lfs-init`-managed pre-commit hook,
which calls the equivalent of this same `rad lfs store` command per staged LFS file. Worked
around for the immediate food-gallery migration by committing with `--no-verify` to skip the
hook (plain `git-lfs` still produces correct LFS pointers; only the IPFS pinning step is
skipped for those files until this is fixed).
## Fix actually shipped
The D/F-conflict hypothesis was exactly right, confirmed by reproducing it locally in the
precise order that triggers it: `rad lfs init` → commit+push a file (writes bare
`refs/notes/rad-lfs` locally, lands namespaced in storage) → `git fetch rad` (pulls that same
note back down as `refs/notes/rad-lfs/<own-peer-id>`, since the fetch fix advertises *every*
peer including yourself) → a second `rad lfs store` call now fails outright with the exact
reported "failed to write LFS note", because the working copy now has both a bare
`refs/notes/rad-lfs` (a leaf ref) and `refs/notes/rad-lfs/<peer>` (implying `refs/notes/rad-lfs`
is a directory) — git can't reconcile that. (Interesting side note: plain `git fetch` itself
hits the *same* conflict from the other direction and just refuses that one ref update cleanly
with "unable to update local ref" — it's specifically `repo.note()`/libgit2's ref-write path
in `store.rs`/`rekey.rs` that fails hard instead, which is why the CLI's error was so much less
informative.)
### The fix
Stop ever writing to the bare `refs/notes/rad-lfs` locally. `crates/radicle-cli/src/lfs_crypto.rs`
now has two constants instead of one:
- `NOTES_REF` (`refs/notes/rad-lfs`, unchanged) — the *remote-side* canonical name: push
destination, and what `list.rs`'s `lfs_notes_refs` looks for within each peer's namespace on
the server. Still correct and unchanged.
- `LOCAL_NOTES_REF` (new: `refs/notes/rad-lfs/local`) — what `rad lfs store`/`rad lfs rekey`
actually write to now. Living under the same `refs/notes/rad-lfs/` prefix as every fetched
peer ref means it's just another sibling leaf ref, structurally incapable of D/F-conflicting
with them — `local` isn't a valid peer ID string, so it can never collide with an actual
fetched peer namespace either.
`rad lfs init`'s push refspec becomes asymmetric: `+refs/notes/rad-lfs/local:refs/notes/rad-lfs`
(local source → bare remote destination — the destination staying bare is correct and
unchanged, since push already lands under the pusher's own namespace server-side regardless of
the local ref name, same as `refs/heads/*`). The fetch refspec is untouched
(`+refs/notes/rad-lfs/*:refs/notes/rad-lfs/*`), already correct from the previous fix.
**Migration for already-affected repos** (this one included): `rad lfs init` now also runs
`migrate_local_notes_ref()` — if a bare `refs/notes/rad-lfs` ref still exists locally, it reads
every note out, deletes the bare ref, then re-writes them all under `refs/notes/rad-lfs/local`.
Nothing is lost. (First implementation of this had its own bug, caught during local
verification: it tried to *create* `refs/notes/rad-lfs/local` while the bare ref still existed
— the exact same D/F conflict, self-inflicted, inside the migration meant to fix it. Fixed by
reading all notes into memory first, deleting the bare ref, *then* writing them to the new
location.) Also removes the old symmetric push refspec entry
(`+refs/notes/rad-lfs:refs/notes/rad-lfs`) the same way the earlier fetch-refspec migration
did, so re-running `rad lfs init` is enough to fully repair an already-configured repo — no
manual `git update-ref`/config surgery needed.
### Verified locally (reproduced the exact bug, then the exact fix, same session)
- Reproduced the failure precisely: fresh repo, commit+push (bare ref written), fetch (creates
the namespaced sibling), second `rad lfs store` call → `✗ Error: failed to write LFS note`,
byte-for-byte the reported symptom.
- Rebuilt with the fix, retried the *exact same failing call* in the *same repo* → succeeds,
both `refs/notes/rad-lfs/local` and `refs/notes/rad-lfs/<peer>` coexist with no conflict.
- Tested the migration path directly: a repo with a pre-existing bare `refs/notes/rad-lfs`
(content: `{"v":1,"cid":"Qmb..."}`) → `rad lfs init` → note correctly moved to
`refs/notes/rad-lfs/local` with identical content, bare ref gone.
- Full round-trip after the fix: committed a new file (pre-commit hook → `rad lfs store`
writes `refs/notes/rad-lfs/local`), pushed (asymmetric refspec correctly maps it to the
bare remote ref), a second checkout fetched it and `rad lfs fetch`'d the content —
SHA-256 matched exactly.
- `rad lfs rekey` smoke-tested post-fix (it writes notes too, via the same `LOCAL_NOTES_REF`
now) — ran cleanly, no crash.
### Deployment needed on already-affected machines
Both `~/Blog/Blog` (author's machine) and the VPS blog repo hit this bug and need the fix
installed, followed by one `rad lfs init` re-run each (to migrate the existing bare ref and
pick up the corrected push refspec) before `rad lfs store`/committing new LFS files will work
cleanly again there. The files committed via the `--no-verify` workaround are fine as-is
(valid LFS pointers, just not yet IPFS-pinned) — re-adding/re-committing them normally (or
running `rad lfs store` on them directly) after the fix is installed will pin them retroactively.

View File

@ -1,60 +0,0 @@
# Status: 10MB IPFS `cat` fix — code done and pushed; package build/publish still needs your input
## Done (verified, no further action needed)
- Root-caused and fixed the bug: `crates/radicle-cli/src/ipfs.rs`'s `cat()` used ureq's
`Body::read_to_vec()`, which silently caps reads at 10MB (a default meant for typical JSON
API responses, not LFS content). Any LFS object over 10MB failed with "the response body is
larger than request limit: 10485760" — this is exactly what broke `blog-deploy.service` on
the VPS today, on `assets/posts/hashcash/thumbnail.xcf` (29MB).
- Fix: raised the limit to 1GiB via ureq's `.with_config().limit(...)`. Commit `35f1f972`,
branch `rad-lfs-ipfs`.
- Verification performed:
1. Added a `#[cfg(test)]` regression test (`ipfs::tests::cat_round_trips_content_larger_than_ureqs_default_10mb_limit`,
`#[ignore]`d since it needs a live Kubo daemon) — a 15MB add+cat round-trip.
2. Confirmed it **fails** with the exact production error when the fix is reverted, and
**passes** with the fix in place.
3. Ran the full `radicle-cli` test suite (123 tests) — one pre-existing, unrelated failure
(`rad_help`, a stale `--help`-output snapshot missing the `lfs` subcommand — confirmed to
fail identically on the unmodified base commit, nothing to do with this change).
4. `cargo clippy` clean (one pre-existing warning in an unrelated file/crate).
5. **Live reproduction against the real file**: moved aside the cached LFS blob for
`thumbnail.xcf` in `~/Blog/Blog`, ran `rad lfs fetch` with the *old* installed binary —
reproduced the identical production error byte-for-byte. Ran it again with the *newly
built* binary — the 10MB error is completely gone; it now correctly proceeds to the next
step (prompting for a passphrase to decrypt the private-repo content, which is expected
behavior and unrelated to this bug — I did not attempt to supply a passphrase). Restored
the blog repo's `.git/lfs/objects` cache back to its original state afterward; no changes
left in `~/Blog/Blog`.
- Pushed to `origin` (the Forgejo remote, `ssh://git.hswro.org:9022/mab122/radicle-heartwood-lfs.git`),
branch `rad-lfs-ipfs`, commit `35f1f972..4ae9756a`.
## Not done — needs your call
I did **not** build or publish a package. Stopping here rather than guessing, because:
1. **No existing Forgejo-package publishing path in this repo.** There's a `build/Dockerfile`
that cross-compiles reproducible `.tar.xz` releases (4 targets: linux/macOS ×
x86_64/aarch64) via `cargo-zigbuild`, but nothing that uploads anywhere — no CI workflow,
no `curl`-to-package-registry script, nothing referencing Forgejo's package API. I don't
know what package type you want registered (generic package? a distro-specific one?), what
name/version scheme you use there, or the auth (token?) needed to publish to
`git.hswro.org`'s package registry. I deliberately did not go hunting through credential
stores (`~/.netrc`, etc.) to find one myself — that's exactly the kind of thing I should ask
about rather than dig for.
2. **The existing Dockerfile's package doesn't include `rad-lfs-transfer`** — only `rad`,
`git-remote-rad`, and `radicle-node` get copied into the release tarball. `rad-lfs-transfer`
is a separate crate/binary (`radicle-lfs-transfer/`) and is required for this fork's whole
LFS-over-IPFS feature to work on a fresh install — worth fixing in the Dockerfile too, but
I didn't want to change the packaging script's scope without confirming that's wanted.
3. **Platform scope is unclear.** The existing Dockerfile builds all 4 targets (needed for a
real public release); the VPS only needs `x86_64-unknown-linux-musl`. Building all 4 takes
meaningfully longer and needs the macOS SDK blob (`build/macos-sdk-11.3.tar.xz`) and Zig
cross-toolchain set up — I can do it, but wanted to confirm whether you want the full
release matrix or just what's needed to update the VPS.
If you want me to keep going on this (rather than picking it up in the session that already
knows this fork's packaging conventions), I'd need: the Forgejo package type/target and an
auth token (however you'd like to provide it — e.g. via an env var you set, not me searching
for it), confirmation on whether to add `rad-lfs-transfer` to the Dockerfile's package list,
and whether to build the full 4-target matrix or just linux/x86_64.

109
README.md
View File

@ -1,114 +1,5 @@
# ❤️🪵
> ## Fork: Git LFS support, backed by IPFS
>
> This is a fork of upstream [`radicle-dev/heartwood`](https://github.com/radicle-dev/heartwood)
> adding Git LFS (large file) support, with large file content stored on each contributor's own
> local IPFS node rather than a central server. Everything below the horizontal rule is
> upstream's own README, unchanged. See [`LFS-IPFS.md`](LFS-IPFS.md) for the full design,
> troubleshooting, and background — this section is just the quick start.
>
> The LFS byte-transfer logic lives in a separate, small companion repository:
> [**radicle-lfs-transfer**](https://git.hswro.org/mab122/radicle-lfs-transfer), included here
> as a git submodule.
>
> ### Dependencies (Arch Linux)
>
> ```sh
> # To build and install rad/radicle-node/radicle-lfs-transfer
> sudo pacman -S --needed rust git openssh base-devel
>
> # To actually use Git LFS (not needed to build/install anything)
> sudo pacman -S --needed git-lfs kubo
> ```
>
> On other distributions: a Rust toolchain (e.g. via [rustup](https://rustup.rs)), Git, OpenSSH,
> a C toolchain — and, only for using `rad lfs`, [Git LFS](https://git-lfs.com) and
> [Kubo](https://docs.ipfs.tech/install/).
>
> ### Zero to usable
>
> ```sh
> # Clone with the submodule
> git clone --branch rad-lfs-ipfs --recurse-submodules \
> ssh://git@git.hswro.org:9022/mab122/radicle-heartwood-lfs.git
> cd radicle-heartwood-lfs
>
> # Build & install rad, radicle-node, git-remote-rad, and rad-lfs-transfer to one place
> cargo install --path crates/radicle-cli --force --locked --root ~/.radicle
> cargo install --path crates/radicle-node --force --locked --root ~/.radicle
> cargo install --path crates/radicle-remote-helper --force --locked --root ~/.radicle
> cargo install --path radicle-lfs-transfer --force --locked --root ~/.radicle
>
> # Add the install root to your PATH (e.g. in ~/.bashrc / ~/.zshrc)
> export PATH="$HOME/.radicle/bin:$PATH"
>
> # Verify
> rad --version
> ```
>
> From here, use `rad` exactly as upstream describes below (`rad auth`, `rad init`, etc). The
> only new commands are `rad lfs init` (run once per repository you want large-file support in),
> `rad lfs rekey` (run after granting a new collaborator access to a **private** repository, so
> they can decrypt previously-committed LFS objects too), and `rad lfs backfill` (retroactively
> pins any LFS-tracked file that got committed without going through the pre-commit hook, e.g.
> `--no-verify`) — see [`LFS-IPFS.md`](LFS-IPFS.md) for those workflows and how private-repo
> content gets encrypted before it reaches IPFS.
> **Nothing above requires IPFS**; Git LFS support specifically needs a running `ipfs daemon`,
> and `rad lfs init` will tell you plainly if one isn't reachable rather than failing confusingly
> later.
>
> ### Quickstart cheatsheet
>
> One-time, per repository (with `ipfs daemon` already running in the background):
>
> ```sh
> rad lfs init
> git lfs track "*.psd" # or whatever large-file patterns you need
> git add .gitattributes
> ```
>
> Day to day — same as plain Git LFS:
>
> ```sh
> git add my-large-file.psd
> git commit -m "Add asset" # pre-commit hook pins it to IPFS, records the CID as a git note
> git push rad # pushes your commit(s) and the refs/notes/rad-lfs mapping together
> ```
>
> Someone else, cloning the repository for the first time:
>
> ```sh
> rad clone rad:<repo-id>
> cd <repo>
> rad lfs init # one-time, needs their own ipfs daemon running
> git lfs pull # fetches large files via IPFS instead of git
> ```
>
> **Private repository?** You'll just be prompted for your keystore passphrase on `commit`/
> `push`/`pull` when needed (ssh-agent alone can't do the key-agreement encryption requires —
> see [`LFS-IPFS.md`](LFS-IPFS.md#encryption-for-private-repositories)). After granting a new
> collaborator access, have an *already-authorized* collaborator run `rad lfs rekey` and push,
> so the newcomer can decrypt files committed before they were added:
>
> ```sh
> rad id update --allow <did> # or add them as a delegate
> rad lfs rekey # run by someone already authorized
> git push rad # publish the updated wrapped keys
> ```
>
> **Repository set up with an older build?** A bare `git push rad` used to push *only* the
> notes mapping, silently leaving your commit unpushed (`rad lfs init` only configured a push
> refspec for the notes ref, not the branch). Fixed — but only for repositories where
> `rad lfs init` has been (re-)run with this build; re-run it once (harmless, idempotent) to
> pick up the fix on a repo set up before this. Until then, push both explicitly:
> `git push rad <branch>` then `git push rad`.
>
> Full design, encryption details, and a longer troubleshooting table:
> [`LFS-IPFS.md`](LFS-IPFS.md).
---
*Radicle Heartwood Protocol & Stack*
Heartwood is the third iteration of the Radicle Protocol, a powerful

View File

@ -2,19 +2,6 @@ use std::env;
use std::process::Command;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Without this, cargo has no way to know it needs to re-run this
// script just because the current commit changed -- by default it
// only reruns a build script when a file inside the crate's own
// source tree changes, and `.git` is outside that entirely. That
// silently let GIT_HEAD/RADICLE_VERSION go stale on ordinary
// incremental rebuilds across commits that didn't happen to touch
// this crate's own sources (discovered live: several `cargo build`s
// in a row kept reporting an old commit's version despite the
// working tree being clean at a newer commit). `.git/logs/HEAD` (the
// reflog) is touched on every commit/checkout/merge, unlike
// `.git/HEAD` itself, which only changes when you switch branches.
println!("cargo::rerun-if-changed=../../.git/logs/HEAD");
// Set a build-time `GIT_HEAD` env var which includes the commit id;
// such that we can tell which code is running.
let hash = env::var("GIT_HEAD").unwrap_or_else(|_| {
@ -26,7 +13,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok().map(|s| s.trim().to_string())
String::from_utf8(output.stdout).ok()
} else {
None
}
@ -57,7 +44,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok().map(|s| s.trim().to_string())
String::from_utf8(output.stdout).ok()
} else {
None
}
@ -74,15 +61,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
version
};
// This is the `rad-lfs-ipfs` fork (Git LFS support backed by IPFS, see
// README.md/LFS-IPFS.md) -- append a semver build-metadata suffix (the
// `+...` part a `git describe`-derived version doesn't otherwise have
// room for) so `--version` unambiguously identifies a binary as this
// fork rather than upstream heartwood, e.g. when checking what a
// deploy target is actually running. Applies to every binary in this
// workspace, since they all share this build script via symlink.
let version = format!("{version}+lfs-ipfs");
// Set a build-time `SOURCE_DATE_EPOCH` env var which includes the commit time.
let commit_time = env::var("SOURCE_DATE_EPOCH").unwrap_or_else(|_| {
Command::new("git")
@ -94,7 +72,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok().map(|s| s.trim().to_string())
String::from_utf8(output.stdout).ok()
} else {
None
}

View File

@ -20,14 +20,10 @@ tor = ["radicle/tor"]
[dependencies]
anyhow = "1"
base64 = { workspace = true }
chacha20poly1305 = { workspace = true }
chrono = { workspace = true, features = ["clock", "std"] }
clap = { version = "4.5.44", features = ["derive"] }
clap_complete = "4.5"
cyphernet = { workspace = true, features = ["ed25519"] }
dunce = { workspace = true }
hkdf = { workspace = true }
human-panic.workspace = true
humantime.workspace = true
itertools.workspace = true
@ -35,7 +31,7 @@ log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
radicle = { workspace = true, features = ["schemars"] }
radicle-cob = { workspace = true }
radicle-crypto = { workspace = true, features = ["cyphernet"] }
radicle-crypto = { workspace = true }
radicle-localtime = { workspace = true }
radicle-surf = { workspace = true }
radicle-term = { workspace = true }
@ -43,11 +39,9 @@ radicle-log = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true, default-features = true }
timeago = { version = "0.4.2", default-features = false }
zeroize = { workspace = true }
tree-sitter = "0.24.4"
tree-sitter-bash = "0.23.3"
tree-sitter-c = "0.23.2"
@ -62,7 +56,6 @@ tree-sitter-ruby = "0.23.1"
tree-sitter-rust = "0.23.2"
tree-sitter-toml-ng = "0.6.0"
tree-sitter-typescript = "0.23.2"
ureq = { workspace = true, features = ["multipart"] }
[target.'cfg(unix)'.dependencies]
backtrace = { workspace = true, optional = true }

View File

@ -14,7 +14,6 @@ pub mod inbox;
pub mod init;
pub mod inspect;
pub mod issue;
pub mod lfs;
pub mod ls;
pub mod node;
pub mod patch;

View File

@ -1,28 +0,0 @@
//! `rad lfs` command implementation.
pub mod backfill;
pub mod fetch;
pub mod fetch_batch;
pub mod init;
pub mod precommit;
pub mod rekey;
pub mod store;
mod args;
use crate::terminal as term;
pub use args::Args;
use args::Command;
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
match args.command {
Command::Init => self::init::run(),
Command::Store { oid, size, path } => self::store::run(oid, size, path, ctx),
Command::Fetch { oid, size, out } => self::fetch::run(oid, size, out, ctx),
Command::Rekey => self::rekey::run(ctx),
Command::Precommit => self::precommit::run(ctx),
Command::Backfill => self::backfill::run(ctx),
Command::FetchBatch => self::fetch_batch::run(ctx),
}
}

View File

@ -1,64 +0,0 @@
use clap::{Parser, Subcommand};
const ABOUT: &str = "Manage Git LFS (large file) support, backed by IPFS";
const LONG_ABOUT: &str = r#"
The `lfs` command manages Git LFS (Large File Storage) support for a
Radicle repository. Large file content is stored on the contributor's
local IPFS (Kubo) node rather than on a seed-hosted HTTP server: there
is no additional server-side infrastructure to run.
See `rad lfs init --help` for the one-time setup command.
"#;
#[derive(Parser, Debug)]
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
pub struct Args {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Set up Git LFS support for this repository, backed by IPFS
Init,
/// Store an LFS object's content in IPFS (invoked by the custom
/// transfer agent; not for interactive use)
#[command(hide = true)]
Store {
#[arg(long)]
oid: String,
#[arg(long)]
size: i64,
path: std::path::PathBuf,
},
/// Fetch an LFS object's content from IPFS (invoked by the custom
/// transfer agent; not for interactive use)
#[command(hide = true)]
Fetch {
#[arg(long)]
oid: String,
#[arg(long)]
size: i64,
#[arg(long)]
out: std::path::PathBuf,
},
/// Grant newly-authorized collaborators access to previously-encrypted
/// LFS objects
Rekey,
/// Store every staged LFS file's content in IPFS in one batch (invoked
/// by the pre-commit hook; not for interactive use). Reads
/// "<oid> <size> <path>" lines from stdin.
#[command(hide = true)]
Precommit,
/// Retroactively pin any LFS-tracked file at HEAD that was committed
/// without going through the pre-commit hook (e.g. `--no-verify`, or
/// the IPFS daemon/`ipfs`/`rad` weren't available at commit time)
Backfill,
/// Long-lived worker that fetches many LFS objects across one process
/// (invoked by the custom transfer agent; not for interactive use).
/// Reads "<oid> <size> <out-path>" lines from stdin, one at a time,
/// writing a JSON response line for each.
#[command(hide = true)]
FetchBatch,
}

View File

@ -1,171 +0,0 @@
//! `rad lfs backfill` — retroactively stores any already-committed
//! LFS-tracked file that doesn't yet have a `refs/notes/rad-lfs` note (so
//! it was never pinned to IPFS). This happens whenever the pre-commit hook
//! didn't run or didn't have what it needed at commit time: a commit made
//! with `--no-verify`, or one made while the `ipfs`/`rad` binaries weren't
//! on `PATH` yet (the hook soft-skips pinning in that case rather than
//! blocking the commit -- see `HOOK_BODY`'s `command -v` checks).
//!
//! Content actually needs to be available locally to backfill it from:
//! this reads from Git LFS's own local object cache
//! (`.git/lfs/objects/<oid prefix>/<oid>`, populated by the `git-lfs`
//! clean filter on `git add`, which -- unlike the pre-commit *hook* --
//! isn't skipped by `--no-verify`), falling back to the working-tree file
//! if that's missing. A file whose content isn't available through either
//! path can't be backfilled from this machine; some other peer that
//! already has it needs to run this instead.
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::ipfs;
use crate::terminal as term;
use super::store::store_object;
pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs backfill` must be run inside a Radicle repository working copy")?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("`rad lfs backfill` needs a git working copy, not a bare repository"))?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
ipfs::check_daemon()?;
// Every path git currently tracks at HEAD (not the working tree or
// index, which may have unrelated local modifications) -- mirrors
// HOOK_BODY's own approach of shelling out to `git` for this rather
// than reimplementing tree/attribute logic against git2 directly.
let ls_files = radicle::git::run(Some(workdir), ["ls-tree", "-r", "-z", "--name-only", "HEAD"])
.context("failed to list files at HEAD")?;
let paths: Vec<String> = String::from_utf8_lossy(&ls_files.stdout)
.split('\0')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let mut candidates = Vec::new();
for path in paths {
// Matches HOOK_BODY's own check: only files the *current*
// `.gitattributes` marks as `filter=lfs` are our concern.
let is_lfs = radicle::git::run(Some(workdir), ["check-attr", "filter", "--", &path])
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).contains(": filter: lfs"))
.unwrap_or(false);
if !is_lfs {
continue;
}
let blob_oid_out = radicle::git::run(Some(workdir), ["rev-parse", &format!("HEAD:{path}")])
.with_context(|| format!("failed to resolve blob oid for `{path}`"))?;
let blob_oid_str = String::from_utf8_lossy(&blob_oid_out.stdout).trim().to_string();
let Ok(blob_oid) = radicle::git::raw::Oid::from_str(&blob_oid_str) else {
continue;
};
candidates.push((path, blob_oid));
}
if candidates.is_empty() {
term::success!("No LFS-tracked files found in HEAD; nothing to backfill");
return Ok(());
}
let mut signer = None;
let mut backfilled = 0;
let mut already_pinned = 0;
let mut unavailable = Vec::new();
for (path, blob_oid) in candidates {
if !crate::lfs_crypto::find_cids(&repo, blob_oid)?.is_empty() {
already_pinned += 1;
continue;
}
let blob = repo
.find_blob(blob_oid)
.with_context(|| format!("failed to read blob for `{path}`"))?;
let Ok(pointer_text) = std::str::from_utf8(blob.content()) else {
continue;
};
let Some(oid) = pointer_text
.lines()
.find_map(|line| line.strip_prefix("oid sha256:"))
else {
continue;
};
let Some(size) = pointer_text
.lines()
.find_map(|line| line.strip_prefix("size "))
.and_then(|s| s.parse::<i64>().ok())
else {
continue;
};
let Some(content_path) = locate_content(workdir, oid, size, &path) else {
unavailable.push(path);
continue;
};
store_object(&profile, &repo, rid, &doc, oid, size, &content_path, &mut signer)
.with_context(|| format!("failed to backfill `{path}`"))?;
term::success!("Backfilled `{path}`");
backfilled += 1;
}
term::blank();
if backfilled > 0 {
term::success!("Backfilled {backfilled} object(s)");
}
if already_pinned > 0 {
term::info!("{already_pinned} object(s) already had a note; left untouched");
}
if !unavailable.is_empty() {
term::warning(format!(
"{} object(s) have no note and no content available locally to backfill from -- \
ask a peer that already has them to run `rad lfs backfill`:",
unavailable.len()
));
for path in &unavailable {
term::warning(format!(" {path}"));
}
}
Ok(())
}
/// Git LFS's own local object cache is the reliable source: the
/// `git-lfs` clean filter populates it on `git add` regardless of
/// whether the pre-commit *hook* ran (`--no-verify` skips hooks, not
/// filters). Falls back to the working-tree file, in case content is
/// present there but the LFS cache was pruned -- rejected if the size on
/// disk doesn't match what the pointer recorded, since that means it's
/// just the unsmudged pointer text, not the real content.
fn locate_content(workdir: &Path, oid: &str, size: i64, path: &str) -> Option<PathBuf> {
if oid.len() >= 4 {
let cached = workdir
.join(".git/lfs/objects")
.join(&oid[0..2])
.join(&oid[2..4])
.join(oid);
if matches_size(&cached, size) {
return Some(cached);
}
}
let working_copy = workdir.join(path);
if matches_size(&working_copy, size) {
return Some(working_copy);
}
None
}
fn matches_size(path: &Path, expected: i64) -> bool {
std::fs::metadata(path)
.map(|meta| meta.len() == expected as u64)
.unwrap_or(false)
}

View File

@ -1,88 +0,0 @@
//! `rad lfs fetch` — plumbing command invoked by the Git LFS custom
//! transfer agent (`rad-lfs-transfer`) to download one LFS object's
//! content from the local IPFS (Kubo) node, decrypting it first if the
//! repository is private.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, anyhow};
use radicle::Profile;
use radicle::git::raw::Repository;
use radicle::identity::RepoId;
use radicle_crypto::ssh::keystore::MemorySigner;
use crate::ipfs;
use crate::lfs_crypto;
use crate::terminal as term;
pub fn run(oid: String, size: i64, out: PathBuf, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs fetch` must be run inside a Radicle repository working copy")?;
let mut signer = None;
fetch_object(&profile, &repo, rid, &oid, size, &out, &mut signer)
}
/// Fetches one LFS object's content from IPFS (decrypting it first for
/// private repos) and writes it to `out`. Factored out of [`run`] so
/// `rad lfs fetch-batch` can process many objects across a single
/// process, loading (and prompting for) the signer at most once across
/// however many objects it ends up handling, instead of once per object.
#[allow(clippy::too_many_arguments)]
pub fn fetch_object(
profile: &Profile,
repo: &Repository,
rid: RepoId,
oid: &str,
size: i64,
out: &Path,
signer: &mut Option<MemorySigner>,
) -> anyhow::Result<()> {
let pointer_text =
format!("version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n");
let blob_oid = repo
.blob(pointer_text.as_bytes())
.context("failed to write LFS pointer blob")?;
let candidates = lfs_crypto::find_envelopes(repo, blob_oid)
.with_context(|| format!("failed to read LFS notes for oid {oid}"))?;
if candidates.is_empty() {
anyhow::bail!(
"no CID recorded for oid {oid}; the peer that has this object needs to push its \
{} ref",
lfs_crypto::NOTES_REF
);
}
ipfs::check_daemon()?;
// Almost always exactly one candidate. More than one only happens if
// two peers independently encrypted byte-identical content (same
// oid/size) producing different ciphertext each time -- try each
// until one actually decrypts for us.
let mut last_err = None;
for envelope in candidates {
let result = match &envelope.enc {
None => ipfs::cat(&envelope.cid),
Some(enc) => ipfs::cat(&envelope.cid).and_then(|ciphertext| {
if signer.is_none() {
*signer = Some(lfs_crypto::load_signer(profile)?);
}
let signer = signer.as_ref().expect("just populated above");
lfs_crypto::decrypt_with(&ciphertext, enc, signer, profile.did(), &rid, oid)
}),
};
match result {
Ok(bytes) => {
std::fs::write(out, bytes)
.with_context(|| format!("failed to write `{}`", out.display()))?;
return Ok(());
}
Err(err) => last_err = Some(err),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("no CID recorded for oid {oid}")))
}

View File

@ -1,67 +0,0 @@
//! `rad lfs fetch-batch` — long-lived plumbing worker invoked by the Git
//! LFS custom transfer agent (`rad-lfs-transfer`) to download multiple LFS
//! objects across a single process, kept alive for the lifetime of one
//! `git lfs pull`/checkout session -- specifically so a private repo's
//! passphrase is only prompted for once per session, not once per file.
//!
//! Unlike `rad lfs precommit`/`backfill` (which know every file upfront
//! and can just read stdin until EOF), this can't collect a batch before
//! starting: git-lfs's custom-transfer protocol requests objects one at a
//! time by default (`rad-lfs-transfer` doesn't negotiate the "concurrent"
//! capability), so the caller only knows about the next object once the
//! previous one has been answered. This process instead stays alive and
//! answers requests one at a time as they arrive, reusing the same
//! unwrapped signer across all of them.
//!
//! Protocol (line-delimited, UTF-8, on stdin/stdout):
//! request: "<oid> <size> <out-path>\n"
//! response: a JSON object, one line per request, in the same order:
//! `{"ok":true}` or `{"ok":false,"error":"<message>"}`
//! The process exits cleanly on stdin EOF (the parent closing its write
//! end signals the transfer session is over).
use std::io::{BufRead as _, Write as _};
use std::path::PathBuf;
use anyhow::Context as _;
use serde_json::json;
use crate::terminal as term;
use super::fetch::fetch_object;
pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs fetch-batch` must be run inside a Radicle repository working copy")?;
let mut signer = None;
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let line = line.context("failed to read stdin")?;
// Each line is "<oid> <size> <out-path>" -- oid is a fixed-length
// hex string and size is decimal, so splitting on the first two
// spaces leaves the path (which may itself contain spaces) intact
// as the remainder. Mirrors `rad lfs precommit`'s input format.
let mut parts = line.splitn(3, ' ');
let (Some(oid), Some(size), Some(out)) = (parts.next(), parts.next(), parts.next())
else {
anyhow::bail!("malformed `rad lfs fetch-batch` input line: {line:?}");
};
let Ok(size) = size.parse::<i64>() else {
anyhow::bail!("invalid size in `rad lfs fetch-batch` input: {size:?}");
};
let out = PathBuf::from(out);
let response = match fetch_object(&profile, &repo, rid, oid, size, &out, &mut signer) {
Ok(()) => json!({"ok": true}),
Err(err) => json!({"ok": false, "error": format!("{err:#}")}),
};
writeln!(stdout, "{response}").context("failed to write response")?;
stdout.flush().context("failed to flush stdout")?;
}
Ok(())
}

View File

@ -1,400 +0,0 @@
//! `rad lfs init` — one-time, idempotent setup of Git LFS support for a
//! Radicle repository, backed by the contributor's local IPFS (Kubo) node.
//!
//! This deliberately does *not* talk to any seed-hosted HTTP LFS server:
//! large file content lives on each peer's own IPFS node, and the
//! oid -> CID mapping is carried in a git-notes ref (`refs/notes/rad-lfs`)
//! that replicates alongside the rest of the repository once the
//! push/fetch refspecs below are configured.
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use anyhow::{Context as _, anyhow};
use radicle::git::raw::Repository;
use crate::git;
use crate::ipfs::{self, LFS_NOTES_REF as NOTES_REF};
use crate::lfs_crypto::{LOCAL_NOTES_REF, NOTE_AUTHOR_EMAIL, NOTE_AUTHOR_NAME};
use crate::terminal as term;
/// Name under which our custom transfer agent is registered with Git LFS.
const TRANSFER_AGENT_NAME: &str = "rad-ipfs";
/// Binary implementing the custom transfer agent. Must be on the user's
/// `PATH`; we intentionally don't assume a specific install location.
const TRANSFER_AGENT_BIN: &str = "rad-lfs-transfer";
/// Name of the remote that `rad init` sets up.
const RAD_REMOTE: &str = "rad";
/// Markers delimiting the block we manage inside `.git/hooks/pre-commit`,
/// so re-running `rad lfs init` updates our block in place instead of
/// duplicating it, and so we don't clobber any hook content that was
/// already there.
const HOOK_BEGIN: &str = "# >>> rad-lfs-init managed pre-commit hook >>>";
const HOOK_END: &str = "# <<< rad-lfs-init managed pre-commit hook <<<";
/// Body of the managed pre-commit hook block (POSIX `sh`).
///
/// For every file staged in the commit that's tracked by Git LFS (i.e.
/// matches a `filter=lfs` pattern in `.gitattributes`), this computes the
/// file's LFS oid (sha256) and size, then feeds all of them to a single
/// `rad lfs precommit` invocation as "<oid> <size> <path>" lines on stdin.
/// `rad lfs precommit` does the actual IPFS add/pin (encrypting first for
/// private repos) and writes the corresponding notes on `refs/notes/rad-lfs`
/// itself — see `commands/lfs/precommit.rs` and `commands/lfs/store.rs`.
///
/// Batching into one `rad` process (rather than one per file) matters for
/// private repos specifically: each file needs the keystore passphrase for
/// an ECDH key-agreement operation, and a separate process per file meant a
/// separate passphrase prompt per file. One process loads it once.
///
/// The hook only computes the cheap, dependency-free oid/size values in
/// shell; the CID/encryption/git-notes logic lives in `rad lfs precommit`
/// since it needs access to the repo's identity/visibility and (for private
/// repos) key material that a plain shell script has no business handling —
/// a git hook shelling out to our own `rad` binary is far simpler than
/// reimplementing that here.
///
/// Requires both `ipfs` and `rad` to be available on `PATH` at commit time.
const HOOK_BODY: &str = r#"rad_lfs_precommit() {
command -v ipfs >/dev/null 2>&1 || {
echo "rad-lfs: 'ipfs' CLI not found on PATH; skipping CID pinning for this commit" >&2
return 0
}
command -v rad >/dev/null 2>&1 || {
echo "rad-lfs: 'rad' CLI not found on PATH; skipping CID pinning for this commit" >&2
return 0
}
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{ print $1 }'
else
shasum -a 256 "$1" | awk '{ print $1 }'
fi
}
staged=$(mktemp) || return 1
batch=$(mktemp) || { rm -f "$staged"; return 1; }
# `-z` avoids git's default C-style quoting of non-ASCII filenames (e.g.
# a literal "\305\202" for a Polish "ł") in `--name-only`'s normal
# output, which would otherwise not match any real file on disk and
# silently drop that file from LFS pinning. NUL-separated records are
# converted to newline-separated here for a plain `read` loop, which is
# safe since filenames practically never contain literal newlines.
git diff --cached --name-only --diff-filter=ACM -z | tr '\0' '\n' > "$staged"
while IFS= read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] || continue
attr=$(git check-attr filter -- "$file" | sed -n 's/.*: filter: //p')
[ "$attr" = "lfs" ] || continue
oid=$(sha256 "$file") || { rm -f "$staged" "$batch"; exit 1; }
size=$(wc -c < "$file" | tr -d ' ')
printf '%s %s %s\n' "$oid" "$size" "$file" >> "$batch"
done < "$staged"
rm -f "$staged"
if [ -s "$batch" ]; then
rad lfs precommit < "$batch" || {
echo "rad-lfs: 'rad lfs precommit' failed" >&2
rm -f "$batch"
exit 1
}
fi
rm -f "$batch"
}
rad_lfs_precommit"#;
pub fn run() -> anyhow::Result<()> {
let (repo, _rid) = radicle::rad::cwd()
.context("`rad lfs init` must be run inside a Radicle repository working copy")?;
let workdir = repo.workdir().ok_or_else(|| {
anyhow!("`rad lfs init` must be run inside a git working copy (not a bare repository)")
})?;
ipfs::check_daemon()?;
term::success!("Found a local IPFS (Kubo) daemon at {}", ipfs::kubo_api_url());
ensure_git_lfs_available()?;
git::git(workdir, ["lfs", "install", "--local"])
.context("failed to run `git lfs install --local`")?;
term::success!("Initialized Git LFS for this repository");
git::git(
workdir,
["config", "lfs.standalonetransferagent", TRANSFER_AGENT_NAME],
)
.context("failed to configure the LFS standalone transfer agent")?;
git::git(
workdir,
[
"config",
&format!("lfs.customtransfer.{TRANSFER_AGENT_NAME}.path"),
TRANSFER_AGENT_BIN,
],
)
.context("failed to configure the LFS custom transfer agent path")?;
term::success!(
"Configured Git LFS to transfer objects via `{TRANSFER_AGENT_BIN}` (backed by IPFS)"
);
// Push maps our *local-only* notes ref (`LOCAL_NOTES_REF`) to the bare
// `NOTES_REF` on the remote: `git push` lands whatever local ref we
// push under the pusher's own namespace on the server regardless of
// the local ref name, the same way `refs/heads/*` does, so the
// destination staying bare is correct and matches what
// `list.rs`'s `lfs_notes_refs` looks for server-side. The *source*
// is deliberately not the bare name too -- see `LOCAL_NOTES_REF`'s
// doc comment for why (a git ref D/F conflict with fetched peer
// refs).
//
// Fetch is different: `refs/notes/rad-lfs` is a per-peer ref (any
// peer can commit an LFS-tracked file, not just delegates), so
// there's no single canonical value the remote can advertise the way
// it does for `refs/heads`/`refs/tags`. The remote instead advertises
// every peer's note individually under `refs/notes/rad-lfs/<peer>`,
// so the fetch refspec needs a wildcard to pull them all; the LFS
// tooling merges across whatever's fetched (see
// `lfs_crypto::find_envelope`) rather than expecting one ref.
let notes_push_refspec = format!("+{LOCAL_NOTES_REF}:{NOTES_REF}");
let fetch_refspec = format!("+{NOTES_REF}/*:{NOTES_REF}/*");
let push_key = format!("remote.{RAD_REMOTE}.push");
let fetch_key = format!("remote.{RAD_REMOTE}.fetch");
// Migration: earlier versions of `rad lfs init` configured a plain,
// non-wildcard fetch refspec for the notes ref, which `git fetch` can
// never satisfy -- the remote never advertises a bare
// `refs/notes/rad-lfs` (only the wildcarded per-peer form below), so
// a stale entry here breaks the *entire* fetch, not just this one
// ref. Remove it if present so re-running `rad lfs init` actually
// fixes an already-configured repository, rather than leaving the
// broken entry alongside the corrected one.
remove_refspec_if_present(workdir, &fetch_key, &format!("+{NOTES_REF}:{NOTES_REF}"))?;
// Migration: earlier versions also pushed from the bare local ref
// (symmetric `+refs/notes/rad-lfs:refs/notes/rad-lfs`). Replace it
// with the corrected asymmetric refspec above.
remove_refspec_if_present(workdir, &push_key, &format!("+{NOTES_REF}:{NOTES_REF}"))?;
ensure_refspec(workdir, &push_key, &notes_push_refspec)?;
ensure_refspec(workdir, &fetch_key, &fetch_refspec)?;
// As soon as *any* explicit push refspec is configured on a remote
// (the notes one above), git stops falling back to its usual
// push.default-driven behavior (pushing whatever branch you're on) for
// a bare `git push rad` -- it only pushes what's explicitly listed.
// Without a branch refspec too, that means a bare `git push rad` after
// committing an LFS-tracked file would silently push *only* the notes
// mapping, not the commit itself -- exactly the gotcha that caused a
// collaborator to see "no CID recorded for oid ..." even though the
// committer had "pushed". Configuring this explicitly, mirroring the
// existing `+refs/heads/*:refs/remotes/rad/*` fetch refspec above,
// makes a single bare `git push rad` push every local branch *and*
// the notes mapping together.
let heads_push_refspec = "+refs/heads/*:refs/heads/*".to_string();
ensure_refspec(workdir, &push_key, &heads_push_refspec)?;
term::success!(
"Configured the `{RAD_REMOTE}` remote so a single `git push {RAD_REMOTE}` pushes your \
branches and the `{NOTES_REF}` notes mapping together"
);
migrate_local_notes_ref(&repo)?;
install_pre_commit_hook(&repo)?;
term::success!("Installed the `rad-lfs` pre-commit hook");
term::blank();
term::success!("Git LFS is now configured for this repository, backed by your local IPFS node.");
term::info!(
"File CIDs are recorded in the `{NOTES_REF}` git-notes ref, which now travels with a plain `git push {RAD_REMOTE}` / `git pull {RAD_REMOTE}` alongside your branches."
);
Ok(())
}
/// Make sure the `git-lfs` binary is installed before we shell out to it,
/// so we can give a clear, actionable error instead of a raw "unknown git
/// command" failure.
fn ensure_git_lfs_available() -> anyhow::Result<()> {
match Command::new("git-lfs").arg("version").output() {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(anyhow!(
"`git-lfs version` failed: {}",
String::from_utf8_lossy(&output.stderr)
)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Err(anyhow!(
"Git LFS is not installed (the `git-lfs` binary was not found on your PATH). \
Install Git LFS see https://git-lfs.com — and try again."
)),
Err(err) => Err(err).context("failed to check for `git-lfs`"),
}
}
/// Add `value` to the (potentially multi-valued) git config key `key`,
/// unless it's already present, so re-running `rad lfs init` doesn't
/// duplicate refspecs.
fn ensure_refspec(workdir: &Path, key: &str, value: &str) -> anyhow::Result<()> {
// `git config --get-all` exits non-zero when the key is unset, which
// isn't an error for us, so we use the lower-level `run` rather than
// `git::git` (which treats a non-zero exit as failure).
let output = radicle::git::run(Some(workdir), ["config", "--get-all", key])
.with_context(|| format!("failed to read git config `{key}`"))?;
let existing = String::from_utf8_lossy(&output.stdout);
if existing.lines().any(|line| line.trim() == value) {
return Ok(());
}
git::git(workdir, ["config", "--add", key, value])
.with_context(|| format!("failed to set git config `{key}`"))?;
Ok(())
}
/// Remove `value` from the (potentially multi-valued) git config key
/// `key`, if present, without disturbing any other values under the same
/// key (e.g. the default `+refs/heads/*:refs/remotes/rad/*` that plain
/// `git remote add` already configures on `remote.<rad>.fetch`).
fn remove_refspec_if_present(workdir: &Path, key: &str, value: &str) -> anyhow::Result<()> {
let output = radicle::git::run(Some(workdir), ["config", "--get-all", key])
.with_context(|| format!("failed to read git config `{key}`"))?;
let existing = String::from_utf8_lossy(&output.stdout);
if !existing.lines().any(|line| line.trim() == value) {
return Ok(());
}
// `git config --unset` matches its value argument as a regex against
// the whole existing value, not literally -- anchor and escape so it
// removes exactly this one entry and nothing else.
let escaped: String = value
.chars()
.map(|c| {
if "\\^$.|?*+()[]{}".contains(c) {
format!("\\{c}")
} else {
c.to_string()
}
})
.collect();
let pattern = format!("^{escaped}$");
git::git(workdir, ["config", "--unset", key, &pattern])
.with_context(|| format!("failed to remove stale git config `{key}` entry"))?;
Ok(())
}
/// Migration: earlier versions of this fork wrote local notes directly to
/// the bare `NOTES_REF` (`refs/notes/rad-lfs`). Since the fetch refspec
/// populates `refs/notes/rad-lfs/<peer>` siblings locally -- including our
/// own peer's copy, fetched back after a push -- a leftover bare ref
/// causes a git ref D/F (file-vs-directory) conflict: `refs/notes/rad-lfs`
/// can't simultaneously be a leaf ref and a directory prefix for
/// `refs/notes/rad-lfs/<peer>` in the same namespace. That made every
/// `rad lfs store`/`rad lfs rekey` call after the first fetch fail
/// outright with "failed to write LFS note".
///
/// If a bare `refs/notes/rad-lfs` ref exists, copy its notes into
/// `LOCAL_NOTES_REF` (so nothing already recorded there is lost) and
/// delete the bare ref, eliminating the conflict. A no-op if the bare ref
/// doesn't exist (fresh setups, or a repo that's already been migrated).
fn migrate_local_notes_ref(repo: &Repository) -> anyhow::Result<()> {
let Ok(mut reference) = repo.find_reference(NOTES_REF) else {
return Ok(());
};
// Read every note out *before* touching anything -- writing to
// `LOCAL_NOTES_REF` while the bare `NOTES_REF` still exists is the
// exact same D/F conflict this migration exists to fix, just
// self-inflicted. The bare ref has to be gone first.
let mut migrated = Vec::new();
if let Ok(notes) = repo.notes(Some(NOTES_REF)) {
for (_, target_oid) in notes.flatten() {
if let Ok(note) = repo.find_note(Some(NOTES_REF), target_oid)
&& let Some(message) = note.message()
{
migrated.push((target_oid, message.to_string()));
}
}
}
reference
.delete()
.context("failed to remove the legacy bare `refs/notes/rad-lfs` ref")?;
if migrated.is_empty() {
return Ok(());
}
let signature = radicle::git::raw::Signature::now(NOTE_AUTHOR_NAME, NOTE_AUTHOR_EMAIL)
.context("failed to construct note signature")?;
for (target_oid, message) in migrated {
repo.note(&signature, &signature, Some(LOCAL_NOTES_REF), target_oid, &message, true)
.with_context(|| {
format!("failed to migrate note for {target_oid} to `{LOCAL_NOTES_REF}`")
})?;
}
term::success!("Migrated local LFS notes from the legacy `{NOTES_REF}` ref");
Ok(())
}
/// Install (or update, or chain onto) the `.git/hooks/pre-commit` hook that
/// pins staged LFS objects to IPFS and records their CID as a git note.
fn install_pre_commit_hook(repo: &Repository) -> anyhow::Result<()> {
let hooks_dir = repo.path().join("hooks");
fs::create_dir_all(&hooks_dir)
.with_context(|| format!("failed to create hooks directory `{}`", hooks_dir.display()))?;
let hook_path = hooks_dir.join("pre-commit");
let existing = fs::read_to_string(&hook_path).unwrap_or_default();
let new_content = if let Some((before, after_begin)) = existing.split_once(HOOK_BEGIN) {
// We've already installed our block before: replace it in place so
// re-running `rad lfs init` doesn't duplicate it, while preserving
// anything the user added before/after it.
let after = after_begin
.split_once(HOOK_END)
.map_or("", |(_, after)| after);
format!("{before}{HOOK_BEGIN}\n{HOOK_BODY}\n{HOOK_END}{after}")
} else if existing.trim().is_empty() {
format!("#!/bin/sh\n\n{HOOK_BEGIN}\n{HOOK_BODY}\n{HOOK_END}\n")
} else {
// Some other hook (not ours) is already installed (`git lfs
// install` itself only installs a pre-push hook, so this is
// usually a hook the user or another tool set up). Chain onto it
// rather than clobbering it.
let mut content = existing;
if !content.ends_with('\n') {
content.push('\n');
}
content.push('\n');
content.push_str(HOOK_BEGIN);
content.push('\n');
content.push_str(HOOK_BODY);
content.push('\n');
content.push_str(HOOK_END);
content.push('\n');
content
};
fs::write(&hook_path, new_content)
.with_context(|| format!("failed to write hook `{}`", hook_path.display()))?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&hook_path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(&hook_path, perms)?;
}
Ok(())
}

View File

@ -1,55 +0,0 @@
//! `rad lfs precommit` — plumbing command invoked once per commit by the
//! `rad-lfs` pre-commit hook. Reads every staged LFS file's oid/size/path
//! from stdin and stores them all in a single process, so a private repo's
//! passphrase (needed for the ECDH key-agreement `rad lfs store` performs)
//! is prompted for at most once per commit, not once per file.
use std::io::BufRead as _;
use std::path::PathBuf;
use anyhow::Context as _;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::terminal as term;
use super::store::store_object;
pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs precommit` must be run inside a Radicle repository working copy")?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
let mut signer = None;
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let line = line.context("failed to read stdin")?;
// Each line is "<oid> <size> <path>" -- oid is a fixed-length hex
// string and size is decimal, so splitting on the first two spaces
// leaves the path (which may itself contain spaces) intact as the
// remainder.
let mut parts = line.splitn(3, ' ');
let (Some(oid), Some(size), Some(path)) = (parts.next(), parts.next(), parts.next())
else {
anyhow::bail!("malformed `rad lfs precommit` input line: {line:?}");
};
let size: i64 = size
.parse()
.with_context(|| format!("invalid size in `rad lfs precommit` input: {size:?}"))?;
store_object(
&profile,
&repo,
rid,
&doc,
oid,
size,
&PathBuf::from(path),
&mut signer,
)
.with_context(|| format!("failed to store LFS object for `{path}`"))?;
}
Ok(())
}

View File

@ -1,152 +0,0 @@
//! `rad lfs rekey` — grants newly-authorized collaborators (new delegates,
//! or newly-added entries in a private repo's allow-list) access to
//! previously-encrypted LFS objects, without re-encrypting or re-uploading
//! any content: it only adds new wrapped-key entries to each object's
//! note for recipients who are missing one.
use std::collections::BTreeSet;
use anyhow::Context as _;
use radicle::git::raw::Signature;
use radicle::identity::Did;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::lfs_crypto::{self, LOCAL_NOTES_REF, NOTE_AUTHOR_EMAIL, NOTE_AUTHOR_NAME};
use crate::terminal as term;
pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs rekey` must be run inside a Radicle repository working copy")?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
let signer = lfs_crypto::load_signer(&profile)?;
let my_did = profile.did();
// The full set of currently-authorized DIDs: delegates the private
// allow-list `my_did` itself (the `sender` parameter of
// `recipients_for` is only meaningful for `encrypt_for`'s "make sure
// the committer can read their own object back" guarantee; here we
// just want the full authorized set, and `my_did` is necessarily
// already a member of it since running `rekey` requires being
// authorized).
let current = lfs_crypto::recipients_for(&doc, my_did);
// Walk every note across every peer's notes ref -- not just the local
// one -- since an object's only note may live under a peer we've
// fetched from rather than one we wrote ourselves.
let targets = lfs_crypto::all_note_targets(&repo)
.context("failed to enumerate LFS notes")?;
if targets.is_empty() {
term::success!("Nothing to rekey: no LFS objects are tracked in this repository yet");
return Ok(());
}
let signature = Signature::now(NOTE_AUTHOR_NAME, NOTE_AUTHOR_EMAIL)
.context("failed to construct note signature")?;
let mut rekeyed_objects = 0;
let mut added_recipients = 0;
for target_oid in targets {
// The note only stores the CID and encryption metadata, not the
// oid/size that were used as HKDF context when wrapping the
// content key. Recover them losslessly from the pointer blob
// itself: `target_oid` *is* the git blob hash of the exact
// deterministic pointer text `store`/`fetch` compute from
// oid/size, so we can parse it back out.
let Ok(blob) = repo.find_blob(target_oid) else {
continue;
};
let Ok(pointer_text) = std::str::from_utf8(blob.content()) else {
continue;
};
let Some(oid) = pointer_text
.lines()
.find_map(|line| line.strip_prefix("oid sha256:"))
else {
continue;
};
let candidates = match lfs_crypto::find_envelopes(&repo, target_oid) {
Ok(candidates) => candidates,
Err(err) => {
term::warning(format!("Skipping object with oid {oid}: {err}"));
continue;
}
};
// Almost always exactly one candidate; more than one only if two
// peers independently encrypted byte-identical content (same
// oid/size) producing different ciphertext each time. Rekey
// whichever ones we're actually authorized to unwrap -- skip the
// rest quietly, they're not our concern.
for mut envelope in candidates {
let Some(enc) = envelope.enc.as_mut() else {
// Public (unencrypted) object: nothing to rekey.
continue;
};
let cek = match lfs_crypto::unwrap_cek(&enc.recipients, &signer, my_did, &rid, oid) {
Ok(cek) => cek,
Err(_) => {
// Not authorized for *this particular* candidate --
// expected and not worth warning about, since another
// candidate for the same object (or none) may apply.
continue;
}
};
let existing: BTreeSet<Did> = enc.recipients.iter().map(|w| w.did).collect();
let missing: Vec<Did> = current.difference(&existing).copied().collect();
if missing.is_empty() {
continue;
}
let cek_key: chacha20poly1305::Key = (*cek).into();
let mut any_added = false;
for recipient in &missing {
match lfs_crypto::wrap_cek_for(&cek_key, &signer, my_did, recipient, &rid, oid) {
Ok(wrapped) => {
enc.recipients.push(wrapped);
added_recipients += 1;
any_added = true;
}
Err(err) => {
term::warning(format!(
"Failed to wrap the content key for {recipient} on object with oid \
{oid}: {err}"
));
}
}
}
if !any_added {
continue;
}
// Always write to our own local ref, regardless of which
// peer's ref the original note came from -- once pushed, this
// lands under our own namespace and is picked up by others
// via the fetch refspec, alongside (not replacing) the
// original note.
let message = serde_json::to_string(&envelope)
.context("failed to serialize LFS note envelope")?;
repo.note(&signature, &signature, Some(LOCAL_NOTES_REF), target_oid, &message, true)
.context("failed to write LFS note")?;
rekeyed_objects += 1;
}
}
if rekeyed_objects == 0 {
term::success!("Nothing to rekey: all recorded recipients already have access");
} else {
term::success!(
"Rekeyed {rekeyed_objects} object(s), added {added_recipients} new recipient(s)"
);
}
Ok(())
}

View File

@ -1,107 +0,0 @@
//! `rad lfs store` — plumbing command invoked by the Git LFS custom
//! transfer agent (`rad-lfs-transfer`) to upload one LFS object's content
//! to the local IPFS (Kubo) node, encrypting it first if the repository
//! is private.
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context as _;
use radicle::Profile;
use radicle::git::raw::{Repository, Signature};
use radicle::identity::{Doc, RepoId};
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use radicle_crypto::ssh::keystore::MemorySigner;
use crate::ipfs;
use crate::lfs_crypto::{self, Envelope, LOCAL_NOTES_REF, NOTE_AUTHOR_EMAIL, NOTE_AUTHOR_NAME};
use crate::terminal as term;
pub fn run(oid: String, size: i64, path: PathBuf, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs store` must be run inside a Radicle repository working copy")?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
let mut signer = None;
let cid = store_object(&profile, &repo, rid, &doc, &oid, size, &path, &mut signer)?;
term::println(cid);
Ok(())
}
/// Stores one LFS object's content in IPFS (encrypting first for private
/// repos) and records the resulting CID as a git note. Factored out of
/// [`run`] so `rad lfs precommit` can process every staged LFS file in a
/// single process, loading (and prompting for) the signer at most once
/// across the whole batch instead of once per file.
#[allow(clippy::too_many_arguments)]
pub fn store_object(
profile: &Profile,
repo: &Repository,
rid: RepoId,
doc: &Doc,
oid: &str,
size: i64,
path: &Path,
signer: &mut Option<MemorySigner>,
) -> anyhow::Result<String> {
let pointer_text =
format!("version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n");
let blob_oid = repo
.blob(pointer_text.as_bytes())
.context("failed to write LFS pointer blob")?;
// Fast path: a note already recorded for this object (typically written
// moments ago by `rad lfs precommit`, or fetched from a peer) means the
// content is already pinned in IPFS -- nothing left to do. This matters
// beyond just avoiding duplicate work: `git push`'s custom-transfer-agent
// subprocess chain has no TTY (its stdio is consumed by the transfer
// protocol, not a terminal), so without this fast path, a private repo's
// `git push` would call back into `store_object` per file and fail
// outright on the passphrase prompt every time, even though the actual
// encryption work was already done (and the passphrase already
// supplied) once, interactively, at commit time.
if let Some(cid) = lfs_crypto::find_cids(repo, blob_oid)?.into_iter().next() {
return Ok(cid);
}
ipfs::check_daemon()?;
let envelope = if doc.visibility().is_public() {
let bytes = std::fs::read(path)
.with_context(|| format!("failed to read `{}`", path.display()))?;
let cid = ipfs::add(&bytes)?;
let _ = ipfs::pin_all(std::slice::from_ref(&cid));
Envelope::plain(cid)
} else {
if signer.is_none() {
*signer = Some(lfs_crypto::load_signer(profile)?);
}
let signer = signer.as_ref().expect("just populated above");
let sender_did = profile.did();
let recipients = lfs_crypto::recipients_for(doc, sender_did);
let plaintext = std::fs::read(path)
.with_context(|| format!("failed to read `{}`", path.display()))?;
let (ciphertext, enc) =
lfs_crypto::encrypt_for(&plaintext, signer, sender_did, &rid, oid, &recipients)?;
let cid = ipfs::add(&ciphertext)?;
let _ = ipfs::pin_all(std::slice::from_ref(&cid));
Envelope {
v: 1,
cid,
enc: Some(enc),
}
};
let cid = envelope.cid.clone();
let message =
serde_json::to_string(&envelope).context("failed to serialize LFS note envelope")?;
let signature = Signature::now(NOTE_AUTHOR_NAME, NOTE_AUTHOR_EMAIL)
.context("failed to construct note signature")?;
repo.note(&signature, &signature, Some(LOCAL_NOTES_REF), blob_oid, &message, true)
.context("failed to write LFS note")?;
Ok(cid)
}

View File

@ -70,19 +70,6 @@ pub fn update(
term::format::tertiary(rid),
);
// Since seeding a repository means keeping a full local copy of it,
// also pin any of its Git-LFS objects that are stored in IPFS, so
// that they follow the same seed/unseed lifecycle.
if let Ok(repo) = profile.storage.repository(rid) {
let cids = crate::ipfs::lfs_cids(&repo.backend);
if !cids.is_empty() {
let pinned = crate::ipfs::pin_all(&cids);
if pinned > 0 {
term::success!("Pinned {pinned} LFS object(s) in IPFS");
}
}
}
Ok(())
}

View File

@ -1,8 +1,5 @@
pub mod args;
use std::collections::BTreeSet;
use radicle::node::policy;
use radicle::{Node, prelude::*};
use crate::terminal as term;
@ -21,70 +18,8 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
}
pub fn delete(rid: RepoId, node: &mut Node, profile: &Profile) -> anyhow::Result<()> {
// Gather the repository's known LFS objects *before* unseeding, since
// we still need access to its git notes at that point.
let cids = profile
.storage
.repository(rid)
.map(|repo| crate::ipfs::lfs_cids(&repo.backend))
.unwrap_or_default();
if profile.unseed(rid, node)? {
term::success!("Seeding policy for {} removed", term::format::tertiary(rid));
}
// Mirror the seed/unseed lifecycle for IPFS pins of this repository's
// Git-LFS objects -- except any CID also referenced by an LFS note in
// some *other* still-seeded repository (e.g. two repositories that
// happen to both track a byte-identical file, giving it the same
// content-addressed CID). Unpinning unconditionally would evict
// content the other repository still needs, breaking it silently the
// next time someone there runs `git lfs fetch`. There's no
// cross-repository pin refcount kept anywhere -- this recomputes the
// still-needed set from every other seeded repository's own notes each
// time, which costs a pass over every other seeded repository but
// requires no extra persistent state, consistent with this design's
// git-notes-as-source-of-truth approach everywhere else.
if !cids.is_empty() {
let still_needed = cids_still_needed_elsewhere(profile, rid)?;
let skipped = cids.iter().filter(|cid| still_needed.contains(*cid)).count();
let safe_to_unpin: Vec<String> =
cids.into_iter().filter(|cid| !still_needed.contains(cid)).collect();
if !safe_to_unpin.is_empty() {
crate::ipfs::unpin_all(&safe_to_unpin);
}
if skipped > 0 {
term::info!(
"Kept {skipped} IPFS object(s) pinned -- still referenced by another seeded repository"
);
}
}
Ok(())
}
/// The set of CIDs (among `excluding`'s own) that are also referenced by an
/// LFS note in some other repository this node is currently seeding.
fn cids_still_needed_elsewhere(
profile: &Profile,
excluding: RepoId,
) -> anyhow::Result<BTreeSet<String>> {
let mut needed = BTreeSet::new();
let store = profile.policies()?;
for entry in store.seed_policies()? {
let policy::SeedPolicy { rid, policy } = entry?;
if rid == excluding {
continue;
}
if !matches!(policy, policy::SeedingPolicy::Allow { .. }) {
continue;
}
if let Ok(repo) = profile.storage.repository(rid) {
needed.extend(crate::ipfs::lfs_cids(&repo.backend));
}
}
Ok(needed)
}

View File

@ -1,246 +0,0 @@
//! Pinning of Git-LFS-backed content in a local IPFS (Kubo) node.
//!
//! This is a prototype of "Git-LFS over IPFS": large files tracked via
//! Git LFS are stored in IPFS rather than as git blobs, and the mapping
//! from an LFS pointer file's git blob oid to the corresponding IPFS CID
//! is recorded as a git note on `refs/notes/rad-lfs` (note content is a
//! JSON [`crate::lfs_crypto::Envelope`], e.g. `{"v":1,"cid":"Qm...",
//! "enc":null}` for a public repo, or with populated `enc` metadata for
//! a private one).
//!
//! Since seeding a repository already means "keep a full local copy of
//! it", we extend that lifecycle to IPFS: seeding a repository pins all
//! of its known LFS objects locally, and unseeding unpins them.
use std::env;
use anyhow::anyhow;
use radicle::git::raw::Repository;
use crate::terminal as term;
/// Git notes ref under which oid -> CID mappings for LFS objects are stored.
///
/// This is the *local* ref name; readers need to merge across every
/// fetched peer's copy too (`refs/notes/rad-lfs/<peer>`) -- see
/// `crate::lfs_crypto::find_envelopes`/`all_note_targets`, which
/// `lfs_cids` below delegates to.
pub use crate::lfs_crypto::NOTES_REF as LFS_NOTES_REF;
/// Default HTTP API address of a local Kubo (IPFS) daemon.
const DEFAULT_KUBO_API_URL: &str = "http://127.0.0.1:5001";
/// Cap on how large an LFS object's content we'll read back from IPFS in
/// one go. `ureq`'s `Body::read_to_vec()` otherwise defaults to a 10MB
/// limit meant for typical HTTP API responses, which is far too small for
/// LFS content (that's the whole point of storing it out-of-band). 1GiB
/// comfortably covers any file worth tracking in this repo; raise if a
/// larger LFS object legitimately needs to be fetched.
const MAX_CAT_RESPONSE_BYTES: u64 = 1024 * 1024 * 1024;
/// Returns the Kubo HTTP API base URL, respecting the `KUBO_API_URL`
/// environment variable if set.
pub(crate) fn kubo_api_url() -> String {
env::var("KUBO_API_URL").unwrap_or_else(|_| DEFAULT_KUBO_API_URL.to_string())
}
/// Check that a local IPFS (Kubo) daemon is reachable, failing with a
/// clear, actionable error otherwise. Used by `rad lfs init` to refuse to
/// proceed silently when there's no daemon to back LFS content with.
pub fn check_daemon() -> anyhow::Result<()> {
let base = kubo_api_url();
let url = format!("{base}/api/v0/id");
match ureq::post(&url).send_empty() {
Ok(_) => Ok(()),
Err(err) if is_daemon_unreachable(&err) => Err(anyhow!(
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
This repo's large-file (LFS) support is backed by your local IPFS node."
)),
Err(err) => Err(anyhow!("IPFS daemon at {base} responded unexpectedly: {err}")),
}
}
/// Collect the set of CIDs referenced by LFS notes, across every peer's
/// notes ref (not just our own — a repo we're seeding may hold objects
/// whose only note came from someone else's `refs/notes/rad-lfs/<peer>`).
///
/// Returns an empty vector if no notes exist, i.e. the repository has no
/// LFS objects tracked yet. This is not an error.
#[must_use]
pub fn lfs_cids(repo: &Repository) -> Vec<String> {
let Ok(targets) = crate::lfs_crypto::all_note_targets(repo) else {
return Vec::new();
};
let mut cids = Vec::new();
for target in targets {
if let Ok(found) = crate::lfs_crypto::find_cids(repo, target) {
cids.extend(found);
}
}
cids
}
/// Add `bytes` to the local IPFS node as a single blob, returning its CID.
pub fn add(bytes: &[u8]) -> anyhow::Result<String> {
use ureq::unversioned::multipart::{Form, Part};
let base = kubo_api_url();
let url = format!("{base}/api/v0/add");
let form = Form::new().part("file", Part::bytes(bytes).file_name("blob"));
let mut response = ureq::post(&url).send(form).map_err(|err| {
if is_daemon_unreachable(&err) {
anyhow!(
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
This repo's large-file (LFS) support is backed by your local IPFS node."
)
} else {
anyhow!("failed to add object to IPFS: {err}")
}
})?;
let body = response
.body_mut()
.read_to_string()
.map_err(|err| anyhow!("failed to read IPFS `add` response: {err}"))?;
// The response is newline-delimited JSON, one object per added file;
// we only ever add a single blob per call, so the first line is all
// we need.
let line = body
.lines()
.next()
.ok_or_else(|| anyhow!("empty response from IPFS `add`"))?;
let value: serde_json::Value = serde_json::from_str(line)
.map_err(|err| anyhow!("failed to parse IPFS `add` response: {err}"))?;
let hash = value
.get("Hash")
.and_then(|h| h.as_str())
.ok_or_else(|| anyhow!("IPFS `add` response missing a `Hash` field"))?;
Ok(hash.to_string())
}
/// Fetch the raw bytes of the object identified by `cid` from the local
/// IPFS node.
pub fn cat(cid: &str) -> anyhow::Result<Vec<u8>> {
let base = kubo_api_url();
let url = format!("{base}/api/v0/cat?arg={cid}");
let mut response = ureq::post(&url).send_empty().map_err(|err| {
if is_daemon_unreachable(&err) {
anyhow!(
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
This repo's large-file (LFS) support is backed by your local IPFS node."
)
} else {
anyhow!("failed to fetch object {cid} from IPFS: {err}")
}
})?;
response
.body_mut()
.with_config()
.limit(MAX_CAT_RESPONSE_BYTES)
.read_to_vec()
.map_err(|err| anyhow!("failed to read IPFS `cat` response for {cid}: {err}"))
}
/// Whether a `ureq` error indicates that the local IPFS daemon could
/// not be reached at all, as opposed to an HTTP-level error response.
fn is_daemon_unreachable(err: &ureq::Error) -> bool {
matches!(
err,
ureq::Error::ConnectionFailed
| ureq::Error::Io(_)
| ureq::Error::HostNotFound
| ureq::Error::Timeout(_)
)
}
/// Pin the given CIDs recursively in the local IPFS daemon.
///
/// Returns the number of CIDs successfully pinned. If the local daemon
/// is not reachable, a warning is printed and `0` is returned; this is
/// not treated as a hard failure, since seeding itself already succeeded.
#[must_use]
pub fn pin_all(cids: &[String]) -> usize {
let base = kubo_api_url();
let mut pinned = 0;
for cid in cids {
let url = format!("{base}/api/v0/pin/add?arg={cid}&recursive=true");
match ureq::post(&url).send_empty() {
Ok(_) => pinned += 1,
Err(err) if is_daemon_unreachable(&err) => {
term::warning(format!(
"Could not reach local IPFS daemon at {base}; LFS objects were not pinned"
));
return pinned;
}
Err(err) => {
term::warning(format!("Failed to pin LFS object {cid} in IPFS: {err}"));
}
}
}
pinned
}
/// Unpin the given CIDs from the local IPFS daemon.
///
/// If the local daemon is not reachable, a warning is printed and the
/// function returns without failing; this is not treated as a hard
/// failure, since unseeding itself already succeeded.
pub fn unpin_all(cids: &[String]) {
let base = kubo_api_url();
for cid in cids {
let url = format!("{base}/api/v0/pin/rm?arg={cid}");
// NOTE: this unconditionally unpins `cid`, even if it's still
// referenced by LFS objects of another repository that remains
// seeded. This prototype has no cross-repo pin refcounting, so
// a CID shared between repositories will be unpinned here
// regardless of whether other seeded repos still need it.
match ureq::post(&url).send_empty() {
Ok(_) => {}
Err(err) if is_daemon_unreachable(&err) => {
term::warning(format!(
"Could not reach local IPFS daemon at {base}; LFS objects were not unpinned"
));
return;
}
Err(err) => {
term::warning(format!("Failed to unpin LFS object {cid} in IPFS: {err}"));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Regression test for the `cat()` response being truncated at
/// `ureq`'s default 10MB read limit for any LFS object larger than
/// that (e.g. the ~29MB `assets/posts/hashcash/thumbnail.xcf` in the
/// blog repo failed to smudge with exactly this error before the
/// `MAX_CAT_RESPONSE_BYTES` fix). Requires a local Kubo daemon, so
/// it's `#[ignore]`d by default: `cargo test -- --ignored`.
#[test]
#[ignore = "requires a local IPFS (Kubo) daemon at 127.0.0.1:5001"]
fn cat_round_trips_content_larger_than_ureqs_default_10mb_limit() {
check_daemon().expect("local Kubo daemon must be reachable for this test");
let bytes = vec![0x42u8; 15 * 1024 * 1024]; // 15MB, past the old 10MB cap
let cid = add(&bytes).expect("add should succeed");
let fetched = cat(&cid).expect("cat should succeed for content past the old 10MB cap");
assert_eq!(fetched.len(), bytes.len());
assert_eq!(fetched, bytes);
unpin_all(std::slice::from_ref(&cid));
}
}

View File

@ -1,418 +0,0 @@
//! Client-side encryption for Git-LFS-over-IPFS objects on private
//! repositories.
//!
//! Radicle's `Visibility::Private` is enforced entirely by access control
//! at the replication layer -- git objects for a private repo are plain,
//! unencrypted objects, protected only by "unauthorized peers are never
//! given them". That protection has no jurisdiction over IPFS's own
//! block-exchange layer: once a legitimate collaborator's IPFS daemon
//! pins LFS content and joins the network, anyone who obtains the CID by
//! any means can fetch the plaintext directly. This module makes sure
//! IPFS only ever stores ciphertext for private repos, using Radicle's
//! own identity keys via envelope encryption (a random per-object content
//! key, wrapped once per authorized recipient).
use std::collections::BTreeSet;
use anyhow::{Context as _, anyhow, bail};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use chacha20poly1305::aead::rand_core::RngCore;
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use cyphernet::Ecdh;
use hkdf::Hkdf;
use radicle::Profile;
use radicle::git::raw::{Oid, Repository};
use radicle::identity::{Did, Doc, RepoId, Visibility};
use radicle_crypto::ssh::keystore::MemorySigner;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use zeroize::Zeroizing;
pub const ENVELOPE_VERSION: u32 = 1;
const HKDF_DOMAIN: &[u8] = b"radicle-lfs-wrap-v1";
const ALG: &str = "xchacha20poly1305";
/// The canonical, bare notes ref name. This is what the *remote* uses --
/// it's the push destination (lands in the pusher's own namespace on the
/// server regardless of the local source ref, same as `refs/heads/*`) and
/// what `crates/radicle-remote-helper/src/list.rs`'s `lfs_notes_refs`
/// looks for within each peer's namespace server-side.
///
/// It is deliberately **not** used as a local write target anymore (see
/// [`LOCAL_NOTES_REF`]): `rad lfs init`'s fetch refspec populates
/// `refs/notes/rad-lfs/<peer>` siblings locally, including our own peer's
/// copy fetched back after a push. A bare `refs/notes/rad-lfs` ref
/// coexisting with `refs/notes/rad-lfs/<peer>` is a git ref D/F
/// (file-vs-directory) conflict -- `refs/notes/rad-lfs` can't
/// simultaneously be a leaf ref and a directory prefix in the same ref
/// namespace -- which made every `rad lfs store` call after the first
/// fetch fail outright with "failed to write LFS note".
pub const NOTES_REF: &str = "refs/notes/rad-lfs";
/// The local, not-yet-pushed note ref -- `rad lfs store`/`rekey` write
/// here instead of the bare [`NOTES_REF`], specifically to avoid the D/F
/// conflict above: living under the same `refs/notes/rad-lfs/` prefix as
/// every fetched peer ref means it's just another sibling leaf ref, never
/// a conflicting directory/file pair. `rad lfs init`'s push refspec maps
/// this local name to the bare [`NOTES_REF`] on the remote.
pub const LOCAL_NOTES_REF: &str = "refs/notes/rad-lfs/local";
/// Glob matching [`LOCAL_NOTES_REF`] and every fetched peer ref, plus (for
/// backward compatibility while any not-yet-migrated repo or peer might
/// still have one) the legacy bare [`NOTES_REF`] itself, purely for
/// reading -- nothing writes there anymore.
const NOTES_REF_GLOB: &str = "refs/notes/rad-lfs*";
/// Committer identity used for `refs/notes/rad-lfs/*` notes. There's no
/// natural "author" for a note that just records a CID, so a fixed
/// identity is used everywhere a note gets written.
pub const NOTE_AUTHOR_NAME: &str = "rad-lfs";
pub const NOTE_AUTHOR_EMAIL: &str = "rad-lfs@localhost";
/// Note content stored on `refs/notes/rad-lfs`, replacing the earlier
/// bare `cid=<cid>` text. `enc` is `None` for public repos (plain
/// passthrough, unchanged behavior).
#[derive(Debug, Serialize, Deserialize)]
pub struct Envelope {
pub v: u32,
pub cid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enc: Option<Encryption>,
}
impl Envelope {
#[must_use]
pub fn plain(cid: String) -> Self {
Self {
v: ENVELOPE_VERSION,
cid,
enc: None,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Encryption {
pub alg: String,
/// Nonce for the content AEAD. Fixed for the object's life -- `rekey`
/// never re-encrypts content, only adds wrapped-key entries.
pub content_nonce: String,
pub recipients: Vec<WrappedKey>,
}
/// One recipient's wrapped copy of the object's content-encryption key.
/// `sender_did` is per-entry, not per-envelope: a `rekey` operation is
/// performed by a different identity (using their own key) than whoever
/// originally committed the object, so each entry must record who
/// actually produced it.
#[derive(Debug, Serialize, Deserialize)]
pub struct WrappedKey {
pub did: Did,
pub sender_did: Did,
pub salt: String,
pub wrapped_cek: String,
}
/// Recipients authorized for a repository: delegates, plus the private
/// allow-list (empty for public repos), plus the sender themselves --
/// don't rely on "delegates always includes the author", make it explicit
/// so the committer can always fetch their own commits back.
#[must_use]
pub fn recipients_for(doc: &Doc, sender: Did) -> BTreeSet<Did> {
let mut set: BTreeSet<Did> = doc.delegates().iter().copied().collect();
if let Visibility::Private { allow } = doc.visibility() {
set.extend(allow.iter().copied());
}
set.insert(sender);
set
}
/// Loads a signer capable of ECDH. Only `MemorySigner` implements the
/// `Ecdh` trait -- ssh-agent (the normal day-to-day flow after `rad
/// auth`) can sign but can't expose the raw key material a key-agreement
/// operation needs. This is a hard limitation of the standard ssh-agent
/// protocol, which only implements signing operations, not general key
/// agreement -- there's no ssh-agent request type for it, so this can't
/// be routed through the agent the way signing is.
///
/// The next best thing, matching the exact fallback
/// `crate::terminal::io::signer` already uses elsewhere in this CLI when
/// ssh-agent isn't available or the key isn't registered with it: if
/// connected to a TTY, prompt interactively for the passphrase instead of
/// requiring `RAD_PASSPHRASE` to be pre-set. This covers the common case
/// (an interactive `git commit` triggering the pre-commit hook, which
/// inherits the terminal's TTY) without needing an env var at all; only
/// genuinely non-interactive contexts (CI, a script with no TTY) still
/// need `RAD_PASSPHRASE`, and get a fast, clear failure rather than a
/// hang, since `inquire` (via `radicle_term::io::passphrase`) returns
/// `Ok(None)` on `NotTTY` rather than blocking.
pub fn load_signer(profile: &Profile) -> anyhow::Result<MemorySigner> {
if !profile.keystore.is_encrypted()? {
return Ok(MemorySigner::load(&profile.keystore, None)?);
}
if let Some(passphrase) = radicle::profile::env::passphrase() {
return Ok(MemorySigner::load(&profile.keystore, Some(passphrase))?);
}
if let Some(passphrase) = prompt_passphrase(profile)? {
return Ok(MemorySigner::load(&profile.keystore, Some(passphrase))?);
}
bail!(
"private-repo LFS operations need to perform a key-agreement (ECDH) operation, which \
requires direct access to your secret key -- ssh-agent can sign but can't do this \
(a protocol limitation, not something to configure around). Run this from a \
terminal to be prompted for your passphrase, or set RAD_PASSPHRASE, or use an \
unencrypted keystore."
)
}
/// Prompts for the keystore passphrase if connected to a TTY. Returns
/// `Ok(None)` (not an error) when there's no TTY to prompt on, so callers
/// can fall through to a clear error instead of hanging.
fn prompt_passphrase(profile: &Profile) -> anyhow::Result<Option<radicle_crypto::ssh::keystore::Passphrase>> {
let validator = crate::terminal::io::PassphraseValidator::new(profile.keystore.clone());
Ok(crate::terminal::io::passphrase(validator)?)
}
/// Encrypts `plaintext` with a fresh content key, then wraps that key once
/// per recipient. Returns the ciphertext to store in IPFS and the
/// encryption metadata to record in the note.
pub fn encrypt_for(
plaintext: &[u8],
sender: &MemorySigner,
sender_did: Did,
rid: &RepoId,
oid: &str,
recipients: &BTreeSet<Did>,
) -> anyhow::Result<(Vec<u8>, Encryption)> {
let cek = XChaCha20Poly1305::generate_key(&mut OsRng);
let content_nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let cipher = XChaCha20Poly1305::new(&cek);
let ciphertext = cipher
.encrypt(&content_nonce, plaintext)
.map_err(|_| anyhow!("content encryption failed"))?;
let mut wrapped = Vec::with_capacity(recipients.len());
for recipient in recipients {
wrapped.push(wrap_cek_for(&cek, sender, sender_did, recipient, rid, oid)?);
}
Ok((
ciphertext,
Encryption {
alg: ALG.to_string(),
content_nonce: BASE64.encode(content_nonce),
recipients: wrapped,
},
))
}
pub fn wrap_cek_for(
cek: &Key,
sender: &MemorySigner,
sender_did: Did,
recipient: &Did,
rid: &RepoId,
oid: &str,
) -> anyhow::Result<WrappedKey> {
let shared = sender
.ecdh(recipient.as_key())
.map_err(|e| anyhow!("ECDH with {recipient} failed: {e}"))?;
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
let wrap_key = derive_wrap_key(&shared, &salt, rid, oid, &sender_did, recipient);
// Fixed all-zero nonce is safe here: `wrap_key` is derived via HKDF
// from a salt that's freshly generated for this exact
// (rid, oid, sender, recipient) wrap operation, so it is never reused
// to encrypt more than this one 32-byte CEK -- key-once nonce reuse
// is not a vulnerability.
let cipher = XChaCha20Poly1305::new(&wrap_key);
let wrapped_cek = cipher
.encrypt(&XNonce::default(), cek.as_slice())
.map_err(|_| anyhow!("failed to wrap key for {recipient}"))?;
Ok(WrappedKey {
did: *recipient,
sender_did,
salt: BASE64.encode(salt),
wrapped_cek: BASE64.encode(wrapped_cek),
})
}
/// HKDF context mixes in more than the raw ECDH shared secret: the shared
/// secret between two fixed DIDs is *static*, reused across every object
/// they ever exchange. Without per-object context here, the "nonce fixed
/// because key-once" argument in `wrap_cek_for` would be false -- the same
/// key could end up wrapping multiple different CEKs. Mixing in `rid` +
/// `oid` (plus a fresh random `salt`) makes every wrap operation
/// cryptographically independent, even between the same two DIDs.
fn derive_wrap_key(
shared: &[u8; 32],
salt: &[u8],
rid: &RepoId,
oid: &str,
sender_did: &Did,
recipient_did: &Did,
) -> Key {
let hk = Hkdf::<Sha256>::new(Some(salt), shared);
let mut info = Vec::new();
info.extend_from_slice(HKDF_DOMAIN);
info.extend_from_slice(rid.to_string().as_bytes());
info.extend_from_slice(oid.as_bytes());
info.extend_from_slice(sender_did.to_string().as_bytes());
info.extend_from_slice(recipient_did.to_string().as_bytes());
let mut out = [0u8; 32];
hk.expand(&info, &mut out)
.expect("32 is a valid HKDF-SHA256 output length");
out.into()
}
/// Recovers the content-encryption key from the caller's own entry among a
/// set of wrapped keys, without needing the ciphertext at all -- this is
/// the exact primitive `rad lfs rekey` needs (it only adds new wrapped-key
/// entries for the same never-changing CEK, it never re-encrypts content).
pub fn unwrap_cek(
recipients: &[WrappedKey],
me: &MemorySigner,
my_did: Did,
rid: &RepoId,
oid: &str,
) -> anyhow::Result<Zeroizing<[u8; 32]>> {
let entry = recipients
.iter()
.find(|w| w.did == my_did)
.ok_or_else(|| anyhow!("you are not an authorized recipient of this object"))?;
let shared = me
.ecdh(entry.sender_did.as_key())
.map_err(|e| anyhow!("ECDH with {} failed: {e}", entry.sender_did))?;
let salt = BASE64
.decode(&entry.salt)
.context("invalid salt in wrapped key entry")?;
let wrap_key = derive_wrap_key(&shared, &salt, rid, oid, &entry.sender_did, &my_did);
let wrapped_cek = BASE64
.decode(&entry.wrapped_cek)
.context("invalid wrapped content key")?;
let cipher = XChaCha20Poly1305::new(&wrap_key);
let cek = cipher
.decrypt(&XNonce::default(), wrapped_cek.as_slice())
.map_err(|_| anyhow!("failed to unwrap key -- wrong key or corrupted data"))?;
let mut out = [0u8; 32];
out.copy_from_slice(&cek);
Ok(Zeroizing::new(out))
}
/// Decrypts object content using the caller's own wrapped-key entry.
pub fn decrypt_with(
ciphertext: &[u8],
enc: &Encryption,
me: &MemorySigner,
my_did: Did,
rid: &RepoId,
oid: &str,
) -> anyhow::Result<Vec<u8>> {
let cek = unwrap_cek(&enc.recipients, me, my_did, rid, oid)?;
let nonce_bytes = BASE64
.decode(&enc.content_nonce)
.context("invalid content nonce")?;
let nonce = XNonce::from_slice(&nonce_bytes);
let key: Key = (*cek).into();
let cipher = XChaCha20Poly1305::new(&key);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow!("content decryption failed -- wrong key or corrupted data"))
}
/// Finds every distinct envelope recorded for `blob_oid`, across the
/// local `refs/notes/rad-lfs` ref and every fetched peer ref under
/// `refs/notes/rad-lfs/<peer>`. Notes are per-peer contributions -- any
/// peer can commit an LFS-tracked file, not just delegates -- so more
/// than one peer's note can exist for the same object, e.g. after a
/// `rad lfs rekey` that added recipients under a different peer's
/// namespace than the original committer.
///
/// Notes sharing the same `cid` are the *same* encrypted object (rekey
/// never changes the ciphertext, only adds wrapped-key entries for it),
/// so their recipient lists are safely unioned together. Notes with
/// *different* `cid`s (which can only happen if two peers independently
/// encrypted byte-identical plaintext -- same oid/size, since that's
/// what determines the pointer blob -- producing different ciphertext
/// each time since encryption uses a fresh random key/nonce) are kept as
/// separate candidates, since a wrapped-key entry from one is only valid
/// against its own ciphertext, never the other's. Callers that need to
/// decrypt should try each returned candidate in turn.
pub fn find_envelopes(repo: &Repository, blob_oid: Oid) -> anyhow::Result<Vec<Envelope>> {
let mut by_cid: Vec<Envelope> = Vec::new();
for name in notes_ref_names(repo)? {
let Ok(note) = repo.find_note(Some(&name), blob_oid) else {
continue;
};
let Some(message) = note.message() else {
continue;
};
let Ok(envelope) = serde_json::from_str::<Envelope>(message) else {
continue;
};
match by_cid.iter_mut().find(|e| e.cid == envelope.cid) {
Some(existing) => merge_recipients(existing, envelope),
None => by_cid.push(envelope),
}
}
Ok(by_cid)
}
/// Convenience wrapper for callers that only need the set of distinct
/// CIDs recorded for `blob_oid` (e.g. seed/unseed pinning, which needs
/// no key material and doesn't care about recipients at all).
pub fn find_cids(repo: &Repository, blob_oid: Oid) -> anyhow::Result<Vec<String>> {
Ok(find_envelopes(repo, blob_oid)?
.into_iter()
.map(|e| e.cid)
.collect())
}
fn merge_recipients(into: &mut Envelope, other: Envelope) {
let (Some(into_enc), Some(other_enc)) = (into.enc.as_mut(), other.enc) else {
return;
};
let existing: BTreeSet<Did> = into_enc.recipients.iter().map(|w| w.did).collect();
into_enc
.recipients
.extend(other_enc.recipients.into_iter().filter(|w| !existing.contains(&w.did)));
}
/// Every distinct blob oid that has at least one note recorded against it,
/// across every notes ref (local plus every fetched peer). Used by
/// `rad lfs rekey` to walk every known LFS object, since a single
/// `notes()` call on one ref would now miss objects whose only note lives
/// under a different peer's ref.
pub fn all_note_targets(repo: &Repository) -> anyhow::Result<BTreeSet<Oid>> {
let mut targets = BTreeSet::new();
for name in notes_ref_names(repo)? {
let Ok(notes) = repo.notes(Some(&name)) else {
continue;
};
for (_, target_oid) in notes.flatten() {
targets.insert(target_oid);
}
}
Ok(targets)
}
fn notes_ref_names(repo: &Repository) -> anyhow::Result<Vec<String>> {
let mut names = Vec::new();
for reference in repo.references_glob(NOTES_REF_GLOB)? {
let reference = reference?;
if let Some(name) = reference.name() {
names.push(name.to_string());
}
}
Ok(names)
}

View File

@ -4,8 +4,6 @@
#![deny(clippy::print_stdout)]
pub mod commands;
pub mod git;
pub mod ipfs;
pub mod lfs_crypto;
pub mod node;
pub mod pager;
pub mod project;

View File

@ -76,7 +76,6 @@ enum Command {
#[command(alias = ".")]
Inspect(inspect::Args),
Issue(issue::Args),
Lfs(lfs::Args),
Ls(ls::Args),
Node(node::Args),
Patch(patch::Args),
@ -272,7 +271,6 @@ fn run_command(command: Command, ctx: impl term::Context) -> Result<(), anyhow::
Command::Init(args) => init::run(args, ctx),
Command::Inspect(args) => inspect::run(args, ctx),
Command::Issue(args) => issue::run(args, ctx),
Command::Lfs(args) => lfs::run(args, ctx),
Command::Ls(args) => ls::run(args, ctx),
Command::Node(args) => node::run(args, ctx),
Command::Patch(args) => patch::run(args, ctx),

View File

@ -32,9 +32,6 @@ pub(super) enum Error {
/// General repository error.
#[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError),
/// Refs error.
#[error(transparent)]
Refs(#[from] radicle::storage::refs::Error),
}
/// List refs for fetching (`git fetch` and `git ls-remote`).
@ -76,34 +73,6 @@ pub(super) fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'st
Ok(mut refs) => lines.append(&mut refs),
Err(e) => eprintln!("remote: error listing patch refs: {e}"),
}
// List each peer's `refs/notes/rad-lfs`, individually, under
// `refs/notes/rad-lfs/<peer-id>`. Notes are per-peer
// contributions -- any peer (not just delegates) can commit an
// LFS-tracked file and write a note for it -- so unlike
// `refs/heads`/`refs/tags` there is no single canonical value to
// resolve here; the reading side merges across every peer's note
// for a given object instead (see `radicle-cli`'s
// `lfs_crypto::find_envelope`). Do not abort the whole fetch if
// this fails, same as patch refs above.
match lfs_notes_refs(stored) {
Ok(mut refs) => lines.append(&mut refs),
Err(e) => eprintln!("remote: error listing LFS notes refs: {e}"),
}
}
Ok(lines)
}
/// List every peer's `refs/notes/rad-lfs`, under `refs/notes/rad-lfs/<peer-id>`.
fn lfs_notes_refs<R: ReadRepository>(stored: &R) -> Result<Vec<String>, Error> {
let mut lines = Vec::new();
let notes_ref = git::fmt::qualified!("refs/notes/rad-lfs");
for (peer, _) in stored.remotes()? {
if let Ok(oid) = stored.reference_oid(&peer, &notes_ref) {
lines.push(format!("{oid} refs/notes/rad-lfs/{peer}"));
}
}
Ok(lines)

@ -1 +0,0 @@
Subproject commit 3215f231d80752266d2438f760c9dc244f1fc828

View File

@ -1,163 +0,0 @@
#!/usr/bin/env bash
#
# Builds release binaries for the LFS-over-IPFS stack (rad, radicle-node,
# git-remote-rad, rad-lfs-transfer) and publishes them as a Forgejo generic
# package, so a deploy target can `curl` a tarball instead of needing a full
# Rust toolchain (see NOTES-lfs-store-note-write-bug.md's VPS deployment
# story for why that matters -- rebuilding in place isn't always possible).
#
# Usage:
# FORGEJO_TOKEN=... ./scripts/publish-package.sh [version]
#
# `version` defaults to `git describe --tags --always --dirty`, sanitized
# for use in a URL path. The token needs the `write:package` scope; it's
# read from $FORGEJO_TOKEN, falling back to ~/.config/forgejo/token if that
# env var isn't set (chmod 600 that file if you use it).
#
# Config (override via env):
# FORGEJO_HOST default: git.hswro.org
# FORGEJO_OWNER default: mab122
# PACKAGE_NAME default: radicle-lfs-ipfs
set -euo pipefail
FORGEJO_HOST="${FORGEJO_HOST:-git.hswro.org}"
FORGEJO_OWNER="${FORGEJO_OWNER:-mab122}"
PACKAGE_NAME="${PACKAGE_NAME:-radicle-lfs-ipfs}"
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
if [ -n "${FORGEJO_TOKEN:-}" ]; then
token="$FORGEJO_TOKEN"
elif [ -r "$HOME/.config/forgejo/token" ]; then
token="$(cat "$HOME/.config/forgejo/token")"
else
echo "error: no Forgejo API token found. Set \$FORGEJO_TOKEN, or put one" \
"(scope: write:package) in ~/.config/forgejo/token (chmod 600)." >&2
exit 1
fi
version_raw="${1:-$(git describe --tags --always --dirty)}"
# Forgejo generic package versions must be URL-path-safe; a `git describe`
# with a "-dirty" suffix or a leading "v" is otherwise fine, but sanitize
# defensively rather than let a stray character break the upload URL.
version="$(printf '%s' "$version_raw" | tr -c 'A-Za-z0-9._-' '-')"
if [ ! -f "$repo_root/radicle-lfs-transfer/Cargo.toml" ]; then
echo "error: radicle-lfs-transfer/ submodule is empty -- run" \
"'git submodule update --init' first." >&2
exit 1
fi
existing_versions="$(curl -sS "https://${FORGEJO_HOST}/api/v1/packages/${FORGEJO_OWNER}?type=generic" \
-H "Authorization: token ${token}" \
| grep -o "\"name\":\"${PACKAGE_NAME}\",\"version\":\"[^\"]*\"" \
| sed -E 's/.*"version":"([^"]*)"/\1/' || true)"
if printf '%s\n' "$existing_versions" | grep -qx "$version"; then
echo "==> $PACKAGE_NAME $version is already published:" \
"https://${FORGEJO_HOST}/${FORGEJO_OWNER}/-/packages/generic/${PACKAGE_NAME}/${version}"
exit 0
fi
# Changelog since whichever previously-published version's commit we can
# find (a `git describe`-style version ends in "g<short-sha>"). Falls back
# to the full log if there's no previous package, or its version string
# doesn't parse -- better a long changelog than a failed release.
prev_sha=""
if [ -n "$existing_versions" ]; then
prev_sha="$(printf '%s\n' "$existing_versions" \
| grep -oE 'g[0-9a-f]{7,40}$' \
| sed 's/^g//' \
| head -1)"
fi
if [ -n "$prev_sha" ] && git cat-file -e "$prev_sha" 2>/dev/null; then
changelog_range="${prev_sha}..HEAD"
else
prev_sha=""
changelog_range="HEAD"
fi
changelog="$(git log --oneline "$changelog_range")"
if [ -z "$changelog" ]; then
changelog="(no commits since the previously published version)"
fi
echo "==> Building release binaries (version: $version)"
cargo build --release --locked -p radicle-cli --bin rad
cargo build --release --locked -p radicle-node
cargo build --release --locked -p radicle-remote-helper
cargo build --release --locked --manifest-path radicle-lfs-transfer/Cargo.toml
target_triple="$(rustc -vV | sed -n 's/host: //p')"
staging="$(mktemp -d)"
trap 'rm -rf "$staging"' EXIT
cp target/release/rad "$staging/"
cp target/release/radicle-node "$staging/"
cp target/release/git-remote-rad "$staging/"
cp radicle-lfs-transfer/target/release/rad-lfs-transfer "$staging/"
# Recorded because a binary built on a newer glibc can fail to run on an
# older one (discovered live deploying this exact stack to a Debian VPS) --
# anyone consuming this package needs to be able to check compatibility
# before copying the binaries over, not find out by them failing to start.
cat > "$staging/MANIFEST.txt" << EOF
package: $PACKAGE_NAME
version: $version
git commit: $(git rev-parse HEAD)
git branch: $(git rev-parse --abbrev-ref HEAD)
built: $(date -u +%Y-%m-%dT%H:%M:%SZ)
target triple: $target_triple
rustc: $(rustc --version)
glibc: $(ldd --version | head -1)
contents:
rad $(target/release/rad --version)
radicle-node $(target/release/radicle-node --version)
git-remote-rad $(target/release/git-remote-rad --version)
rad-lfs-transfer (no --version; speaks git-lfs custom-transfer protocol on stdin/stdout)
changelog (since ${prev_sha:-the beginning}):
$changelog
EOF
printf 'Changes since %s:\n\n%s\n' "${prev_sha:-the beginning}" "$changelog" > "$staging/CHANGELOG.txt"
archive_name="${PACKAGE_NAME}-${version}-${target_triple}.tar.gz"
archive_path="$staging/../${archive_name}"
tar -C "$staging" -czf "$archive_path" .
sha256sum "$archive_path" | awk '{print $1}' > "${archive_path}.sha256"
echo "==> Built $(du -h "$archive_path" | cut -f1) archive: $archive_name"
upload() {
local file="$1" name="$2"
local url="https://${FORGEJO_HOST}/api/packages/${FORGEJO_OWNER}/generic/${PACKAGE_NAME}/${version}/${name}"
local status
status="$(curl -sS -o /tmp/forgejo-upload-response.$$ -w '%{http_code}' \
-H "Authorization: token ${token}" \
-X PUT "$url" \
-T "$file")"
if [ "$status" = "201" ]; then
echo " uploaded: $name"
elif [ "$status" = "409" ]; then
echo " error: $name already exists at version $version -- bump the version" \
"or delete the existing package file in Forgejo first." >&2
cat /tmp/forgejo-upload-response.$$ >&2
rm -f /tmp/forgejo-upload-response.$$
exit 1
else
echo " error: upload of $name failed (HTTP $status)" >&2
cat /tmp/forgejo-upload-response.$$ >&2
rm -f /tmp/forgejo-upload-response.$$
exit 1
fi
rm -f /tmp/forgejo-upload-response.$$
}
echo "==> Uploading to https://${FORGEJO_HOST}/${FORGEJO_OWNER}/-/packages/generic/${PACKAGE_NAME}/${version}"
upload "$archive_path" "$archive_name"
upload "${archive_path}.sha256" "${archive_name}.sha256"
upload "$staging/CHANGELOG.txt" "CHANGELOG.txt"
echo "==> Done: https://${FORGEJO_HOST}/${FORGEJO_OWNER}/-/packages/generic/${PACKAGE_NAME}/${version}"