use std::path::Path;
use std::sync::Arc;
use k256::schnorr::SigningKey;
use rand_core::{OsRng, RngCore};
use serde_json::{json, Value};
use solid_pod_rs_nostr::{DefaultSsrfCheck, Event, SsrfCheck};
use crate::config::{AnnounceKey, ForgeConfig};
use crate::error::ForgeError;
use crate::gitmark::{anchor_tags, did_short, read_repo_trail, AnchorFacts, TRAIL_FILE};
use crate::ownership::valid_name_segment;
use crate::publish::{publish_event, valid_relay_url, RelayResult, RELAY_TIMEOUT};
use crate::repo::browse::{self, EntryKind};
use crate::request::{ForgeRequest, ForgeResponse};
use crate::{ForgeAgent, ForgeService};
pub const KIND_REPO: u64 = 30617;
pub const KIND_STATE: u64 = 30618;
pub const DESCRIPTION_CAP: usize = 512;
pub const README_LINE_CAP: usize = 160;
pub const README_RENDER_CAP: u64 = 512 * 1024;
pub const ANNOUNCE_KEY_FILE: &str = "announce-key";
const DESCRIPTION_FILE_CAP: u64 = 64 * 1024;
pub fn sign_event(
sk: &SigningKey,
kind: u64,
tags: Vec<Vec<String>>,
content: &str,
created_at: u64,
) -> Result<Event, ForgeError> {
let mut ev = Event {
id: String::new(),
pubkey: hex::encode(sk.verifying_key().to_bytes()),
created_at,
kind,
tags,
content: content.to_string(),
sig: String::new(),
};
ev.id = ev.canonical_id();
let id = hex::decode(&ev.id).map_err(|e| ForgeError::Backend(format!("event id: {e}")))?;
let mut aux = [0u8; 32];
OsRng.fill_bytes(&mut aux);
let sig = sk
.sign_raw(&id, &aux)
.map_err(|e| ForgeError::Backend(format!("schnorr sign: {e}")))?;
ev.sig = hex::encode(sig.to_bytes());
Ok(ev)
}
#[derive(Clone)]
pub struct AnnounceSigner {
key: SigningKey,
pubkey: String,
}
impl std::fmt::Debug for AnnounceSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnnounceSigner")
.field("pubkey", &self.pubkey)
.finish_non_exhaustive()
}
}
impl AnnounceSigner {
fn from_key(key: SigningKey) -> Self {
let pubkey = hex::encode(key.verifying_key().to_bytes());
Self { key, pubkey }
}
#[must_use]
pub fn from_secret_hex(secret_hex: &str) -> Option<Self> {
let bytes = hex::decode(secret_hex.trim()).ok()?;
SigningKey::from_slice(&bytes).ok().map(Self::from_key)
}
#[must_use]
pub fn generate() -> Self {
Self::from_key(<SigningKey as k256::elliptic_curve::Generate>::generate())
}
pub fn load_or_create(
plugin_dir: &Path,
configured: Option<&AnnounceKey>,
) -> std::io::Result<Self> {
if let Some(signer) = configured
.and_then(AnnounceKey::valid_hex)
.and_then(|h| Self::from_secret_hex(&h))
{
return Ok(signer);
}
let path = plugin_dir.join(ANNOUNCE_KEY_FILE);
for _ in 0..3 {
match std::fs::read_to_string(&path) {
Ok(text) => {
let t = text.trim();
let lowercase_hex =
t.len() == 64 && t.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
if let Some(s) = lowercase_hex.then(|| Self::from_secret_hex(t)).flatten() {
tighten_permissions(&path);
return Ok(s);
}
std::fs::remove_file(&path)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let fresh = Self::generate();
match write_new_key_file(&path, &hex::encode(fresh.key.to_bytes())) {
Ok(()) => return Ok(fresh),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
Err(std::io::Error::other(
"announce key file kept changing while it was being created",
))
}
#[must_use]
pub fn pubkey_hex(&self) -> &str {
&self.pubkey
}
#[must_use]
pub fn npub(&self) -> String {
let bytes = self.key.verifying_key().to_bytes();
match bech32::Hrp::parse("npub") {
Ok(hrp) => bech32::encode::<bech32::Bech32>(hrp, &bytes).unwrap_or_default(),
Err(_) => String::new(),
}
}
pub fn sign(
&self,
kind: u64,
tags: Vec<Vec<String>>,
content: &str,
created_at: u64,
) -> Result<Event, ForgeError> {
sign_event(&self.key, kind, tags, content, created_at)
}
}
fn write_new_key_file(path: &Path, hex: &str) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(format!("{hex}\n").as_bytes())?;
f.flush()
}
fn tighten_permissions(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
if meta.permissions().mode() & 0o077 != 0 {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
}
#[cfg(not(unix))]
let _ = path;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoFacts {
pub owner: String,
pub name: String,
pub description: String,
pub base: String,
pub relays: Vec<String>,
pub default_branch: String,
pub heads: Vec<(String, String)>,
pub anchor: Option<AnchorFacts>,
}
fn cap_chars(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
#[must_use]
pub fn repo_tags(f: &RepoFacts) -> Vec<Vec<String>> {
let description = cap_chars(&f.description, DESCRIPTION_CAP);
let mut tags = vec![
vec!["d".to_string(), format!("{}/{}", f.owner, f.name)],
vec!["name".to_string(), f.name.clone()],
vec!["description".to_string(), description],
vec!["web".to_string(), f.base.clone()],
vec!["clone".to_string(), format!("{}.git", f.base)],
];
if !f.relays.is_empty() {
let mut t = vec!["relays".to_string()];
t.extend(f.relays.iter().cloned());
tags.push(t);
}
if is_lower_hex64(&f.owner) {
tags.push(vec!["maintainers".to_string(), f.owner.clone()]);
}
if let Some(a) = &f.anchor {
tags.extend(anchor_tags(a));
}
tags
}
#[must_use]
pub fn state_tags(f: &RepoFacts) -> Vec<Vec<String>> {
let default_ref = format!("refs/heads/{}", f.default_branch);
let mut heads = f.heads.clone();
heads.sort_by(|a, b| match (a.0 == default_ref, b.0 == default_ref) {
(true, _) => std::cmp::Ordering::Less,
(_, true) => std::cmp::Ordering::Greater,
_ => a.0.cmp(&b.0),
});
let mut tags = vec![vec!["d".to_string(), format!("{}/{}", f.owner, f.name)]];
for (refname, sha) in heads {
tags.push(vec![refname, sha]);
}
tags.push(vec!["HEAD".to_string(), format!("ref: {default_ref}")]);
if let Some(a) = &f.anchor {
tags.extend(anchor_tags(a));
}
tags
}
fn is_lower_hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
pub fn build_repo_event(
signer: &AnnounceSigner,
facts: &RepoFacts,
created_at: u64,
) -> Result<Event, ForgeError> {
let content = cap_chars(&facts.description, DESCRIPTION_CAP);
signer.sign(KIND_REPO, repo_tags(facts), &content, created_at)
}
pub fn build_state_event(
signer: &AnnounceSigner,
facts: &RepoFacts,
created_at: u64,
) -> Result<Event, ForgeError> {
signer.sign(KIND_STATE, state_tags(facts), "", created_at)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrailState {
Absent,
Unanchored,
Anchored(AnchorFacts),
}
fn is_readme(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
matches!(
lower.as_str(),
"readme" | "readme.md" | "readme.markdown" | "readme.txt"
)
}
fn readme_summary(text: &str) -> Option<String> {
let first = text.split('\n').find(|l| !l.trim().is_empty())?;
let stripped = first
.trim_start_matches('#')
.trim_start_matches(char::is_whitespace);
Some(cap_chars(stripped.trim(), README_LINE_CAP))
}
pub async fn derive_description(git_dir: &Path, default_branch: &str) -> String {
if let Some(d) = read_description_file(git_dir).await {
return d;
}
if !browse::has_commits(git_dir).await {
return String::new();
}
let Ok(entries) = browse::list_tree(git_dir, default_branch, "").await else {
return String::new();
};
let readme = entries.iter().find(|e| {
matches!(e.kind, EntryKind::File | EntryKind::Symlink)
&& is_readme(&e.name)
&& e.size.is_some_and(|s| s < README_RENDER_CAP)
});
let Some(readme) = readme else {
return String::new();
};
let Ok(bytes) =
browse::read_blob(git_dir, default_branch, &readme.name, README_RENDER_CAP).await
else {
return String::new();
};
if bytes.iter().take(8000).any(|b| *b == 0) {
return String::new();
}
readme_summary(&String::from_utf8_lossy(&bytes)).unwrap_or_default()
}
async fn read_description_file(git_dir: &Path) -> Option<String> {
use tokio::io::AsyncReadExt;
let file = tokio::fs::File::open(git_dir.join("description"))
.await
.ok()?;
let mut buf = Vec::new();
file.take(DESCRIPTION_FILE_CAP)
.read_to_end(&mut buf)
.await
.ok()?;
let text = String::from_utf8_lossy(&buf);
let d = text.trim();
(!d.is_empty() && !d.starts_with("Unnamed repository")).then(|| d.to_string())
}
pub async fn gather_facts(
git_dir: &Path,
owner: &str,
name: &str,
base: &str,
relays: &[String],
forge_trail: TrailState,
) -> Result<RepoFacts, ForgeError> {
if !valid_name_segment(owner) || !valid_name_segment(name) {
return Err(ForgeError::PathTraversal(format!("{owner}/{name}")));
}
let default_branch = browse::default_branch(git_dir).await;
let description = derive_description(git_dir, &default_branch).await;
let heads = browse::list_heads_full(git_dir).await.unwrap_or_default();
let trail_url = format!("{base}/{TRAIL_FILE}");
let anchor = match forge_trail {
TrailState::Anchored(facts) => Some(facts),
TrailState::Unanchored => None,
TrailState::Absent => repo_held_anchor(git_dir, &default_branch, &trail_url).await,
};
Ok(RepoFacts {
owner: owner.to_string(),
name: name.to_string(),
description,
base: base.to_string(),
relays: relays.to_vec(),
default_branch,
heads,
anchor,
})
}
async fn repo_held_anchor(git_dir: &Path, branch: &str, trail_url: &str) -> Option<AnchorFacts> {
read_repo_trail(git_dir, branch)
.await
.and_then(|t| t.anchor_facts(trail_url))
}
#[derive(Clone)]
pub(crate) struct Announcer {
pub(crate) signer: AnnounceSigner,
pub(crate) relays: Vec<String>,
pub(crate) ssrf: Arc<dyn SsrfCheck>,
}
impl Announcer {
pub(crate) fn new(cfg: &ForgeConfig, plugin_dir: &Path) -> Result<Self, ForgeError> {
let signer = AnnounceSigner::load_or_create(plugin_dir, cfg.announce_key.as_ref())?;
let relays: Vec<String> = cfg
.announce_relays
.iter()
.filter(|r| valid_relay_url(r))
.cloned()
.collect();
tracing::info!(
"forge: nostr announce identity {} - {}",
did_short(signer.pubkey_hex()),
if relays.is_empty() {
"emission DISABLED (set announce_relays to opt in)".to_string()
} else {
format!("NIP-34 emission to {} relay(s)", relays.len())
}
);
Ok(Self {
signer,
relays,
ssrf: Arc::new(DefaultSsrfCheck),
})
}
}
#[derive(Debug, Clone)]
pub struct AnnounceEvents {
pub event: Event,
pub state: Event,
}
#[derive(Debug, Clone)]
pub struct AnnounceOutcome {
pub published: bool,
pub events: Option<AnnounceEvents>,
pub relays: Vec<RelayResult>,
pub state_relays: Vec<RelayResult>,
}
fn event_view(e: &Event) -> Value {
json!({ "id": e.id, "pubkey": e.pubkey, "kind": e.kind, "tags": e.tags })
}
fn relay_views(rs: &[RelayResult]) -> Value {
serde_json::to_value(rs).unwrap_or(Value::Null)
}
fn tally(rs: &[RelayResult]) -> String {
format!("{}/{}", rs.iter().filter(|r| r.ok).count(), rs.len())
}
fn repo_base(origin: &str, prefix: &str, owner: &str, name: &str) -> String {
let origin = origin.strip_suffix('/').unwrap_or(origin);
format!("{origin}{prefix}/{owner}/{name}")
}
impl ForgeService {
#[must_use]
pub fn with_announce_ssrf(mut self, ssrf: Arc<dyn SsrfCheck>) -> Self {
let mut a = (*self.announcer).clone();
a.ssrf = ssrf;
self.announcer = Arc::new(a);
self
}
#[cfg(feature = "anchoring")]
fn forge_trail_state(&self, owner: &str, name: &str, trail_url: &str) -> TrailState {
use crate::trail::ForgeTrailSource;
if !self.has_forge_trail(owner, name) {
return TrailState::Absent;
}
match self.anchor_facts(owner, name, trail_url) {
Some(facts) => TrailState::Anchored(facts),
None => TrailState::Unanchored,
}
}
#[cfg(not(feature = "anchoring"))]
#[allow(clippy::unused_self)]
fn forge_trail_state(&self, _owner: &str, _name: &str, _trail_url: &str) -> TrailState {
TrailState::Absent
}
#[must_use]
pub fn announce_pubkey(&self) -> &str {
self.announcer.signer.pubkey_hex()
}
#[must_use]
pub fn announce_relays(&self) -> &[String] {
&self.announcer.relays
}
pub async fn build_announce_events(
&self,
owner: &str,
name: &str,
origin: &str,
) -> Result<AnnounceEvents, ForgeError> {
let git_dir = self.resolve_repo(owner, name).await?;
let base = repo_base(origin, &self.prefix(), owner, name);
let a = &self.announcer;
let trail_url = format!("{base}/{TRAIL_FILE}");
let forge_trail = self.forge_trail_state(owner, name, &trail_url);
let facts = gather_facts(&git_dir, owner, name, &base, &a.relays, forge_trail).await?;
let now = crate::now_secs();
Ok(AnnounceEvents {
event: build_repo_event(&a.signer, &facts, now)?,
state: build_state_event(&a.signer, &facts, now)?,
})
}
pub async fn announce_repo(
&self,
owner: &str,
name: &str,
origin: &str,
) -> Result<AnnounceOutcome, ForgeError> {
if self.announcer.relays.is_empty() {
return Ok(AnnounceOutcome {
published: false,
events: None,
relays: Vec::new(),
state_relays: Vec::new(),
});
}
let events = self.build_announce_events(owner, name, origin).await?;
let a = &self.announcer;
let relays = publish_event(&events.event, &a.relays, RELAY_TIMEOUT, a.ssrf.as_ref()).await;
let state_relays =
publish_event(&events.state, &a.relays, RELAY_TIMEOUT, a.ssrf.as_ref()).await;
Ok(AnnounceOutcome {
published: true,
events: Some(events),
relays,
state_relays,
})
}
pub fn announce_repo_in_background(&self, owner: &str, name: &str, origin: &str) {
if self.announcer.relays.is_empty() {
return;
}
let svc = self.clone();
let (owner, name, origin) = (owner.to_string(), name.to_string(), origin.to_string());
tokio::spawn(async move {
match svc.announce_repo(&owner, &name, &origin).await {
Ok(o) => {
if let Some(e) = &o.events {
tracing::info!(
"forge: announced {owner}/{name} (NIP-34 30617 {}... + 30618 state {}...) to {} relay(s)",
&e.event.id[..8],
&e.state.id[..8],
tally(&o.relays)
);
}
}
Err(e) => tracing::warn!("forge: announce {owner}/{name} failed: {e}"),
}
});
}
pub(crate) async fn h_api_nostr(
&self,
req: &ForgeRequest,
owner: &str,
repo: &str,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_known_repo(owner, repo).await?;
if !matches!(req.method.to_ascii_uppercase().as_str(), "GET" | "HEAD") {
return Ok(ForgeResponse::error(405, "method not allowed"));
}
let origin = req.host_url.as_deref().unwrap_or("");
let events = self.build_announce_events(owner, repo, origin).await?;
let a = &self.announcer;
Ok(ForgeResponse::json(
200,
&json!({
"pubkey": a.signer.pubkey_hex(),
"npub": a.signer.npub(),
"relays": a.relays,
"relaysConfigured": !a.relays.is_empty(),
"event": events.event,
"state": events.state,
"events": [events.event, events.state],
}),
))
}
pub(crate) async fn h_api_announce(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
owner: &str,
repo: &str,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_known_repo(owner, repo).await?;
if !req.method.eq_ignore_ascii_case("POST") {
return Ok(ForgeResponse::error(405, "method not allowed"));
}
let Some(agent_owner) = agent.owner() else {
return Ok(ForgeResponse::error(401, "authentication required")
.with_header("www-authenticate", "Bearer realm=\"forge\""));
};
if agent_owner != owner {
return Err(ForgeError::Forbidden(
"only the repo owner may announce this repository".into(),
));
}
let origin = req.host_url.as_deref().unwrap_or("");
let events = self.build_announce_events(owner, repo, origin).await?;
let a = &self.announcer;
if a.relays.is_empty() {
return Ok(ForgeResponse::json(
200,
&json!({
"published": false,
"reason": "no relays configured; set announce_relays to publish (NIP-34 emission is opt-in)",
"event": event_view(&events.event),
"state": event_view(&events.state),
"relays": [],
}),
));
}
let relays = publish_event(&events.event, &a.relays, RELAY_TIMEOUT, a.ssrf.as_ref()).await;
let state_relays =
publish_event(&events.state, &a.relays, RELAY_TIMEOUT, a.ssrf.as_ref()).await;
tracing::info!(
"forge: {owner}/{repo} announced on demand (30617 {}... + 30618 {}...) to {} relay(s)",
&events.event.id[..8],
&events.state.id[..8],
tally(&relays)
);
Ok(ForgeResponse::json(
200,
&json!({
"published": true,
"event": event_view(&events.event),
"state": event_view(&events.state),
"relays": relay_views(&relays),
"stateRelays": relay_views(&state_relays),
}),
))
}
async fn resolve_known_repo(&self, owner: &str, repo: &str) -> Result<(), ForgeError> {
match self.resolve_repo(owner, repo).await {
Ok(_) => Ok(()),
Err(ForgeError::PathTraversal(_)) => Err(ForgeError::NotFound("not found".into())),
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn facts() -> RepoFacts {
RepoFacts {
owner: "casey".into(),
name: "demo".into(),
description: "Demo".into(),
base: "https://forge.example/forge/casey/demo".into(),
relays: vec![],
default_branch: "main".into(),
heads: vec![("refs/heads/main".into(), "a".repeat(40))],
anchor: None,
}
}
fn anchor() -> AnchorFacts {
AnchorFacts {
chain: "tbtc4".into(),
genesis_txid: "a".repeat(64),
frontier_txid: "b".repeat(64),
frontier_vout: 1,
frontier_commit: "c".repeat(40),
trail_url: "https://forge.example/forge/casey/demo/blocktrails.json".into(),
}
}
fn val<'a>(tags: &'a [Vec<String>], k: &str) -> Vec<&'a str> {
tags.iter()
.find(|t| t[0] == k)
.map(|t| t[1..].iter().map(String::as_str).collect())
.unwrap_or_default()
}
#[test]
fn jss_test_kind_30617_required_tags_and_a_valid_signature() {
let signer = AnnounceSigner::from_secret_hex(&"11".repeat(32)).unwrap();
let ev = build_repo_event(&signer, &facts(), 1_760_000_000).unwrap();
assert_eq!(ev.kind, 30617);
assert_eq!(ev.pubkey, signer.pubkey_hex());
assert_eq!(
ev.id,
ev.canonical_id(),
"id = sha256 of the canonical serialisation"
);
assert!(ev.verify().is_ok(), "schnorr sig verifies against the id");
assert_eq!(val(&ev.tags, "d"), ["casey/demo"]);
assert_eq!(val(&ev.tags, "name"), ["demo"]);
assert_eq!(
val(&ev.tags, "web"),
["https://forge.example/forge/casey/demo"]
);
assert_eq!(
val(&ev.tags, "clone"),
["https://forge.example/forge/casey/demo.git"]
);
assert_eq!(
ev.content,
val(&ev.tags, "description")[0],
"content = description"
);
for absent in ["relays", "maintainers", "anchor", "r"] {
assert!(
!ev.tags.iter().any(|t| t[0] == absent),
"{absent} must be absent for an unconfigured, unanchored WebID-owned repo"
);
}
}
#[test]
fn jss_test_anchored_repo_carries_r_anchor_chain_txo_commit_on_both_events() {
let mut f = facts();
f.anchor = Some(anchor());
let signer = AnnounceSigner::generate();
for ev in [
build_repo_event(&signer, &f, 1).unwrap(),
build_state_event(&signer, &f, 1).unwrap(),
] {
assert_eq!(
val(&ev.tags, "r"),
["https://forge.example/forge/casey/demo/blocktrails.json"]
);
assert_eq!(val(&ev.tags, "anchor"), ["tbtc4", &"a".repeat(64)]);
assert_eq!(val(&ev.tags, "chain"), ["tbtc4"]);
assert_eq!(
val(&ev.tags, "txo"),
[format!("txo:tbtc4:{}:1", "b".repeat(64))]
);
assert_eq!(val(&ev.tags, "commit"), ["c".repeat(40)]);
}
}
#[test]
fn jss_test_kind_30618_head_and_default_branch_ref_with_empty_content() {
let signer = AnnounceSigner::generate();
let st = build_state_event(&signer, &facts(), 5).unwrap();
assert_eq!(st.kind, 30618);
assert_eq!(st.pubkey, signer.pubkey_hex(), "same key as the 30617");
assert!(st.verify().is_ok());
assert_eq!(st.content, "");
let announce = build_repo_event(&signer, &facts(), 5).unwrap();
assert_eq!(
val(&st.tags, "d"),
val(&announce.tags, "d"),
"30617 and 30618 share the d"
);
assert_eq!(val(&st.tags, "HEAD"), ["ref: refs/heads/main"]);
assert_eq!(val(&st.tags, "refs/heads/main"), ["a".repeat(40)]);
assert!(!st.tags.iter().any(|t| t[0] == "anchor" || t[0] == "r"));
}
#[test]
fn jss_test_nostr_owned_repo_names_the_owner_as_maintainer() {
let pk = "ab".repeat(32);
let mut f = facts();
f.owner = pk.clone();
f.relays = vec!["wss://relay.example".into()];
let tags = repo_tags(&f);
assert_eq!(val(&tags, "maintainers"), [pk.as_str()]);
assert_eq!(val(&tags, "relays"), ["wss://relay.example"]);
let pos = |k: &str| tags.iter().position(|t| t[0] == k).unwrap();
assert!(pos("relays") < pos("maintainers"));
for not_hex in [&"AB".repeat(32), &"ab".repeat(31), &"zz".repeat(32)] {
let mut g = facts();
g.owner = not_hex.clone();
assert!(!repo_tags(&g).iter().any(|t| t[0] == "maintainers"));
}
}
#[test]
fn state_heads_put_the_default_first_then_byte_order() {
let sha = |c: char| c.to_string().repeat(40);
let mut f = facts();
f.default_branch = "trunk".into();
f.heads = vec![
("refs/heads/b".into(), sha('1')),
("refs/heads/trunk".into(), sha('2')),
("refs/heads/a".into(), sha('3')),
];
let names: Vec<_> = state_tags(&f).iter().map(|t| t[0].clone()).collect();
assert_eq!(
names,
[
"d",
"refs/heads/trunk",
"refs/heads/a",
"refs/heads/b",
"HEAD"
]
);
f.heads.clear();
let tags = state_tags(&f);
assert_eq!(tags.len(), 2, "an empty repo has the d and HEAD tags only");
assert_eq!(tags[1], ["HEAD", "ref: refs/heads/trunk"]);
}
#[test]
fn description_is_capped_in_tag_and_content() {
let mut f = facts();
f.description = "x".repeat(600);
let signer = AnnounceSigner::generate();
let ev = build_repo_event(&signer, &f, 1).unwrap();
assert_eq!(ev.content.chars().count(), DESCRIPTION_CAP);
assert_eq!(val(&ev.tags, "description")[0], ev.content);
}
#[test]
fn readme_summary_strips_headings_and_caps() {
assert_eq!(
readme_summary("\n\n## Title here \nbody").as_deref(),
Some("Title here")
);
assert_eq!(readme_summary("#\tTabbed").as_deref(), Some("Tabbed"));
assert_eq!(readme_summary("plain").as_deref(), Some("plain"));
assert_eq!(readme_summary(" \n\t\n"), None);
let long = format!("# {}", "y".repeat(300));
assert_eq!(
readme_summary(&long).unwrap().chars().count(),
README_LINE_CAP
);
assert!(is_readme("README") && is_readme("Readme.MD") && is_readme("readme.txt"));
assert!(!is_readme("README.rst") && !is_readme("readme.md.bak") && !is_readme("notes"));
}
#[test]
fn sign_roundtrip_verifies_and_a_tampered_event_does_not() {
let sk = SigningKey::from_slice(&[9u8; 32]).unwrap();
let ev = sign_event(
&sk,
30618,
vec![vec!["d".into(), "x/y".into()]],
"",
1_760_000_000,
)
.unwrap();
assert!(ev.verify().is_ok());
let mut bad = ev.clone();
bad.tags.push(vec!["extra".into()]);
assert!(bad.verify().is_err());
let mut bad_sig = ev;
bad_sig.sig = "0".repeat(128);
assert!(bad_sig.verify().is_err());
}
#[cfg(feature = "mirror")]
#[test]
fn signed_events_pass_the_mirror_reader_verifier() {
let sk = SigningKey::from_slice(&[3u8; 32]).unwrap();
let ev = sign_event(
&sk,
KIND_STATE,
vec![
vec!["d".into(), "x".into()],
vec!["refs/heads/main".into(), "a".repeat(40)],
],
"",
1_760_000_000,
)
.unwrap();
assert!(crate::mirror::verify_event(&ev));
}
#[test]
fn signatures_are_randomised_but_ids_are_deterministic() {
let sk = SigningKey::from_slice(&[5u8; 32]).unwrap();
let a = sign_event(&sk, 1, vec![], "same", 7).unwrap();
let b = sign_event(&sk, 1, vec![], "same", 7).unwrap();
assert_eq!(a.id, b.id);
assert_ne!(a.sig, b.sig, "BIP-340 aux randomness differs per signature");
assert!(a.verify().is_ok() && b.verify().is_ok());
}
#[test]
fn jss_test_announce_key_is_generated_once_and_persisted_0600() {
let dir = tempfile::tempdir().unwrap();
let first = AnnounceSigner::load_or_create(dir.path(), None).unwrap();
let path = dir.path().join(ANNOUNCE_KEY_FILE);
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.trim().len(), 64);
assert!(text
.trim()
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')));
assert!(text.ends_with('\n'));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
let derived = AnnounceSigner::from_secret_hex(text.trim()).unwrap();
assert_eq!(derived.pubkey_hex(), first.pubkey_hex());
let again = AnnounceSigner::load_or_create(dir.path(), None).unwrap();
assert_eq!(again.pubkey_hex(), first.pubkey_hex(), "stable identity");
}
#[test]
fn configured_key_wins_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let cfg = AnnounceKey::new("11".repeat(32).to_ascii_uppercase());
let s = AnnounceSigner::load_or_create(dir.path(), Some(&cfg)).unwrap();
assert_eq!(
s.pubkey_hex(),
AnnounceSigner::from_secret_hex(&"11".repeat(32))
.unwrap()
.pubkey_hex()
);
assert!(!dir.path().join(ANNOUNCE_KEY_FILE).exists());
}
#[test]
fn a_malformed_configured_or_persisted_key_falls_back_silently() {
let dir = tempfile::tempdir().unwrap();
let bad = AnnounceKey::new("not-hex");
let s = AnnounceSigner::load_or_create(dir.path(), Some(&bad)).unwrap();
assert!(dir.path().join(ANNOUNCE_KEY_FILE).exists());
std::fs::write(dir.path().join(ANNOUNCE_KEY_FILE), "garbage\n").unwrap();
let s2 = AnnounceSigner::load_or_create(dir.path(), None).unwrap();
assert_ne!(s.pubkey_hex(), s2.pubkey_hex());
let text = std::fs::read_to_string(dir.path().join(ANNOUNCE_KEY_FILE)).unwrap();
assert_eq!(
AnnounceSigner::from_secret_hex(text.trim())
.unwrap()
.pubkey_hex(),
s2.pubkey_hex()
);
std::fs::write(
dir.path().join(ANNOUNCE_KEY_FILE),
format!("{}\n", "0".repeat(64)),
)
.unwrap();
let s3 = AnnounceSigner::load_or_create(dir.path(), None).unwrap();
assert_ne!(s3.pubkey_hex(), s2.pubkey_hex());
}
#[cfg(unix)]
#[test]
fn a_loose_existing_key_file_is_tightened() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(ANNOUNCE_KEY_FILE);
std::fs::write(&path, format!("{}\n", "11".repeat(32))).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let _ = AnnounceSigner::load_or_create(dir.path(), None).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn the_secret_is_never_printed() {
let secret = "5a".repeat(32);
let s = AnnounceSigner::from_secret_hex(&secret).unwrap();
let shown = format!("{s:?}");
assert!(!shown.contains(&secret) && shown.contains(s.pubkey_hex()));
let cfg = ForgeConfig {
announce_key: Some(AnnounceKey::new(secret.clone())),
..Default::default()
};
assert!(!format!("{cfg:?}").contains(&secret));
}
#[test]
fn npub_is_bech32_of_the_pubkey() {
let s = AnnounceSigner::from_secret_hex(&"11".repeat(32)).unwrap();
let (hrp, data) = bech32::decode(&s.npub()).unwrap();
assert_eq!(hrp.as_str(), "npub");
assert_eq!(hex::encode(data), s.pubkey_hex());
}
#[test]
fn repo_base_trims_one_trailing_slash_and_allows_a_missing_origin() {
assert_eq!(
repo_base("https://f.example/", "/forge", "a", "b"),
"https://f.example/forge/a/b"
);
assert_eq!(repo_base("", "/forge", "a", "b"), "/forge/a/b");
assert_eq!(
repo_base("https://f.example", "", "a", "b"),
"https://f.example/a/b"
);
}
}