Skip to main content

solid_pod_rs_forge/
gitmark.rs

1//! Repo-held git-mark trails and the NIP-34 anchor tags they feed.
2//!
3//! A repository can carry its own Blocktrails trail: the agent that owns the
4//! repo marks each commit with the `git mark` CLI, using the repo's own key,
5//! and commits the resulting `blocktrails.json` (profile `gitmark`). The forge
6//! then holds no key and records nothing. It reads the file at the default
7//! branch tip, re-derives every mark's taproot address from the base key and
8//! the commits, links each mark to its chain's explorer, serves the committed
9//! document, and names the latest on-chain mark in its NIP-34 events.
10//!
11//! This is the Rust counterpart of the JSS `forge` plugin's repo-held trail
12//! path (`repoTrail`, `gitmarkStateHash`, `anchorTags`, `blocktrailsResp`) and
13//! `didShort`. JSS is cited by function name only; the implementation is
14//! original, built on [`solid_pod_rs::blocktrail`] and
15//! `solid_pod_rs::mrc20`.
16//!
17//! ## State bytes
18//!
19//! A git-mark trail hashes each state as `SHA-256(commit hash as text)`. The
20//! forge's own marks hash a JSON record instead. Both use the same chained
21//! BIP-341 `TapTweak` walk, so the walk is `solid_pod_rs::mrc20`'s and only
22//! the state strings differ: here they are the commits, verbatim.
23//!
24//! ## Features
25//!
26//! Reading, validating and tagging a trail needs nothing optional. Re-deriving
27//! the per-mark addresses needs the Bitcoin stack and so the `anchoring`
28//! feature; without it [`RepoMark::address`] and [`RepoMark::program`] are
29//! `None`.
30
31use std::path::Path;
32
33use serde_json::Value;
34use solid_pod_rs::blocktrail::BlocktrailTxo;
35
36use crate::repo::browse;
37
38/// The trail document's name at the repository root.
39pub const TRAIL_FILE: &str = "blocktrails.json";
40
41/// Largest `blocktrails.json` the forge will read, in bytes. A trail gains one
42/// short line per mark, so this admits thousands of marks while bounding the
43/// work a hostile push can ask of a page render.
44pub const MAX_TRAIL_BYTES: u64 = 512 * 1024;
45
46/// Shorten a `did:nostr` public key for display: `did:nostr:` plus the first
47/// eight characters and an ellipsis. The hex stays canonical in every path and
48/// identifier; this is for rendering only (JSS `didShort`).
49///
50/// # Examples
51///
52/// ```
53/// use solid_pod_rs_forge::gitmark::did_short;
54///
55/// let hex = format!("4a578b54{}", "ab".repeat(28));
56/// assert_eq!(did_short(&hex), "did:nostr:4a578b54…");
57/// // shorter input is shown whole, never padded
58/// assert_eq!(did_short("abc"), "did:nostr:abc…");
59/// ```
60#[must_use]
61pub fn did_short(hex: &str) -> String {
62    let head: String = hex.chars().take(8).collect();
63    format!("did:nostr:{head}…")
64}
65
66/// The explorer web base for a chain token, for links a *person* clicks.
67///
68/// Covers every chain the `git mark` CLI knows: Bitcoin mainnet and the public
69/// testnets on mempool.space, `txbt4` on mempool.guide and `xbt` on Kilombino.
70/// `None` for a chain with no public explorer (for example `regtest`), where a
71/// page should show the txid as plain text.
72///
73/// # Examples
74///
75/// ```
76/// use solid_pod_rs_forge::gitmark::explorer_web;
77///
78/// assert_eq!(explorer_web("tbtc4"), Some("https://mempool.space/testnet4"));
79/// assert_eq!(explorer_web("txbt4"), Some("https://mempool.guide/testnet4"));
80/// assert_eq!(explorer_web("xbt"), Some("https://mempool.kilombino.com"));
81/// assert_eq!(explorer_web("regtest"), None);
82/// ```
83#[must_use]
84pub fn explorer_web(chain: &str) -> Option<&'static str> {
85    match chain {
86        "btc" | "mainnet" | "bitcoin" => Some("https://mempool.space"),
87        "tbtc4" => Some("https://mempool.space/testnet4"),
88        "tbtc3" => Some("https://mempool.space/testnet"),
89        "signet" => Some("https://mempool.space/signet"),
90        "txbt4" => Some("https://mempool.guide/testnet4"),
91        "xbt" => Some("https://mempool.kilombino.com"),
92        _ => None,
93    }
94}
95
96/// The explorer page for one transaction, or `None` when the chain has no
97/// public explorer or `txid` is not 64 lowercase-or-uppercase hex characters.
98///
99/// # Examples
100///
101/// ```
102/// use solid_pod_rs_forge::gitmark::explorer_tx_url;
103///
104/// let txid = "cd".repeat(32);
105/// assert_eq!(
106///     explorer_tx_url("txbt4", &txid).unwrap(),
107///     format!("https://mempool.guide/testnet4/tx/{txid}")
108/// );
109/// assert!(explorer_tx_url("txbt4", "not-a-txid").is_none());
110/// ```
111#[must_use]
112pub fn explorer_tx_url(chain: &str, txid: &str) -> Option<String> {
113    if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
114        return None;
115    }
116    explorer_web(chain).map(|web| format!("{web}/tx/{txid}"))
117}
118
119/// The network name `solid_pod_rs::mrc20::bt_address` takes for a chain
120/// token, or `None` for a chain whose address prefix the encoder does not
121/// know (`regtest` uses `bcrt`, which it does not emit).
122///
123/// Mainnet tokens (`btc`, `mainnet`, `bitcoin`, `xbt`) give `bc1p…`; the
124/// public testnets and `txbt4` give `tb1p…`; `gitmark` gives `gm1p…`.
125///
126/// # Examples
127///
128/// ```
129/// use solid_pod_rs_forge::gitmark::network_for_chain;
130///
131/// assert_eq!(network_for_chain("tbtc4"), Some("testnet4"));
132/// assert_eq!(network_for_chain("xbt"), Some("mainnet"));
133/// assert_eq!(network_for_chain("gitmark"), Some("gitmark"));
134/// assert_eq!(network_for_chain("regtest"), None);
135/// ```
136#[must_use]
137pub fn network_for_chain(chain: &str) -> Option<&'static str> {
138    match chain {
139        "btc" | "mainnet" | "bitcoin" | "xbt" => Some("mainnet"),
140        "tbtc4" | "tbtc3" | "signet" | "txbt4" => Some("testnet4"),
141        "gitmark" => Some("gitmark"),
142        _ => None,
143    }
144}
145
146/// One mark of a repo-held trail.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct RepoMark {
149    /// Position in the trail, from 0.
150    pub index: usize,
151    /// The commit this mark commits to, exactly as the file spells it.
152    pub commit: String,
153    /// The mark's 32-byte witness program as hex, re-derived from the base key
154    /// and the commits so far. `None` without the `anchoring` feature, or for
155    /// a base key that is not a valid point.
156    pub program: Option<String>,
157    /// The mark's taproot address on the trail's chain, re-derived. `None`
158    /// when `program` is, or when the chain's address prefix is unknown.
159    pub address: Option<String>,
160    /// The mark's on-chain output, when the file's `txo` entry parses.
161    pub txo: Option<BlocktrailTxo>,
162    /// The file's `txo` entry for this mark, verbatim.
163    pub txo_uri: Option<String>,
164}
165
166/// A git-mark trail committed to a repository, read and re-derived.
167#[derive(Debug, Clone, PartialEq)]
168pub struct RepoTrail {
169    /// The committed document, as parsed.
170    pub doc: Value,
171    /// The branch it was read from.
172    pub branch: String,
173    /// The chain token the marks live on.
174    pub chain: String,
175    /// The base key as a full compressed point (`02`/`03` plus x).
176    pub pubkey_base: String,
177    /// The marks, one per well-formed commit in `states`.
178    pub marks: Vec<RepoMark>,
179}
180
181/// The facts a NIP-34 event needs to name a trail's on-chain frontier.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct AnchorFacts {
184    /// The chain token.
185    pub chain: String,
186    /// The transaction id of the first on-chain mark (the genesis anchor).
187    pub genesis_txid: String,
188    /// The transaction id of the latest on-chain mark.
189    pub frontier_txid: String,
190    /// The output index of the latest on-chain mark.
191    pub frontier_vout: u32,
192    /// The commit the latest on-chain mark commits to.
193    pub frontier_commit: String,
194    /// Absolute URL of the served trail document.
195    pub trail_url: String,
196}
197
198impl RepoTrail {
199    /// The base key's x-only form (64 lowercase hex), which is the agent's
200    /// `did:nostr` public key.
201    #[must_use]
202    pub fn xonly(&self) -> String {
203        self.pubkey_base[2..].to_ascii_lowercase()
204    }
205
206    /// The anchor facts for the NIP-34 events, or `None` while no mark has an
207    /// on-chain output. `trail_url` is where the forge serves the document.
208    #[must_use]
209    pub fn anchor_facts(&self, trail_url: &str) -> Option<AnchorFacts> {
210        let first = self.marks.iter().find_map(|m| m.txo.as_ref())?;
211        let (last_mark, last) = self
212            .marks
213            .iter()
214            .rev()
215            .find_map(|m| m.txo.as_ref().map(|t| (m, t)))?;
216        Some(AnchorFacts {
217            chain: self.chain.clone(),
218            genesis_txid: first.txid.to_ascii_lowercase(),
219            frontier_txid: last.txid.to_ascii_lowercase(),
220            frontier_vout: last.vout,
221            frontier_commit: last_mark.commit.clone(),
222            trail_url: trail_url.to_string(),
223        })
224    }
225
226    /// The trail document as the hosted verifier wants it: pretty-printed JSON.
227    #[must_use]
228    pub fn document_json(&self) -> String {
229        serde_json::to_string_pretty(&self.doc).unwrap_or_else(|_| "{}".to_string())
230    }
231}
232
233/// The NIP-34 anchor tags both repository events (kinds 30617 and 30618)
234/// carry once a repository is anchored, in emission order.
235///
236/// `r` (the trail document) and `anchor` (chain and genesis txid) are the
237/// forge's own; `chain`, `txo` and `commit` name the **latest** on-chain mark
238/// the way `git mark announce` does, so one consumer reads forge-anchored and
239/// agent-announced repositories alike (JSS `anchorTags`).
240///
241/// # Examples
242///
243/// ```
244/// use solid_pod_rs_forge::gitmark::{anchor_tags, AnchorFacts};
245///
246/// let facts = AnchorFacts {
247///     chain: "txbt4".into(),
248///     genesis_txid: "ab".repeat(32),
249///     frontier_txid: "cd".repeat(32),
250///     frontier_vout: 0,
251///     frontier_commit: "2".repeat(40),
252///     trail_url: "https://forge.example/forge/casey/agent/blocktrails.json".into(),
253/// };
254/// let tags = anchor_tags(&facts);
255/// assert_eq!(tags[1], ["anchor", "txbt4", &"ab".repeat(32)]);
256/// assert_eq!(tags[3], ["txo", &format!("txo:txbt4:{}:0", "cd".repeat(32))]);
257/// ```
258#[must_use]
259pub fn anchor_tags(facts: &AnchorFacts) -> Vec<Vec<String>> {
260    vec![
261        vec!["r".into(), facts.trail_url.clone()],
262        vec![
263            "anchor".into(),
264            facts.chain.clone(),
265            facts.genesis_txid.clone(),
266        ],
267        vec!["chain".into(), facts.chain.clone()],
268        vec![
269            "txo".into(),
270            format!(
271                "txo:{}:{}:{}",
272                facts.chain, facts.frontier_txid, facts.frontier_vout
273            ),
274        ],
275        vec!["commit".into(), facts.frontier_commit.clone()],
276    ]
277}
278
279fn is_base_point(s: &str) -> bool {
280    s.len() == 66
281        && (s.starts_with("02") || s.starts_with("03"))
282        && s.bytes().all(|b| b.is_ascii_hexdigit())
283}
284
285/// A commit a git-mark trail can name: 40 hex characters (JSS keeps the file's
286/// spelling, so a trail is re-derived from exactly what was committed).
287fn is_commit(s: &str) -> bool {
288    s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
289}
290
291/// Parse a committed `blocktrails.json` as a repo-held git-mark trail.
292///
293/// Accepts only `@type` `Blocktrail` with profile `gitmark`, a base key that is
294/// a full compressed point and a string `chain`; anything else is `None`, as
295/// is input over [`MAX_TRAIL_BYTES`]. States that are not 40-hex commits are
296/// dropped, as the JSS reader does; `txo` entries that do not parse leave
297/// [`RepoMark::txo`] empty rather than failing the trail.
298///
299/// # Examples
300///
301/// ```
302/// use solid_pod_rs_forge::gitmark::parse_repo_trail;
303///
304/// let raw = serde_json::json!({
305///     "@type": "Blocktrail", "version": "0.0.3", "profile": "gitmark",
306///     "pubkeyBase": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa",
307///     "chain": "txbt4",
308///     "states": ["1".repeat(40)],
309///     "txo": [format!("txo:txbt4:{}:0?amount=9700&commit={}", "ab".repeat(32), "1".repeat(40))],
310/// })
311/// .to_string();
312/// let trail = parse_repo_trail(&raw, "main").unwrap();
313/// assert_eq!(trail.marks.len(), 1);
314/// assert_eq!(trail.marks[0].txo.as_ref().unwrap().amount, Some(9700));
315/// // a forge-held style document is not a repo-held trail
316/// assert!(parse_repo_trail(r#"{"@type":"Blocktrail","profile":"monochrome"}"#, "main").is_none());
317/// ```
318#[must_use]
319pub fn parse_repo_trail(raw: &str, branch: &str) -> Option<RepoTrail> {
320    if raw.len() as u64 > MAX_TRAIL_BYTES {
321        return None;
322    }
323    let doc: Value = serde_json::from_str(raw).ok()?;
324    if doc.get("@type")?.as_str()? != "Blocktrail" || doc.get("profile")?.as_str()? != "gitmark" {
325        return None;
326    }
327    let pubkey_base = doc.get("pubkeyBase")?.as_str()?.to_string();
328    if !is_base_point(&pubkey_base) {
329        return None;
330    }
331    let chain = doc.get("chain")?.as_str()?.to_string();
332    let commits: Vec<String> = doc
333        .get("states")
334        .and_then(Value::as_array)
335        .map(|a| {
336            a.iter()
337                .filter_map(Value::as_str)
338                .filter(|c| is_commit(c))
339                .map(str::to_string)
340                .collect()
341        })
342        .unwrap_or_default();
343    let txo_uris: Vec<Option<String>> = doc
344        .get("txo")
345        .and_then(Value::as_array)
346        .map(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect())
347        .unwrap_or_default();
348
349    let (programs, addresses) = derive(&pubkey_base, &chain, &commits);
350    let marks = commits
351        .into_iter()
352        .enumerate()
353        .map(|(index, commit)| {
354            let txo_uri = txo_uris.get(index).cloned().flatten();
355            let txo = txo_uri
356                .as_deref()
357                .and_then(|u| BlocktrailTxo::parse_uri(u).ok());
358            RepoMark {
359                index,
360                commit,
361                program: programs.get(index).cloned(),
362                address: addresses.get(index).cloned(),
363                txo,
364                txo_uri,
365            }
366        })
367        .collect();
368    Some(RepoTrail {
369        doc,
370        branch: branch.to_string(),
371        chain,
372        pubkey_base,
373        marks,
374    })
375}
376
377/// Re-derive every mark's witness program and address (one walk, linear in the
378/// number of marks). Empty when the `anchoring` feature is off or the key or
379/// state is refused; the address list is empty alone for an unknown chain.
380#[cfg(feature = "anchoring")]
381fn derive(pubkey_base: &str, chain: &str, commits: &[String]) -> (Vec<String>, Vec<String>) {
382    use solid_pod_rs::mrc20::{bt_trail_addresses, bt_trail_outputs};
383    let Ok(outputs) = bt_trail_outputs(pubkey_base, commits) else {
384        return (Vec::new(), Vec::new());
385    };
386    let programs = outputs.iter().map(hex::encode).collect();
387    let addresses = network_for_chain(chain)
388        .and_then(|net| bt_trail_addresses(pubkey_base, commits, net).ok())
389        .unwrap_or_default();
390    (programs, addresses)
391}
392
393#[cfg(not(feature = "anchoring"))]
394fn derive(_pubkey_base: &str, _chain: &str, _commits: &[String]) -> (Vec<String>, Vec<String>) {
395    (Vec::new(), Vec::new())
396}
397
398/// Read the repo-held trail at the tip of `branch` in a bare repository, or
399/// `None` when the repository has no valid `blocktrails.json` there.
400///
401/// The ref and path are validated by [`browse::read_blob`] before they reach
402/// git, and the blob is size-checked before it is read.
403///
404/// Every failure (missing file, oversized blob, bad ref, unparsable JSON) means
405/// "this repository does not carry a trail", so there is no error to report.
406pub async fn read_repo_trail(git_dir: &Path, branch: &str) -> Option<RepoTrail> {
407    let rev = format!("refs/heads/{branch}");
408    let bytes = browse::read_blob(git_dir, &rev, TRAIL_FILE, MAX_TRAIL_BYTES)
409        .await
410        .ok()?;
411    parse_repo_trail(std::str::from_utf8(&bytes).ok()?, branch)
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use serde_json::json;
418
419    // Vectors generated by running the JSS `forge/lib/blocktrails.js`
420    // (`gitmarkStateHash`, `trailProgram`, `trailAddress`, `didShort`) at
421    // plugins@63a16cb with base key = secp256k1 pubkey of 0x11*32.
422    const BASE: &str = "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa";
423    const C0: &str = "1111111111111111111111111111111111111111";
424    const C1: &str = "2222222222222222222222222222222222222222";
425    #[cfg(feature = "anchoring")]
426    const P0: &str = "f1f4e2a2289fbfbae2f5d10e8a554d777a44c97dd5593db7df947acf1418532b";
427    #[cfg(feature = "anchoring")]
428    const P1: &str = "4e3706f136ba546fe29bbb93c5a20903cbdfbf71f6ff81f611501dae7bf84743";
429    #[cfg(feature = "anchoring")]
430    const A0_TB: &str = "tb1p786w9g3gn7lm4ch46y8g542dwaayfjta64vnmd7lj3av79qc2v4sf843nu";
431    #[cfg(feature = "anchoring")]
432    const A1_TB: &str = "tb1pfcmsdufkhf2xlc5mhwfutgsfq09al0m37mlcras32qw6u7lcgapslzrkyu";
433    #[cfg(feature = "anchoring")]
434    const A0_BC: &str = "bc1p786w9g3gn7lm4ch46y8g542dwaayfjta64vnmd7lj3av79qc2v4s70r7fn";
435
436    fn txid_a() -> String {
437        "ab".repeat(32)
438    }
439    fn txid_b() -> String {
440        "cd".repeat(32)
441    }
442
443    fn doc(chain: &str) -> String {
444        json!({
445            "@type": "Blocktrail", "version": "0.0.3", "profile": "gitmark",
446            "pubkeyBase": BASE, "chain": chain,
447            "states": [C0, C1],
448            "txo": [
449                format!("txo:{chain}:{}:0?amount=9700&commit={C0}", txid_a()),
450                format!("txo:{chain}:{}:0?amount=9400&commit={C1}", txid_b()),
451            ],
452        })
453        .to_string()
454    }
455
456    #[test]
457    fn did_short_is_eight_chars_and_an_ellipsis() {
458        let hex = format!("4a578b54{}", "ab".repeat(28));
459        assert_eq!(did_short(&hex), "did:nostr:4a578b54…");
460    }
461
462    #[test]
463    fn explorer_table_matches_the_git_mark_chains() {
464        assert_eq!(explorer_web("btc"), Some("https://mempool.space"));
465        assert_eq!(explorer_web("tbtc3"), Some("https://mempool.space/testnet"));
466        assert_eq!(explorer_web("signet"), Some("https://mempool.space/signet"));
467        assert_eq!(explorer_web("regtest"), None);
468        assert_eq!(
469            explorer_tx_url("txbt4", &txid_b()).unwrap(),
470            format!("https://mempool.guide/testnet4/tx/{}", txid_b())
471        );
472        assert!(explorer_tx_url("txbt4", &txid_b()[..10]).is_none());
473        assert!(explorer_tx_url("regtest", &txid_b()).is_none());
474    }
475
476    #[test]
477    fn parses_the_committed_document() {
478        let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
479        assert_eq!(t.chain, "txbt4");
480        assert_eq!(t.branch, "main");
481        assert_eq!(t.marks.len(), 2);
482        assert_eq!(t.marks[1].commit, C1);
483        let txo = t.marks[1].txo.as_ref().unwrap();
484        assert_eq!((txo.vout, txo.amount), (0, Some(9400)));
485        assert_eq!(txo.txid, txid_b());
486        assert_eq!(t.xonly(), &BASE[2..]);
487        // The served document is the committed one, nothing added.
488        let served: Value = serde_json::from_str(&t.document_json()).unwrap();
489        assert_eq!(served["profile"], "gitmark");
490        assert_eq!(served["states"], json!([C0, C1]));
491        assert_eq!(served["pubkeyBase"], BASE);
492    }
493
494    #[test]
495    fn refuses_what_is_not_a_repo_held_gitmark_trail() {
496        for bad in [
497            "not json".to_string(),
498            r#"{"@type":"Blocktrail","profile":"monochrome","pubkeyBase":"x","chain":"tbtc4"}"#
499                .to_string(),
500            json!({"@type":"Other","profile":"gitmark","pubkeyBase":BASE,"chain":"tbtc4"})
501                .to_string(),
502            // x-only is not a full point: the file must publish parity
503            json!({"@type":"Blocktrail","profile":"gitmark","pubkeyBase":&BASE[2..],"chain":"tbtc4"})
504                .to_string(),
505            json!({"@type":"Blocktrail","profile":"gitmark","pubkeyBase":BASE}).to_string(),
506        ] {
507            assert!(parse_repo_trail(&bad, "main").is_none(), "{bad}");
508        }
509        let huge = format!("{{\"pad\":\"{}\"}}", "x".repeat(MAX_TRAIL_BYTES as usize));
510        assert!(parse_repo_trail(&huge, "main").is_none());
511    }
512
513    #[test]
514    fn drops_malformed_states_and_tolerates_bad_txo_entries() {
515        let raw = json!({
516            "@type": "Blocktrail", "profile": "gitmark", "pubkeyBase": BASE, "chain": "tbtc4",
517            "states": [C0, "short", 7, C1],
518            "txo": ["garbage", null],
519        })
520        .to_string();
521        let t = parse_repo_trail(&raw, "main").unwrap();
522        assert_eq!(t.marks.len(), 2, "only the two commits survive");
523        assert!(t.marks.iter().all(|m| m.txo.is_none()));
524        assert_eq!(t.marks[0].txo_uri.as_deref(), Some("garbage"));
525        assert!(t.anchor_facts("https://x/blocktrails.json").is_none());
526    }
527
528    #[cfg(feature = "anchoring")]
529    #[test]
530    fn re_derives_addresses_with_gitmark_state_bytes() {
531        let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
532        assert_eq!(t.marks[0].program.as_deref(), Some(P0));
533        assert_eq!(t.marks[1].program.as_deref(), Some(P1));
534        assert_eq!(t.marks[0].address.as_deref(), Some(A0_TB));
535        assert_eq!(t.marks[1].address.as_deref(), Some(A1_TB));
536        // txbt4 is a testnet-style chain, xbt a mainnet-style one
537        let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
538        assert_eq!(t.marks[0].address.as_deref(), Some(A0_TB));
539        let t = parse_repo_trail(&doc("xbt"), "main").unwrap();
540        assert_eq!(t.marks[0].address.as_deref(), Some(A0_BC));
541        // The forge-held state convention hashes a JSON record: other bytes,
542        // other address.
543        let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
544        let forge_held = solid_pod_rs::mrc20::bt_address(
545            BASE,
546            &[format!(
547                r#"{{"commit":"{C0}","repo":"casey/agent","branch":"main"}}"#
548            )],
549            "testnet4",
550        )
551        .unwrap();
552        assert_ne!(t.marks[0].address.as_deref(), Some(forge_held.as_str()));
553    }
554
555    #[cfg(feature = "anchoring")]
556    #[test]
557    fn unknown_chain_has_programs_but_no_address() {
558        let t = parse_repo_trail(&doc("regtest"), "main").unwrap();
559        assert_eq!(t.marks[0].program.as_deref(), Some(P0));
560        assert!(t.marks.iter().all(|m| m.address.is_none()));
561    }
562
563    #[cfg(not(feature = "anchoring"))]
564    #[test]
565    fn without_anchoring_nothing_is_derived() {
566        let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
567        assert!(t
568            .marks
569            .iter()
570            .all(|m| m.program.is_none() && m.address.is_none()));
571    }
572
573    #[test]
574    fn anchor_tags_name_the_latest_mark_and_the_genesis() {
575        let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
576        let url = "https://forge.example/forge/casey/agent/blocktrails.json";
577        let tags = anchor_tags(&t.anchor_facts(url).unwrap());
578        let val = |k: &str| tags.iter().find(|t| t[0] == k).unwrap()[1..].to_vec();
579        assert_eq!(val("chain"), ["txbt4"]);
580        assert_eq!(
581            val("txo"),
582            [format!("txo:txbt4:{}:0", txid_b())],
583            "the latest mark, not the genesis"
584        );
585        assert_eq!(val("commit"), [C1]);
586        assert_eq!(val("anchor"), ["txbt4".to_string(), txid_a()]);
587        assert_eq!(val("r"), [url]);
588    }
589
590    #[test]
591    fn a_trail_with_no_onchain_mark_names_no_anchor() {
592        let raw = json!({
593            "@type": "Blocktrail", "profile": "gitmark", "pubkeyBase": BASE, "chain": "tbtc4",
594            "states": [C0], "txo": [],
595        })
596        .to_string();
597        let t = parse_repo_trail(&raw, "main").unwrap();
598        assert!(t.anchor_facts("https://x").is_none());
599    }
600
601    #[tokio::test]
602    async fn reads_the_trail_from_a_bare_repository_and_ignores_its_absence() {
603        use std::process::Command;
604        let git_ok = Command::new("git").arg("--version").output().is_ok();
605        if !git_ok {
606            return;
607        }
608        let td = tempfile::tempdir().unwrap();
609        let work = td.path().join("work");
610        let bare = td.path().join("agent.git");
611        let run = |dir: &Path, args: &[&str]| {
612            let out = Command::new("git")
613                .args(args)
614                .current_dir(dir)
615                .env("GIT_AUTHOR_NAME", "t")
616                .env("GIT_AUTHOR_EMAIL", "t@example.com")
617                .env("GIT_COMMITTER_NAME", "t")
618                .env("GIT_COMMITTER_EMAIL", "t@example.com")
619                .output()
620                .unwrap();
621            assert!(out.status.success(), "git {args:?}: {out:?}");
622        };
623        std::fs::create_dir(&work).unwrap();
624        run(&work, &["init", "--quiet", "-b", "main"]);
625        std::fs::write(work.join("a.txt"), "first\n").unwrap();
626        run(&work, &["add", "-A"]);
627        run(&work, &["commit", "--quiet", "-m", "first"]);
628        run(
629            td.path(),
630            &[
631                "clone",
632                "--quiet",
633                "--bare",
634                work.to_str().unwrap(),
635                "agent.git",
636            ],
637        );
638        // no trail file yet
639        assert!(read_repo_trail(&bare, "main").await.is_none());
640
641        std::fs::write(work.join(TRAIL_FILE), doc("txbt4")).unwrap();
642        run(&work, &["add", "-A"]);
643        run(&work, &["commit", "--quiet", "-m", "trail"]);
644        run(&work, &["push", "--quiet", bare.to_str().unwrap(), "main"]);
645        let t = read_repo_trail(&bare, "main").await.unwrap();
646        assert_eq!(t.marks.len(), 2);
647        // a hostile branch name never reaches git
648        assert!(read_repo_trail(&bare, "--output=/etc/x").await.is_none());
649    }
650}