From edf366ed2009c817beed1d402d8d3bdd9f33f8fd Mon Sep 17 00:00:00 2001 From: Akshay Oppiliappan Date: Sat, 3 Oct 2026 17:28:01 +0100 Subject: [PATCH] knot2: let knot-events' wire types build without tokio the event log and subscriber gate sit behind a default 'log' feature, so the worker publishes refUpdate events with the same types knot2 does. Co-Authored-By: Claude Opus 5.5 (1M context) --- knot2/crates/knot-events/Cargo.toml | 6 +- knot2/crates/knot-events/src/lib.rs | 850 +---- knot2/crates/knot-events/src/log.rs | 852 +++++ knot2/worker/.cargo/config.toml | 2 + knot2/worker/.gitignore | 5 + knot2/worker/Cargo.lock | 4133 ++++++++++++++++++++++++ knot2/worker/Cargo.toml | 60 + knot2/worker/parity/.gitignore | 3 + knot2/worker/parity/compose.yml | 44 + knot2/worker/parity/fixture.sh | 108 + knot2/worker/parity/parity.mjs | 331 ++ knot2/worker/parity/wrangler.dev.jsonc | 19 + knot2/worker/src/artifacts.rs | 132 + knot2/worker/src/auth.rs | 64 + knot2/worker/src/clock.rs | 33 + knot2/worker/src/error.rs | 66 + knot2/worker/src/events.rs | 138 + knot2/worker/src/git.rs | 826 +++++ knot2/worker/src/identity.rs | 85 + knot2/worker/src/knot.rs | 413 +++ knot2/worker/src/lib.rs | 221 ++ knot2/worker/src/odb.rs | 217 ++ knot2/worker/src/pack.rs | 394 +++ knot2/worker/src/plc.rs | 153 + knot2/worker/src/proxy.rs | 279 ++ knot2/worker/src/reads.rs | 491 +++ knot2/worker/src/remote.rs | 75 + knot2/worker/src/repos.rs | 248 ++ knot2/worker/src/respond.rs | 69 + knot2/worker/src/source.rs | 110 + knot2/worker/src/state.rs | 434 +++ knot2/worker/src/transport.rs | 72 + knot2/worker/wrangler.jsonc | 27 + 33 files changed, 10116 insertions(+), 844 deletions(-) create mode 100644 knot2/crates/knot-events/src/log.rs create mode 100644 knot2/worker/.cargo/config.toml create mode 100644 knot2/worker/.gitignore create mode 100644 knot2/worker/Cargo.lock create mode 100644 knot2/worker/Cargo.toml create mode 100644 knot2/worker/parity/.gitignore create mode 100644 knot2/worker/parity/compose.yml create mode 100644 knot2/worker/parity/fixture.sh create mode 100644 knot2/worker/parity/parity.mjs create mode 100644 knot2/worker/parity/wrangler.dev.jsonc create mode 100644 knot2/worker/src/artifacts.rs create mode 100644 knot2/worker/src/auth.rs create mode 100644 knot2/worker/src/clock.rs create mode 100644 knot2/worker/src/error.rs create mode 100644 knot2/worker/src/events.rs create mode 100644 knot2/worker/src/git.rs create mode 100644 knot2/worker/src/identity.rs create mode 100644 knot2/worker/src/knot.rs create mode 100644 knot2/worker/src/lib.rs create mode 100644 knot2/worker/src/odb.rs create mode 100644 knot2/worker/src/pack.rs create mode 100644 knot2/worker/src/plc.rs create mode 100644 knot2/worker/src/proxy.rs create mode 100644 knot2/worker/src/reads.rs create mode 100644 knot2/worker/src/remote.rs create mode 100644 knot2/worker/src/repos.rs create mode 100644 knot2/worker/src/respond.rs create mode 100644 knot2/worker/src/source.rs create mode 100644 knot2/worker/src/state.rs create mode 100644 knot2/worker/src/transport.rs create mode 100644 knot2/worker/wrangler.jsonc diff --git a/knot2/crates/knot-events/Cargo.toml b/knot2/crates/knot-events/Cargo.toml index 1852f2888..7fa95bd4f 100644 --- a/knot2/crates/knot-events/Cargo.toml +++ b/knot2/crates/knot-events/Cargo.toml @@ -5,9 +5,13 @@ edition.workspace = true rust-version.workspace = true license.workspace = true +[features] +default = ["log"] +log = ["dep:tokio"] + [dependencies] knot-types = { workspace = true } knot-runtime = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true } +tokio = { workspace = true, optional = true } diff --git a/knot2/crates/knot-events/src/lib.rs b/knot2/crates/knot-events/src/lib.rs index edf5ccf04..bb922d0b0 100644 --- a/knot2/crates/knot-events/src/lib.rs +++ b/knot2/crates/knot-events/src/lib.rs @@ -1,13 +1,8 @@ -use std::collections::{BTreeSet, HashMap, VecDeque}; -use std::net::IpAddr; -use std::sync::{Arc, Mutex}; - use serde::ser::SerializeMap; use serde::{Serialize, Serializer}; use serde_json::Value; -use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch}; -use knot_runtime::{Clock, UnixMicros}; +use knot_runtime::UnixMicros; use knot_types::{ AccountDid, ChangedFiles, Email, LanguageBytes, LanguageName, ObjectFormat, Oid, OwnerDid, PushOptions, RefName, RefTransition, RepoDid, RepoPath, Tid, @@ -28,7 +23,7 @@ impl EventCursor { self.0 } - fn from_unix_micros(micros: UnixMicros) -> Self { + pub fn from_unix_micros(micros: UnixMicros) -> Self { Self((micros.get() as i64).saturating_mul(1_000)) } } @@ -291,839 +286,8 @@ impl Publish for RepoCollaboratorUpdate { const NSID: &'static str = "sh.tangled.repo.collaboratorUpdate"; } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReplayEvents(std::num::NonZeroUsize); - -impl ReplayEvents { - pub fn new(value: usize) -> Option { - std::num::NonZeroUsize::new(value).map(Self) - } - - pub fn get(self) -> usize { - self.0.get() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReplayBytes(std::num::NonZeroUsize); - -impl ReplayBytes { - pub fn new(value: usize) -> Option { - std::num::NonZeroUsize::new(value).map(Self) - } - - pub fn get(self) -> usize { - self.0.get() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReplayBounds { - events: ReplayEvents, - bytes: ReplayBytes, -} - -impl ReplayBounds { - pub fn new(events: ReplayEvents, bytes: ReplayBytes) -> Self { - Self { events, bytes } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BatchEnd { - CaughtUp, - Bounded, -} - -pub struct Replayed { - pub events: Vec>, - pub end: BatchEnd, -} - -struct Entry { - event: Arc, - bytes: usize, -} - -struct Ring { - entries: VecDeque, - bytes: usize, - last_micros: UnixMicros, - pending: BTreeSet, -} - -struct Inner { - bounds: ReplayBounds, - ring: Mutex, - head: watch::Sender, -} - -impl Inner { - fn lock(&self) -> std::sync::MutexGuard<'_, Ring> { - self.ring - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - // leave that guiness be, boy! it needs to - fn settle(&self, ring: std::sync::MutexGuard<'_, Ring>) { - let head = stable_head(&ring); - drop(ring); - self.head.send_if_modified(|current| { - let changed = *current != head; - *current = head; - changed - }); - } -} - -fn stable_head(ring: &Ring) -> EventCursor { - let stable = match ring.pending.iter().next().copied() { - Some(horizon) => ring - .entries - .partition_point(|entry| entry.event.created < horizon), - None => ring.entries.len(), - }; - stable - .checked_sub(1) - .and_then(|index| ring.entries.get(index)) - .map(|entry| entry.event.created) - .unwrap_or(EventCursor::START) -} - -fn value_bytes(value: &Value) -> usize { - let node = std::mem::size_of::(); - match value { - Value::Null | Value::Bool(_) | Value::Number(_) => node, - Value::String(text) => node + text.len(), - Value::Array(items) => node + items.iter().map(value_bytes).sum::(), - Value::Object(fields) => { - node + fields - .iter() - .map(|(key, field)| key.len() + value_bytes(field)) - .sum::() - } - } -} - -fn insert_sorted(ring: &mut Ring, event: Event, bounds: ReplayBounds) { - let bytes = std::mem::size_of::() + value_bytes(&event.payload); - let position = ring - .entries - .partition_point(|existing| existing.event.created < event.created); - ring.entries.insert( - position, - Entry { - event: Arc::new(event), - bytes, - }, - ); - ring.bytes += bytes; - evict_oldest(ring, bounds); -} - -fn evict_oldest(ring: &mut Ring, bounds: ReplayBounds) { - let over = ring.entries.len() > bounds.events.get() - || (ring.bytes > bounds.bytes.get() && ring.entries.len() > 1); - if let Some(evicted) = over.then(|| ring.entries.pop_front()).flatten() { - ring.bytes -= evicted.bytes; - evict_oldest(ring, bounds); - } -} - -pub struct EventLog { - clock: C, - inner: Arc, -} - -impl EventLog { - pub fn new(clock: C, bounds: ReplayBounds) -> Self { - Self { - clock, - inner: Arc::new(Inner { - bounds, - ring: Mutex::new(Ring { - entries: VecDeque::new(), - bytes: 0, - last_micros: UnixMicros::new(0), - pending: BTreeSet::new(), - }), - head: watch::Sender::new(EventCursor::START), - }), - } - } - - fn next_cursor(&self, ring: &mut Ring) -> (UnixMicros, EventCursor) { - let micros = self.clock.now_unix_micros().max(ring.last_micros.next()); - ring.last_micros = micros; - (micros, EventCursor::from_unix_micros(micros)) - } - - pub fn publish(&self, payload: &P) -> EventCursor { - let payload = serde_json::to_value(payload).expect("event payload serializes to JSON"); - let mut ring = self.inner.lock(); - let (micros, created) = self.next_cursor(&mut ring); - insert_sorted( - &mut ring, - Event { - rkey: Tid::from_time(micros.get(), 0), - nsid: P::NSID, - payload, - created, - }, - self.inner.bounds, - ); - self.inner.settle(ring); - created - } - - pub fn reserve(&self) -> Reservation { - let mut ring = self.inner.lock(); - let (micros, cursor) = self.next_cursor(&mut ring); - ring.pending.insert(cursor); - drop(ring); - Reservation { - inner: Arc::clone(&self.inner), - cursor, - micros, - fulfilled: false, - } - } - - pub fn replay(&self, after: EventCursor, bounds: ReplayBounds) -> Replayed { - let ring = self.inner.lock(); - // The corresponding read side guarantee of `reserve`. - let horizon = ring.pending.iter().next().copied(); - let visible = |entry: &&Entry| { - entry.event.created > after - && horizon.is_none_or(|horizon| entry.event.created < horizon) - }; - let events: Vec> = ring - .entries - .iter() - .filter(visible) - .take(bounds.events.get()) - // Why the first event gets to ignore the byte bound? - // Imagine a consumer whose next event is by itself wider - // than the entire bound, right - - // every batch it requests would come back empty, - // its cursor would never advance, - // it would ask again, repeat. - // Sending that one event alone over the bound - // is the only way. - .scan(0usize, |spent, entry| { - let first = *spent == 0; - *spent += entry.bytes; - (first || *spent <= bounds.bytes.get()).then(|| Arc::clone(&entry.event)) - }) - .collect(); - let end = match ring.entries.iter().filter(visible).nth(events.len()) { - Some(_) => BatchEnd::Bounded, - None => BatchEnd::CaughtUp, - }; - Replayed { events, end } - } - - pub fn subscribe(&self) -> watch::Receiver { - self.inner.head.subscribe() - } -} - -pub struct Reservation { - inner: Arc, - cursor: EventCursor, - micros: UnixMicros, - fulfilled: bool, -} - -impl Reservation { - pub fn cursor(&self) -> EventCursor { - self.cursor - } - - pub fn fulfill(mut self, payload: &P) { - let payload = serde_json::to_value(payload).expect("event payload serializes to JSON"); - let mut ring = self.inner.lock(); - insert_sorted( - &mut ring, - Event { - rkey: Tid::from_time(self.micros.get(), 0), - nsid: P::NSID, - payload, - created: self.cursor, - }, - self.inner.bounds, - ); - ring.pending.remove(&self.cursor); - self.inner.settle(ring); - self.fulfilled = true; - } -} - -impl Drop for Reservation { - fn drop(&mut self) { - if self.fulfilled { - return; - } - let mut ring = self.inner.lock(); - ring.pending.remove(&self.cursor); - self.inner.settle(ring); - } -} - -knot_types::scalar_newtype! { - pub struct GlobalSubscriberLimit(usize); - pub struct PerPeerSubscriberLimit(usize); -} - -pub struct SubscriberGate { - global: Arc, - per_peer_max: usize, - peers: Mutex>, -} - -impl SubscriberGate { - pub fn new(global_max: GlobalSubscriberLimit, per_peer_max: PerPeerSubscriberLimit) -> Self { - Self { - global: Arc::new(Semaphore::new(global_max.get().max(1))), - per_peer_max: per_peer_max.get().max(1), - peers: Mutex::new(HashMap::new()), - } - } - - pub fn try_admit(self: &Arc, peer: IpAddr) -> Option { - let global = Arc::clone(&self.global).try_acquire_owned().ok()?; - let mut peers = self - .peers - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let current = peers.get(&peer).copied().unwrap_or(0); - if current >= self.per_peer_max { - return None; - } - peers.insert(peer, current + 1); - Some(SubscriberPermit { - _global: global, - gate: Arc::clone(self), - peer, - }) - } -} - -pub struct SubscriberPermit { - _global: OwnedSemaphorePermit, - gate: Arc, - peer: IpAddr, -} - -impl Drop for SubscriberPermit { - fn drop(&mut self) { - let mut peers = self - .gate - .peers - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(count) = peers.get_mut(&self.peer) { - *count -= 1; - if *count == 0 { - peers.remove(&self.peer); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use knot_runtime::{ManualClock, UnixMicros}; - - fn bounds(events: usize, bytes: usize) -> ReplayBounds { - ReplayBounds::new( - ReplayEvents::new(events).unwrap(), - ReplayBytes::new(bytes).unwrap(), - ) - } - - fn log(capacity: usize) -> EventLog { - EventLog::new( - ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), - bounds(capacity, 1 << 20), - ) - } - - fn replay(log: &EventLog, after: EventCursor, limit: usize) -> Vec> { - log.replay(after, bounds(limit, 1 << 30)).events - } - - fn update() -> GitRefUpdate { - GitRefUpdate::new( - RepoDid::new("did:plc:limpet").unwrap(), - Some(OwnerDid::new("did:web:olaren.dev").unwrap()), - AccountDid::new("did:plc:nel").unwrap(), - ) - } - - fn wire(log: &EventLog, payload: &P) -> serde_json::Value { - log.publish(payload); - serde_json::to_value(&*replay(log, EventCursor::START, 1).remove(0)).unwrap() - } - - #[test] - fn publish_nsids_are_valid_type_names() { - [ - GitRefUpdate::NSID, - KnotMemberUpdate::NSID, - RepoCollaboratorUpdate::NSID, - ] - .iter() - .for_each(|nsid| { - assert!(knot_types::TypeName::new(*nsid).is_ok(), "{nsid}"); - }); - } - - #[test] - fn a_frozen_clock_still_yields_strictly_increasing_cursors_and_distinct_rkeys() { - let log = log(8); - let cursors: Vec<_> = (0..3).map(|_| log.publish(&update())).collect(); - assert!(cursors.windows(2).all(|pair| pair[0] < pair[1])); - let events = replay(&log, EventCursor::START, 8); - let rkeys: std::collections::BTreeSet<_> = events - .iter() - .map(|event| event.rkey.as_str().to_string()) - .collect(); - assert_eq!(rkeys.len(), 3); - } - - #[test] - fn the_ring_evicts_the_oldest_event_past_capacity() { - let log = log(2); - let first = log.publish(&update()); - log.publish(&update()); - log.publish(&update()); - let replayed = replay(&log, EventCursor::START, 8); - assert_eq!(replayed.len(), 2); - assert!(replayed.iter().all(|event| event.created > first)); - } - - #[test] - fn a_wide_event_evicts_by_bytes_long_before_the_ring_fills() { - let wide = |count: usize| { - update().with_changed_files(fill_changed( - (0..count).map(|index| format!("crates/knot-events/src/f{index}.rs")), - )) - }; - let log = EventLog::new( - ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), - bounds(1_024, 64 * 1_024), - ); - (0..16).for_each(|_| { - log.publish(&wide(512)); - }); - let replayed = replay(&log, EventCursor::START, 1_024); - assert!( - (1..16).contains(&replayed.len()), - "the byte maximum evicts before the event maximum does: {}", - replayed.len() - ); - - let one = EventLog::new( - ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), - bounds(1_024, 1), - ); - let only = one.publish(&wide(512)); - assert_eq!( - replay(&one, EventCursor::START, 8) - .iter() - .map(|event| event.created) - .collect::>(), - vec![only], - "the ring keeps the one event wider than the whole byte maximum" - ); - } - - fn fill_changed(paths: impl Iterator) -> ChangedFiles { - let mut budget = knot_types::ChangedFilesBudget::new(); - let _ = paths.into_iter().try_for_each(|path| { - budget.admit(knot_types::RepoPath::new(path).expect("test path is well-formed")) - }); - budget.finish() - } - - #[test] - fn replay_honors_the_cursor_and_the_limit() { - let log = log(8); - let cursors: Vec<_> = (0..4).map(|_| log.publish(&update())).collect(); - let after_second = replay(&log, cursors[1], 8); - assert_eq!( - after_second - .iter() - .map(|event| event.created) - .collect::>(), - cursors[2..].to_vec() - ); - assert_eq!(replay(&log, EventCursor::START, 2).len(), 2); - assert!(replay(&log, cursors[3], 8).is_empty()); - } - - #[test] - fn a_replay_batch_stops_at_the_byte_maximum_and_reports_whether_more_remains() { - let log = EventLog::new( - ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), - bounds(1_024, 1 << 20), - ); - assert_eq!( - log.replay(EventCursor::START, bounds(8, 1 << 20)).end, - BatchEnd::CaughtUp, - "an empty ring has nothing left to send" - ); - let wide = update().with_changed_files(fill_changed( - (0..512).map(|index| format!("crates/knot-events/src/f{index}.rs")), - )); - let cursors: Vec = (0..8).map(|_| log.publish(&wide)).collect(); - - let batch = log.replay(EventCursor::START, bounds(1_024, 16 * 1_024)); - assert!( - (1..8).contains(&batch.events.len()), - "the byte maximum stops the batch before the event maximum does: {}", - batch.events.len() - ); - assert_eq!(batch.end, BatchEnd::Bounded); - - let rest = log.replay( - batch.events.last().expect("the batch is nonempty").created, - bounds(1_024, 1 << 30), - ); - assert_eq!(rest.end, BatchEnd::CaughtUp); - assert_eq!( - batch.events.len() + rest.events.len(), - cursors.len(), - "the two batches together are every event, with none repeated or skipped" - ); - let head = log.replay(cursors[7], bounds(8, 1 << 20)); - assert!(head.events.is_empty() && head.end == BatchEnd::CaughtUp); - - let single = log.replay(EventCursor::START, bounds(1_024, 1)); - assert_eq!( - single.events.len(), - 1, - "an event wider than the whole batch maximum is sent alone" - ); - assert_eq!(single.end, BatchEnd::Bounded); - } - - #[test] - fn a_subscriber_observes_the_head_advance() { - let log = log(8); - let mut head = log.subscribe(); - assert_eq!(*head.borrow_and_update(), EventCursor::START); - let created = log.publish(&update()); - assert!(head.has_changed().unwrap()); - assert_eq!(*head.borrow_and_update(), created); - } - - #[test] - fn the_wire_event_matches_the_eventstream_shape() { - let wire = wire(&log(8), &update()); - assert_eq!(wire["nsid"], "sh.tangled.git.refUpdate"); - assert_eq!(wire["created"].as_i64().unwrap() % 1_000, 0); - assert_eq!(wire["rkey"].as_str().unwrap().len(), 13); - let payload = &wire["event"]; - assert_eq!(payload["$type"], "sh.tangled.git.refUpdate"); - assert_eq!(payload["committerDid"], "did:plc:nel"); - assert_eq!(payload["ownerDid"], "did:web:olaren.dev"); - assert_eq!(payload["repo"], "did:plc:limpet"); - assert_eq!(payload["meta"], serde_json::Value::Null); - assert_eq!(payload["ref"], ""); - assert_eq!( - payload["oldSha"], "", - "a record about no ref sends the empty sha" - ); - assert_eq!(payload["newSha"], ""); - } - - #[test] - fn an_absent_sha_of_a_transition_is_the_null_oid_of_the_repo_object_format() { - let new = Oid::from_hex(&"cd".repeat(32)).unwrap(); - let created = &wire( - &log(8), - &GitRefUpdate::new( - RepoDid::new("did:plc:limpet").unwrap(), - None, - AccountDid::new("did:plc:nel").unwrap(), - ) - .on_ref( - RefName::new("refs/heads/fresh").unwrap(), - RefTransition::Create { new }, - ObjectFormat::SHA256, - ), - )["event"]; - assert_eq!(created["oldSha"], "0".repeat(64)); - assert_eq!(created["newSha"], new.to_hex()); - - let old = Oid::from_hex(&"ab".repeat(20)).unwrap(); - let rebuilt = update() - .on_ref( - RefName::new("refs/heads/fresh").unwrap(), - RefTransition::Create { - new: Oid::from_hex(&"cd".repeat(20)).unwrap(), - }, - ObjectFormat::SHA1, - ) - .on_ref( - RefName::new("refs/heads/gone").unwrap(), - RefTransition::Delete { old }, - ObjectFormat::SHA1, - ); - let payload = &wire(&log(8), &rebuilt)["event"]; - assert_eq!( - payload["ref"], "refs/heads/gone", - "a later transition replaces the earlier one whole" - ); - assert_eq!(payload["oldSha"], old.to_hex()); - assert_eq!(payload["newSha"], "0".repeat(40)); - } - - fn peer(last: u8) -> IpAddr { - IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, last)) - } - - #[test] - fn gate_enforces_limits_and_prunes() { - let global = Arc::new(SubscriberGate::new( - GlobalSubscriberLimit::new(2), - PerPeerSubscriberLimit::new(8), - )); - let first = global - .try_admit(peer(1)) - .expect("first subscriber is admitted"); - let _second = global - .try_admit(peer(2)) - .expect("second subscriber is admitted"); - assert!( - global.try_admit(peer(3)).is_none(), - "third subscriber is refused once the global limit is reached" - ); - drop(first); - assert!( - global.try_admit(peer(3)).is_some(), - "freeing global slot admits waiting subscriber" - ); - - let per_peer = Arc::new(SubscriberGate::new( - GlobalSubscriberLimit::new(16), - PerPeerSubscriberLimit::new(2), - )); - let _socket = per_peer - .try_admit(peer(1)) - .expect("first socket is admitted"); - let second = per_peer - .try_admit(peer(1)) - .expect("second socket is admitted"); - assert!( - per_peer.try_admit(peer(1)).is_none(), - "third socket from same peer is refused at the per-peer limit" - ); - assert!( - per_peer.try_admit(peer(2)).is_some(), - "different peer keeps its own budget" - ); - drop(second); - assert!( - per_peer.try_admit(peer(1)).is_some(), - "freed per-peer slot is reusable" - ); - - let prune = Arc::new(SubscriberGate::new( - GlobalSubscriberLimit::new(16), - PerPeerSubscriberLimit::new(2), - )); - let permit = prune.try_admit(peer(1)).expect("admitted"); - drop(permit); - assert!( - prune - .peers - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .is_empty(), - "peer map prunes peer once its last socket closes" - ); - } - - #[test] - fn a_ref_update_includes_its_computed_meta_on_the_wire() { - let wire = wire( - &log(8), - &update().with_meta(RefUpdateMeta::new( - true, - vec![EmailCommitCount::new( - Email::new("nel@oyster.cafe"), - CommitCount::new(3), - )], - vec![LanguageSize::new( - LanguageName::new("Rust"), - LanguageBytes::new(1234), - )], - )), - ); - let meta = &wire["event"]["meta"]; - assert_eq!(meta["isDefaultRef"], true); - assert_eq!( - meta["commitCount"]["byEmail"][0]["email"], - "nel@oyster.cafe" - ); - assert_eq!(meta["commitCount"]["byEmail"][0]["count"], 3); - assert_eq!(meta["langBreakdown"]["inputs"][0]["lang"], "Rust"); - assert_eq!(meta["langBreakdown"]["inputs"][0]["size"], 1234); - } - - #[test] - fn an_empty_breakdown_omits_the_optional_meta_arrays() { - let wire = wire( - &log(8), - &update().with_meta(RefUpdateMeta::new(false, Vec::new(), Vec::new())), - ); - let meta = &wire["event"]["meta"]; - assert_eq!(meta["isDefaultRef"], false); - assert!(meta["commitCount"].get("byEmail").is_none()); - assert!(meta.get("langBreakdown").is_none()); - } - - #[test] - fn acl_updates_match_eventstream_shape() { - let log = log(8); - log.publish(&KnotMemberUpdate::added( - AccountDid::new("did:plc:nel").unwrap(), - )); - log.publish(&KnotMemberUpdate::removed( - AccountDid::new("did:plc:olaren").unwrap(), - )); - log.publish(&RepoCollaboratorUpdate::added( - AccountDid::new("did:plc:nel").unwrap(), - RepoDid::new("did:plc:limpet").unwrap(), - )); - log.publish(&RepoCollaboratorUpdate::removed( - AccountDid::new("did:plc:nel").unwrap(), - RepoDid::new("did:plc:limpet").unwrap(), - )); - let events = replay(&log, EventCursor::START, 8); - - let member_added = serde_json::to_value(&*events[0]).unwrap(); - assert_eq!(member_added["nsid"], "sh.tangled.knot.memberUpdate"); - assert_eq!(member_added["event"]["op"], "add"); - assert_eq!(member_added["event"]["subject"], "did:plc:nel"); - assert!(member_added["event"].get("$type").is_none()); - let member_removed = serde_json::to_value(&*events[1]).unwrap(); - assert_eq!(member_removed["event"]["op"], "remove"); - assert_eq!(member_removed["event"]["subject"], "did:plc:olaren"); - - let collab_added = serde_json::to_value(&*events[2]).unwrap(); - assert_eq!(collab_added["nsid"], "sh.tangled.repo.collaboratorUpdate"); - assert_eq!(collab_added["event"]["op"], "add"); - assert_eq!(collab_added["event"]["subject"], "did:plc:nel"); - assert_eq!(collab_added["event"]["repo"], "did:plc:limpet"); - assert!(collab_added["event"].get("$type").is_none()); - let collab_removed = serde_json::to_value(&*events[3]).unwrap(); - assert_eq!(collab_removed["event"]["op"], "remove"); - assert_eq!(collab_removed["event"]["repo"], "did:plc:limpet"); - } - - fn cursors(log: &EventLog) -> Vec { - replay(log, EventCursor::START, 64) - .iter() - .map(|event| event.created) - .collect() - } - - #[test] - fn a_reservation_holds_back_later_events_until_it_is_fulfilled() { - let log = log(8); - let early = log.publish(&update()); - let reservation = log.reserve(); - let later = log.publish(&update()); - assert!(reservation.cursor() > early && reservation.cursor() < later); - assert_eq!( - cursors(&log), - vec![early], - "event published after reservation waits behind it" - ); - let mid = reservation.cursor(); - reservation.fulfill(&update()); - assert_eq!( - cursors(&log), - vec![early, mid, later], - "fulfilling reservation releases it and event queued behind it, in cursor order" - ); - } - - #[test] - fn out_of_order_fulfillment_still_replays_in_cursor_order() { - let log = log(8); - let first = log.reserve(); - let second = log.reserve(); - let (c1, c2) = (first.cursor(), second.cursor()); - assert!(c1 < c2); - second.fulfill(&update()); - assert!( - cursors(&log).is_empty(), - "later reservation stays hidden while earlier one is outstanding" - ); - first.fulfill(&update()); - assert_eq!( - cursors(&log), - vec![c1, c2], - "both surface in cursor order regardless of fulfillment order" - ); - } - - #[test] - fn a_dropped_reservation_unblocks_the_horizon_without_an_event() { - let log = log(8); - let reservation = log.reserve(); - let later = log.publish(&update()); - assert!( - cursors(&log).is_empty(), - "later event waits behind unfulfilled reservation" - ); - drop(reservation); - assert_eq!( - cursors(&log), - vec![later], - "dropping reservation surfaces queued event and leaves no gap" - ); - } - - #[test] - fn the_head_holds_at_the_last_stable_event_until_a_reservation_is_fulfilled() { - let log = log(8); - let mut head = log.subscribe(); - let early = log.publish(&update()); - assert_eq!(*head.borrow_and_update(), early); - let reservation = log.reserve(); - let later = log.publish(&update()); - assert_eq!( - *head.borrow_and_update(), - early, - "head holds while lower-cursor reservation is pending" - ); - reservation.fulfill(&update()); - assert_eq!( - *head.borrow_and_update(), - later, - "fulfilling reservation advances head past released events" - ); - } - - #[test] - fn an_anonymous_owner_is_omitted_from_the_wire() { - let wire = wire( - &log(8), - &GitRefUpdate::new( - RepoDid::new("did:plc:limpet").unwrap(), - None, - AccountDid::new("did:plc:nel").unwrap(), - ), - ); - assert!(wire["event"].get("ownerDid").is_none()); - } -} +// the in-memory log and its subscriber gate need tokio; the wire types above don't +#[cfg(feature = "log")] +mod log; +#[cfg(feature = "log")] +pub use log::*; diff --git a/knot2/crates/knot-events/src/log.rs b/knot2/crates/knot-events/src/log.rs new file mode 100644 index 000000000..9ff022979 --- /dev/null +++ b/knot2/crates/knot-events/src/log.rs @@ -0,0 +1,852 @@ +use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::net::IpAddr; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch}; + +use knot_runtime::{Clock, UnixMicros}; +use knot_types::Tid; + +use crate::{Event, EventCursor, Publish}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplayEvents(std::num::NonZeroUsize); + +impl ReplayEvents { + pub fn new(value: usize) -> Option { + std::num::NonZeroUsize::new(value).map(Self) + } + + pub fn get(self) -> usize { + self.0.get() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplayBytes(std::num::NonZeroUsize); + +impl ReplayBytes { + pub fn new(value: usize) -> Option { + std::num::NonZeroUsize::new(value).map(Self) + } + + pub fn get(self) -> usize { + self.0.get() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplayBounds { + events: ReplayEvents, + bytes: ReplayBytes, +} + +impl ReplayBounds { + pub fn new(events: ReplayEvents, bytes: ReplayBytes) -> Self { + Self { events, bytes } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BatchEnd { + CaughtUp, + Bounded, +} + +pub struct Replayed { + pub events: Vec>, + pub end: BatchEnd, +} + +struct Entry { + event: Arc, + bytes: usize, +} + +struct Ring { + entries: VecDeque, + bytes: usize, + last_micros: UnixMicros, + pending: BTreeSet, +} + +struct Inner { + bounds: ReplayBounds, + ring: Mutex, + head: watch::Sender, +} + +impl Inner { + fn lock(&self) -> std::sync::MutexGuard<'_, Ring> { + self.ring + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + // leave that guiness be, boy! it needs to + fn settle(&self, ring: std::sync::MutexGuard<'_, Ring>) { + let head = stable_head(&ring); + drop(ring); + self.head.send_if_modified(|current| { + let changed = *current != head; + *current = head; + changed + }); + } +} + +fn stable_head(ring: &Ring) -> EventCursor { + let stable = match ring.pending.iter().next().copied() { + Some(horizon) => ring + .entries + .partition_point(|entry| entry.event.created < horizon), + None => ring.entries.len(), + }; + stable + .checked_sub(1) + .and_then(|index| ring.entries.get(index)) + .map(|entry| entry.event.created) + .unwrap_or(EventCursor::START) +} + +fn value_bytes(value: &Value) -> usize { + let node = std::mem::size_of::(); + match value { + Value::Null | Value::Bool(_) | Value::Number(_) => node, + Value::String(text) => node + text.len(), + Value::Array(items) => node + items.iter().map(value_bytes).sum::(), + Value::Object(fields) => { + node + fields + .iter() + .map(|(key, field)| key.len() + value_bytes(field)) + .sum::() + } + } +} + +fn insert_sorted(ring: &mut Ring, event: Event, bounds: ReplayBounds) { + let bytes = std::mem::size_of::() + value_bytes(&event.payload); + let position = ring + .entries + .partition_point(|existing| existing.event.created < event.created); + ring.entries.insert( + position, + Entry { + event: Arc::new(event), + bytes, + }, + ); + ring.bytes += bytes; + evict_oldest(ring, bounds); +} + +fn evict_oldest(ring: &mut Ring, bounds: ReplayBounds) { + let over = ring.entries.len() > bounds.events.get() + || (ring.bytes > bounds.bytes.get() && ring.entries.len() > 1); + if let Some(evicted) = over.then(|| ring.entries.pop_front()).flatten() { + ring.bytes -= evicted.bytes; + evict_oldest(ring, bounds); + } +} + +pub struct EventLog { + clock: C, + inner: Arc, +} + +impl EventLog { + pub fn new(clock: C, bounds: ReplayBounds) -> Self { + Self { + clock, + inner: Arc::new(Inner { + bounds, + ring: Mutex::new(Ring { + entries: VecDeque::new(), + bytes: 0, + last_micros: UnixMicros::new(0), + pending: BTreeSet::new(), + }), + head: watch::Sender::new(EventCursor::START), + }), + } + } + + fn next_cursor(&self, ring: &mut Ring) -> (UnixMicros, EventCursor) { + let micros = self.clock.now_unix_micros().max(ring.last_micros.next()); + ring.last_micros = micros; + (micros, EventCursor::from_unix_micros(micros)) + } + + pub fn publish(&self, payload: &P) -> EventCursor { + let payload = serde_json::to_value(payload).expect("event payload serializes to JSON"); + let mut ring = self.inner.lock(); + let (micros, created) = self.next_cursor(&mut ring); + insert_sorted( + &mut ring, + Event { + rkey: Tid::from_time(micros.get(), 0), + nsid: P::NSID, + payload, + created, + }, + self.inner.bounds, + ); + self.inner.settle(ring); + created + } + + pub fn reserve(&self) -> Reservation { + let mut ring = self.inner.lock(); + let (micros, cursor) = self.next_cursor(&mut ring); + ring.pending.insert(cursor); + drop(ring); + Reservation { + inner: Arc::clone(&self.inner), + cursor, + micros, + fulfilled: false, + } + } + + pub fn replay(&self, after: EventCursor, bounds: ReplayBounds) -> Replayed { + let ring = self.inner.lock(); + // The corresponding read side guarantee of `reserve`. + let horizon = ring.pending.iter().next().copied(); + let visible = |entry: &&Entry| { + entry.event.created > after + && horizon.is_none_or(|horizon| entry.event.created < horizon) + }; + let events: Vec> = ring + .entries + .iter() + .filter(visible) + .take(bounds.events.get()) + // Why the first event gets to ignore the byte bound? + // Imagine a consumer whose next event is by itself wider + // than the entire bound, right - + // every batch it requests would come back empty, + // its cursor would never advance, + // it would ask again, repeat. + // Sending that one event alone over the bound + // is the only way. + .scan(0usize, |spent, entry| { + let first = *spent == 0; + *spent += entry.bytes; + (first || *spent <= bounds.bytes.get()).then(|| Arc::clone(&entry.event)) + }) + .collect(); + let end = match ring.entries.iter().filter(visible).nth(events.len()) { + Some(_) => BatchEnd::Bounded, + None => BatchEnd::CaughtUp, + }; + Replayed { events, end } + } + + pub fn subscribe(&self) -> watch::Receiver { + self.inner.head.subscribe() + } +} + +pub struct Reservation { + inner: Arc, + cursor: EventCursor, + micros: UnixMicros, + fulfilled: bool, +} + +impl Reservation { + pub fn cursor(&self) -> EventCursor { + self.cursor + } + + pub fn fulfill(mut self, payload: &P) { + let payload = serde_json::to_value(payload).expect("event payload serializes to JSON"); + let mut ring = self.inner.lock(); + insert_sorted( + &mut ring, + Event { + rkey: Tid::from_time(self.micros.get(), 0), + nsid: P::NSID, + payload, + created: self.cursor, + }, + self.inner.bounds, + ); + ring.pending.remove(&self.cursor); + self.inner.settle(ring); + self.fulfilled = true; + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + if self.fulfilled { + return; + } + let mut ring = self.inner.lock(); + ring.pending.remove(&self.cursor); + self.inner.settle(ring); + } +} + +knot_types::scalar_newtype! { + pub struct GlobalSubscriberLimit(usize); + pub struct PerPeerSubscriberLimit(usize); +} + +pub struct SubscriberGate { + global: Arc, + per_peer_max: usize, + peers: Mutex>, +} + +impl SubscriberGate { + pub fn new(global_max: GlobalSubscriberLimit, per_peer_max: PerPeerSubscriberLimit) -> Self { + Self { + global: Arc::new(Semaphore::new(global_max.get().max(1))), + per_peer_max: per_peer_max.get().max(1), + peers: Mutex::new(HashMap::new()), + } + } + + pub fn try_admit(self: &Arc, peer: IpAddr) -> Option { + let global = Arc::clone(&self.global).try_acquire_owned().ok()?; + let mut peers = self + .peers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let current = peers.get(&peer).copied().unwrap_or(0); + if current >= self.per_peer_max { + return None; + } + peers.insert(peer, current + 1); + Some(SubscriberPermit { + _global: global, + gate: Arc::clone(self), + peer, + }) + } +} + +pub struct SubscriberPermit { + _global: OwnedSemaphorePermit, + gate: Arc, + peer: IpAddr, +} + +impl Drop for SubscriberPermit { + fn drop(&mut self) { + let mut peers = self + .gate + .peers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(count) = peers.get_mut(&self.peer) { + *count -= 1; + if *count == 0 { + peers.remove(&self.peer); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::*; + + use std::net::IpAddr; + use std::sync::Arc; + + use knot_runtime::{ManualClock, UnixMicros}; + + fn bounds(events: usize, bytes: usize) -> ReplayBounds { + ReplayBounds::new( + ReplayEvents::new(events).unwrap(), + ReplayBytes::new(bytes).unwrap(), + ) + } + + fn log(capacity: usize) -> EventLog { + EventLog::new( + ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), + bounds(capacity, 1 << 20), + ) + } + + fn replay(log: &EventLog, after: EventCursor, limit: usize) -> Vec> { + log.replay(after, bounds(limit, 1 << 30)).events + } + + fn update() -> GitRefUpdate { + GitRefUpdate::new( + RepoDid::new("did:plc:limpet").unwrap(), + Some(OwnerDid::new("did:web:olaren.dev").unwrap()), + AccountDid::new("did:plc:nel").unwrap(), + ) + } + + fn wire(log: &EventLog, payload: &P) -> serde_json::Value { + log.publish(payload); + serde_json::to_value(&*replay(log, EventCursor::START, 1).remove(0)).unwrap() + } + + #[test] + fn publish_nsids_are_valid_type_names() { + [ + GitRefUpdate::NSID, + KnotMemberUpdate::NSID, + RepoCollaboratorUpdate::NSID, + ] + .iter() + .for_each(|nsid| { + assert!(knot_types::TypeName::new(*nsid).is_ok(), "{nsid}"); + }); + } + + #[test] + fn a_frozen_clock_still_yields_strictly_increasing_cursors_and_distinct_rkeys() { + let log = log(8); + let cursors: Vec<_> = (0..3).map(|_| log.publish(&update())).collect(); + assert!(cursors.windows(2).all(|pair| pair[0] < pair[1])); + let events = replay(&log, EventCursor::START, 8); + let rkeys: std::collections::BTreeSet<_> = events + .iter() + .map(|event| event.rkey.as_str().to_string()) + .collect(); + assert_eq!(rkeys.len(), 3); + } + + #[test] + fn the_ring_evicts_the_oldest_event_past_capacity() { + let log = log(2); + let first = log.publish(&update()); + log.publish(&update()); + log.publish(&update()); + let replayed = replay(&log, EventCursor::START, 8); + assert_eq!(replayed.len(), 2); + assert!(replayed.iter().all(|event| event.created > first)); + } + + #[test] + fn a_wide_event_evicts_by_bytes_long_before_the_ring_fills() { + let wide = |count: usize| { + update().with_changed_files(fill_changed( + (0..count).map(|index| format!("crates/knot-events/src/f{index}.rs")), + )) + }; + let log = EventLog::new( + ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), + bounds(1_024, 64 * 1_024), + ); + (0..16).for_each(|_| { + log.publish(&wide(512)); + }); + let replayed = replay(&log, EventCursor::START, 1_024); + assert!( + (1..16).contains(&replayed.len()), + "the byte maximum evicts before the event maximum does: {}", + replayed.len() + ); + + let one = EventLog::new( + ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), + bounds(1_024, 1), + ); + let only = one.publish(&wide(512)); + assert_eq!( + replay(&one, EventCursor::START, 8) + .iter() + .map(|event| event.created) + .collect::>(), + vec![only], + "the ring keeps the one event wider than the whole byte maximum" + ); + } + + fn fill_changed(paths: impl Iterator) -> ChangedFiles { + let mut budget = knot_types::ChangedFilesBudget::new(); + let _ = paths.into_iter().try_for_each(|path| { + budget.admit(knot_types::RepoPath::new(path).expect("test path is well-formed")) + }); + budget.finish() + } + + #[test] + fn replay_honors_the_cursor_and_the_limit() { + let log = log(8); + let cursors: Vec<_> = (0..4).map(|_| log.publish(&update())).collect(); + let after_second = replay(&log, cursors[1], 8); + assert_eq!( + after_second + .iter() + .map(|event| event.created) + .collect::>(), + cursors[2..].to_vec() + ); + assert_eq!(replay(&log, EventCursor::START, 2).len(), 2); + assert!(replay(&log, cursors[3], 8).is_empty()); + } + + #[test] + fn a_replay_batch_stops_at_the_byte_maximum_and_reports_whether_more_remains() { + let log = EventLog::new( + ManualClock::new(UnixMicros::new(1_700_000_000_000_000)), + bounds(1_024, 1 << 20), + ); + assert_eq!( + log.replay(EventCursor::START, bounds(8, 1 << 20)).end, + BatchEnd::CaughtUp, + "an empty ring has nothing left to send" + ); + let wide = update().with_changed_files(fill_changed( + (0..512).map(|index| format!("crates/knot-events/src/f{index}.rs")), + )); + let cursors: Vec = (0..8).map(|_| log.publish(&wide)).collect(); + + let batch = log.replay(EventCursor::START, bounds(1_024, 16 * 1_024)); + assert!( + (1..8).contains(&batch.events.len()), + "the byte maximum stops the batch before the event maximum does: {}", + batch.events.len() + ); + assert_eq!(batch.end, BatchEnd::Bounded); + + let rest = log.replay( + batch.events.last().expect("the batch is nonempty").created, + bounds(1_024, 1 << 30), + ); + assert_eq!(rest.end, BatchEnd::CaughtUp); + assert_eq!( + batch.events.len() + rest.events.len(), + cursors.len(), + "the two batches together are every event, with none repeated or skipped" + ); + let head = log.replay(cursors[7], bounds(8, 1 << 20)); + assert!(head.events.is_empty() && head.end == BatchEnd::CaughtUp); + + let single = log.replay(EventCursor::START, bounds(1_024, 1)); + assert_eq!( + single.events.len(), + 1, + "an event wider than the whole batch maximum is sent alone" + ); + assert_eq!(single.end, BatchEnd::Bounded); + } + + #[test] + fn a_subscriber_observes_the_head_advance() { + let log = log(8); + let mut head = log.subscribe(); + assert_eq!(*head.borrow_and_update(), EventCursor::START); + let created = log.publish(&update()); + assert!(head.has_changed().unwrap()); + assert_eq!(*head.borrow_and_update(), created); + } + + #[test] + fn the_wire_event_matches_the_eventstream_shape() { + let wire = wire(&log(8), &update()); + assert_eq!(wire["nsid"], "sh.tangled.git.refUpdate"); + assert_eq!(wire["created"].as_i64().unwrap() % 1_000, 0); + assert_eq!(wire["rkey"].as_str().unwrap().len(), 13); + let payload = &wire["event"]; + assert_eq!(payload["$type"], "sh.tangled.git.refUpdate"); + assert_eq!(payload["committerDid"], "did:plc:nel"); + assert_eq!(payload["ownerDid"], "did:web:olaren.dev"); + assert_eq!(payload["repo"], "did:plc:limpet"); + assert_eq!(payload["meta"], serde_json::Value::Null); + assert_eq!(payload["ref"], ""); + assert_eq!( + payload["oldSha"], "", + "a record about no ref sends the empty sha" + ); + assert_eq!(payload["newSha"], ""); + } + + #[test] + fn an_absent_sha_of_a_transition_is_the_null_oid_of_the_repo_object_format() { + let new = Oid::from_hex(&"cd".repeat(32)).unwrap(); + let created = &wire( + &log(8), + &GitRefUpdate::new( + RepoDid::new("did:plc:limpet").unwrap(), + None, + AccountDid::new("did:plc:nel").unwrap(), + ) + .on_ref( + RefName::new("refs/heads/fresh").unwrap(), + RefTransition::Create { new }, + ObjectFormat::SHA256, + ), + )["event"]; + assert_eq!(created["oldSha"], "0".repeat(64)); + assert_eq!(created["newSha"], new.to_hex()); + + let old = Oid::from_hex(&"ab".repeat(20)).unwrap(); + let rebuilt = update() + .on_ref( + RefName::new("refs/heads/fresh").unwrap(), + RefTransition::Create { + new: Oid::from_hex(&"cd".repeat(20)).unwrap(), + }, + ObjectFormat::SHA1, + ) + .on_ref( + RefName::new("refs/heads/gone").unwrap(), + RefTransition::Delete { old }, + ObjectFormat::SHA1, + ); + let payload = &wire(&log(8), &rebuilt)["event"]; + assert_eq!( + payload["ref"], "refs/heads/gone", + "a later transition replaces the earlier one whole" + ); + assert_eq!(payload["oldSha"], old.to_hex()); + assert_eq!(payload["newSha"], "0".repeat(40)); + } + + fn peer(last: u8) -> IpAddr { + IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, last)) + } + + #[test] + fn gate_enforces_limits_and_prunes() { + let global = Arc::new(SubscriberGate::new( + GlobalSubscriberLimit::new(2), + PerPeerSubscriberLimit::new(8), + )); + let first = global + .try_admit(peer(1)) + .expect("first subscriber is admitted"); + let _second = global + .try_admit(peer(2)) + .expect("second subscriber is admitted"); + assert!( + global.try_admit(peer(3)).is_none(), + "third subscriber is refused once the global limit is reached" + ); + drop(first); + assert!( + global.try_admit(peer(3)).is_some(), + "freeing global slot admits waiting subscriber" + ); + + let per_peer = Arc::new(SubscriberGate::new( + GlobalSubscriberLimit::new(16), + PerPeerSubscriberLimit::new(2), + )); + let _socket = per_peer + .try_admit(peer(1)) + .expect("first socket is admitted"); + let second = per_peer + .try_admit(peer(1)) + .expect("second socket is admitted"); + assert!( + per_peer.try_admit(peer(1)).is_none(), + "third socket from same peer is refused at the per-peer limit" + ); + assert!( + per_peer.try_admit(peer(2)).is_some(), + "different peer keeps its own budget" + ); + drop(second); + assert!( + per_peer.try_admit(peer(1)).is_some(), + "freed per-peer slot is reusable" + ); + + let prune = Arc::new(SubscriberGate::new( + GlobalSubscriberLimit::new(16), + PerPeerSubscriberLimit::new(2), + )); + let permit = prune.try_admit(peer(1)).expect("admitted"); + drop(permit); + assert!( + prune + .peers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty(), + "peer map prunes peer once its last socket closes" + ); + } + + #[test] + fn a_ref_update_includes_its_computed_meta_on_the_wire() { + let wire = wire( + &log(8), + &update().with_meta(RefUpdateMeta::new( + true, + vec![EmailCommitCount::new( + Email::new("nel@oyster.cafe"), + CommitCount::new(3), + )], + vec![LanguageSize::new( + LanguageName::new("Rust"), + LanguageBytes::new(1234), + )], + )), + ); + let meta = &wire["event"]["meta"]; + assert_eq!(meta["isDefaultRef"], true); + assert_eq!( + meta["commitCount"]["byEmail"][0]["email"], + "nel@oyster.cafe" + ); + assert_eq!(meta["commitCount"]["byEmail"][0]["count"], 3); + assert_eq!(meta["langBreakdown"]["inputs"][0]["lang"], "Rust"); + assert_eq!(meta["langBreakdown"]["inputs"][0]["size"], 1234); + } + + #[test] + fn an_empty_breakdown_omits_the_optional_meta_arrays() { + let wire = wire( + &log(8), + &update().with_meta(RefUpdateMeta::new(false, Vec::new(), Vec::new())), + ); + let meta = &wire["event"]["meta"]; + assert_eq!(meta["isDefaultRef"], false); + assert!(meta["commitCount"].get("byEmail").is_none()); + assert!(meta.get("langBreakdown").is_none()); + } + + #[test] + fn acl_updates_match_eventstream_shape() { + let log = log(8); + log.publish(&KnotMemberUpdate::added( + AccountDid::new("did:plc:nel").unwrap(), + )); + log.publish(&KnotMemberUpdate::removed( + AccountDid::new("did:plc:olaren").unwrap(), + )); + log.publish(&RepoCollaboratorUpdate::added( + AccountDid::new("did:plc:nel").unwrap(), + RepoDid::new("did:plc:limpet").unwrap(), + )); + log.publish(&RepoCollaboratorUpdate::removed( + AccountDid::new("did:plc:nel").unwrap(), + RepoDid::new("did:plc:limpet").unwrap(), + )); + let events = replay(&log, EventCursor::START, 8); + + let member_added = serde_json::to_value(&*events[0]).unwrap(); + assert_eq!(member_added["nsid"], "sh.tangled.knot.memberUpdate"); + assert_eq!(member_added["event"]["op"], "add"); + assert_eq!(member_added["event"]["subject"], "did:plc:nel"); + assert!(member_added["event"].get("$type").is_none()); + let member_removed = serde_json::to_value(&*events[1]).unwrap(); + assert_eq!(member_removed["event"]["op"], "remove"); + assert_eq!(member_removed["event"]["subject"], "did:plc:olaren"); + + let collab_added = serde_json::to_value(&*events[2]).unwrap(); + assert_eq!(collab_added["nsid"], "sh.tangled.repo.collaboratorUpdate"); + assert_eq!(collab_added["event"]["op"], "add"); + assert_eq!(collab_added["event"]["subject"], "did:plc:nel"); + assert_eq!(collab_added["event"]["repo"], "did:plc:limpet"); + assert!(collab_added["event"].get("$type").is_none()); + let collab_removed = serde_json::to_value(&*events[3]).unwrap(); + assert_eq!(collab_removed["event"]["op"], "remove"); + assert_eq!(collab_removed["event"]["repo"], "did:plc:limpet"); + } + + fn cursors(log: &EventLog) -> Vec { + replay(log, EventCursor::START, 64) + .iter() + .map(|event| event.created) + .collect() + } + + #[test] + fn a_reservation_holds_back_later_events_until_it_is_fulfilled() { + let log = log(8); + let early = log.publish(&update()); + let reservation = log.reserve(); + let later = log.publish(&update()); + assert!(reservation.cursor() > early && reservation.cursor() < later); + assert_eq!( + cursors(&log), + vec![early], + "event published after reservation waits behind it" + ); + let mid = reservation.cursor(); + reservation.fulfill(&update()); + assert_eq!( + cursors(&log), + vec![early, mid, later], + "fulfilling reservation releases it and event queued behind it, in cursor order" + ); + } + + #[test] + fn out_of_order_fulfillment_still_replays_in_cursor_order() { + let log = log(8); + let first = log.reserve(); + let second = log.reserve(); + let (c1, c2) = (first.cursor(), second.cursor()); + assert!(c1 < c2); + second.fulfill(&update()); + assert!( + cursors(&log).is_empty(), + "later reservation stays hidden while earlier one is outstanding" + ); + first.fulfill(&update()); + assert_eq!( + cursors(&log), + vec![c1, c2], + "both surface in cursor order regardless of fulfillment order" + ); + } + + #[test] + fn a_dropped_reservation_unblocks_the_horizon_without_an_event() { + let log = log(8); + let reservation = log.reserve(); + let later = log.publish(&update()); + assert!( + cursors(&log).is_empty(), + "later event waits behind unfulfilled reservation" + ); + drop(reservation); + assert_eq!( + cursors(&log), + vec![later], + "dropping reservation surfaces queued event and leaves no gap" + ); + } + + #[test] + fn the_head_holds_at_the_last_stable_event_until_a_reservation_is_fulfilled() { + let log = log(8); + let mut head = log.subscribe(); + let early = log.publish(&update()); + assert_eq!(*head.borrow_and_update(), early); + let reservation = log.reserve(); + let later = log.publish(&update()); + assert_eq!( + *head.borrow_and_update(), + early, + "head holds while lower-cursor reservation is pending" + ); + reservation.fulfill(&update()); + assert_eq!( + *head.borrow_and_update(), + later, + "fulfilling reservation advances head past released events" + ); + } + + #[test] + fn an_anonymous_owner_is_omitted_from_the_wire() { + let wire = wire( + &log(8), + &GitRefUpdate::new( + RepoDid::new("did:plc:limpet").unwrap(), + None, + AccountDid::new("did:plc:nel").unwrap(), + ), + ); + assert!(wire["event"].get("ownerDid").is_none()); + } +} diff --git a/knot2/worker/.cargo/config.toml b/knot2/worker/.cargo/config.toml new file mode 100644 index 000000000..2e07606d5 --- /dev/null +++ b/knot2/worker/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/knot2/worker/.gitignore b/knot2/worker/.gitignore new file mode 100644 index 000000000..82af3f499 --- /dev/null +++ b/knot2/worker/.gitignore @@ -0,0 +1,5 @@ +target/ +build/ +node_modules/ +.wrangler/ +.dev.vars diff --git a/knot2/worker/Cargo.lock b/knot2/worker/Cargo.lock new file mode 100644 index 000000000..62ed69052 --- /dev/null +++ b/knot2/worker/Cargo.lock @@ -0,0 +1,4133 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.118", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cbor4ii" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "serde", + "serde_bytes", + "unsigned-varint", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.118", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless 0.8.0", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gengo-language" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f4ac35e0d9289625d1266dd236eca53fa8cb0450dea944fe3a99a85d664c82" +dependencies = [ + "glob", + "indexmap", + "proc-macro2", + "quote", + "regex", + "serde", + "serde_yaml", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasm-bindgen", +] + +[[package]] +name = "gix-actor" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d43f12e246d3bf7ec624c8fc15ac4a4b62b7c4c6f586cb82be6c90bf84c9d02" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-date" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ecab64a98bbac9f8e02990a9ea5e3c974a7d49b95f2bd70ad94ad22fa6b48c" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b" +dependencies = [ + "bstr", + "getrandom 0.4.3", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" +dependencies = [ + "gix-trace", + "gix-utils", + "libc", + "prodash", +] + +[[package]] +name = "gix-filter" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "sha2 0.11.0", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e30b93eea8718baf7d8153fcb938e2926175bbf18097c09f1c01b6f0be0563" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-lock" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-object" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-packetline" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-revision" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f" +dependencies = [ + "bitflags 2.13.0", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f5756abffe0917827aac683b13684ed99875bc398fa1f9b8f479b0681ef9e6" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27850097e1ff9515f46a0dad0f5f9c9d020e972727772dabab9450690c4adb22" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" + +[[package]] +name = "gix-traverse" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed" +dependencies = [ + "bitflags 2.13.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-utils" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" +dependencies = [ + "fastrand", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipld-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda" +dependencies = [ + "cid", + "serde", + "serde_bytes", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jacquard-common" +version = "0.12.1" +source = "git+https://tangled.org/oppi.li/jacquard?rev=bb32de36f1cc2727954fba6377e0433c391d34f3#bb32de36f1cc2727954fba6377e0433c391d34f3" +dependencies = [ + "base64", + "bon", + "bytes", + "chrono", + "ciborium", + "ciborium-io", + "cid", + "fluent-uri", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hashbrown 0.15.5", + "http", + "ipld-core", + "k256", + "maitake-sync", + "miette", + "multibase", + "multihash", + "oxilangtag", + "p256", + "phf", + "postcard", + "rand 0.9.4", + "regex", + "regex-automata", + "regex-lite", + "reqwest", + "rustversion", + "serde", + "serde_bytes", + "serde_html_form", + "serde_ipld_dagcbor", + "serde_json", + "signature", + "smol_str", + "spin 0.10.0", + "thiserror", + "tokio", + "tokio-util", + "trait-variant", + "unicode-segmentation", +] + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "knot-atproto" +version = "2.0.0" +dependencies = [ + "base32", + "base64", + "bs58", + "bytes", + "cid", + "futures", + "http", + "k256", + "knot-runtime", + "knot-types", + "serde", + "serde_ipld_dagcbor", + "serde_json", + "sha2 0.11.0", + "thiserror", + "url", +] + +[[package]] +name = "knot-capability" +version = "0.0.1" + +[[package]] +name = "knot-gitcore" +version = "2.0.0" +dependencies = [ + "flate2", + "gix-actor", + "gix-diff", + "gix-hash", + "gix-object", + "knot-types", + "thiserror", +] + +[[package]] +name = "knot-langs" +version = "2.0.0" +dependencies = [ + "gengo-language", + "knot-types", + "regex", +] + +[[package]] +name = "knot-runtime" +version = "2.0.0" +dependencies = [ + "bytes", + "futures", + "getrandom 0.4.3", + "http", + "k256", + "knot-types", + "serde", + "thiserror", + "url", +] + +[[package]] +name = "knot-types" +version = "2.0.0" +dependencies = [ + "cid", + "gix-hash", + "http", + "ipld-core", + "jacquard-common", + "serde", + "serde_ipld_dagcbor", + "serde_json", + "sha2 0.11.0", + "thiserror", + "trusted-proxies", + "url", +] + +[[package]] +name = "knot-wire" +version = "2.0.0" +dependencies = [ + "base64", + "chrono", + "cid", + "form_urlencoded", + "http", + "jacquard-common", + "knot-gitcore", + "knot-types", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.11.0", + "url", +] + +[[package]] +name = "knot-worker" +version = "2.0.0" +dependencies = [ + "base64", + "bytes", + "flate2", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", + "gix-diff", + "gix-hash", + "gix-object", + "gix-revision", + "gix-revwalk", + "gix-traverse", + "http", + "js-sys", + "k256", + "knot-atproto", + "knot-capability", + "knot-gitcore", + "knot-langs", + "knot-runtime", + "knot-types", + "knot-wire", + "send_wrapper", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "worker", +] + +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "maitake-sync" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6816ab14147f80234c675b80ed6dc4f440d8a1cefc158e766067aedb84c0bcd5" +dependencies = [ + "cordyceps", + "loom", + "mycelium-bitfield", + "pin-project", + "portable-atomic", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "serde", + "unsigned-varint", +] + +[[package]] +name = "mycelium-bitfield" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e0cc5e2c585acbd15c5ce911dff71e1f4d5313f43345873311c4f5efd741cc" + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oxilangtag" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3b4eb570abd4a1dcb062c31fd37b832264d9dc7292c3e69acfe926c87b063f" +dependencies = [ + "serde", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_html_form" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "serde_core", +] + +[[package]] +name = "serde_ipld_dagcbor" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" +dependencies = [ + "cbor4ii", + "ipld-core", + "scopeguard", + "serde", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "trusted-proxies" +version = "0.0.1" +dependencies = [ + "ipnet", + "thiserror", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ce9bdac782bbca70d6ac48a3ba18b873c848a706d6dc3bd9a03c1b96f04693" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "worker" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057bce8ec66c80b7e270d884c312e2a4ff60eb997fd02936656106c06a964e5" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d7389670b55aab89760be149007aeff773cdad7b650d807f7fb556d04bb304" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 3.0.6", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a440092e7ae7c5feb5507fbdc6aee0cc241d132757c83e1729766a24cddff82" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/knot2/worker/Cargo.toml b/knot2/worker/Cargo.toml new file mode 100644 index 000000000..eb9d80d24 --- /dev/null +++ b/knot2/worker/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "knot-worker" +version = "2.0.0" +edition = "2024" +rust-version = "1.96" +license = "MIT" + +# knot2 as a cloudflare worker, with cloudflare artifacts as its git storage. +# its own workspace: it only builds for wasm32-unknown-unknown. +[workspace] + +[lib] +crate-type = ["cdylib"] +path = "src/lib.rs" + +[dependencies] +knot-types = { path = "../crates/knot-types" } +knot-gitcore = { path = "../crates/knot-gitcore" } +knot-wire = { path = "../crates/knot-wire" } +knot-langs = { path = "../crates/knot-langs", default-features = false } +knot-atproto = { path = "../crates/knot-atproto", default-features = false } +knot-runtime = { path = "../crates/knot-runtime", default-features = false } +knot-capability = { path = "../../crates/knot-capability" } + +gix-object = { version = "0.61", features = ["sha1", "sha256"] } +gix-hash = { version = "0.25", features = ["sha1", "sha256"] } +gix-diff = { version = "0.64", default-features = false, features = ["blob", "wasm"] } +gix-traverse = "0.58" +gix-revision = { version = "0.46", default-features = false, features = ["merge_base", "sha1", "sha256"] } +gix-revwalk = "0.32" + +worker = { version = "0.8.7", features = ["queue"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +send_wrapper = { version = "0.6", features = ["futures"] } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +http = "1" +url = "2" +bytes = "1" +futures = "0.3" +flate2 = "1" +sha2 = "0.11" +base64 = "0.22" +k256 = { version = "0.13", features = ["ecdsa"] } +thiserror = "2" + +getrandom = { version = "0.4", features = ["wasm_js"] } +getrandom-02 = { package = "getrandom", version = "0.2", features = ["js"] } +getrandom-03 = { package = "getrandom", version = "0.3", features = ["wasm_js"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 + +[patch.crates-io] +jacquard-common = { git = "https://tangled.org/oppi.li/jacquard", rev = "bb32de36f1cc2727954fba6377e0433c391d34f3" } diff --git a/knot2/worker/parity/.gitignore b/knot2/worker/parity/.gitignore new file mode 100644 index 000000000..e53e3f5bc --- /dev/null +++ b/knot2/worker/parity/.gitignore @@ -0,0 +1,3 @@ +out/ +.dev.vars +.wrangler/ diff --git a/knot2/worker/parity/compose.yml b/knot2/worker/parity/compose.yml new file mode 100644 index 000000000..1120fd703 --- /dev/null +++ b/knot2/worker/parity/compose.yml @@ -0,0 +1,44 @@ +# a sha1 knot2 built from this tree, joined to a running localinfra stack, so +# the worker can be compared against it. run from the repo root: +# podman compose -p knot2parity -f knot2/worker/parity/compose.yml up -d --build +# it expects localinfra's `master` project (plc, pds, dns, caddy) to be up. + +services: + knot2-parity: + dns: [11.0.0.254] + build: + context: ../../.. + dockerfile: localinfra/knot2.Dockerfile + restart: unless-stopped + environment: + KNOT_HOSTNAME: knot2-parity.tngl.boltless.dev + KNOT_ADMINS: ${PARITY_ADMIN_DID:?the did allowed to create repos} + KNOT_SCAN_PATH: /home/git/repositories + KNOT_SSH_HOST_KEY_FILE: /home/git/keys/host_key + KNOT_SEALED_KEY_FILE: /home/git/keys/sealed.bin + KNOT_MASTER_KEY_ENV: KNOT_MASTER_KEY + KNOT_MASTER_KEY: "Zm9yLXBhcml0eS10ZXN0cy1vbmx5LW5vdC1hLXNlY3I=" # dev only + KNOT_PLC_DIRECTORY: https://plc.tngl.boltless.dev + KNOT_EXTRA_CA_FILE: /etc/ssl/certs/tngl.crt + KNOT_GIT_OBJECT_FORMAT: sha1 + KNOT_TLS_HTTP3: "false" + KNOT_CONTRIBUTION_POLICY: anyone + KNOT_LISTEN_REQUEST_TIMEOUT_MS: "600000" + RUST_LOG: info + ports: + - "127.0.0.1:5556:5555" + volumes: + - knot2-parity-data:/home/git + - ${LOCALINFRA_CA:?path to localinfra/certs/root.crt}:/etc/ssl/certs/tngl.crt:ro + networks: [tngl, upstream] + +volumes: + knot2-parity-data: + +networks: + tngl: + external: true + name: master_tngl + upstream: + external: true + name: master_upstream-cache diff --git a/knot2/worker/parity/fixture.sh b/knot2/worker/parity/fixture.sh new file mode 100644 index 000000000..47f15b43d --- /dev/null +++ b/knot2/worker/parity/fixture.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# a deterministic sha1 repo that touches every read path the knot serves: +# several timezones, branches, a merge, annotated and lightweight tags, binary, +# executable, symlink, unicode and no-trailing-newline files, a rename, a +# submodule, and a hand-built commit with gpgsig and change-id headers. +set -euo pipefail + +dir=${1:?usage: fixture.sh } +rm -rf "$dir" +git init -q -b main --object-format=sha1 "$dir" +cd "$dir" +git config core.autocrlf false +git config commit.gpgsign false +git config tag.gpgsign false + +at() { + export GIT_AUTHOR_NAME="${3:-Alice Example}" GIT_AUTHOR_EMAIL="${4:-alice@example.com}" + export GIT_COMMITTER_NAME="${5:-Alice Example}" GIT_COMMITTER_EMAIL="${6:-alice@example.com}" + export GIT_AUTHOR_DATE="$1" GIT_COMMITTER_DATE="$2" +} + +commit() { + git add -A + git commit -q --allow-empty -m "$1" +} + +at "2026-01-05T10:00:00+05:30" "2026-01-05T10:00:00+05:30" +mkdir -p src docs +printf '# parity\n\nA fixture for comparing knots.\n' > README.md +printf 'fn main() {\n println!("hello");\n}\n' > src/main.rs +printf 'pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\npub fn sub(a: i32, b: i32) -> i32 {\n a - b\n}\n' > src/lib.rs +printf 'pub fn old() {}\n' > src/old.rs +printf '# guide\n\nsome docs\n' > docs/guide.md +printf 'MIT\n' > LICENSE +commit "initial import" +git tag -a v0.1 -m "first release + +with a body paragraph" + +at "2026-01-06T09:30:00+00:00" "2026-01-06T11:15:00-04:00" "Bob Example" "bob@example.com" +mkdir -p go assets +printf 'package main\n\nfunc main() {}\n' > go/main.go +printf '#!/bin/sh\necho hi\n' > script.sh +chmod +x script.sh +ln -s README.md link +printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89' > assets/pixel.png +head -c 3000 /dev/zero | tr '\0' '\001' > assets/blob.bin +commit "add go, assets, a script and a link" +base=$(git rev-parse HEAD) + +git checkout -q -b feature +at "2026-01-07T08:00:00+09:00" "2026-01-07T08:00:00+09:00" "Carol Example" "carol@example.com" +printf 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("feature")\n}\n' > go/main.go +commit "feature: print something" +at "2026-01-07T09:00:00+09:00" "2026-01-07T09:00:00+09:00" "Carol Example" "carol@example.com" +printf 'feature notes\n' > NOTES.txt +commit "feature: add notes" + +git checkout -q main +at "2026-01-08T12:00:00+00:00" "2026-01-08T12:00:00+00:00" +printf 'pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\npub fn sub(a: i32, b: i32) -> i32 {\n a - b\n}\n\npub fn mul(a: i32, b: i32) -> i32 {\n a * b\n}\n' > src/lib.rs +git rm -q docs/guide.md +mkdir -p unicode +printf 'naïve\n' > "unicode/naïve file.txt" +printf 'no newline at the end' > nonl.txt +commit "lib: multiply, drop the guide, add unicode + +Longer body explaining the change. + + - with an indented list + - and trailing spaces " +git tag v0.2 + +at "2026-01-09T18:45:00-08:00" "2026-01-09T18:45:00-08:00" +git merge -q --no-ff feature -m "Merge branch 'feature'" + +at "2026-01-10T07:00:00+01:00" "2026-01-10T07:00:00+01:00" +git mv src/old.rs src/new.rs +seq 1 4000 | sed 's/^/line /' > big.txt +printf '[submodule "vendor/dep"]\n\tpath = vendor/dep\n\turl = https://example.com/dep.git\n\tbranch = stable\n' > .gitmodules +git add -A +git update-index --add --cacheinfo "160000,$base,vendor/dep" +git commit -q -m "rename, big file, submodule" + +# a commit only a hand-built object can make: gpgsig and change-id headers +tree=$(git rev-parse HEAD^{tree}) +parent=$(git rev-parse HEAD) +raw=$(mktemp) +{ + printf 'tree %s\n' "$tree" + printf 'parent %s\n' "$parent" + printf 'author Dana Example 1768150800 +0200\n' + printf 'committer Dana Example 1768154400 -0130\n' + printf 'gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEE\n -----END PGP SIGNATURE-----\n' + printf 'change-id kxqyrzlmnoptwsuv\n' + printf '\n' + printf 'signed: a commit with extra headers\n\nand a body.\n' +} > "$raw" +signed=$(git hash-object -t commit -w --literally "$raw") +rm -f "$raw" +git update-ref refs/heads/main "$signed" + +# a branch that only points at a tag object, and one more branch tip +git branch stale "$base" +git tag -a v0.3 -m "annotated on main" main + +git gc -q --prune=now +git rev-parse HEAD >/dev/null diff --git a/knot2/worker/parity/parity.mjs b/knot2/worker/parity/parity.mjs new file mode 100644 index 000000000..2b424b193 --- /dev/null +++ b/knot2/worker/parity/parity.mjs @@ -0,0 +1,331 @@ +#!/usr/bin/env node +// compares the worker knot against a native knot2, request by request. +// +// node parity.mjs setup-native create the fixture repo on the native knot and push it +// node parity.mjs register-worker point the local worker at the native repo over git +// node parity.mjs create-worker create the repo on the worker (artifacts) and push it +// node parity.mjs compare run the request matrix against both and diff +// node parity.mjs events diff the ref updates each knot announced +// +// NODE_EXTRA_CA_CERTS must name localinfra's root.crt so the pds is trusted. + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const out = join(here, "out"); +const statePath = join(out, "state.json"); +const fixture = join(out, "fixture"); + +const env = { + pds: process.env.PARITY_PDS ?? "https://pds.tngl.boltless.dev", + handle: process.env.PARITY_HANDLE ?? "alice.pds.tngl.boltless.dev", + password: process.env.PARITY_PASSWORD ?? "password", + native: process.env.PARITY_NATIVE ?? "http://127.0.0.1:5556", + nativeDid: process.env.PARITY_NATIVE_DID ?? "did:web:knot2-parity.tngl.boltless.dev", + worker: process.env.PARITY_WORKER ?? "http://127.0.0.1:8787", + workerDid: process.env.PARITY_WORKER_DID ?? "did:web:knot-worker.tngl.boltless.dev", + // what the worker itself can reach the native knot at, for register-worker + nativeFromWorker: process.env.PARITY_NATIVE_FROM_WORKER ?? "http://127.0.0.1:5556", + rkey: process.env.PARITY_RKEY ?? "parity", + git: process.env.GIT ?? "git", +}; + +const load = () => (existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {}); +const save = (state) => writeFileSync(statePath, JSON.stringify(state, null, 2)); + +async function json(url, init = {}) { + const response = await fetch(url, init); + const text = await response.text(); + if (!response.ok) throw new Error(`${init.method ?? "GET"} ${url}: ${response.status} ${text}`); + return text ? JSON.parse(text) : {}; +} + +async function session() { + return json(`${env.pds}/xrpc/com.atproto.server.createSession`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: env.handle, password: env.password }), + }); +} + +async function serviceAuth(access, aud, lxm, seconds = 60) { + const exp = Math.floor(Date.now() / 1000) + seconds; + const url = `${env.pds}/xrpc/com.atproto.server.getServiceAuth?aud=${encodeURIComponent(aud)}&lxm=${encodeURIComponent(lxm)}&exp=${exp}`; + const { token } = await json(url, { headers: { authorization: `Bearer ${access}` } }); + return token; +} + +function git(...args) { + return execFileSync(env.git, args, { cwd: fixture, encoding: "utf8" }).trim(); +} + +function pushTo(url) { + execFileSync(env.git, ["push", "--quiet", url, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"], { + cwd: fixture, + stdio: "inherit", + }); +} + +async function createRepo(base, aud, access) { + const token = await serviceAuth(access, aud, "sh.tangled.repo.create"); + return json(`${base}/xrpc/sh.tangled.repo.create`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ rkey: env.rkey, name: env.rkey, defaultBranch: "main" }), + }); +} + +async function setupNative() { + const { accessJwt, did } = await session(); + const created = await createRepo(env.native, env.nativeDid, accessJwt); + const push = await serviceAuth(accessJwt, env.nativeDid, "sh.tangled.repo.push", 300); + const remote = new URL(`${env.native}/${created.repoDid}`); + remote.username = "x"; + remote.password = push; + pushTo(remote.toString()); + save({ ...load(), owner: did, native: created.repoDid }); + console.log(`native repo ${created.repoDid}`); +} + +async function registerWorker() { + const state = load(); + if (!state.native) throw new Error("run setup-native first"); + await json(`${env.worker}/_dev/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + did: state.native, + owner: state.owner, + rkey: env.rkey, + name: env.rkey, + artifacts: "parity-dev", + remote: `${env.nativeFromWorker}/${state.native}`, + created_at: Math.floor(Date.now() / 1000), + default_branch: null, + }), + }); + save({ ...state, worker: state.native }); + console.log(`worker reads ${state.native} from the native knot`); +} + +async function createWorker() { + const { accessJwt, did } = await session(); + const created = await createRepo(env.worker, env.workerDid, accessJwt); + const token = process.env.PARITY_ARTIFACTS_TOKEN; + if (!token) throw new Error("set PARITY_ARTIFACTS_TOKEN to a write token for the new artifacts repo"); + const remote = new URL(`${env.worker}/${created.repoDid}`); + remote.username = "x"; + remote.password = token.split("?expires=")[0]; + pushTo(remote.toString()); + save({ ...load(), owner: did, worker: created.repoDid }); + console.log(`worker repo ${created.repoDid}`); +} + +function matrix(state) { + const sha = (rev) => git("rev-parse", rev); + const n = encodeURIComponent; + const shas = { + head: sha("main"), + signed: sha("main"), + rename: sha("main~1"), + merge: sha("main~2"), + root: sha("v0.1^{commit}"), + second: sha("stale"), + feature: sha("feature"), + }; + // each request is run against both knots with that knot's repo did + const reads = [ + ["tree", "ref=main"], + ["tree", ""], + ["tree", "ref=main&path=src"], + ["tree", "ref=main&path=unicode"], + ["tree", "ref=v0.1"], + ["tree", "ref=feature&path=go"], + ["tree", `ref=${shas.merge}`], + ["tree", "ref=main&path=missing"], + ["tree", "ref=main&path=README.md"], + ["tree", "ref=main&path=vendor/dep"], + ["tree", "ref=nope"], + ["tree", "ref=refs/cobs/x"], + ["tree", "ref=main~2"], + ["log", "ref=main"], + ["log", "ref=main&limit=2"], + ["log", "ref=main&limit=2&cursor=2"], + ["log", "ref=feature"], + ["log", "ref=v0.2"], + ["log", "ref=main&limit=0"], + ["log", "ref=main&limit=abc"], + ["branches", ""], + ["branches", "limit=1"], + ["branches", "limit=1&cursor=1"], + ["branch", "name=main"], + ["branch", "name=feature"], + ["branch", "name=nope"], + ["branch", ""], + ["tags", ""], + ["tags", "limit=1&cursor=1"], + ["tag", "tag=v0.1"], + ["tag", "tag=v0.2"], + ["tag", "tag=refs/tags/v0.3"], + ["tag", "tag=nope"], + ["tag", ""], + ["blob", "ref=main&path=README.md"], + ["blob", "ref=main&path=src/lib.rs"], + ["blob", "ref=main&path=assets/pixel.png"], + ["blob", "ref=main&path=assets/blob.bin"], + ["blob", "ref=main&path=link"], + ["blob", "ref=main&path=script.sh"], + ["blob", "ref=main&path=nonl.txt"], + ["blob", `ref=main&path=${n("unicode/naïve file.txt")}`], + ["blob", "ref=main&path=big.txt"], + ["blob", "ref=main&path=vendor/dep"], + ["blob", "ref=main&path=src"], + ["blob", "ref=main&path=missing.txt"], + ["blob", "ref=main"], + ["blob", "ref=main&path=README.md&raw=true"], + ["blob", "ref=main&path=assets/pixel.png&raw=true"], + ["blob", "ref=main&path=assets/blob.bin&raw=true"], + ["blob", "ref=v0.1&path=docs/guide.md"], + ["diff", "ref=main"], + ["diff", `ref=${shas.rename}`], + ["diff", `ref=${shas.merge}`], + ["diff", `ref=${shas.root}`], + ["diff", "ref=v0.2"], + ["diff", `ref=${shas.second}`], + ["compare", "rev1=v0.1&rev2=main"], + ["compare", "rev1=main&rev2=feature"], + ["compare", "rev1=feature&rev2=main"], + ["compare", `rev1=${shas.root}&rev2=${shas.second}`], + ["compare", "rev1=v0.2&rev2=v0.3"], + ["compare", "rev1=main"], + ["compare", "rev1=nope&rev2=main"], + ["languages", "ref=main"], + ["languages", "ref=v0.1"], + ["languages", ""], + ["getDefaultBranch", ""], + ].map(([method, query]) => ({ nsid: `sh.tangled.repo.${method}`, query, repo: true })); + const others = [ + { nsid: "sh.tangled.git.listRefs", query: "", repo: true }, + { nsid: "sh.tangled.git.listRefs", query: "limit=2", repo: true }, + { nsid: "sh.tangled.git.listRefs", query: "limit=2&cursor=2", repo: true }, + { nsid: "sh.tangled.repo.describeRepo", query: "", repoDid: true }, + { nsid: "sh.tangled.repo.tree", query: "ref=main", repo: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, + { nsid: "sh.tangled.repo.tree", query: "ref=main", repo: "not-a-did" }, + { nsid: "sh.tangled.repo.log", query: "ref=main", ownerRkey: true }, + ]; + return [...reads, ...others]; +} + +function url(base, did, owner, request) { + const params = new URLSearchParams(request.query); + if (request.repo === true) params.set("repo", did); + else if (typeof request.repo === "string") params.set("repo", request.repo); + if (request.repoDid) params.set("repoDid", did); + if (request.ownerRkey) params.set("repo", `${owner}/${env.rkey}`); + const query = params.toString(); + return `${base}/xrpc/${request.nsid}${query ? `?${query}` : ""}`; +} + +const HEADERS = [ + "content-type", + "etag", + "cache-control", + "content-security-policy", + "x-content-type-options", + "content-disposition", + "link", +]; + +async function capture(target) { + const response = await fetch(target); + const body = Buffer.from(await response.arrayBuffer()); + const headers = Object.fromEntries(HEADERS.map((name) => [name, response.headers.get(name)])); + return { status: response.status, headers, body }; +} + +function firstDifference(a, b, path = "$") { + if (typeof a !== typeof b) return `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`; + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) + return `${path}: array length ${a.length} vs ${Array.isArray(b) ? b.length : "non-array"}`; + for (let i = 0; i < a.length; i++) { + const found = firstDifference(a[i], b[i], `${path}[${i}]`); + if (found) return found; + } + return null; + } + if (a && typeof a === "object") { + const keys = [...new Set([...Object.keys(a), ...Object.keys(b ?? {})])]; + for (const key of keys) { + if (!(key in a) || !(key in (b ?? {}))) return `${path}.${key}: present on one side only`; + const found = firstDifference(a[key], b[key], `${path}.${key}`); + if (found) return found; + } + return null; + } + return a === b ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`; +} + +async function compare() { + const state = load(); + if (!state.native || !state.worker) throw new Error("set up both repos first"); + const requests = matrix(state); + const normalize = (buffer) => + buffer + .toString("latin1") + .split(state.worker) + .join(state.native) + .split(env.workerDid) + .join(env.nativeDid) + .split(env.worker) + .join(env.native); + let same = 0; + const differing = []; + for (const request of requests) { + const nativeUrl = url(env.native, state.native, state.owner, request); + const workerUrl = url(env.worker, state.worker, state.owner, request); + const [native, worker] = await Promise.all([capture(nativeUrl), capture(workerUrl)]); + const label = `${request.nsid}?${request.query}`; + const problems = []; + if (native.status !== worker.status) problems.push(`status ${native.status} vs ${worker.status}`); + for (const name of HEADERS) { + const a = native.headers[name]; + const b = worker.headers[name] && normalize(Buffer.from(worker.headers[name], "latin1")); + if (a !== b) problems.push(`header ${name}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`); + } + const a = native.body.toString("latin1"); + const b = normalize(worker.body); + if (a !== b) { + let detail = `body differs (${native.body.length} vs ${worker.body.length} bytes)`; + try { + detail = `body ${firstDifference(JSON.parse(a), JSON.parse(b))}`; + } catch {} + problems.push(detail); + } + if (problems.length === 0) same++; + else differing.push({ label, problems, native: a.slice(0, 400), worker: b.slice(0, 400) }); + } + for (const diff of differing) { + console.log(`\n✗ ${diff.label}`); + diff.problems.forEach((problem) => console.log(` ${problem}`)); + } + console.log(`\n${same}/${requests.length} identical`); + writeFileSync(join(out, "compare.json"), JSON.stringify(differing, null, 2)); + process.exitCode = differing.length ? 1 : 0; +} + +const commands = { + "setup-native": setupNative, + "register-worker": registerWorker, + "create-worker": createWorker, + compare, +}; +const command = commands[process.argv[2]]; +if (!command) { + console.error(`usage: parity.mjs ${Object.keys(commands).join("|")}`); + process.exit(2); +} +await command(); diff --git a/knot2/worker/parity/wrangler.dev.jsonc b/knot2/worker/parity/wrangler.dev.jsonc new file mode 100644 index 000000000..32da6845a --- /dev/null +++ b/knot2/worker/parity/wrangler.dev.jsonc @@ -0,0 +1,19 @@ +// a local knot for parity runs against localinfra: no artifacts binding, so +// repos are registered against a native knot's git remote via /_dev/register +{ + "name": "knot-dev", + "main": "../build/worker/shim.mjs", + "compatibility_date": "2026-10-01", + "durable_objects": { + "bindings": [{ "name": "KNOT_STATE", "class_name": "KnotState" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["KnotState"] }], + "vars": { + "KNOT_HOSTNAME": "knot-worker.tngl.boltless.dev", + "KNOT_SERVICE_URL": "https://knot-worker.tngl.boltless.dev", + "KNOT_ADMINS": "did:plc:q52vzjvixmuh5ioodssgp7ir", + "KNOT_PLC_URL": "https://plc.tngl.boltless.dev", + "KNOT_OBJECT_FORMAT": "sha1", + "KNOT_DEV_ROUTES": "true" + } +} diff --git a/knot2/worker/src/artifacts.rs b/knot2/worker/src/artifacts.rs new file mode 100644 index 000000000..86940240d --- /dev/null +++ b/knot2/worker/src/artifacts.rs @@ -0,0 +1,132 @@ +// the cloudflare artifacts binding. workers-rs has no wrapper for it, so its +// methods are called through js; shapes per developers.cloudflare.com/artifacts + +use js_sys::{Function, Object, Promise, Reflect}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use worker::Env; + +use crate::error::KnotError; + +pub struct Artifacts { + binding: JsValue, +} + +pub struct CreatedRepo { + pub name: String, + pub remote: String, +} + +pub struct MintedToken { + pub plaintext: String, + pub expires_millis: i64, +} + +fn js_error(context: &str, error: JsValue) -> KnotError { + let code = Reflect::get(&error, &"code".into()) + .ok() + .and_then(|code| code.as_string()) + .unwrap_or_default(); + let message = Reflect::get(&error, &"message".into()) + .ok() + .and_then(|message| message.as_string()) + .or_else(|| error.as_string()) + .unwrap_or_else(|| format!("{error:?}")); + match code.as_str() { + "ALREADY_EXISTS" => KnotError::Wire(knot_wire::WireError::conflict(message)), + "NOT_FOUND" => KnotError::Wire(knot_wire::WireError::not_found(message)), + _ => KnotError::upstream(format!("artifacts {context}: {message}")), + } +} + +async fn call(target: &JsValue, method: &str, args: &[JsValue]) -> Result { + let function: Function = Reflect::get(target, &method.into()) + .map_err(|error| js_error(method, error))? + .dyn_into() + .map_err(|_| KnotError::internal(format!("artifacts binding has no {method}()")))?; + let array = js_sys::Array::new(); + args.iter().for_each(|arg| { + array.push(arg); + }); + let returned = function + .apply(target, &array) + .map_err(|error| js_error(method, error))?; + match returned.dyn_into::() { + Ok(promise) => JsFuture::from(promise) + .await + .map_err(|error| js_error(method, error)), + Err(value) => Ok(value), + } +} + +fn string_field(value: &JsValue, field: &str) -> Option { + Reflect::get(value, &field.into()).ok()?.as_string() +} + +fn number_field(value: &JsValue, field: &str) -> Option { + Reflect::get(value, &field.into()).ok()?.as_f64() +} + +// "...?expires=" on a token, else the field the binding returned +fn expiry_millis(token: &str, value: &JsValue) -> i64 { + token + .split_once("?expires=") + .and_then(|(_, seconds)| seconds.parse::().ok()) + .map(|seconds| seconds * 1000) + .or_else(|| { + string_field(value, "expiresAt") + .map(|at| js_sys::Date::parse(&at) as i64) + .or_else(|| number_field(value, "expiresAt").map(|at| at as i64)) + }) + .unwrap_or(0) +} + +impl Artifacts { + pub fn from_env(env: &Env) -> Result { + let env: &JsValue = env.as_ref(); + let binding = Reflect::get(env, &"ARTIFACTS".into()) + .map_err(|_| KnotError::internal("ARTIFACTS binding missing"))?; + if binding.is_undefined() { + return Err(KnotError::internal("ARTIFACTS binding missing")); + } + Ok(Self { binding }) + } + + pub async fn create(&self, name: &str, default_branch: &str, description: &str) -> Result { + let opts = Object::new(); + Reflect::set(&opts, &"setDefaultBranch".into(), &default_branch.into()) + .map_err(|error| js_error("create", error))?; + Reflect::set(&opts, &"description".into(), &description.into()) + .map_err(|error| js_error("create", error))?; + let created = call(&self.binding, "create", &[name.into(), opts.into()]).await?; + Ok(CreatedRepo { + name: string_field(&created, "name").unwrap_or_else(|| name.to_string()), + remote: string_field(&created, "remote") + .ok_or_else(|| KnotError::upstream("artifacts create returned no remote"))?, + }) + } + + pub async fn delete(&self, name: &str) -> Result { + match call(&self.binding, "delete", &[name.into()]).await { + Ok(deleted) => Ok(deleted.as_bool().unwrap_or(true)), + Err(KnotError::Wire(error)) if error.status == http::StatusCode::NOT_FOUND => Ok(false), + Err(error) => Err(error), + } + } + + async fn repo(&self, name: &str) -> Result { + call(&self.binding, "get", &[name.into()]).await + } + + pub async fn create_token(&self, name: &str, scope: &str, ttl_seconds: u32) -> Result { + let repo = self.repo(name).await?; + let minted = call(&repo, "createToken", &[scope.into(), ttl_seconds.into()]).await?; + let plaintext = string_field(&minted, "plaintext") + .ok_or_else(|| KnotError::upstream("artifacts createToken returned no token"))?; + let expires_millis = expiry_millis(&plaintext, &minted); + Ok(MintedToken { + plaintext, + expires_millis, + }) + } +} diff --git a/knot2/worker/src/auth.rs b/knot2/worker/src/auth.rs new file mode 100644 index 000000000..5e9a31eab --- /dev/null +++ b/knot2/worker/src/auth.rs @@ -0,0 +1,64 @@ +// service-auth jwts, checked the way knot-xrpc's authenticate does + +use knot_atproto::{AtprotoError, PlcDirectory, ServiceJwt}; +use knot_runtime::Clock; +use knot_types::{AccountDid, KnotId, Nsid}; +use knot_wire::WireError; + +use crate::clock::JsClock; +use crate::error::KnotError; +use crate::knot::Knot; +use crate::transport::WorkerHttp; + +fn strip_bearer(value: &str) -> Option<&str> { + let (scheme, rest) = value.split_once(' ')?; + scheme.eq_ignore_ascii_case("Bearer").then_some(rest) +} + +pub fn bearer(authorization: Option<&str>) -> Result { + authorization + .and_then(strip_bearer) + .map(str::trim) + .and_then(|token| ServiceJwt::new(token).ok()) + .ok_or_else(|| { + KnotError::Wire(WireError::auth_required( + "Missing or malformed Bearer authorization header", + )) + }) +} + +fn map_verify(error: AtprotoError) -> KnotError { + match error.is_transient() { + true => KnotError::Wire(WireError::upstream_unavailable(error.to_string())), + false => KnotError::Wire(WireError::auth_required(error.to_string())), + } +} + +pub fn plc_directory(knot: &Knot) -> Result { + let url = url::Url::parse(&knot.config.plc_url) + .map_err(|error| KnotError::internal(format!("KNOT_PLC_URL: {error}")))?; + PlcDirectory::new(url).map_err(|error| KnotError::internal(error.to_string())) +} + +pub async fn authenticate(knot: &Knot, authorization: Option<&str>, nsid: &str) -> Result { + let token = bearer(authorization)?; + let knot_did = KnotId::new(knot.config.knot_did()) + .map_err(|error| KnotError::internal(error.to_string()))?; + let method = Nsid::new_owned(nsid).map_err(|error| KnotError::internal(error.to_string()))?; + let checked = knot_atproto::check_service_jwt(&token, &knot_did, &method, JsClock.now_unix_micros()) + .map_err(map_verify)?; + let identity = knot_atproto::fetch_identity(&WorkerHttp, &plc_directory(knot)?, &checked.issuer) + .await + .map_err(|error| map_verify(AtprotoError::from(error)))?; + checked.verify(&identity).map_err(map_verify)?; + let expires = knot_atproto::jti_horizon(checked.exp).get() as i64; + if !knot + .admit_jti(checked.issuer.as_str(), checked.jti.as_str(), expires) + .await? + { + return Err(map_verify(AtprotoError::Replay { + jti: checked.jti.clone(), + })); + } + Ok(checked.issuer) +} diff --git a/knot2/worker/src/clock.rs b/knot2/worker/src/clock.rs new file mode 100644 index 000000000..67ccb4a68 --- /dev/null +++ b/knot2/worker/src/clock.rs @@ -0,0 +1,33 @@ +// std::time::Instant panics on wasm32-unknown-unknown; time comes from js here + +use knot_runtime::{Clock, UnixMicros}; + +pub fn now_millis() -> f64 { + js_sys::Date::now() +} + +pub fn now_seconds() -> i64 { + (now_millis() / 1000.0) as i64 +} + +#[derive(Clone, Copy, Debug)] +pub struct Deadline(Option); + +impl Deadline { + pub fn after_millis(millis: u64) -> Self { + Self(Some(now_millis() + millis as f64)) + } + + pub fn passed(self) -> bool { + self.0.is_some_and(|at| now_millis() >= at) + } +} + +#[derive(Clone, Copy, Default)] +pub struct JsClock; + +impl Clock for JsClock { + fn now_unix_micros(&self) -> UnixMicros { + UnixMicros::new((now_millis() * 1000.0) as u64) + } +} diff --git a/knot2/worker/src/error.rs b/knot2/worker/src/error.rs new file mode 100644 index 000000000..d86ed09fa --- /dev/null +++ b/knot2/worker/src/error.rs @@ -0,0 +1,66 @@ +use knot_gitcore::CoreError; +use knot_wire::WireError; + +#[derive(Debug, Clone)] +pub enum KnotError { + Wire(WireError), + Git(CoreError), +} + +impl KnotError { + pub fn internal(message: impl Into) -> Self { + KnotError::Wire(WireError::internal(message)) + } + + pub fn upstream(message: impl Into) -> Self { + KnotError::Wire(WireError::upstream_unavailable(message)) + } + + pub fn into_wire(self) -> WireError { + match self { + KnotError::Wire(error) => error, + KnotError::Git(error) => error.into(), + } + } +} + +impl std::fmt::Display for KnotError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KnotError::Wire(error) => error.fmt(f), + KnotError::Git(error) => error.fmt(f), + } + } +} + +impl From for KnotError { + fn from(error: WireError) -> Self { + KnotError::Wire(error) + } +} + +impl From for KnotError { + fn from(error: CoreError) -> Self { + KnotError::Git(error) + } +} + +impl From for KnotError { + fn from(error: worker::Error) -> Self { + KnotError::internal(error.to_string()) + } +} + +impl From for KnotError { + fn from(error: knot_atproto::AtprotoError) -> Self { + use knot_atproto::AtprotoError; + if error.is_transient() { + return KnotError::Wire(WireError::upstream_unavailable(error.to_string())); + } + match error { + AtprotoError::Resolve(_) => KnotError::Wire(WireError::invalid_request(error.to_string())), + AtprotoError::PlcSubmit { .. } => KnotError::Wire(WireError::bad_gateway(error.to_string())), + other => KnotError::internal(other.to_string()), + } + } +} diff --git a/knot2/worker/src/events.rs b/knot2/worker/src/events.rs new file mode 100644 index 000000000..3f2045250 --- /dev/null +++ b/knot2/worker/src/events.rs @@ -0,0 +1,138 @@ +// sh.tangled.git.refUpdate on the legacy /events stream, in the go knot's json +// shape (tangled.GitRefUpdate), which knotfeed's legacy decoder reads + +use std::collections::BTreeMap; + +use knot_gitcore::{CommitRange, LogLimit}; +use knot_types::Oid; +use serde::Serialize; + +use crate::error::KnotError; +use crate::knot::Knot; +use crate::state::Hosted; + +pub const REF_UPDATE_NSID: &str = "sh.tangled.git.refUpdate"; +const MAX_COUNTED_COMMITS: usize = 1000; + +#[derive(Serialize)] +struct EmailCount { + count: i64, + email: String, +} + +#[derive(Serialize)] +struct CommitCount { + #[serde(rename = "byEmail")] + by_email: Vec, +} + +#[derive(Serialize)] +struct LangBreakdown { + inputs: Vec, +} + +#[derive(Serialize)] +struct Meta { + #[serde(rename = "commitCount")] + commit_count: CommitCount, + #[serde(rename = "isDefaultRef")] + is_default_ref: bool, + #[serde(rename = "langBreakdown")] + lang_breakdown: LangBreakdown, +} + +#[derive(Serialize)] +struct RefUpdate { + #[serde(rename = "$type")] + record_type: &'static str, + #[serde(rename = "committerDid")] + committer_did: String, + meta: Option, + #[serde(rename = "newSha")] + new_sha: String, + #[serde(rename = "oldSha")] + old_sha: String, + #[serde(rename = "ownerDid")] + owner_did: String, + #[serde(rename = "pushOptions", skip_serializing_if = "Vec::is_empty")] + push_options: Vec, + #[serde(rename = "ref")] + ref_name: String, + repo: String, +} + +#[derive(Debug, Clone)] +pub struct RefChange { + pub name: String, + pub old: String, + pub new: String, +} + +fn is_zero(sha: &str) -> bool { + sha.bytes().all(|byte| byte == b'0') +} + +async fn meta_of(knot: &Knot, hosted: &Hosted, change: &RefChange) -> Option { + if is_zero(&change.new) { + return None; + } + let repo = knot.open_hosted(hosted).await.ok()?; + let new = Oid::from_hex(&change.new).ok()?; + let commits = match Oid::from_hex(&change.old).ok().filter(|_| !is_zero(&change.old)) { + Some(old) => repo + .commits_between(CommitRange { base: old, head: new }, LogLimit::new(MAX_COUNTED_COMMITS)) + .await + .ok()?, + None => repo + .log_window(new, knot_gitcore::LogSkip::new(0), LogLimit::new(MAX_COUNTED_COMMITS)) + .await + .ok()? + .0 + .into_iter() + .map(|commit| commit.id) + .collect(), + }; + let mut by_email: BTreeMap = BTreeMap::new(); + for oid in commits { + if let Ok(commit) = repo.find_commit(oid).await { + *by_email.entry(commit.committer.email.to_string()).or_default() += 1; + } + } + Some(Meta { + commit_count: CommitCount { + by_email: by_email + .into_iter() + .map(|(email, count)| EmailCount { count, email }) + .collect(), + }, + is_default_ref: repo + .default_branch() + .is_some_and(|name| name.as_str() == change.name), + lang_breakdown: LangBreakdown { inputs: Vec::new() }, + }) +} + +pub async fn emit_ref_updates( + knot: &Knot, + hosted: &Hosted, + committer: Option<&str>, + changes: &[RefChange], +) -> Result<(), KnotError> { + for change in changes { + let update = RefUpdate { + record_type: "", + committer_did: committer.unwrap_or(&hosted.owner).to_string(), + meta: meta_of(knot, hosted, change).await, + new_sha: change.new.clone(), + old_sha: change.old.clone(), + owner_did: hosted.owner.clone(), + push_options: Vec::new(), + ref_name: change.name.clone(), + repo: hosted.did.clone(), + }; + let event = serde_json::to_value(&update).map_err(|error| KnotError::internal(error.to_string()))?; + let dedup = format!("{}|{}|{}|{}", hosted.did, change.name, change.old, change.new); + knot.append_event(REF_UPDATE_NSID, event, Some(dedup)).await?; + } + Ok(()) +} diff --git a/knot2/worker/src/git.rs b/knot2/worker/src/git.rs new file mode 100644 index 000000000..a80660a7b --- /dev/null +++ b/knot2/worker/src/git.rs @@ -0,0 +1,826 @@ +// the reads knot-git does with gix on disk, done here over objects pulled from +// artifacts on demand. every algorithm runs the same gix crate code knot-git +// runs (traverse, revision, diff, object parsing), so results match by +// construction rather than by reimplementation. + +use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; +use std::rc::Rc; + +use gix_hash::ObjectId; +use gix_object::{Kind, TreeRefIter}; +use knot_gitcore::{ + BinaryBudget, BinaryDiff, BranchInfo, Commit, CommitRange, CoreError, EntryKind, FilePatch, + LastCommit, LogLimit, LogSkip, PatchBody, PatchRange, PatchStatus, PathEntry, RefClass, Side, + SizedEntry, Submodule, TagInfo, +}; +use knot_types::{LanguageBytes, LanguageName, ObjectFormat, Oid, RefName, RepoPath, TagName}; + +use crate::clock::Deadline; +use crate::error::KnotError; +use crate::odb::{ObjectSource, Objects, Odb}; + +const MAX_TREE_DEPTH: usize = 1024; +const MAX_TAG_DEPTH: usize = 32; + +// the refs of a repo as ls-refs reported them, symrefs already resolved +#[derive(Debug, Clone, Default)] +pub struct RefSnapshot { + pub head_symref: Option, + pub head_target: Option, + pub refs: Vec<(RefName, Oid)>, +} + +impl RefSnapshot { + pub fn find(&self, name: &str) -> Option { + self.refs + .iter() + .find(|(candidate, _)| candidate.as_str() == name) + .map(|(_, target)| *target) + } +} + +pub struct Repo { + pub odb: Odb, + pub refs: RefSnapshot, + pub format: ObjectFormat, +} + +fn load(objects: &Objects, id: Oid) -> Result<(Kind, Rc<[u8]>), CoreError> { + objects + .get(&id.object_id()) + .ok_or(CoreError::ObjectNotFound(id)) +} + +fn backend(error: impl std::fmt::Display) -> CoreError { + CoreError::Backend(error.to_string()) +} + +fn peel_tags(objects: &Objects, id: Oid) -> Result<(Oid, Kind), CoreError> { + let mut current = id; + for _ in 0..=MAX_TAG_DEPTH { + let (kind, data) = load(objects, current).map_err(backend)?; + if kind != Kind::Tag { + return Ok((current, kind)); + } + current = Oid::from( + gix_object::TagRefIter::from_bytes(&data, current.object_id().kind()) + .target_id() + .map_err(backend)?, + ); + } + Err(CoreError::DepthExceeded("annotated tag chain")) +} + +fn peel_to_commit(objects: &Objects, id: Oid) -> Result { + match peel_tags(objects, id)? { + (commit, Kind::Commit) => Ok(commit), + _ => Err(CoreError::ObjectType { + oid: id, + expected: "commit", + }), + } +} + +fn commit_tree(objects: &Objects, commit: Oid) -> Result { + let (kind, data) = load(objects, commit)?; + if kind != Kind::Commit { + return Err(CoreError::ObjectType { + oid: commit, + expected: "commit", + }); + } + knot_gitcore::commit_tree(commit, &data) +} + +fn find_commit(objects: &Objects, oid: Oid) -> Result { + let (kind, data) = load(objects, oid)?; + if kind != Kind::Commit { + return Err(CoreError::ObjectType { + oid, + expected: "commit", + }); + } + knot_gitcore::parse_commit(oid, &data) +} + +fn tree_bytes(objects: &Objects, tree: Oid) -> Result, CoreError> { + let (kind, data) = load(objects, tree).map_err(backend)?; + match kind { + Kind::Tree => Ok(data), + _ => Err(backend(format!("object {tree} isn't a tree"))), + } +} + +fn empty_tree(format: ObjectFormat) -> Oid { + Oid::from(ObjectId::empty_tree(format.kind())) +} + +fn tree_or_empty(objects: &Objects, tree: Oid, format: ObjectFormat) -> Result, CoreError> { + match tree == empty_tree(format) { + true => Ok(Rc::from(Vec::new())), + false => tree_bytes(objects, tree), + } +} + +fn entry_at(objects: &Objects, commit: Oid, path: &RepoPath) -> Result, CoreError> { + let root = commit_tree(objects, commit)?; + let data = tree_bytes(objects, root)?; + let mut buffer = Vec::new(); + let found = TreeRefIter::from_bytes(&data, root.object_id().kind()) + .lookup_entry_by_path(objects, &mut buffer, path.as_str()) + .map_err(backend)?; + Ok(found.map(|entry| PathEntry { + oid: Oid::from(entry.oid), + kind: entry.mode.kind().into(), + })) +} + +fn dir_tree_id(objects: &Objects, commit: Oid, dir: Option<&RepoPath>) -> Result, CoreError> { + let root = commit_tree(objects, commit)?; + let Some(dir) = dir else { + return Ok(Some(root)); + }; + let data = tree_bytes(objects, root)?; + let mut buffer = Vec::new(); + match TreeRefIter::from_bytes(&data, root.object_id().kind()) + .lookup_entry_by_path(objects, &mut buffer, dir.as_str()) + .map_err(backend)? + { + Some(entry) if entry.mode.is_tree() => Ok(Some(Oid::from(entry.oid))), + _ => Ok(None), + } +} + +fn entry_oids_of_tree(objects: &Objects, tree: Oid) -> Result, CoreError> { + let data = tree_bytes(objects, tree)?; + Ok(knot_gitcore::parse_tree(tree, &data)? + .entries + .into_iter() + .map(|entry| (entry.name, entry.oid)) + .collect()) +} + +// gix's rev walk as knot-git configures it: newest commit time first, all parents +fn walk(objects: &Objects, start: Oid, hidden: Option) -> Result, CoreError> { + let hidden = hidden.filter(|oid| objects.has(&oid.object_id())); + let walk = gix_traverse::commit::Simple::filtered(Some(start.object_id()), objects, |_| true) + .sorting(gix_traverse::commit::simple::Sorting::ByCommitTime( + gix_traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .map_err(|error| CoreError::RevWalk(error.to_string()))? + .parents(gix_traverse::commit::Parents::All) + .commit_graph(None) + .hide(hidden.map(|oid| oid.object_id())) + .map_err(|error| CoreError::RevWalk(error.to_string()))?; + walk.map(|info| { + info.map(|info| Oid::from(info.id)) + .map_err(|error| CoreError::RevWalk(error.to_string())) + }) + .collect() +} + +fn merge_base(objects: &Objects, one: Oid, two: Oid) -> Result, CoreError> { + let mut graph = gix_revwalk::Graph::new(objects, None); + match gix_revision::merge_base(one.object_id(), &[two.object_id()], &mut graph) { + Ok(Some(bases)) => Ok(Some(Oid::from(*bases.first()))), + Ok(None) => Ok(None), + Err(error) => Err(backend(error)), + } +} + +fn subject_line(message: &str) -> String { + knot_gitcore::subject_line(message) +} + +impl Repo { + pub fn object_format(&self) -> ObjectFormat { + self.format + } + + pub fn default_branch(&self) -> Option { + self.refs + .head_symref + .clone() + .filter(|name| RefClass::of(name).is_public()) + } + + pub fn head(&self) -> Option<(RefName, Oid)> { + Some((self.default_branch()?, self.refs.head_target?)) + } + + pub fn references(&self) -> &[(RefName, Oid)] { + &self.refs.refs + } + + pub fn find_ref(&self, name: &RefName) -> Option { + self.refs.find(name.as_str()) + } + + fn public_tips(&self) -> Vec { + self.refs + .refs + .iter() + .filter(|(name, _)| RefClass::of(name).is_public()) + .map(|(_, target)| *target) + .collect() + } + + pub fn hidden_ref_commit_name(&self, spec: &str) -> Option { + let name = match spec.starts_with("refs/") { + true => RefName::new(spec.to_string()), + false => RefName::new(format!("refs/{spec}")), + } + .ok() + .filter(|name| RefClass::of(name) == RefClass::Hidden)?; + self.find_ref(&name) + } + + pub async fn hidden_ref_commit(&self, spec: &str) -> Option { + let target = self.hidden_ref_commit_name(spec)?; + self.peel_to_commit(target).await.ok() + } + + // what gix rev_parse_single resolves for the spec shapes knot callers send: + // full object ids, ref names with git's dwim order, HEAD, and ~n/^n suffixes + pub async fn resolve_revision(&self, spec: &str) -> Result, KnotError> { + if spec.is_empty() || spec.contains('\0') { + return Ok(None); + } + let (base, suffix) = split_suffix(spec); + let Some(mut current) = self.resolve_base(base).await? else { + return Ok(None); + }; + for step in suffix { + let next = self + .odb + .settle(|objects| -> Result, CoreError> { + match step { + Step::Ancestor(generations) => { + let mut at = peel_to_commit(objects, current)?; + for _ in 0..generations { + let commit = find_commit(objects, at)?; + match commit.parents.first() { + Some(parent) => at = *parent, + None => return Ok(None), + } + } + Ok(Some(at)) + } + Step::Parent(0) => peel_to_commit(objects, current).map(Some), + Step::Parent(nth) => { + let commit = find_commit(objects, peel_to_commit(objects, current)?)?; + Ok(commit.parents.get(nth - 1).copied()) + } + Step::PeelCommit => peel_to_commit(objects, current).map(Some), + Step::PeelAny => peel_tags(objects, current).map(|(oid, _)| Some(oid)), + } + }) + .await?; + match next { + Ok(Some(oid)) => current = oid, + _ => return Ok(None), + } + } + Ok(Some(current)) + } + + async fn resolve_base(&self, base: &str) -> Result, KnotError> { + let hex_len = self.format.null_oid().to_hex().len(); + if base.len() == hex_len + && let Ok(oid) = Oid::from_hex(base) + && self.odb.object(oid.object_id()).await?.is_some() + { + return Ok(Some(oid)); + } + if base == "HEAD" || base == "@" { + return Ok(self.refs.head_target); + } + let candidates = [ + base.to_string(), + format!("refs/{base}"), + format!("refs/tags/{base}"), + format!("refs/heads/{base}"), + format!("refs/remotes/{base}"), + format!("refs/remotes/{base}/HEAD"), + ]; + Ok(candidates.iter().find_map(|name| self.refs.find(name))) + } + + pub async fn peel_to_commit(&self, oid: Oid) -> Result { + Ok(self.odb.settle(|objects| peel_to_commit(objects, oid)).await??) + } + + pub async fn peel_to_tree(&self, oid: Oid) -> Result { + Ok(self + .odb + .settle(|objects| -> Result { + let (peeled, kind) = peel_tags(objects, oid)?; + match kind { + Kind::Tree => Ok(peeled), + Kind::Commit => commit_tree(objects, peeled), + _ => Err(backend(format!("object {oid} doesn't peel to a tree"))), + } + }) + .await??) + } + + pub async fn find_commit(&self, oid: Oid) -> Result { + Ok(self.odb.settle(|objects| find_commit(objects, oid)).await??) + } + + pub async fn merge_base(&self, one: Oid, two: Oid) -> Result, KnotError> { + Ok(self.odb.settle(|objects| merge_base(objects, one, two)).await??) + } + + pub async fn reachable_from_public(&self, target: Oid) -> Result { + let tips = self.public_tips(); + let peeled = self + .odb + .settle(|objects| { + tips.iter() + .filter_map(|tip| peel_to_commit(objects, *tip).ok()) + .collect::>() + }) + .await?; + if peeled.contains(&target) { + return Ok(true); + } + for tip in peeled { + if self.merge_base(target, tip).await? == Some(target) { + return Ok(true); + } + } + Ok(false) + } + + pub async fn commits_between(&self, range: CommitRange, limit: LogLimit) -> Result, KnotError> { + let walked = self + .odb + .settle(|objects| walk(objects, range.head, Some(range.base))) + .await??; + Ok(walked.into_iter().take(limit.get()).collect()) + } + + pub async fn log_window( + &self, + start: Oid, + skip: LogSkip, + limit: LogLimit, + ) -> Result<(Vec, usize), KnotError> { + let walked = self.odb.settle(|objects| walk(objects, start, None)).await??; + let total = walked.len(); + let window: Vec = walked.into_iter().skip(skip.get()).take(limit.get()).collect(); + let commits = self + .odb + .settle(|objects| { + window + .iter() + .map(|oid| find_commit(objects, *oid)) + .collect::, _>>() + }) + .await??; + Ok((commits, total)) + } + + pub async fn entry_at(&self, commit: Oid, path: &RepoPath) -> Result, KnotError> { + Ok(self.odb.settle(|objects| entry_at(objects, commit, path)).await??) + } + + pub async fn read_blob(&self, oid: Oid) -> Result, KnotError> { + match self.odb.object(oid.object_id()).await? { + Some((Kind::Blob, data)) => Ok(data.to_vec()), + Some(_) => Err(CoreError::ObjectType { + oid, + expected: "blob", + } + .into()), + None => Err(CoreError::ObjectNotFound(oid).into()), + } + } + + pub async fn blob_size(&self, oid: Oid) -> Result { + self.odb + .blob_size(oid.object_id()) + .await? + .ok_or_else(|| CoreError::ObjectNotFound(oid).into()) + } + + async fn sized(&self, tree: Oid) -> Result, KnotError> { + let entries = self + .odb + .settle(|objects| -> Result<_, CoreError> { + let data = tree_bytes(objects, tree)?; + knot_gitcore::parse_tree(tree, &data) + }) + .await?? + .entries; + let blobs: Vec = entries + .iter() + .filter(|entry| { + matches!( + entry.kind, + EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link + ) + }) + .map(|entry| entry.oid.object_id()) + .collect(); + let sizes = self.odb.blob_sizes(blobs).await?; + Ok(entries + .into_iter() + .map(|entry| { + let size = match entry.kind { + EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link => sizes + .get(&entry.oid.object_id()) + .copied() + .flatten() + .unwrap_or(0), + EntryKind::Tree | EntryKind::Commit => 0, + }; + SizedEntry { + name: entry.name, + oid: entry.oid, + kind: entry.kind, + size, + } + }) + .collect()) + } + + pub async fn tree_entries_at( + &self, + commit: Oid, + path: Option<&RepoPath>, + ) -> Result>, KnotError> { + let Some(path) = path else { + let root = self.odb.settle(|objects| commit_tree(objects, commit)).await??; + return self.sized(root).await.map(Some); + }; + match self.entry_at(commit, path).await? { + None => Ok(None), + Some(entry) if entry.kind == EntryKind::Tree => self.sized(entry.oid).await.map(Some), + Some(entry) if entry.kind == EntryKind::Commit => Ok(None), + Some(_) => Ok(Some(Vec::new())), + } + } + + // knot-git's last-commit attribution, step for step + pub async fn last_commits( + &self, + start: Oid, + dir: Option<&RepoPath>, + names: &[String], + deadline: Deadline, + ) -> Result, KnotError> { + let walked = self.odb.settle(|objects| walk(objects, start, None)).await??; + let mut pending: HashSet<&str> = names.iter().map(String::as_str).collect(); + let mut attributed = HashMap::new(); + let mut dir_trees: HashMap> = HashMap::new(); + for oid in walked { + if pending.is_empty() || deadline.passed() { + break; + } + let commit = self.find_commit(oid).await?; + if commit.parents.len() > 1 { + continue; + } + let mut dir_tree_of = async |commit: Oid| -> Result, KnotError> { + if let Some(known) = dir_trees.get(&commit) { + return Ok(*known); + } + let id = self + .odb + .settle(|objects| dir_tree_id(objects, commit, dir)) + .await??; + dir_trees.insert(commit, id); + Ok(id) + }; + let here_tree = dir_tree_of(oid).await?; + let parent_tree = match commit.parents.first().copied() { + Some(parent) => dir_tree_of(parent).await?, + None => None, + }; + if here_tree == parent_tree || here_tree.is_none() { + continue; + } + let here_id = here_tree.expect("checked above"); + let (here, parent) = self + .odb + .settle(|objects| -> Result<_, CoreError> { + let here = entry_oids_of_tree(objects, here_id)?; + let parent = parent_tree + .map(|tree| entry_oids_of_tree(objects, tree)) + .transpose()? + .unwrap_or_default(); + Ok((here, parent)) + }) + .await??; + let changed: Vec = pending + .iter() + .filter(|name| here.contains_key(**name) && here.get(**name) != parent.get(**name)) + .map(|name| name.to_string()) + .collect(); + changed.iter().for_each(|name| { + pending.remove(name.as_str()); + }); + changed.into_iter().for_each(|name| { + attributed.insert( + name, + LastCommit { + id: oid, + subject: subject_line(&commit.message), + time: commit.author.time, + }, + ); + }); + } + Ok(attributed) + } + + pub async fn submodules(&self, commit: Oid) -> Result, KnotError> { + let gitmodules = RepoPath::new(".gitmodules").expect("literal path is well-formed"); + let Some(entry) = self.entry_at(commit, &gitmodules).await? else { + return Ok(Vec::new()); + }; + if !entry.kind.is_file() { + return Ok(Vec::new()); + } + let raw = self.read_blob(entry.oid).await?; + Ok(knot_gitcore::gitmodules(&raw)) + } + + pub async fn branch_list(&self) -> Result, KnotError> { + let branches: Vec<_> = self + .refs + .refs + .iter() + .filter_map(|(name, target)| name.branch_name().map(|branch| (branch, *target))) + .collect(); + let mut out = Vec::with_capacity(branches.len()); + for (name, target) in branches { + let (kind, data) = self + .odb + .object(target.object_id()) + .await? + .ok_or_else(|| backend(format!("object {target} not found")))?; + out.push(BranchInfo { + name, + tip: knot_gitcore::branch_tip(target, kind.into(), &data)?, + }); + } + Ok(out) + } + + pub async fn tag_list(&self) -> Result, KnotError> { + let tags: Vec<(TagName, Oid)> = self + .refs + .refs + .iter() + .filter_map(|(name, target)| name.tag_name().map(|tag| (tag, *target))) + .collect(); + let mut out = Vec::with_capacity(tags.len()); + for (name, target) in tags { + let (kind, data) = self + .odb + .object(target.object_id()) + .await? + .ok_or_else(|| backend(format!("object {target} not found")))?; + out.push(knot_gitcore::tag_info(name, target, kind.into(), &data)?); + } + Ok(out) + } + + async fn side_size(&self, side: &Side) -> Result, KnotError> { + let blob = match knot_gitcore::needs_blob(side) { + Some(oid) => Some(self.blob_size(oid).await?), + None => None, + }; + Ok(knot_gitcore::side_size(side, blob)) + } + + async fn side_content(&self, side: &Side) -> Result, KnotError> { + match knot_gitcore::needs_blob(side) { + Some(oid) => self.read_blob(oid).await, + None => Ok(knot_gitcore::synthesized_content(side)), + } + } + + // gix's tree diff with rename tracking off, as knot-git runs it + async fn changed_sides(&self, range: PatchRange) -> Result, KnotError> { + let format = self.format; + let records = self + .odb + .settle(|objects| -> Result<_, CoreError> { + let new_tree = commit_tree(objects, peel_to_commit(objects, range.head)?)?; + let old_tree = match range.base { + Some(base) => commit_tree(objects, peel_to_commit(objects, base)?)?, + None => empty_tree(format), + }; + let old = tree_or_empty(objects, old_tree, format)?; + let new = tree_or_empty(objects, new_tree, format)?; + let kind = format.kind(); + let mut recorder = gix_diff::tree::Recorder::default() + .track_location(Some(gix_diff::tree::recorder::Location::Path)); + gix_diff::tree( + TreeRefIter::from_bytes(&old, kind), + TreeRefIter::from_bytes(&new, kind), + &mut gix_diff::tree::State::default(), + objects, + &mut recorder, + ) + .map_err(backend)?; + Ok(recorder.records) + }) + .await??; + use gix_diff::tree::recorder::Change; + Ok(records + .into_iter() + .map(|change| match change { + Change::Addition { + entry_mode, + oid, + path, + .. + } => ( + PatchStatus::Added, + path.to_string(), + Side::Absent, + Side::Present { + oid: Oid::from(oid), + kind: entry_mode.kind().into(), + }, + ), + Change::Deletion { + entry_mode, + oid, + path, + .. + } => ( + PatchStatus::Deleted, + path.to_string(), + Side::Present { + oid: Oid::from(oid), + kind: entry_mode.kind().into(), + }, + Side::Absent, + ), + Change::Modification { + previous_entry_mode, + previous_oid, + entry_mode, + oid, + path, + } => ( + PatchStatus::Modified, + path.to_string(), + Side::Present { + oid: Oid::from(previous_oid), + kind: previous_entry_mode.kind().into(), + }, + Side::Present { + oid: Oid::from(oid), + kind: entry_mode.kind().into(), + }, + ), + }) + .collect()) + } + + pub async fn commit_patches( + &self, + range: PatchRange, + budget: &mut BinaryBudget, + ) -> Result, KnotError> { + let sides = self.changed_sides(range).await?; + let absent = self.format.null_oid(); + let mut patches = Vec::new(); + for (status, path, old, new) in sides { + let is_tree = |side: &Side| { + matches!( + side, + Side::Present { + kind: EntryKind::Tree, + .. + } + ) + }; + if is_tree(&old) || is_tree(&new) { + continue; + } + let path = RepoPath::new(path).map_err(|error| CoreError::Decode(error.to_string()))?; + let past = knot_gitcore::past_diff_budget( + self.side_size(&old).await?, + self.side_size(&new).await?, + ); + let body = match past { + Some(sizes) => PatchBody::Binary(BinaryDiff::Omitted(sizes)), + None => { + let old_content = self.side_content(&old).await?; + let new_content = self.side_content(&new).await?; + knot_gitcore::patch_body(&old, &new, &old_content, &new_content, budget)? + } + }; + patches.push(knot_gitcore::file_patch(status, path, &old, &new, absent, body)); + } + Ok(patches) + } + + // knot-langs' walk, with its classification + pub async fn languages( + &self, + commit: Oid, + deadline: Deadline, + ) -> Result, KnotError> { + let mut sizes = HashMap::new(); + let root = self.peel_to_tree(commit).await?; + let _walked = self + .walk_languages(root, String::new(), 0, deadline, &mut sizes) + .await?; + Ok(sizes) + } + + fn walk_languages<'a>( + &'a self, + tree: Oid, + dir: String, + depth: usize, + deadline: Deadline, + sizes: &'a mut HashMap, + ) -> futures::future::LocalBoxFuture<'a, Result, KnotError>> { + Box::pin(async move { + if depth > MAX_TREE_DEPTH || deadline.passed() { + return Ok(ControlFlow::Break(())); + } + let entries = self.sized(tree).await?; + for entry in entries { + if deadline.passed() { + return Ok(ControlFlow::Break(())); + } + let path = match dir.is_empty() { + true => entry.name.clone(), + false => format!("{dir}/{}", entry.name), + }; + match entry.kind { + EntryKind::Tree => { + if knot_langs::classify::descends(&path) + && self + .walk_languages(entry.oid, path, depth + 1, deadline, sizes) + .await? + .is_break() + { + return Ok(ControlFlow::Break(())); + } + } + EntryKind::Blob | EntryKind::BlobExecutable => { + if knot_langs::classify::counts(&path) { + let content = match knot_langs::classify::reads_content(entry.size) { + true => { + let blob = self.read_blob(entry.oid).await?; + blob[..blob.len().min(knot_langs::classify::READ_LIMIT)].to_vec() + } + false => Vec::new(), + }; + knot_langs::classify::tally(&path, entry.size, &content, sizes); + } + } + EntryKind::Link | EntryKind::Commit => {} + } + } + Ok(ControlFlow::Continue(())) + }) + } +} + +enum Step { + Ancestor(usize), + Parent(usize), + PeelCommit, + PeelAny, +} + +// "main~2^2" -> ("main", [~2, ^2]) +fn split_suffix(spec: &str) -> (&str, Vec) { + let cut = spec.find(['~', '^']).unwrap_or(spec.len()); + let (base, mut rest) = spec.split_at(cut); + let mut steps = Vec::new(); + while let Some(op) = rest.chars().next() { + rest = &rest[1..]; + if op == '^' && rest.starts_with('{') { + let end = rest.find('}').unwrap_or(rest.len()); + steps.push(match &rest[1..end] { + "commit" => Step::PeelCommit, + _ => Step::PeelAny, + }); + rest = rest.get(end + 1..).unwrap_or(""); + continue; + } + let digits = rest.chars().take_while(char::is_ascii_digit).count(); + let count = rest[..digits].parse::().ok(); + rest = &rest[digits..]; + steps.push(match op { + '~' => Step::Ancestor(count.unwrap_or(1)), + _ => Step::Parent(count.unwrap_or(1)), + }); + } + (base, steps) +} diff --git a/knot2/worker/src/identity.rs b/knot2/worker/src/identity.rs new file mode 100644 index 000000000..bd2059e64 --- /dev/null +++ b/knot2/worker/src/identity.rs @@ -0,0 +1,85 @@ +// the knot's own identity endpoints, as knot-server and knot-xrpc's service.rs serve them + +use knot_capability::Capability; +use knot_runtime::Signer; +use knot_types::{KnotId, KnotServiceUrl}; +use serde::Serialize; + +use crate::error::KnotError; +use crate::knot::Knot; +use crate::respond::{Reply, json_reply, text_reply}; + +const WIRE_VERSION: &str = "v1.15.0"; +const HOMEPAGE: &str = include_str!("../../crates/knot-server/src/homepage.html"); + +#[derive(Serialize)] +struct VersionWire { + did: String, + version: &'static str, + capabilities: Vec<&'static str>, +} + +#[derive(Serialize)] +struct HealthWire { + version: String, +} + +#[derive(Serialize)] +struct OwnerWire<'a> { + owner: &'a str, +} + +fn json(value: &T) -> Result { + serde_json::to_vec(value) + .map(json_reply) + .map_err(|error| KnotError::internal(error.to_string())) +} + +pub fn did_document(knot: &Knot) -> Result { + let signer = crate::plc::signer(knot)?; + let knot_did = KnotId::new(knot.config.knot_did()).map_err(|error| KnotError::internal(error.to_string()))?; + let service = KnotServiceUrl::new(knot.config.service_url.clone()) + .map_err(|error| KnotError::internal(error.to_string()))?; + json(&knot_atproto::knot_did_document( + &knot_did, + &signer.public_key(), + &service, + )) +} + +pub fn describe_knot(knot: &Knot) -> Result { + json(&VersionWire { + did: knot.config.knot_did(), + version: WIRE_VERSION, + // ref updates go out on the legacy /events stream, not subscribeRepos + capabilities: Capability::SERVED + .iter() + .filter(|capability| **capability != Capability::AtprotoFirehose) + .map(|capability| capability.token()) + .collect(), + }) +} + +pub fn owner(knot: &Knot) -> Result { + json(&OwnerWire { + owner: knot.config.admins.first().map(String::as_str).unwrap_or_default(), + }) +} + +pub fn health() -> Result { + // the native knot reports knot-xrpc's crate version here + json(&HealthWire { + version: "knot 2.0.0".to_string(), + }) +} + +pub fn describe_server(knot: &Knot) -> Result { + json(&serde_json::json!({ + "availableUserDomains": [], + "did": knot.config.knot_did(), + })) +} + +pub fn homepage() -> Reply { + text_reply(http::StatusCode::OK, "text/html; charset=utf-8", HOMEPAGE) +} diff --git a/knot2/worker/src/knot.rs b/knot2/worker/src/knot.rs new file mode 100644 index 000000000..a7592af68 --- /dev/null +++ b/knot2/worker/src/knot.rs @@ -0,0 +1,413 @@ +use knot_types::{ObjectFormat, OwnerDid, RepoDid, RepoRkey}; +use knot_wire::query::RepoArg; +use knot_wire::reads as wire_reads; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use worker::{Env, Method, Request, RequestInit, Stub}; + +use crate::artifacts::Artifacts; +use crate::error::KnotError; +use crate::git::{RefSnapshot, Repo}; +use crate::odb::Odb; +use crate::remote::Remote; +use crate::source::{PackSource, cached_objects}; +use crate::state::{Hosted, PendingPlc}; + +pub type KnotRepo = Repo; + +pub struct Config { + pub hostname: String, + pub service_url: String, + pub admins: Vec, + pub plc_url: String, + pub open_admission: bool, + pub object_format: ObjectFormat, +} + +impl Config { + fn from_env(env: &Env) -> Self { + let var = |name: &str| env.var(name).map(|var| var.to_string()).ok(); + let hostname = var("KNOT_HOSTNAME").unwrap_or_else(|| "localhost".to_string()); + let service_url = var("KNOT_SERVICE_URL") + .unwrap_or_else(|| format!("https://{hostname}")) + .trim_end_matches('/') + .to_string(); + Self { + hostname, + service_url, + admins: var("KNOT_ADMINS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|did| !did.is_empty()) + .map(str::to_string) + .collect(), + plc_url: var("KNOT_PLC_URL").unwrap_or_else(|| "https://plc.directory".to_string()), + open_admission: var("KNOT_OPEN_ADMISSION").as_deref() == Some("true"), + object_format: match var("KNOT_OBJECT_FORMAT").as_deref() { + Some("sha256") => ObjectFormat::SHA256, + _ => ObjectFormat::SHA1, + }, + } + } + + pub fn knot_did(&self) -> String { + format!("did:web:{}", self.hostname) + } + + pub fn format_name(&self) -> &'static str { + match self.object_format.kind() { + gix_hash::Kind::Sha256 => "sha256", + _ => "sha1", + } + } +} + +pub struct Knot { + pub env: Env, + pub config: Config, +} + +#[derive(Deserialize)] +struct Created { + created: i64, +} + +impl Knot { + pub fn new(env: Env) -> Self { + let config = Config::from_env(&env); + Self { env, config } + } + + fn state(&self) -> Result { + Ok(self.env.durable_object("KNOT_STATE")?.get_by_name("knot")?) + } + + async fn call( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let mut init = RequestInit::new(); + init.with_method(method); + if let Some(body) = body { + init.with_body(Some(body.to_string().into())); + } + let request = Request::new_with_init(&format!("https://knot-state{path}"), &init)?; + let mut response = self.state()?.fetch_with_request(request).await?; + match response.status_code() { + 200..=299 => { + let text = response.text().await?; + match text.is_empty() { + true => Ok(serde_json::from_str("null") + .map_err(|error| KnotError::internal(error.to_string()))?), + false => serde_json::from_str(&text) + .map_err(|error| KnotError::internal(error.to_string())), + } + } + 409 => Err(KnotError::Wire(knot_wire::WireError::conflict( + response.text().await.unwrap_or_default(), + ))), + status => Err(KnotError::internal(format!( + "knot state answered HTTP {status}" + ))), + } + } + + fn encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() + } + + pub async fn registry_entry(&self, did: &RepoDid) -> Result, KnotError> { + let hosted: Option = self + .call( + Method::Get, + &format!("/repos/get?did={}", Self::encode(did.as_str())), + None, + ) + .await?; + hosted.map(RegistryEntry::try_from).transpose() + } + + pub async fn hosted(&self, did: &RepoDid) -> Result, KnotError> { + self.call( + Method::Get, + &format!("/repos/get?did={}", Self::encode(did.as_str())), + None, + ) + .await + } + + pub async fn hosted_by_owner(&self, owner: &str, rkey: &str) -> Result, KnotError> { + self.call( + Method::Get, + &format!( + "/repos/resolve?owner={}&rkey={}", + Self::encode(owner), + Self::encode(rkey) + ), + None, + ) + .await + } + + pub async fn hosted_by_name(&self, owner: &str, name: &str) -> Result, KnotError> { + self.call( + Method::Get, + &format!( + "/repos/by-name?owner={}&name={}", + Self::encode(owner), + Self::encode(name) + ), + None, + ) + .await + } + + pub async fn hosted_repos(&self) -> Result, KnotError> { + let dids: Vec = self.call(Method::Get, "/repos/list", None).await?; + Ok(dids + .into_iter() + .filter_map(|did| RepoDid::new(did).ok()) + .collect()) + } + + pub async fn register(&self, hosted: &Hosted) -> Result<(), KnotError> { + let _: serde_json::Value = self + .call( + Method::Post, + "/repos/put", + Some(serde_json::to_value(hosted).map_err(|error| KnotError::internal(error.to_string()))?), + ) + .await?; + Ok(()) + } + + pub async fn deregister(&self, did: &RepoDid) -> Result<(), KnotError> { + let _: serde_json::Value = self + .call(Method::Post, "/repos/delete", Some(serde_json::json!(did.as_str()))) + .await?; + Ok(()) + } + + pub async fn set_default_branch(&self, did: &RepoDid, branch: &str) -> Result<(), KnotError> { + let _: serde_json::Value = self + .call( + Method::Post, + "/repos/default-branch", + Some(serde_json::json!([did.as_str(), branch])), + ) + .await?; + Ok(()) + } + + pub async fn append_event( + &self, + nsid: &str, + event: serde_json::Value, + dedup: Option, + ) -> Result { + let created: Created = self + .call( + Method::Post, + "/events/append", + Some(serde_json::json!({ "nsid": nsid, "event": event, "dedup": dedup })), + ) + .await?; + Ok(created.created) + } + + pub async fn hosted_by_artifacts(&self, artifacts: &str) -> Result, KnotError> { + self.call( + Method::Get, + &format!("/repos/by-artifacts?artifacts={}", Self::encode(artifacts)), + None, + ) + .await + } + + pub async fn events_socket(&self, cursor: Option<&str>) -> Result { + let url = match cursor { + Some(cursor) => format!("https://knot-state/events?cursor={}", Self::encode(cursor)), + None => "https://knot-state/events".to_string(), + }; + let headers = worker::Headers::new(); + headers.set("Upgrade", "websocket")?; + let mut init = RequestInit::new(); + init.with_headers(headers); + let request = Request::new_with_init(&url, &init)?; + Ok(self.state()?.fetch_with_request(request).await?) + } + + pub async fn admit_jti(&self, issuer: &str, jti: &str, expires_micros: i64) -> Result { + match self + .call::( + Method::Post, + "/jti/admit", + Some(serde_json::json!({ "issuer": issuer, "jti": jti, "expires_micros": expires_micros })), + ) + .await + { + Ok(_) => Ok(true), + Err(KnotError::Wire(error)) if error.status == http::StatusCode::CONFLICT => Ok(false), + Err(error) => Err(error), + } + } + + pub async fn queue_plc(&self, pending: &PendingPlc) -> Result<(), KnotError> { + let _: serde_json::Value = self + .call( + Method::Post, + "/plc/put", + Some(serde_json::to_value(pending).map_err(|error| KnotError::internal(error.to_string()))?), + ) + .await?; + Ok(()) + } + + pub async fn due_plc(&self) -> Result, KnotError> { + let due: Vec = self.call(Method::Get, "/plc/due", None).await?; + Ok(due + .into_iter() + .filter_map(|row| { + Some(PendingPlc { + did: row.get("did")?.as_str()?.to_string(), + operation: serde_json::from_str(row.get("operation")?.as_str()?).ok()?, + attempts: row.get("attempts")?.as_i64()?, + next_at_millis: row.get("next_at_millis")?.as_i64()?, + }) + }) + .collect()) + } + + pub async fn plc_done(&self, did: &str) -> Result<(), KnotError> { + let _: serde_json::Value = self + .call(Method::Post, "/plc/done", Some(serde_json::json!(did))) + .await?; + Ok(()) + } + + // the hosted repo a request names, or the errors knot-xrpc gives for it + pub async fn resolve(&self, arg: &RepoArg) -> Result { + let found = match arg { + RepoArg::Did(did) => self.hosted(did).await?, + RepoArg::OwnerRkey { owner, rkey } => { + self.hosted_by_owner(owner.as_str(), rkey.as_str()).await? + } + }; + found.ok_or_else(|| wire_reads::repo_not_found().into()) + } + + pub fn artifacts(&self) -> Result { + Artifacts::from_env(&self.env) + } + + // a token the worker itself uses against the repo's remote + pub async fn read_token(&self, hosted: &Hosted) -> Result, KnotError> { + if !hosted.remote.contains(".artifacts.cloudflare.net") { + return Ok(self.env.var("KNOT_REMOTE_TOKEN").ok().map(|var| var.to_string())); + } + let cached: Option = self + .call( + Method::Get, + &format!("/tokens/get?artifacts={}", Self::encode(&hosted.artifacts)), + None, + ) + .await?; + if let Some(cached) = cached { + return Ok(Some(cached.token)); + } + let minted = self + .artifacts()? + .create_token(&hosted.artifacts, "read", 3600) + .await?; + let _: serde_json::Value = self + .call( + Method::Post, + "/tokens/put", + Some(serde_json::json!({ + "artifacts": hosted.artifacts, + "token": minted.plaintext, + "expires_millis": minted.expires_millis, + })), + ) + .await?; + Ok(Some(minted.plaintext)) + } + + pub async fn remote(&self, hosted: &Hosted) -> Result { + Ok(Remote { + url: hosted.remote.clone(), + token: self.read_token(hosted).await?, + object_format: self.config.format_name(), + }) + } + + pub async fn snapshot_of(&self, hosted: &Hosted) -> Result { + let mut snapshot = self.remote(hosted).await?.ls_refs().await?; + // artifacts can't move HEAD after creation; the knot keeps the choice + if let Some(branch) = &hosted.default_branch { + snapshot.head_symref = knot_types::RefName::new(format!("refs/heads/{branch}")).ok(); + snapshot.head_target = snapshot + .head_symref + .as_ref() + .and_then(|name| snapshot.find(name.as_str())); + } + Ok(snapshot) + } + + pub async fn ref_snapshot(&self, did: &RepoDid) -> Result { + let hosted = self + .hosted(did) + .await? + .ok_or_else(|| KnotError::from(wire_reads::repo_not_found()))?; + self.snapshot_of(&hosted).await + } + + pub async fn open_hosted(&self, hosted: &Hosted) -> Result { + let remote = self.remote(hosted).await?; + let refs = remote.ls_refs().await?; + let refs = match &hosted.default_branch { + Some(branch) => { + let mut refs = refs; + refs.head_symref = knot_types::RefName::new(format!("refs/heads/{branch}")).ok(); + refs.head_target = refs + .head_symref + .as_ref() + .and_then(|name| refs.find(name.as_str())); + refs + } + None => refs, + }; + let hash = self.config.object_format.kind(); + let source = PackSource::new(hosted.did.clone(), remote, &refs, hash); + Ok(Repo { + odb: Odb::with_objects(source, cached_objects(&hosted.did)), + refs, + format: self.config.object_format, + }) + } + + pub async fn open(&self, arg: &RepoArg) -> Result { + let hosted = self.resolve(arg).await?; + self.open_hosted(&hosted).await + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RegistryEntry { + pub owner: OwnerDid, + pub rkey: RepoRkey, +} + +impl TryFrom for RegistryEntry { + type Error = KnotError; + + fn try_from(hosted: Hosted) -> Result { + Ok(Self { + owner: OwnerDid::new(hosted.owner).map_err(|error| KnotError::internal(error.to_string()))?, + rkey: RepoRkey::new(hosted.rkey).map_err(|error| KnotError::internal(error.to_string()))?, + }) + } +} diff --git a/knot2/worker/src/lib.rs b/knot2/worker/src/lib.rs new file mode 100644 index 000000000..845823a49 --- /dev/null +++ b/knot2/worker/src/lib.rs @@ -0,0 +1,221 @@ +// knot2 as a cloudflare worker, its repos stored in cloudflare artifacts + +mod artifacts; +mod auth; +mod clock; +mod error; +mod events; +mod git; +mod identity; +mod knot; +mod odb; +mod pack; +mod plc; +mod proxy; +mod reads; +mod remote; +mod repos; +mod respond; +mod source; +mod state; +mod transport; + +pub use state::KnotState; + +use serde::Deserialize; +use worker::{ + Context, Env, MessageBatch, MessageExt, Method, Request, Response, Result, ScheduleContext, + ScheduledEvent, event, +}; + +use crate::knot::Knot; +use crate::reads::Limits; +use crate::respond::respond; + +fn limits(env: &Env) -> Limits { + let number = |name: &str, default: u64| { + env.var(name) + .ok() + .and_then(|var| var.to_string().parse::().ok()) + .unwrap_or(default) + }; + Limits { + response: number("KNOT_XRPC_MAX_RESPONSE_BYTES", 5_242_880) as usize, + tree_last_commit_ms: number("KNOT_TREE_LAST_COMMIT_BUDGET_MS", 10_000), + blob_last_commit_ms: number("KNOT_BLOB_LAST_COMMIT_BUDGET_MS", 10_000), + languages_ms: number("KNOT_LANGUAGES_BUDGET_MS", 20_000), + } +} + +fn with_cors(response: Result) -> Result { + let response = response?; + let headers = response.headers().clone(); + headers.set("Access-Control-Allow-Origin", "*")?; + Ok(response.with_headers(headers)) +} + +fn dev_routes(env: &Env) -> bool { + env.var("KNOT_DEV_ROUTES") + .map(|var| var.to_string() == "true") + .unwrap_or(false) +} + +fn not_found() -> Result { + Response::error("", 404) +} + +fn method_not_allowed() -> Result { + Response::error("", 405) +} + +#[event(fetch)] +async fn fetch(mut req: Request, env: Env, ctx: Context) -> Result { + let knot = Knot::new(env); + let path = req.path(); + let query = req.url()?.query().unwrap_or_default().to_string(); + let limits = limits(&knot.env); + let method = req.method(); + + if let Some(git) = proxy::parse(&path) { + return with_cors(proxy::serve(&knot, &ctx, git, req).await); + } + + let read = |result| respond(result); + let response = match path.as_str() { + "/" => identity::homepage().into_response(), + "/.well-known/did.json" => respond(identity::did_document(&knot)), + "/xrpc/_health" => respond(identity::health()), + "/xrpc/sh.tangled.owner" => respond(identity::owner(&knot)), + "/xrpc/org.tangled.knot.describeKnot" => respond(identity::describe_knot(&knot)), + "/xrpc/com.atproto.server.describeServer" => respond(identity::describe_server(&knot)), + "/events" => { + let cursor = req + .url()? + .query_pairs() + .find(|(name, _)| name == "cursor") + .map(|(_, value)| value.into_owned()); + return match knot.events_socket(cursor.as_deref()).await { + Ok(response) => Ok(response), + Err(error) => respond(Err(error)), + }; + } + // registers a repo against any smart-http remote, so a local run can read + // a native knot's repo over git exactly as it would read artifacts + "/_dev/register" if dev_routes(&knot.env) && method == Method::Post => { + let hosted: state::Hosted = req.json().await?; + respond(knot.register(&hosted).await.map(|()| respond::empty_json())) + } + _ if path.starts_with("/xrpc/") && method == Method::Post => { + let authorization = req.headers().get("Authorization")?; + let body = req.bytes().await?; + match path.as_str() { + "/xrpc/sh.tangled.repo.create" => { + let result = repos::create(&knot, authorization.as_deref(), &body).await; + if result.is_ok() { + let env = knot.env.clone(); + ctx.wait_until(async move { + let _ = plc::sweep(&Knot::new(env)).await; + }); + } + respond(result) + } + "/xrpc/sh.tangled.repo.delete" => { + respond(repos::delete(&knot, authorization.as_deref(), &body).await) + } + "/xrpc/sh.tangled.repo.setDefaultBranch" => respond( + repos::set_default_branch(&knot, authorization.as_deref(), &body).await, + ), + _ => not_found(), + } + } + _ if path.starts_with("/xrpc/") && method != Method::Get => method_not_allowed(), + "/xrpc/sh.tangled.repo.tree" => read(reads::tree(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.log" => read(reads::log(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.branches" => read(reads::branches(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.branch" => read(reads::branch(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.tags" => read(reads::tags(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.tag" => read(reads::tag(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.blob" => { + let if_none_match: Vec = req.headers().get("If-None-Match")?.into_iter().collect(); + read(reads::blob(&knot, &query, &if_none_match, &limits).await) + } + "/xrpc/sh.tangled.repo.blame" => read(reads::blame(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.diff" => read(reads::diff(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.compare" => read(reads::compare(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.archive" => read(reads::archive(&knot, &query, &req, &limits).await), + "/xrpc/sh.tangled.repo.languages" => read(reads::languages(&knot, &query, &limits).await), + "/xrpc/sh.tangled.repo.getDefaultBranch" => { + read(reads::get_default_branch(&knot, &query, &limits).await) + } + "/xrpc/sh.tangled.repo.describeRepo" => read(reads::describe_repo(&knot, &query, &limits).await), + "/xrpc/sh.tangled.git.listRefs" => read(reads::list_refs(&knot, &query, &limits).await), + "/xrpc/sh.tangled.sync.listRepos" => read(reads::list_repos(&knot, &query, &limits).await), + _ => not_found(), + }; + with_cors(response) +} + +// artifacts event subscriptions deliver cf.artifacts.repo.pushed here, which +// covers pushes that went straight to the artifacts remote instead of the knot +#[derive(Deserialize)] +struct ArtifactsEvent { + #[serde(rename = "type")] + kind: String, + source: ArtifactsSource, + payload: Option, +} + +#[derive(Deserialize)] +struct ArtifactsSource { + #[serde(rename = "repoName")] + repo_name: Option, +} + +#[derive(Deserialize)] +struct PushedPayload { + #[serde(rename = "ref")] + ref_name: String, + before: String, + after: String, +} + +#[event(queue)] +async fn queue(batch: MessageBatch, env: Env, _ctx: Context) -> Result<()> { + let knot = Knot::new(env); + for message in batch.messages()? { + let Ok(event) = serde_json::from_value::(message.body().clone()) else { + message.ack(); + continue; + }; + if !event.kind.ends_with("repo.pushed") { + message.ack(); + continue; + } + let (Some(repo_name), Some(payload)) = (event.source.repo_name, event.payload) else { + message.ack(); + continue; + }; + match knot.hosted_by_artifacts(&repo_name).await { + Ok(Some(hosted)) => { + source::forget(&hosted.did); + let change = events::RefChange { + name: payload.ref_name, + old: payload.before, + new: payload.after, + }; + match events::emit_ref_updates(&knot, &hosted, None, &[change]).await { + Ok(()) => message.ack(), + Err(_) => message.retry(), + } + } + Ok(None) => message.ack(), + Err(_) => message.retry(), + } + } + Ok(()) +} + +#[event(scheduled)] +async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { + let _ = plc::sweep(&Knot::new(env)).await; +} diff --git a/knot2/worker/src/odb.rs b/knot2/worker/src/odb.rs new file mode 100644 index 000000000..aabc53579 --- /dev/null +++ b/knot2/worker/src/odb.rs @@ -0,0 +1,217 @@ +use std::cell::RefCell; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::rc::Rc; + +use futures::future::LocalBoxFuture; +use gix_hash::{ObjectId, oid}; +use gix_object::{Data, Kind}; + +use crate::error::KnotError; + +// how many fetch-and-retry rounds one read may take before it gives up +const MAX_ROUNDS: usize = 4096; +// commits fetched past the ones a walk asked for, so walks don't pay a round per commit +const PARENT_LOOKAHEAD: usize = 64; + +pub struct Fetched { + pub id: ObjectId, + pub kind: Kind, + pub data: Vec, +} + +// where raw objects come from; the artifacts repo in production +pub trait ObjectSource { + fn fetch<'a>(&'a self, ids: Vec) -> LocalBoxFuture<'a, Result, KnotError>>; + + // size of a blob without pulling its bytes, when the source can tell; + // None means "fetch the blob and measure it" + fn blob_size<'a>(&'a self, id: ObjectId) -> LocalBoxFuture<'a, Result, KnotError>>; +} + +#[derive(Default)] +struct Store { + objects: HashMap)>, + absent: HashSet, + missing: BTreeSet, +} + +// a synchronous object database over what has been fetched so far; a lookup of +// something not yet fetched answers "not found" and is remembered, so the caller +// can fetch it and run the read again +#[derive(Clone, Default)] +pub struct Objects { + store: Rc>, +} + +impl Objects { + pub fn insert(&self, id: ObjectId, kind: Kind, data: Vec) { + self.store + .borrow_mut() + .objects + .insert(id, (kind, Rc::from(data))); + } + + pub fn get(&self, id: &oid) -> Option<(Kind, Rc<[u8]>)> { + let mut store = self.store.borrow_mut(); + match store.objects.get(id) { + Some((kind, data)) => Some((*kind, data.clone())), + None => { + if !store.absent.contains(id) { + store.missing.insert(id.to_owned()); + } + None + } + } + } + + pub fn has(&self, id: &oid) -> bool { + self.get(id).is_some() + } + + fn take_missing(&self) -> BTreeSet { + std::mem::take(&mut self.store.borrow_mut().missing) + } + + fn mark_absent(&self, id: ObjectId) { + self.store.borrow_mut().absent.insert(id); + } + + fn knows(&self, id: &oid) -> bool { + let store = self.store.borrow(); + store.objects.contains_key(id) || store.absent.contains(id) + } +} + +impl gix_object::Find for Objects { + fn try_find<'a>( + &self, + id: &oid, + buffer: &'a mut Vec, + ) -> Result>, gix_object::find::Error> { + Ok(self.get(id).map(|(kind, data)| { + buffer.clear(); + buffer.extend_from_slice(&data); + Data { + kind, + object_hash: id.kind(), + data: buffer.as_slice(), + } + })) + } +} + +impl gix_object::FindHeader for Objects { + fn try_header(&self, id: &oid) -> Result, gix_object::find::Error> { + Ok(self.get(id).map(|(kind, data)| gix_object::Header { + kind, + size: data.len() as u64, + })) + } +} + +pub struct Odb { + source: S, + objects: Objects, + sizes: RefCell>>, +} + +impl Odb { + pub fn with_objects(source: S, objects: Objects) -> Self { + Self { + source, + objects, + sizes: RefCell::new(HashMap::new()), + } + } + + // run `read` until it completes without touching an object we don't have. + // `read` must be deterministic given the objects it can see. + pub async fn settle(&self, mut read: impl FnMut(&Objects) -> T) -> Result { + for _ in 0..MAX_ROUNDS { + let out = read(&self.objects); + let missing = self.objects.take_missing(); + if missing.is_empty() { + return Ok(out); + } + self.fetch(missing.into_iter().collect()).await?; + } + Err(KnotError::internal("object reads didn't settle")) + } + + pub async fn fetch(&self, ids: Vec) -> Result<(), KnotError> { + let mut wanted: Vec = ids + .into_iter() + .filter(|id| !self.objects.knows(id)) + .collect(); + if wanted.is_empty() { + return Ok(()); + } + let asked: HashSet = wanted.iter().copied().collect(); + let mut extra = 0usize; + let mut received = HashSet::new(); + while !wanted.is_empty() { + let batch = std::mem::take(&mut wanted); + let fetched = self.source.fetch(batch).await?; + let mut parents = Vec::new(); + fetched.into_iter().for_each(|object| { + if object.kind == Kind::Commit && extra < PARENT_LOOKAHEAD { + gix_object::CommitRefIter::from_bytes(&object.data, object.id.kind()) + .parent_ids() + .filter(|parent| !self.objects.knows(parent)) + .for_each(|parent| parents.push(parent)); + } + received.insert(object.id); + self.objects.insert(object.id, object.kind, object.data); + }); + parents.sort(); + parents.dedup(); + parents.truncate(PARENT_LOOKAHEAD.saturating_sub(extra)); + extra += parents.len(); + wanted = parents; + } + asked + .into_iter() + .filter(|id| !received.contains(id)) + .for_each(|id| self.objects.mark_absent(id)); + Ok(()) + } + + pub async fn object(&self, id: ObjectId) -> Result)>, KnotError> { + self.settle(|objects| objects.get(&id)).await + } + + // None when the object is missing or isn't a blob + pub async fn blob_size(&self, id: ObjectId) -> Result, KnotError> { + if let Some(known) = self.sizes.borrow().get(&id) { + return Ok(*known); + } + let held = self + .objects + .store + .borrow() + .objects + .get(&id) + .map(|(kind, data)| (*kind == Kind::Blob).then_some(data.len() as u64)); + let size = match held { + Some(size) => size, + None => match self.source.blob_size(id).await? { + Some(size) => Some(size), + None => self + .object(id) + .await? + .and_then(|(kind, data)| (kind == Kind::Blob).then_some(data.len() as u64)), + }, + }; + self.sizes.borrow_mut().insert(id, size); + Ok(size) + } + + pub async fn blob_sizes(&self, ids: Vec) -> Result>, KnotError> { + let lookups = ids.into_iter().map(|id| async move { (id, self.blob_size(id).await) }); + futures::future::join_all(lookups) + .await + .into_iter() + .map(|(id, size)| size.map(|size| (id, size))) + .collect() + } +} diff --git a/knot2/worker/src/pack.rs b/knot2/worker/src/pack.rs new file mode 100644 index 000000000..0fca70dd2 --- /dev/null +++ b/knot2/worker/src/pack.rs @@ -0,0 +1,394 @@ +// git wire protocol v2 over smart http: pkt-lines, ls-refs, fetch, and the +// packfile that comes back. no gix-pack here: the vendored one is unix-only. + +use std::collections::HashMap; +use std::io::Read; + +use gix_hash::ObjectId; +use gix_object::Kind; +use knot_types::{Oid, RefName}; + +use crate::error::KnotError; +use crate::git::RefSnapshot; +use crate::odb::Fetched; + +const FLUSH: &[u8] = b"0000"; +const DELIM: &[u8] = b"0001"; + +fn malformed(what: impl std::fmt::Display) -> KnotError { + KnotError::upstream(format!("malformed git response: {what}")) +} + +pub fn pkt_line(out: &mut Vec, line: &str) { + out.extend_from_slice(format!("{:04x}", line.len() + 4).as_bytes()); + out.extend_from_slice(line.as_bytes()); +} + +pub enum Pkt<'a> { + Flush, + Delim, + ResponseEnd, + Data(&'a [u8]), +} + +pub fn pkt_lines(mut input: &[u8]) -> impl Iterator, KnotError>> { + std::iter::from_fn(move || { + if input.is_empty() { + return None; + } + if input.len() < 4 { + input = &[]; + return Some(Err(malformed("truncated pkt-line length"))); + } + let len = match std::str::from_utf8(&input[..4]) + .ok() + .and_then(|hex| usize::from_str_radix(hex, 16).ok()) + { + Some(len) => len, + None => { + input = &[]; + return Some(Err(malformed("bad pkt-line length"))); + } + }; + let pkt = match len { + 0 => Pkt::Flush, + 1 => Pkt::Delim, + 2 => Pkt::ResponseEnd, + 3 => { + input = &[]; + return Some(Err(malformed("reserved pkt-line length"))); + } + _ if len > input.len() => { + input = &[]; + return Some(Err(malformed("pkt-line runs past the body"))); + } + _ => Pkt::Data(&input[4..len]), + }; + input = &input[len.max(4)..]; + Some(Ok(pkt)) + }) +} + +pub fn ls_refs_request(object_format: &str) -> Vec { + let mut out = Vec::new(); + pkt_line(&mut out, "command=ls-refs\n"); + pkt_line(&mut out, "agent=knot-worker/2\n"); + pkt_line(&mut out, &format!("object-format={object_format}\n")); + out.extend_from_slice(DELIM); + pkt_line(&mut out, "symrefs\n"); + pkt_line(&mut out, "peel\n"); + pkt_line(&mut out, "unborn\n"); + out.extend_from_slice(FLUSH); + out +} + +pub fn parse_ls_refs(body: &[u8]) -> Result { + let mut snapshot = RefSnapshot::default(); + for pkt in pkt_lines(body) { + let line = match pkt? { + Pkt::Data(line) => line, + Pkt::Flush | Pkt::Delim | Pkt::ResponseEnd => continue, + }; + let line = std::str::from_utf8(line) + .map_err(malformed)? + .trim_end_matches('\n'); + if let Some(message) = line.strip_prefix("ERR ") { + return Err(KnotError::upstream(format!("git remote refused ls-refs: {message}"))); + } + let mut parts = line.split(' '); + let (Some(target), Some(name)) = (parts.next(), parts.next()) else { + return Err(malformed(format!("ls-refs line {line:?}"))); + }; + let symref = parts.find_map(|attr| attr.strip_prefix("symref-target:")); + if name == "HEAD" { + snapshot.head_symref = symref.and_then(|target| RefName::new(target.to_string()).ok()); + snapshot.head_target = match target { + "unborn" => None, + hex => Some(Oid::from_hex(hex).map_err(malformed)?), + }; + continue; + } + if target == "unborn" { + continue; + } + let oid = Oid::from_hex(target).map_err(malformed)?; + if let Ok(name) = RefName::new(name.to_string()) { + snapshot.refs.push((name, oid)); + } + } + snapshot + .refs + .sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); + Ok(snapshot) +} + +pub fn fetch_request(object_format: &str, wants: &[ObjectId], haves: &[ObjectId]) -> Vec { + let mut out = Vec::new(); + pkt_line(&mut out, "command=fetch\n"); + pkt_line(&mut out, "agent=knot-worker/2\n"); + pkt_line(&mut out, &format!("object-format={object_format}\n")); + out.extend_from_slice(DELIM); + pkt_line(&mut out, "ofs-delta\n"); + pkt_line(&mut out, "no-progress\n"); + wants + .iter() + .for_each(|want| pkt_line(&mut out, &format!("want {want}\n"))); + haves + .iter() + .for_each(|have| pkt_line(&mut out, &format!("have {have}\n"))); + pkt_line(&mut out, "done\n"); + out.extend_from_slice(FLUSH); + out +} + +// the pack bytes from a v2 fetch response's packfile section +pub fn packfile_of(body: &[u8]) -> Result, KnotError> { + let mut in_pack = false; + let mut pack = Vec::new(); + for pkt in pkt_lines(body) { + match pkt? { + Pkt::Data(line) if !in_pack => { + if line.starts_with(b"ERR ") { + return Err(KnotError::upstream(format!( + "git remote refused fetch: {}", + String::from_utf8_lossy(&line[4..]) + ))); + } + if line == b"packfile\n" || line == b"packfile" { + in_pack = true; + } + } + Pkt::Data(line) => match line.split_first() { + Some((1, data)) => pack.extend_from_slice(data), + Some((2, _)) => {} + Some((3, message)) => { + return Err(KnotError::upstream(format!( + "git remote failed fetch: {}", + String::from_utf8_lossy(message) + ))); + } + _ => return Err(malformed("unknown sideband channel")), + }, + Pkt::Flush | Pkt::Delim | Pkt::ResponseEnd => {} + } + } + match in_pack { + true => Ok(pack), + false => Err(malformed("fetch response had no packfile section")), + } +} + +enum Entry { + Base(Kind, Vec), + OfsDelta(usize, Vec), + RefDelta(ObjectId, Vec), +} + +fn kind_of(code: u8) -> Option { + match code { + 1 => Some(Kind::Commit), + 2 => Some(Kind::Tree), + 3 => Some(Kind::Blob), + 4 => Some(Kind::Tag), + _ => None, + } +} + +fn inflate(input: &[u8], size: usize) -> Result<(Vec, usize), KnotError> { + let mut decoder = flate2::bufread::ZlibDecoder::new(input); + let mut out = Vec::with_capacity(size); + decoder.read_to_end(&mut out).map_err(malformed)?; + if out.len() != size { + return Err(malformed("pack entry inflated to the wrong size")); + } + Ok((out, decoder.total_in() as usize)) +} + +fn varint(bytes: &[u8], at: &mut usize) -> Result { + let mut value = 0usize; + let mut shift = 0; + loop { + let byte = *bytes.get(*at).ok_or_else(|| malformed("delta header ran out"))?; + *at += 1; + value |= ((byte & 0x7f) as usize) << shift; + shift += 7; + if byte & 0x80 == 0 { + return Ok(value); + } + } +} + +fn apply_delta(base: &[u8], delta: &[u8]) -> Result, KnotError> { + let mut at = 0; + let base_len = varint(delta, &mut at)?; + let result_len = varint(delta, &mut at)?; + if base_len != base.len() { + return Err(malformed("delta base size mismatch")); + } + let mut out = Vec::with_capacity(result_len); + while at < delta.len() { + let op = delta[at]; + at += 1; + if op & 0x80 != 0 { + let mut offset = 0usize; + let mut size = 0usize; + for bit in 0..4 { + if op & (1 << bit) != 0 { + offset |= (*delta.get(at).ok_or_else(|| malformed("delta copy"))? as usize) << (8 * bit); + at += 1; + } + } + for bit in 0..3 { + if op & (1 << (4 + bit)) != 0 { + size |= (*delta.get(at).ok_or_else(|| malformed("delta copy"))? as usize) << (8 * bit); + at += 1; + } + } + if size == 0 { + size = 0x10000; + } + let chunk = base + .get(offset..offset + size) + .ok_or_else(|| malformed("delta copy out of range"))?; + out.extend_from_slice(chunk); + } else if op != 0 { + let chunk = delta + .get(at..at + op as usize) + .ok_or_else(|| malformed("delta insert out of range"))?; + out.extend_from_slice(chunk); + at += op as usize; + } else { + return Err(malformed("reserved delta opcode")); + } + } + if out.len() != result_len { + return Err(malformed("delta result size mismatch")); + } + Ok(out) +} + +// every object in `pack`, deltas resolved against the pack itself or `known` +pub fn unpack( + pack: &[u8], + hash: gix_hash::Kind, + mut known: impl FnMut(&ObjectId) -> Option<(Kind, Vec)>, +) -> Result, KnotError> { + if pack.len() < 12 || &pack[..4] != b"PACK" { + return Err(malformed("missing pack header")); + } + let version = u32::from_be_bytes(pack[4..8].try_into().expect("four bytes")); + if version != 2 && version != 3 { + return Err(malformed(format!("pack version {version}"))); + } + let count = u32::from_be_bytes(pack[8..12].try_into().expect("four bytes")) as usize; + let mut at = 12; + let mut entries: Vec<(usize, Entry)> = Vec::with_capacity(count); + for _ in 0..count { + let start = at; + let mut byte = *pack.get(at).ok_or_else(|| malformed("pack truncated"))?; + at += 1; + let code = (byte >> 4) & 0x7; + let mut size = (byte & 0x0f) as usize; + let mut shift = 4; + while byte & 0x80 != 0 { + byte = *pack.get(at).ok_or_else(|| malformed("pack truncated"))?; + at += 1; + size |= ((byte & 0x7f) as usize) << shift; + shift += 7; + } + let entry = match code { + 6 => { + let mut byte = *pack.get(at).ok_or_else(|| malformed("pack truncated"))?; + at += 1; + let mut back = (byte & 0x7f) as usize; + while byte & 0x80 != 0 { + byte = *pack.get(at).ok_or_else(|| malformed("pack truncated"))?; + at += 1; + back = ((back + 1) << 7) | (byte & 0x7f) as usize; + } + let (data, used) = inflate(&pack[at..], size)?; + at += used; + Entry::OfsDelta( + start + .checked_sub(back) + .ok_or_else(|| malformed("ofs-delta before pack start"))?, + data, + ) + } + 7 => { + let len = hash.len_in_bytes(); + let base = ObjectId::try_from( + pack.get(at..at + len) + .ok_or_else(|| malformed("pack truncated"))?, + ) + .map_err(malformed)?; + at += len; + let (data, used) = inflate(&pack[at..], size)?; + at += used; + Entry::RefDelta(base, data) + } + code => { + let kind = kind_of(code).ok_or_else(|| malformed(format!("pack object type {code}")))?; + let (data, used) = inflate(&pack[at..], size)?; + at += used; + Entry::Base(kind, data) + } + }; + entries.push((start, entry)); + } + + let by_offset: HashMap = entries + .iter() + .enumerate() + .map(|(index, (offset, _))| (*offset, index)) + .collect(); + let mut resolved: Vec)>> = vec![None; entries.len()]; + let mut by_id: HashMap = HashMap::new(); + + // bases first, then deltas until nothing moves; delta chains resolve in a few passes + let mut progressed = true; + while progressed { + progressed = false; + for index in 0..entries.len() { + if resolved[index].is_some() { + continue; + } + let object = match &entries[index].1 { + Entry::Base(kind, data) => Some((*kind, data.clone())), + Entry::OfsDelta(base_offset, delta) => { + let base = by_offset + .get(base_offset) + .ok_or_else(|| malformed("ofs-delta base isn't an entry"))?; + match &resolved[*base] { + Some((kind, base)) => Some((*kind, apply_delta(base, delta)?)), + None => None, + } + } + Entry::RefDelta(base_id, delta) => { + let base = match by_id.get(base_id) { + Some(base) => resolved[*base].clone(), + None => known(base_id), + }; + match base { + Some((kind, base)) => Some((kind, apply_delta(&base, delta)?)), + None => None, + } + } + }; + if let Some((kind, data)) = object { + let id = gix_object::compute_hash(hash, kind, &data).map_err(malformed)?; + by_id.insert(id, index); + resolved[index] = Some((kind, data)); + progressed = true; + } + } + } + resolved + .into_iter() + .map(|object| { + let (kind, data) = object.ok_or_else(|| malformed("delta base missing from pack"))?; + let id = gix_object::compute_hash(hash, kind, &data).map_err(malformed)?; + Ok(Fetched { id, kind, data }) + }) + .collect() +} diff --git a/knot2/worker/src/plc.rs b/knot2/worker/src/plc.rs new file mode 100644 index 000000000..906b8f89e --- /dev/null +++ b/knot2/worker/src/plc.rs @@ -0,0 +1,153 @@ +// repo dids on plc: the genesis op queued at create, then the update that names +// the knot's key and pds, the same convergence knot-xrpc's sweeper drives + +use knot_atproto::{AtprotoError, RepoTarget}; +use knot_runtime::{HttpRequest, HttpTransport, K256Signer, Signer}; +use knot_types::{KnotServiceUrl, RepoDid}; +use serde_json::Value; + +use crate::clock::now_millis; +use crate::error::KnotError; +use crate::knot::Knot; +use crate::state::PendingPlc; +use crate::transport::WorkerHttp; + +const PLC_TOMBSTONE_TYPE: &str = "plc_tombstone"; +const SETTLE_RECHECK_MS: i64 = 5_000; +const BACKOFF_START_MS: i64 = 30_000; +const BACKOFF_CAP_MS: i64 = 3_600_000; +const MAX_FAILURES: i64 = 12; + +enum Outcome { + Settled, + Submitted, + Retriable(#[allow(dead_code)] String), + GivenUp(#[allow(dead_code)] String), +} + +pub fn signer(knot: &Knot) -> Result { + let hex = knot + .env + .secret("KNOT_SIGNING_KEY") + .map_err(|_| KnotError::internal("KNOT_SIGNING_KEY secret is missing"))? + .to_string(); + let bytes = (0..hex.trim().len()) + .step_by(2) + .map(|at| u8::from_str_radix(&hex.trim()[at..at + 2], 16)) + .collect::, _>>() + .map_err(|_| KnotError::internal("KNOT_SIGNING_KEY isn't hex"))?; + K256Signer::from_slice(&bytes).map_err(|_| KnotError::internal("KNOT_SIGNING_KEY isn't a secp256k1 key")) +} + +pub fn service_url(knot: &Knot) -> Result { + KnotServiceUrl::new(knot.config.service_url.clone()) + .map_err(|error| KnotError::internal(format!("KNOT_SERVICE_URL: {error}"))) +} + +async fn last_operation(knot: &Knot, did: &RepoDid) -> Result, KnotError> { + let mut url = url::Url::parse(&format!( + "{}/{}", + knot.config.plc_url.trim_end_matches('/'), + did.as_str() + )) + .map_err(|error| KnotError::internal(error.to_string()))?; + url.set_path(&format!("{}/log/last", url.path().trim_end_matches('/'))); + let response = WorkerHttp + .execute(HttpRequest::get(url)) + .await + .map_err(|error| KnotError::upstream(error.to_string()))?; + if response.status.as_u16() == 404 { + return Ok(None); + } + if !response.status.is_success() { + return Err(KnotError::upstream(format!( + "plc log for {did} returned HTTP {}", + response.status.as_u16() + ))); + } + serde_json::from_slice(&response.body) + .map(Some) + .map_err(|error| KnotError::upstream(error.to_string())) +} + +async fn post(knot: &Knot, did: &RepoDid, operation: &Value) -> Outcome { + let plc = match crate::auth::plc_directory(knot) { + Ok(plc) => plc, + Err(error) => return Outcome::GivenUp(error.to_string()), + }; + match knot_atproto::submit_plc_operation(&WorkerHttp, &plc, did, operation).await { + Ok(()) => Outcome::Submitted, + Err(AtprotoError::PlcSubmit { status, .. }) if !status.is_transient() => Outcome::GivenUp( + format!("the directory rejected this operation with HTTP {status}"), + ), + Err(error) => Outcome::Retriable(error.to_string()), + } +} + +async fn reconcile(knot: &Knot, target: &RepoTarget, pending: &PendingPlc) -> Outcome { + let Ok(did) = RepoDid::new(pending.did.clone()) else { + return Outcome::GivenUp(format!("{} isn't a repo did", pending.did)); + }; + let last_op = match last_operation(knot, &did).await { + Ok(Some(op)) => op, + Ok(None) => return post(knot, &did, &pending.operation).await, + Err(error) => return Outcome::Retriable(error.to_string()), + }; + if last_op.get("type").and_then(Value::as_str) == Some(PLC_TOMBSTONE_TYPE) { + return Outcome::GivenUp(format!("{did} was tombstoned in the directory")); + } + match target.satisfied_by(&last_op) { + Ok(true) => Outcome::Settled, + Ok(false) => { + let signer = match signer(knot) { + Ok(signer) => signer, + Err(error) => return Outcome::GivenUp(error.to_string()), + }; + match knot_atproto::update_operation(&signer, &last_op, target) { + Ok(update) => post(knot, &did, &update).await, + Err(error) => Outcome::GivenUp(error.to_string()), + } + } + Err(error) => Outcome::GivenUp(error.to_string()), + } +} + +pub async fn sweep(knot: &Knot) -> Result<(), KnotError> { + let signer = signer(knot)?; + let target = RepoTarget::new(&signer.public_key(), &service_url(knot)?); + for pending in knot.due_plc().await? { + // a submit is followed by a recheck, which settles or sends the update + let mut rounds = 0; + let outcome = loop { + rounds += 1; + match reconcile(knot, &target, &pending).await { + Outcome::Submitted if rounds < 3 => continue, + other => break other, + } + }; + let now = now_millis() as i64; + match outcome { + Outcome::Settled | Outcome::GivenUp(_) => knot.plc_done(&pending.did).await?, + Outcome::Submitted => { + knot.queue_plc(&PendingPlc { + next_at_millis: now + SETTLE_RECHECK_MS, + ..pending + }) + .await? + } + Outcome::Retriable(_) if pending.attempts + 1 >= MAX_FAILURES => { + knot.plc_done(&pending.did).await? + } + Outcome::Retriable(_) => { + let backoff = (BACKOFF_START_MS << pending.attempts.min(16)).min(BACKOFF_CAP_MS); + knot.queue_plc(&PendingPlc { + attempts: pending.attempts + 1, + next_at_millis: now + backoff, + ..pending + }) + .await? + } + } + } + Ok(()) +} diff --git a/knot2/worker/src/proxy.rs b/knot2/worker/src/proxy.rs new file mode 100644 index 000000000..cad5ef795 --- /dev/null +++ b/knot2/worker/src/proxy.rs @@ -0,0 +1,279 @@ +// knot2's git smart-http routes, forwarded to the repo's artifacts remote. +// fetches use the knot's read token; pushes carry the pusher's own artifacts +// token. a push's ref commands and report-status are read on the way through +// so the knot can announce what moved. + +use worker::{Fetch, Headers, Method, Request, RequestInit, Response}; + +use crate::error::KnotError; +use crate::events::RefChange; +use crate::knot::Knot; +use crate::pack::{Pkt, pkt_lines}; +use crate::state::Hosted; + +const NO_CACHE: [(&str, &str); 3] = [ + ("Expires", "Fri, 01 Jan 1980 00:00:00 GMT"), + ("Pragma", "no-cache"), + ("Cache-Control", "no-cache, max-age=0, must-revalidate"), +]; + +pub enum GitRoute { + InfoRefs, + UploadPack, + ReceivePack, +} + +pub struct GitPath { + pub owner_or_repo: String, + pub name: Option, + pub route: GitRoute, +} + +pub fn parse(path: &str) -> Option { + let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + let (prefix, route) = match segments.as_slice() { + [prefix @ .., "info", "refs"] => (prefix, GitRoute::InfoRefs), + [prefix @ .., "git-upload-pack"] => (prefix, GitRoute::UploadPack), + [prefix @ .., "git-receive-pack"] => (prefix, GitRoute::ReceivePack), + _ => return None, + }; + match prefix { + [did] => Some(GitPath { + owner_or_repo: did.to_string(), + name: None, + route, + }), + [owner, name] => Some(GitPath { + owner_or_repo: owner.to_string(), + name: Some(name.to_string()), + route, + }), + _ => None, + } +} + +fn plain(status: u16, body: &str) -> worker::Result { + let headers = Headers::new(); + headers.set("Content-Type", "text/plain; charset=utf-8")?; + Ok(Response::from_bytes(body.as_bytes().to_vec())? + .with_status(status) + .with_headers(headers)) +} + +async fn resolve(knot: &Knot, path: &GitPath) -> Result, KnotError> { + match &path.name { + None => { + let Ok(did) = knot_types::RepoDid::new(path.owner_or_repo.clone()) else { + return Ok(None); + }; + knot.hosted(&did).await + } + Some(name) => { + if !path.owner_or_repo.starts_with("did:") { + return Ok(None); + } + let bare = name.strip_suffix(".git").unwrap_or(name); + match knot.hosted_by_name(&path.owner_or_repo, name).await? { + Some(found) => Ok(Some(found)), + None if bare != name => knot.hosted_by_name(&path.owner_or_repo, bare).await, + None => Ok(None), + } + } + } +} + +fn copy_header(from: &Headers, to: &Headers, name: &str) -> worker::Result<()> { + if let Some(value) = from.get(name)? { + to.set(name, &value)?; + } + Ok(()) +} + +// " \0caps" lines up to the first flush +fn push_commands(body: &[u8]) -> (Vec, bool) { + let mut changes = Vec::new(); + let mut report = false; + for pkt in pkt_lines(body) { + match pkt { + Ok(Pkt::Data(line)) => { + let (command, caps) = match line.iter().position(|byte| *byte == 0) { + Some(at) => (&line[..at], Some(&line[at + 1..])), + None => (line, None), + }; + if let Some(caps) = caps { + report = String::from_utf8_lossy(caps) + .split(' ') + .any(|cap| cap.trim() == "report-status" || cap.trim() == "report-status-v2"); + } + let text = String::from_utf8_lossy(command); + let mut parts = text.trim_end().splitn(3, ' '); + if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) { + changes.push(RefChange { + name: name.to_string(), + old: old.to_string(), + new: new.to_string(), + }); + } + } + _ => break, + } + } + (changes, report) +} + +// refs report-status says landed, looking through a sideband if there is one +fn accepted_refs(body: &[u8]) -> Option> { + let mut inner = Vec::new(); + let mut plain_lines = Vec::new(); + for pkt in pkt_lines(body) { + let Ok(Pkt::Data(line)) = pkt else { continue }; + match line.split_first() { + Some((1, data)) => inner.extend_from_slice(data), + Some((2 | 3, _)) => {} + _ => plain_lines.push(line.to_vec()), + } + } + let lines: Vec> = match inner.is_empty() { + true => plain_lines, + false => pkt_lines(&inner) + .filter_map(|pkt| match pkt { + Ok(Pkt::Data(line)) => Some(line.to_vec()), + _ => None, + }) + .collect(), + }; + let text: Vec = lines + .iter() + .map(|line| String::from_utf8_lossy(line).trim_end().to_string()) + .collect(); + if !text.iter().any(|line| line.starts_with("unpack ")) { + return None; + } + Some( + text.iter() + .filter_map(|line| line.strip_prefix("ok ").map(str::to_string)) + .collect(), + ) +} + +fn gunzip(body: &[u8]) -> Vec { + use std::io::Read; + let mut out = Vec::new(); + match flate2::read::GzDecoder::new(body).read_to_end(&mut out) { + Ok(_) => out, + Err(_) => body.to_vec(), + } +} + +pub async fn serve( + knot: &Knot, + ctx: &worker::Context, + path: GitPath, + mut req: Request, +) -> worker::Result { + let hosted = match resolve(knot, &path).await { + Ok(Some(hosted)) => hosted, + Ok(None) => return plain(404, "repository not found"), + Err(error) => return plain(503, &error.to_string()), + }; + let url = req.url()?; + let service = url + .query_pairs() + .find(|(name, _)| name == "service") + .map(|(_, value)| value.into_owned()); + let receive = match path.route { + GitRoute::ReceivePack => true, + GitRoute::InfoRefs => service.as_deref() == Some("git-receive-pack"), + GitRoute::UploadPack => false, + }; + if let GitRoute::InfoRefs = path.route + && !matches!(service.as_deref(), Some("git-upload-pack") | Some("git-receive-pack")) + { + return plain(400, "unsupported service"); + } + + let incoming = req.headers().clone(); + let outgoing = Headers::new(); + for name in ["Content-Type", "Content-Encoding", "Accept", "Git-Protocol", "User-Agent"] { + copy_header(&incoming, &outgoing, name)?; + } + match receive { + true => match incoming.get("Authorization")? { + Some(value) => outgoing.set("Authorization", &value)?, + None => { + let headers = Headers::new(); + headers.set("WWW-Authenticate", "Basic realm=\"knot\"")?; + headers.set("Content-Type", "text/plain; charset=utf-8")?; + return Ok(Response::from_bytes(b"push needs an artifacts token".to_vec())? + .with_status(401) + .with_headers(headers)); + } + }, + false => match knot.read_token(&hosted).await { + Ok(Some(token)) => outgoing.set("Authorization", &format!("Bearer {token}"))?, + Ok(None) => {} + Err(error) => return plain(503, &error.to_string()), + }, + } + + let base = hosted.remote.trim_end_matches('/'); + let (target, method, body) = match path.route { + GitRoute::InfoRefs => ( + format!("{base}/info/refs?service={}", service.unwrap_or_default()), + Method::Get, + None, + ), + GitRoute::UploadPack => (format!("{base}/git-upload-pack"), Method::Post, Some(req.bytes().await?)), + GitRoute::ReceivePack => (format!("{base}/git-receive-pack"), Method::Post, Some(req.bytes().await?)), + }; + let commands = match (&path.route, &body) { + (GitRoute::ReceivePack, Some(body)) => { + let plain_body = match incoming.get("Content-Encoding")?.as_deref() { + Some("gzip") => gunzip(body), + _ => body.clone(), + }; + Some(push_commands(&plain_body)) + } + _ => None, + }; + + let mut init = RequestInit::new(); + init.with_method(method).with_headers(outgoing); + if let Some(body) = body { + init.with_body(Some(js_sys::Uint8Array::from(body.as_slice()).into())); + } + let mut upstream = Fetch::Request(Request::new_with_init(&target, &init)?) + .send() + .await?; + let status = upstream.status_code(); + let headers = Headers::new(); + copy_header(upstream.headers(), &headers, "Content-Type")?; + copy_header(upstream.headers(), &headers, "WWW-Authenticate")?; + for (name, value) in NO_CACHE { + headers.set(name, value)?; + } + let response_body = upstream.bytes().await?; + + if let Some((changes, report)) = commands + && (200..300).contains(&status) + && !changes.is_empty() + { + let landed: Vec = match (report, accepted_refs(&response_body)) { + (true, Some(accepted)) => changes + .into_iter() + .filter(|change| accepted.contains(&change.name)) + .collect(), + _ => changes, + }; + let knot_env = knot.env.clone(); + ctx.wait_until(async move { + crate::source::forget(&hosted.did); + let knot = Knot::new(knot_env); + let _ = crate::events::emit_ref_updates(&knot, &hosted, None, &landed).await; + }); + } + + Ok(Response::from_bytes(response_body)? + .with_status(status) + .with_headers(headers)) +} diff --git a/knot2/worker/src/reads.rs b/knot2/worker/src/reads.rs new file mode 100644 index 000000000..f06652101 --- /dev/null +++ b/knot2/worker/src/reads.rs @@ -0,0 +1,491 @@ +// the sh.tangled.repo read endpoints, step for step with knot-xrpc's reads.rs: +// same checks in the same order, same knot-wire assembly, objects from artifacts + +use knot_gitcore::{BinaryBudget, CommitRange, EntryKind, LogLimit, LogSkip, PatchRange}; +use knot_types::{Oid, RepoPath}; +use knot_wire::WireError; +use knot_wire::reads::{ + self as wire_reads, ArchiveParams, BlameParams, BlobParams, BranchParams, BranchesParams, + CompareParams, DefaultBranchParams, DescribeRepoOut, DescribeRepoParams, DiffParams, + LanguagesParams, ListRefsOut, ListRefsParams, ListReposOut, ListReposParams, LogParams, + MAX_COMPARE_COMMITS, ReadmeOut, TagParams, TagsParams, TreeParams, +}; +use knot_wire::query::{Revspec, parse_query}; +use serde::Serialize; + +use crate::clock::Deadline; +use crate::error::KnotError; +use crate::knot::{Knot, KnotRepo}; +use crate::respond::{Reply, json_reply, raw_reply}; + +pub struct Limits { + pub response: usize, + pub tree_last_commit_ms: u64, + pub blob_last_commit_ms: u64, + pub languages_ms: u64, +} + +impl Limits { + fn binary_patch(&self) -> BinaryBudget { + BinaryBudget::new(self.response as u64 / 4 / 3) + } +} + +fn json(value: impl Serialize, limit: usize) -> Result { + wire_reads::json(value, limit) + .map(json_reply) + .map_err(KnotError::from) +} + +fn ref_not_found() -> KnotError { + wire_reads::ref_not_found().into() +} + +async fn commit_for(repo: &KnotRepo, refspec: &Revspec) -> Result { + let refspec = refspec.as_str(); + if wire_reads::names_reserved(refspec) { + return Err(ref_not_found()); + } + let oid = match refspec.is_empty() { + true => repo.head().map(|(_, target)| target), + false => repo.resolve_revision(refspec).await?, + } + .ok_or_else(ref_not_found)?; + let commit = repo.peel_to_commit(oid).await.map_err(|_| ref_not_found())?; + if repo.hidden_ref_commit(refspec).await == Some(commit) { + return Ok(commit); + } + match repo.reachable_from_public(commit).await { + Ok(true) => Ok(commit), + Ok(false) => Err(ref_not_found()), + Err(error) => Err(error), + } +} + +async fn readme_of( + repo: &KnotRepo, + commit: Oid, + dir: Option<&RepoPath>, + entries: &[knot_gitcore::SizedEntry], + response_limit: usize, +) -> ReadmeOut { + for entry in entries.iter().filter(|entry| wire_reads::is_readme(entry)) { + let path = match dir { + None => RepoPath::new(entry.name.as_str()).ok(), + Some(dir) => RepoPath::new(format!("{dir}/{}", entry.name)).ok(), + }; + let Some(path) = path else { continue }; + let Some(target) = repo.entry_at(commit, &path).await.ok().flatten() else { + continue; + }; + let Ok(size) = repo.blob_size(target.oid).await else { + continue; + }; + if size > wire_reads::readme_serving_limit(response_limit) { + continue; + } + let Ok(contents) = repo.read_blob(target.oid).await else { + continue; + }; + if let Ok(contents) = String::from_utf8(contents) { + return ReadmeOut { + filename: entry.name.clone(), + contents, + }; + } + } + ReadmeOut::empty() +} + +pub async fn tree(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: TreeParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let commit = commit_for(&repo, ¶ms.refspec).await?; + let dir = params.path.dir().ok_or_else(wire_reads::path_not_found)?; + let entries = repo + .tree_entries_at(commit, dir) + .await? + .ok_or_else(wire_reads::path_not_found)?; + let names: Vec = entries.iter().map(|entry| entry.name.clone()).collect(); + let attributed = repo + .last_commits(commit, dir, &names, Deadline::after_millis(limits.tree_last_commit_ms)) + .await + .unwrap_or_default(); + let newest_commit = match wire_reads::newest(&attributed) { + Some(last) => repo.find_commit(last.id).await.ok(), + None => None, + }; + let readme = readme_of(&repo, commit, dir, &entries, limits.response).await; + json( + wire_reads::tree_out( + ¶ms.refspec, + ¶ms.path, + &entries, + &attributed, + newest_commit.as_ref(), + readme, + ), + limits.response, + ) +} + +pub async fn log(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: LogParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let start = commit_for(&repo, ¶ms.refspec).await?; + let (commits, total) = repo + .log_window( + start, + LogSkip::new(params.cursor.get()), + LogLimit::new(params.limit.get()), + ) + .await?; + json(wire_reads::log_out(¶ms, &commits, total), limits.response) +} + +pub async fn branches(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: BranchesParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let branches = repo.branch_list().await?; + let default = repo.default_branch(); + json( + wire_reads::branches_out( + ¶ms, + branches, + default.as_ref().map(|name| name.as_str()), + repo.object_format().null_oid(), + ), + limits.response, + ) +} + +pub async fn branch(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: BranchParams = parse_query(query)?; + let repo_arg = ¶ms.repo; + let Some(name) = params.name.get().cloned() else { + knot.resolve(repo_arg).await?; + return Err(wire_reads::missing_name().into()); + }; + let repo = knot.open(repo_arg).await?; + let target = repo + .find_ref(&name.head_ref()) + .ok_or_else(wire_reads::branch_not_found)?; + let commit = repo + .find_commit(target) + .await + .map_err(|_| wire_reads::branch_not_found())?; + let default = repo.default_branch(); + json( + wire_reads::branch_out(&name, target, &commit, default.as_ref().map(|name| name.as_str())), + limits.response, + ) +} + +pub async fn tags(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: TagsParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let tags = repo.tag_list().await?; + json(wire_reads::tags_out(¶ms, tags), limits.response) +} + +pub async fn tag(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: TagParams = parse_query(query)?; + let Some(name) = params.tag.get().cloned() else { + knot.resolve(¶ms.repo).await?; + return Err(wire_reads::missing_tag().into()); + }; + let repo = knot.open(¶ms.repo).await?; + let tags = repo.tag_list().await?; + json(wire_reads::tag_out(&name, tags)?, limits.response) +} + +pub async fn blob( + knot: &Knot, + query: &str, + if_none_match: &[String], + limits: &Limits, +) -> Result { + let params: BlobParams = parse_query(query)?; + knot.resolve(¶ms.repo).await?; + if params.path.as_str().is_empty() { + return Err(wire_reads::missing_path().into()); + } + let repo = knot.open(¶ms.repo).await?; + let path = params.path.as_str().to_string(); + let raw = params.raw.requested(); + let commit = commit_for(&repo, ¶ms.refspec).await?; + let submodules = repo.submodules(commit).await.unwrap_or_default(); + if let Some(submodule) = wire_reads::submodule_at(&submodules, &path) { + return json(wire_reads::submodule_out(¶ms, submodule), limits.response); + } + let file_path = params.path.file().ok_or_else(wire_reads::file_not_found)?; + let entry = repo + .entry_at(commit, file_path) + .await? + .filter(|entry| { + matches!( + entry.kind, + EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link + ) + }) + .ok_or_else(wire_reads::file_not_found)?; + if repo + .blob_size(entry.oid) + .await + .map_err(|_| wire_reads::file_not_found())? + > wire_reads::blob_serving_limit(raw, limits.response) + { + return Err(wire_reads::blob_too_large().into()); + } + let contents = repo + .read_blob(entry.oid) + .await + .map_err(|_| wire_reads::file_not_found())?; + let mime = wire_reads::blob_mime(&path, &contents); + + if raw { + return wire_reads::serve_raw(if_none_match.iter().map(String::as_str), mime, contents) + .map(raw_reply) + .map_err(KnotError::from); + } + + let dir = file_path.parent(); + let name = file_path.file_name().to_string(); + let attributed = repo + .last_commits( + commit, + dir.as_ref(), + std::slice::from_ref(&name), + Deadline::after_millis(limits.blob_last_commit_ms), + ) + .await + .ok() + .and_then(|attributed| attributed.get(&name).cloned()); + let last_commit = match attributed { + Some(last) => { + let author = repo.find_commit(last.id).await.ok(); + Some(wire_reads::LastCommitOut::of(&last, author.as_ref())) + } + None => None, + }; + json( + wire_reads::blob_out(¶ms, mime, &contents, last_commit), + limits.response, + ) +} + +pub async fn blame(knot: &Knot, query: &str, _limits: &Limits) -> Result { + let params: BlameParams = parse_query(query)?; + knot.resolve(¶ms.repo).await?; + if params.path.as_str().is_empty() { + return Err(wire_reads::missing_path().into()); + } + // gix-blame needs a blocking object database walk with a wall-clock budget; + // not served from artifacts yet + Err(WireError::named( + http::StatusCode::NOT_IMPLEMENTED, + "MethodNotImplemented", + "blame isn't available on this knot yet", + ) + .into()) +} + +pub async fn diff(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: DiffParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let target = commit_for(&repo, ¶ms.refspec).await?; + let commit = repo.find_commit(target).await?; + let patches = repo + .commit_patches( + PatchRange { + base: commit.parents.first().copied(), + head: target, + }, + &mut BinaryBudget::Omit, + ) + .await?; + json(wire_reads::diff_out(¶ms, &commit, &patches), limits.response) +} + +pub async fn compare(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: CompareParams = parse_query(query)?; + knot.resolve(¶ms.repo).await?; + let (rev1, rev2) = params.revs()?; + let repo = knot.open(¶ms.repo).await?; + let mut series_binary = limits.binary_patch(); + let mut combined_binary = limits.binary_patch(); + let resolve = async |rev: &str| -> Result { + let revision_not_found = || KnotError::from(wire_reads::revision_not_found(rev)); + if wire_reads::names_reserved(rev) { + return Err(revision_not_found()); + } + let resolved = repo.resolve_revision(rev).await?; + let commit = match resolved { + Some(oid) => repo.peel_to_commit(oid).await.ok(), + None => None, + } + .ok_or_else(revision_not_found)?; + if repo.hidden_ref_commit(rev).await == Some(commit) { + return Ok(commit); + } + match repo.reachable_from_public(commit).await { + Ok(true) => Ok(commit), + Ok(false) => Err(revision_not_found()), + Err(error) => Err(error), + } + }; + let base = resolve(&rev1).await?; + let head = resolve(&rev2).await?; + let compare_error = |error: KnotError| KnotError::from(wire_reads::compare_error(error)); + let between = repo + .commits_between( + CommitRange { base, head }, + LogLimit::new(MAX_COMPARE_COMMITS + 1), + ) + .await + .map_err(compare_error)?; + if between.len() > MAX_COMPARE_COMMITS { + return Err(wire_reads::compare_too_long().into()); + } + let mut found = Vec::with_capacity(between.len()); + for oid in between { + found.push(repo.find_commit(oid).await.map_err(compare_error)?); + } + let commits = wire_reads::compare_series(found); + let mut entries = Vec::with_capacity(commits.len()); + for commit in &commits { + let patches = repo + .commit_patches( + PatchRange { + base: commit.parents.first().copied(), + head: commit.id, + }, + &mut series_binary, + ) + .await + .map_err(compare_error)?; + entries.push(wire_reads::series_entry(commit, &patches)); + } + let merge_base = repo.merge_base(base, head).await.ok().flatten(); + let combined = match (commits.len() >= 2, merge_base) { + (true, Some(merge_base)) => repo + .commit_patches( + PatchRange { + base: Some(merge_base), + head, + }, + &mut combined_binary, + ) + .await + .ok(), + _ => None, + }; + json( + wire_reads::compare_out( + base, + head, + merge_base, + entries, + combined, + series_binary.omitted() || combined_binary.omitted(), + ), + limits.response, + ) +} + +pub async fn languages(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: LanguagesParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let commit = commit_for(&repo, ¶ms.refspec).await?; + let sizes = repo + .languages(commit, Deadline::after_millis(limits.languages_ms)) + .await?; + json(wire_reads::languages_out(¶ms, &sizes), limits.response) +} + +pub async fn get_default_branch( + knot: &Knot, + query: &str, + limits: &Limits, +) -> Result { + let params: DefaultBranchParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let default = repo.default_branch(); + json( + wire_reads::default_branch_out(default.as_ref().map(|name| name.as_str()))?, + limits.response, + ) +} + +pub async fn describe_repo(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: DescribeRepoParams = parse_query(query)?; + let hosted = knot + .registry_entry(¶ms.repo_did) + .await? + .ok_or_else(wire_reads::repo_not_found)?; + json( + DescribeRepoOut { + repo_did: params.repo_did, + owner_did: hosted.owner, + rkey: hosted.rkey, + content: None, + reason: None, + }, + limits.response, + ) +} + +pub async fn list_refs(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: ListRefsParams = parse_query(query)?; + let repo = knot.open(¶ms.repo).await?; + let head = repo.head(); + let out: ListRefsOut = wire_reads::list_refs_out( + ¶ms, + repo.references(), + head.as_ref().map(|(name, target)| (name.as_str(), *target)), + ); + json(out, limits.response) +} + +pub async fn list_repos(knot: &Knot, query: &str, limits: &Limits) -> Result { + let params: ListReposParams = parse_query(query)?; + let hosted = knot.hosted_repos().await?; + let (page, cursor) = wire_reads::list_repos_page(¶ms, hosted); + let mut repos = Vec::with_capacity(page.len()); + for did in page { + let head = knot + .ref_snapshot(&did) + .await + .ok() + .and_then(|snapshot| { + let name = snapshot + .head_symref + .filter(|name| knot_gitcore::RefClass::of(name).is_public())?; + Some((name, snapshot.head_target?)) + }); + repos.push(wire_reads::repo_wire( + did, + head.as_ref().map(|(name, target)| (name.as_str(), *target)), + )); + } + json(ListReposOut { repos, cursor }, limits.response) +} + +pub async fn archive( + knot: &Knot, + query: &str, + _request: &worker::Request, + _limits: &Limits, +) -> Result { + let params: ArchiveParams = parse_query(query)?; + knot.resolve(¶ms.repo).await?; + // gix-archive streams entries from a worker thread; wasm has none, so this + // waits on writing the tar/zip directly in the worker + Err(WireError::named( + http::StatusCode::NOT_IMPLEMENTED, + "MethodNotImplemented", + "archives aren't available on this knot yet", + ) + .into()) +} + diff --git a/knot2/worker/src/remote.rs b/knot2/worker/src/remote.rs new file mode 100644 index 000000000..1016c8901 --- /dev/null +++ b/knot2/worker/src/remote.rs @@ -0,0 +1,75 @@ +// a smart-http git remote: the artifacts repo behind a knot repo + +use worker::{Fetch, Headers, Method, Request, RequestInit}; + +use crate::error::KnotError; +use crate::git::RefSnapshot; +use crate::pack; + +#[derive(Clone, Debug)] +pub struct Remote { + // e.g. https://.artifacts.cloudflare.net/git//.git + pub url: String, + // an artifacts token; sent as a bearer + pub token: Option, + pub object_format: &'static str, +} + +impl Remote { + fn endpoint(&self, suffix: &str) -> String { + format!("{}/{suffix}", self.url.trim_end_matches('/')) + } + + fn headers(&self, content_type: Option<&str>, accept: &str) -> Result { + let headers = Headers::new(); + if let Some(content_type) = content_type { + headers.set("Content-Type", content_type)?; + } + headers.set("Accept", accept)?; + headers.set("Git-Protocol", "version=2")?; + headers.set("User-Agent", "git/2.47.0 knot-worker/2")?; + if let Some(token) = &self.token { + headers.set("Authorization", &format!("Bearer {token}"))?; + } + Ok(headers) + } + + async fn upload_pack(&self, body: Vec) -> Result, KnotError> { + let mut init = RequestInit::new(); + init.with_method(Method::Post) + .with_headers(self.headers( + Some("application/x-git-upload-pack-request"), + "application/x-git-upload-pack-result", + )?) + .with_body(Some(js_sys::Uint8Array::from(body.as_slice()).into())); + let request = Request::new_with_init(&self.endpoint("git-upload-pack"), &init)?; + let mut response = Fetch::Request(request).send().await?; + let status = response.status_code(); + let bytes = response.bytes().await?; + if !(200..300).contains(&status) { + return Err(KnotError::upstream(format!( + "git remote answered upload-pack with HTTP {status}: {}", + String::from_utf8_lossy(&bytes[..bytes.len().min(256)]) + ))); + } + Ok(bytes) + } + + pub async fn ls_refs(&self) -> Result { + let body = self + .upload_pack(pack::ls_refs_request(self.object_format)) + .await?; + pack::parse_ls_refs(&body) + } + + pub async fn fetch_pack( + &self, + wants: &[gix_hash::ObjectId], + haves: &[gix_hash::ObjectId], + ) -> Result, KnotError> { + let body = self + .upload_pack(pack::fetch_request(self.object_format, wants, haves)) + .await?; + pack::packfile_of(&body) + } +} diff --git a/knot2/worker/src/repos.rs b/knot2/worker/src/repos.rs new file mode 100644 index 000000000..35ba5fde0 --- /dev/null +++ b/knot2/worker/src/repos.rs @@ -0,0 +1,248 @@ +// repo lifecycle, mirroring knot-xrpc's repos.rs and branches.rs. a repo is an +// artifacts repo; its did is minted on plc with the knot's key, like knot2 does. + +use knot_runtime::{OsEntropy, Signer}; +use knot_types::{ActorId, BranchName, OwnerDid, RepoDid, RepoName, RepoRkey}; +use knot_wire::WireError; +use serde::{Deserialize, Serialize}; + +use crate::clock::now_millis; +use crate::error::KnotError; +use crate::knot::Knot; +use crate::plc; +use crate::respond::{Reply, empty_json, json_reply}; +use crate::state::{Hosted, PendingPlc}; + +pub const CREATE_NSID: &str = "sh.tangled.repo.create"; +pub const DELETE_NSID: &str = "sh.tangled.repo.delete"; +pub const SET_DEFAULT_BRANCH_NSID: &str = "sh.tangled.repo.setDefaultBranch"; + +#[derive(Deserialize)] +struct CreateInput { + rkey: RepoRkey, + name: RepoName, + #[serde(rename = "defaultBranch")] + default_branch: Option, + #[serde(default)] + source: Option, + #[serde(rename = "repoDid")] + repo_did: Option, +} + +#[derive(Serialize)] +struct CreateOutput { + #[serde(rename = "repoDid")] + repo_did: RepoDid, + key: ActorId, +} + +#[derive(Deserialize)] +struct DeleteInput { + repo: RepoDid, + #[serde(default)] + force: bool, +} + +#[derive(Deserialize)] +struct SetDefaultBranchInput { + repo: RepoDid, + #[serde(rename = "defaultBranch")] + default_branch: BranchName, +} + +fn decode(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|error| { + KnotError::Wire(WireError::invalid_request(format!( + "Invalid request body: {error}" + ))) + }) +} + +fn is_admin(knot: &Knot, actor: &str) -> bool { + knot.config.admins.iter().any(|admin| admin == actor) +} + +// artifacts repo names allow letters, digits, '.', '_' and '-' +fn artifacts_name(did: &RepoDid) -> String { + did.as_str() + .trim_start_matches("did:") + .chars() + .map(|c| match c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + true => c, + false => '-', + }) + .collect() +} + +fn json(value: &T) -> Result { + serde_json::to_vec(value) + .map(json_reply) + .map_err(|error| KnotError::internal(error.to_string())) +} + +pub async fn create(knot: &Knot, authorization: Option<&str>, body: &[u8]) -> Result { + let actor = crate::auth::authenticate(knot, authorization, CREATE_NSID).await?; + if !(is_admin(knot, actor.as_str()) || knot.config.open_admission) { + return Err(KnotError::Wire(WireError::forbidden( + "only knot admin or member may create repositories", + ))); + } + let input: CreateInput = decode(body)?; + if knot + .hosted_by_owner(actor.as_str(), input.rkey.as_str()) + .await? + .is_some() + { + return Err(KnotError::Wire(WireError::conflict( + "repository with that record key already exists for this owner", + ))); + } + if input.source.as_ref().is_some_and(|source| !source.is_null() && source != "") { + return Err(KnotError::Wire(WireError::invalid_request( + "forks and imports aren't available on this knot yet", + ))); + } + if input.repo_did.is_some() { + return Err(KnotError::Wire(WireError::invalid_request( + "reserve this repoDid for your own account via sh.tangled.repo.reserveKey before creating it", + ))); + } + + let owner = OwnerDid::new(actor.as_str()).map_err(|error| KnotError::internal(error.to_string()))?; + let signer = plc::signer(knot)?; + let service_url = plc::service_url(knot)?; + let nonce = knot_atproto::MintNonce::mint(&OsEntropy, &owner, &input.rkey); + let prepared = knot_atproto::prepare_repo_did(&signer, &service_url, &nonce) + .map_err(|error| KnotError::internal(error.to_string()))?; + let repo_did = prepared.did.clone(); + + let default_branch = input + .default_branch + .as_ref() + .map(|branch| branch.as_str().to_string()) + .unwrap_or_else(|| "main".to_string()); + let artifacts = artifacts_name(&repo_did); + let created = knot + .artifacts()? + .create(&artifacts, &default_branch, input.name.as_str()) + .await + .map_err(|error| match error { + KnotError::Wire(error) if error.status == http::StatusCode::CONFLICT => { + KnotError::Wire(WireError::conflict("repository already exists on disk")) + } + other => other, + })?; + + knot.register(&Hosted { + did: repo_did.as_str().to_string(), + owner: owner.as_str().to_string(), + rkey: input.rkey.as_str().to_string(), + name: input.name.as_str().to_string(), + artifacts: created.name, + remote: created.remote, + created_at: crate::clock::now_seconds(), + default_branch: None, + }) + .await?; + knot.queue_plc(&PendingPlc { + did: repo_did.as_str().to_string(), + operation: prepared.operation().clone(), + attempts: 0, + next_at_millis: now_millis() as i64, + }) + .await?; + + json(&CreateOutput { + repo_did, + key: ActorId::from_secp256k1(signer.public_key().as_bytes()), + }) +} + +async fn record_present(knot: &Knot, owner: &str, rkey: &str) -> Result { + let account = knot_types::AccountDid::new(owner.to_string()) + .map_err(|error| KnotError::internal(error.to_string()))?; + let identity = knot_atproto::fetch_identity( + &crate::transport::WorkerHttp, + &crate::auth::plc_directory(knot)?, + &account, + ) + .await + .map_err(|error| KnotError::upstream(error.to_string()))?; + let mut url = identity.pds.url().clone(); + url.set_path(&format!( + "{}/xrpc/com.atproto.repo.getRecord", + url.path().trim_end_matches('/') + )); + url.query_pairs_mut() + .append_pair("repo", owner) + .append_pair("collection", "sh.tangled.repo") + .append_pair("rkey", rkey); + let response = knot_runtime::HttpTransport::execute( + &crate::transport::WorkerHttp, + knot_runtime::HttpRequest::get(url), + ) + .await + .map_err(|error| KnotError::upstream(error.to_string()))?; + Ok(response.status.is_success()) +} + +pub async fn delete(knot: &Knot, authorization: Option<&str>, body: &[u8]) -> Result { + let actor = crate::auth::authenticate(knot, authorization, DELETE_NSID).await?; + let input: DeleteInput = decode(body)?; + let Some(hosted) = knot.hosted(&input.repo).await? else { + return Ok(empty_json()); + }; + let admin = is_admin(knot, actor.as_str()); + if !(admin || hosted.owner == actor.as_str()) { + return Err(KnotError::Wire(WireError::forbidden( + "only repository owner or a knot admin may delete it", + ))); + } + if input.force && !admin { + return Err(KnotError::Wire(WireError::forbidden( + "only knot admin may force a delete past the PDS record check", + ))); + } + if !input.force && record_present(knot, &hosted.owner, &hosted.rkey).await.unwrap_or(false) { + return Err(KnotError::Wire(WireError::conflict( + "sh.tangled.repo record still exists on the owner's PDS. Remove it there first or force the delete.", + ))); + } + knot.artifacts()?.delete(&hosted.artifacts).await?; + knot.deregister(&input.repo).await?; + knot.plc_done(input.repo.as_str()).await?; + crate::source::forget(&hosted.did); + Ok(empty_json()) +} + +pub async fn set_default_branch( + knot: &Knot, + authorization: Option<&str>, + body: &[u8], +) -> Result { + let actor = crate::auth::authenticate(knot, authorization, SET_DEFAULT_BRANCH_NSID).await?; + let input: SetDefaultBranchInput = decode(body)?; + let hosted = knot + .hosted(&input.repo) + .await? + .ok_or_else(|| KnotError::from(knot_wire::reads::repo_not_found()))?; + if hosted.owner != actor.as_str() { + return Err(KnotError::Wire(WireError::forbidden( + "only repository owner or a collaborator may change its branches", + ))); + } + let snapshot = knot.snapshot_of(&hosted).await?; + let branches: Vec<_> = snapshot + .refs + .iter() + .filter(|(name, _)| knot_gitcore::is_branch(name)) + .collect(); + let wanted = input.default_branch.head_ref(); + if !branches.is_empty() && !branches.iter().any(|(name, _)| *name == wanted) { + return Err(KnotError::Wire(WireError::not_found("no such branch to set as default"))); + } + knot.set_default_branch(&input.repo, input.default_branch.as_str()) + .await?; + crate::source::forget(&hosted.did); + Ok(empty_json()) +} diff --git a/knot2/worker/src/respond.rs b/knot2/worker/src/respond.rs new file mode 100644 index 000000000..bf7b43e6e --- /dev/null +++ b/knot2/worker/src/respond.rs @@ -0,0 +1,69 @@ +use http::{HeaderName, StatusCode}; +use knot_wire::WireError; +use knot_wire::reads::RawResponse; +use worker::{Headers, Response, ResponseBuilder}; + +use crate::error::KnotError; + +// a response before it becomes a worker::Response, so handlers stay testable +pub struct Reply { + pub status: StatusCode, + pub headers: Vec<(HeaderName, String)>, + pub body: Vec, +} + +pub fn json_reply(body: Vec) -> Reply { + Reply { + status: StatusCode::OK, + headers: vec![(http::header::CONTENT_TYPE, "application/json".to_string())], + body, + } +} + +pub fn raw_reply(raw: RawResponse) -> Reply { + Reply { + status: raw.status, + headers: raw.headers, + body: raw.body, + } +} + +pub fn text_reply(status: StatusCode, content_type: &str, body: impl Into>) -> Reply { + Reply { + status, + headers: vec![(http::header::CONTENT_TYPE, content_type.to_string())], + body: body.into(), + } +} + +pub fn empty_json() -> Reply { + json_reply(b"{}".to_vec()) +} + +pub fn error_reply(error: &WireError) -> Reply { + Reply { + status: error.status, + headers: vec![(http::header::CONTENT_TYPE, "application/json".to_string())], + body: error.body(), + } +} + +impl Reply { + pub fn into_response(self) -> worker::Result { + let headers = Headers::new(); + for (name, value) in &self.headers { + headers.append(name.as_str(), value)?; + } + Ok(ResponseBuilder::new() + .with_status(self.status.as_u16()) + .with_headers(headers) + .fixed(self.body)) + } +} + +pub fn respond(result: Result) -> worker::Result { + match result { + Ok(reply) => reply.into_response(), + Err(error) => error_reply(&error.into_wire()).into_response(), + } +} diff --git a/knot2/worker/src/source.rs b/knot2/worker/src/source.rs new file mode 100644 index 000000000..45c1ce783 --- /dev/null +++ b/knot2/worker/src/source.rs @@ -0,0 +1,110 @@ +// objects for a repo, pulled from its remote as packs. an isolate keeps what it +// pulled per repo, so a warm isolate answers later reads from memory and only +// asks the remote for what the new ref tips added. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use futures::future::LocalBoxFuture; +use gix_hash::ObjectId; + +use crate::error::KnotError; +use crate::git::RefSnapshot; +use crate::odb::{Fetched, ObjectSource, Objects}; +use crate::remote::Remote; + +#[derive(Default)] +struct Synced { + objects: Objects, + tips: HashSet, +} + +thread_local! { + static CACHE: RefCell>>> = RefCell::new(HashMap::new()); +} + +// what an isolate already holds for `key`, and the store to keep adding to +pub fn cached_objects(key: &str) -> Objects { + synced(key).borrow().objects.clone() +} + +fn synced(key: &str) -> Rc> { + CACHE.with(|cache| { + cache + .borrow_mut() + .entry(key.to_string()) + .or_default() + .clone() + }) +} + +pub fn forget(key: &str) { + CACHE.with(|cache| { + cache.borrow_mut().remove(key); + }); +} + +pub struct PackSource { + key: String, + remote: Remote, + tips: Vec, + hash: gix_hash::Kind, +} + +impl PackSource { + pub fn new(key: impl Into, remote: Remote, refs: &RefSnapshot, hash: gix_hash::Kind) -> Self { + let mut tips: Vec = refs + .refs + .iter() + .map(|(_, target)| target.object_id()) + .chain(refs.head_target.map(|target| target.object_id())) + .collect(); + tips.sort(); + tips.dedup(); + Self { + key: key.into(), + remote, + tips, + hash, + } + } + + async fn sync(&self) -> Result, KnotError> { + let state = synced(&self.key); + let (wants, haves): (Vec, Vec) = { + let state = state.borrow(); + let wants = self + .tips + .iter() + .filter(|tip| !state.tips.contains(*tip) && !state.objects.has(tip)) + .copied() + .collect(); + (wants, state.tips.iter().copied().collect()) + }; + if wants.is_empty() { + return Ok(Vec::new()); + } + let pack = self.remote.fetch_pack(&wants, &haves).await?; + let objects = state.borrow().objects.clone(); + let fetched = crate::pack::unpack(&pack, self.hash, |id| { + objects + .get(id) + .map(|(kind, data)| (kind, data.to_vec())) + })?; + state.borrow_mut().tips.extend(self.tips.iter().copied()); + Ok(fetched) + } +} + +impl ObjectSource for PackSource { + fn fetch<'a>(&'a self, _ids: Vec) -> LocalBoxFuture<'a, Result, KnotError>> { + // a pack brings everything the tips reach; anything still missing after + // that isn't reachable from a ref and stays missing + Box::pin(self.sync()) + } + + fn blob_size<'a>(&'a self, _id: ObjectId) -> LocalBoxFuture<'a, Result, KnotError>> { + Box::pin(async { Ok(None) }) + } +} diff --git a/knot2/worker/src/state.rs b/knot2/worker/src/state.rs new file mode 100644 index 000000000..f8fed4de1 --- /dev/null +++ b/knot2/worker/src/state.rs @@ -0,0 +1,434 @@ +// the one durable object behind a knot: repo registry, the legacy event log +// with its websocket subscribers, replay protection, and cached artifacts tokens + +use serde::{Deserialize, Serialize}; +use worker::{ + DurableObject, Env, Request, Response, Result, SqlStorage, SqlStorageValue, State, WebSocket, + WebSocketIncomingMessage, WebSocketPair, durable_object, +}; + +use crate::clock::now_millis; + +const REPLAY_BATCH: usize = 100; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hosted { + pub did: String, + pub owner: String, + pub rkey: String, + pub name: String, + // the artifacts repo name and its smart-http remote + pub artifacts: String, + pub remote: String, + pub created_at: i64, + pub default_branch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Appended { + pub nsid: String, + pub event: serde_json::Value, + // a push seen both through the proxy and on the artifacts queue lands once + #[serde(default)] + pub dedup: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Admit { + pub issuer: String, + pub jti: String, + pub expires_micros: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedToken { + pub artifacts: String, + pub token: String, + pub expires_millis: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingPlc { + pub did: String, + pub operation: serde_json::Value, + pub attempts: i64, + pub next_at_millis: i64, +} + +// a legacy /events frame, field order as the go knot's eventstream.Event +#[derive(Serialize)] +struct Frame<'a> { + rkey: &'a str, + nsid: &'a str, + event: &'a serde_json::value::RawValue, + created: i64, +} + +#[derive(Deserialize)] +struct EventRow { + created: i64, + rkey: String, + nsid: String, + event: String, +} + +#[durable_object] +pub struct KnotState { + state: State, + #[allow(dead_code)] + env: Env, +} + +fn sql(state: &State) -> SqlStorage { + state.storage().sql() +} + +fn migrate(sql: &SqlStorage) -> Result<()> { + for statement in [ + "create table if not exists repos (did text primary key, owner text not null, rkey text not null, name text not null, artifacts text not null, remote text not null, created_at integer not null, default_branch text)", + "create unique index if not exists repos_owner_rkey on repos (owner, rkey)", + "create table if not exists events (created integer primary key, rkey text not null, nsid text not null, event text not null)", + "create table if not exists jti (issuer text not null, jti text not null, expires integer not null, primary key (issuer, jti))", + "create table if not exists tokens (artifacts text primary key, token text not null, expires integer not null)", + "create table if not exists plc (did text primary key, operation text not null, attempts integer not null, next_at integer not null)", + "create table if not exists seen (key text primary key, created integer not null)", + ] { + sql.exec(statement, None)?; + } + Ok(()) +} + +fn tid_of(micros: i64) -> String { + // a TID: 53 bits of microseconds and a zero clock id, base32-sortable + const ALPHABET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; + let value = ((micros as u64) & ((1 << 53) - 1)) << 10; + (0..13) + .rev() + .map(|index| ALPHABET[((value >> (index * 5)) & 31) as usize] as char) + .collect() +} + +fn frame_of(row: &EventRow) -> Option { + let event = serde_json::value::RawValue::from_string(row.event.clone()).ok()?; + serde_json::to_string(&Frame { + rkey: &row.rkey, + nsid: &row.nsid, + event: &event, + created: row.created, + }) + .ok() +} + +fn query<'a>(req: &'a Request, key: &str) -> Option { + req.url() + .ok()? + .query_pairs() + .find(|(name, _)| name == key) + .map(|(_, value)| value.into_owned()) +} + +fn conflict(message: &str) -> Result { + Response::error(message, 409) +} + +impl KnotState { + fn sql(&self) -> SqlStorage { + sql(&self.state) + } + + fn hosted(&self, clause: &str, bindings: Vec) -> Result> { + self.sql() + .exec( + &format!( + "select did, owner, rkey, name, artifacts, remote, created_at, default_branch from repos {clause}" + ), + Some(bindings), + )? + .to_array::() + } + + fn put_repo(&self, hosted: Hosted) -> Result { + if !self.hosted("where did = ?", vec![hosted.did.clone().into()])?.is_empty() { + return conflict("did taken"); + } + if !self + .hosted( + "where owner = ? and rkey = ?", + vec![hosted.owner.clone().into(), hosted.rkey.clone().into()], + )? + .is_empty() + { + return conflict("rkey taken"); + } + self.sql().exec( + "insert into repos (did, owner, rkey, name, artifacts, remote, created_at, default_branch) values (?, ?, ?, ?, ?, ?, ?, ?)", + Some(vec![ + hosted.did.into(), + hosted.owner.into(), + hosted.rkey.into(), + hosted.name.into(), + hosted.artifacts.into(), + hosted.remote.into(), + hosted.created_at.into(), + hosted + .default_branch + .map(SqlStorageValue::from) + .unwrap_or(SqlStorageValue::Null), + ]), + )?; + Response::ok("") + } + + fn append(&self, appended: Appended) -> Result { + if let Some(key) = &appended.dedup { + let seen = self + .sql() + .exec("select key from seen where key = ?", Some(vec![key.clone().into()]))? + .to_array::()?; + if !seen.is_empty() { + return Response::from_json(&serde_json::json!({ "created": 0 })); + } + } + let last: Option = self + .sql() + .exec("select max(created) as created from events", None)? + .to_array::()? + .first() + .and_then(|row| row.get("created")?.as_i64()); + let now_nanos = (now_millis() * 1_000_000.0) as i64; + let created = now_nanos.max(last.unwrap_or(0) + 1); + let row = EventRow { + created, + rkey: tid_of(created / 1000), + nsid: appended.nsid, + event: appended.event.to_string(), + }; + self.sql().exec( + "insert into events (created, rkey, nsid, event) values (?, ?, ?, ?)", + Some(vec![ + row.created.into(), + row.rkey.clone().into(), + row.nsid.clone().into(), + row.event.clone().into(), + ]), + )?; + if let Some(key) = &appended.dedup { + self.sql().exec( + "insert or ignore into seen (key, created) values (?, ?)", + Some(vec![key.clone().into(), created.into()]), + )?; + } + if let Some(frame) = frame_of(&row) { + self.state.get_websockets().iter().for_each(|socket| { + let _ = socket.send_with_str(&frame); + }); + } + Response::from_json(&serde_json::json!({ "created": created })) + } + + fn replay(&self, socket: &WebSocket, cursor: i64) -> Result<()> { + let mut after = cursor; + loop { + let rows = self + .sql() + .exec( + "select created, rkey, nsid, event from events where created > ? order by created asc limit ?", + Some(vec![after.into(), (REPLAY_BATCH as i64).into()]), + )? + .to_array::()?; + let Some(last) = rows.last() else { + return Ok(()); + }; + after = last.created; + rows.iter() + .filter_map(frame_of) + .try_for_each(|frame| socket.send_with_str(frame))?; + if rows.len() < REPLAY_BATCH { + return Ok(()); + } + } + } + + fn admit(&self, admit: Admit) -> Result { + let now_micros = (now_millis() * 1000.0) as i64; + self.sql().exec( + "delete from jti where expires < ?", + Some(vec![now_micros.into()]), + )?; + let seen = self + .sql() + .exec( + "select issuer from jti where issuer = ? and jti = ?", + Some(vec![admit.issuer.clone().into(), admit.jti.clone().into()]), + )? + .to_array::()?; + if !seen.is_empty() { + return conflict("replayed"); + } + self.sql().exec( + "insert into jti (issuer, jti, expires) values (?, ?, ?)", + Some(vec![ + admit.issuer.into(), + admit.jti.into(), + admit.expires_micros.into(), + ]), + )?; + Response::ok("") + } +} + +impl DurableObject for KnotState { + fn new(state: State, env: Env) -> Self { + let _ = migrate(&sql(&state)); + Self { state, env } + } + + async fn fetch(&self, mut req: Request) -> Result { + let path = req.path(); + match (req.method(), path.as_str()) { + (worker::Method::Get, "/repos/get") => { + let did = query(&req, "did").unwrap_or_default(); + Response::from_json(&self.hosted("where did = ?", vec![did.into()])?.pop()) + } + (worker::Method::Get, "/repos/resolve") => { + let owner = query(&req, "owner").unwrap_or_default(); + let rkey = query(&req, "rkey").unwrap_or_default(); + Response::from_json( + &self + .hosted("where owner = ? and rkey = ?", vec![owner.into(), rkey.into()])? + .pop(), + ) + } + (worker::Method::Get, "/repos/by-name") => { + let owner = query(&req, "owner").unwrap_or_default(); + let name = query(&req, "name").unwrap_or_default(); + // an rkey match first, then the oldest repo carrying that name + let by_rkey = self.hosted( + "where owner = ? and rkey = ?", + vec![owner.clone().into(), name.clone().into()], + )?; + let found = match by_rkey.into_iter().next() { + Some(found) => Some(found), + None => self + .hosted( + "where owner = ? and name = ? order by created_at asc limit 1", + vec![owner.into(), name.into()], + )? + .pop(), + }; + Response::from_json(&found) + } + (worker::Method::Get, "/repos/by-artifacts") => { + let artifacts = query(&req, "artifacts").unwrap_or_default(); + Response::from_json(&self.hosted("where artifacts = ?", vec![artifacts.into()])?.pop()) + } + (worker::Method::Get, "/repos/list") => { + let dids: Vec = self + .hosted("order by did asc", Vec::new())? + .into_iter() + .map(|hosted| hosted.did) + .collect(); + Response::from_json(&dids) + } + (worker::Method::Post, "/repos/put") => self.put_repo(req.json().await?), + (worker::Method::Post, "/repos/delete") => { + let did: String = req.json().await?; + self.sql() + .exec("delete from repos where did = ?", Some(vec![did.into()]))?; + Response::ok("") + } + (worker::Method::Post, "/repos/default-branch") => { + let (did, branch): (String, String) = req.json().await?; + self.sql().exec( + "update repos set default_branch = ? where did = ?", + Some(vec![branch.into(), did.into()]), + )?; + Response::ok("") + } + (worker::Method::Post, "/events/append") => self.append(req.json().await?), + (worker::Method::Get, "/events") => { + let cursor = query(&req, "cursor") + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(0); + let pair = WebSocketPair::new()?; + self.state.accept_web_socket(&pair.server); + self.replay(&pair.server, cursor)?; + Response::from_websocket(pair.client) + } + (worker::Method::Post, "/jti/admit") => self.admit(req.json().await?), + (worker::Method::Get, "/tokens/get") => { + let artifacts = query(&req, "artifacts").unwrap_or_default(); + let now = now_millis() as i64; + let cached = self + .sql() + .exec( + "select artifacts, token, expires as expires_millis from tokens where artifacts = ? and expires > ?", + Some(vec![artifacts.into(), (now + 60_000).into()]), + )? + .to_array::()? + .pop(); + Response::from_json(&cached) + } + (worker::Method::Post, "/tokens/put") => { + let cached: CachedToken = req.json().await?; + self.sql().exec( + "insert or replace into tokens (artifacts, token, expires) values (?, ?, ?)", + Some(vec![ + cached.artifacts.into(), + cached.token.into(), + cached.expires_millis.into(), + ]), + )?; + Response::ok("") + } + (worker::Method::Post, "/plc/put") => { + let pending: PendingPlc = req.json().await?; + self.sql().exec( + "insert or replace into plc (did, operation, attempts, next_at) values (?, ?, ?, ?)", + Some(vec![ + pending.did.into(), + pending.operation.to_string().into(), + pending.attempts.into(), + pending.next_at_millis.into(), + ]), + )?; + Response::ok("") + } + (worker::Method::Get, "/plc/due") => { + let now = now_millis() as i64; + let due: Vec = self + .sql() + .exec( + "select did, operation, attempts, next_at as next_at_millis from plc where next_at <= ?", + Some(vec![now.into()]), + )? + .to_array()?; + Response::from_json(&due) + } + (worker::Method::Post, "/plc/done") => { + let did: String = req.json().await?; + self.sql() + .exec("delete from plc where did = ?", Some(vec![did.into()]))?; + Response::ok("") + } + _ => Response::error("not found", 404), + } + } + + async fn websocket_message(&self, socket: WebSocket, message: WebSocketIncomingMessage) -> Result<()> { + if let WebSocketIncomingMessage::String(text) = message + && text == "ping" + { + socket.send_with_str("pong")?; + } + Ok(()) + } + + async fn websocket_close(&self, _socket: WebSocket, _code: usize, _reason: String, _clean: bool) -> Result<()> { + Ok(()) + } + + async fn websocket_error(&self, _socket: WebSocket, _error: worker::Error) -> Result<()> { + Ok(()) + } +} diff --git a/knot2/worker/src/transport.rs b/knot2/worker/src/transport.rs new file mode 100644 index 000000000..cdad9f3b9 --- /dev/null +++ b/knot2/worker/src/transport.rs @@ -0,0 +1,72 @@ +// knot-runtime's http seam over the workers fetch api, so knot-atproto's did +// resolution and plc submission run unchanged. the isolate is single-threaded; +// SendWrapper only satisfies the trait's Send bound. + +use bytes::Bytes; +use http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use knot_runtime::{HttpFuture, HttpRequest, HttpResponse, HttpTransport, NetworkError}; +use send_wrapper::SendWrapper; +use worker::{Fetch, Headers, Method, Request, RequestInit}; + +#[derive(Clone, Copy, Default)] +pub struct WorkerHttp; + +fn method_of(method: &http::Method) -> Method { + match *method { + http::Method::POST => Method::Post, + http::Method::PUT => Method::Put, + http::Method::DELETE => Method::Delete, + http::Method::HEAD => Method::Head, + http::Method::PATCH => Method::Patch, + _ => Method::Get, + } +} + +async fn send(request: HttpRequest) -> Result { + let headers = Headers::new(); + for (name, value) in request.headers.iter() { + let value = value + .to_str() + .map_err(|error| NetworkError::Build(error.to_string()))?; + headers + .append(name.as_str(), value) + .map_err(|error| NetworkError::Build(error.to_string()))?; + } + let mut init = RequestInit::new(); + init.with_method(method_of(&request.method)).with_headers(headers); + if let Some(body) = request.body { + init.with_body(Some(js_sys::Uint8Array::from(body.as_ref()).into())); + } + let outgoing = Request::new_with_init(request.url.as_str(), &init) + .map_err(|error| NetworkError::Build(error.to_string()))?; + let mut response = Fetch::Request(outgoing) + .send() + .await + .map_err(|error| NetworkError::Connect(error.to_string()))?; + let status = StatusCode::from_u16(response.status_code()) + .map_err(|error| NetworkError::Request(error.to_string()))?; + let mut headers = HeaderMap::new(); + for (name, value) in response.headers().entries() { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(&value), + ) { + headers.append(name, value); + } + } + let body = response + .bytes() + .await + .map_err(|error| NetworkError::Body(error.to_string()))?; + Ok(HttpResponse { + status, + headers, + body: Bytes::from(body), + }) +} + +impl HttpTransport for WorkerHttp { + fn execute(&self, request: HttpRequest) -> HttpFuture { + Box::pin(SendWrapper::new(send(request))) + } +} diff --git a/knot2/worker/wrangler.jsonc b/knot2/worker/wrangler.jsonc new file mode 100644 index 000000000..8523df8d2 --- /dev/null +++ b/knot2/worker/wrangler.jsonc @@ -0,0 +1,27 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "knot", + "main": "build/worker/shim.mjs", + "compatibility_date": "2026-10-01", + "build": { + "command": "worker-build --release" + }, + "artifacts": [{ "binding": "ARTIFACTS", "namespace": "knot" }], + "durable_objects": { + "bindings": [{ "name": "KNOT_STATE", "class_name": "KnotState" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["KnotState"] }], + "queues": { + "consumers": [{ "queue": "knot-artifacts-events", "max_batch_size": 50 }] + }, + "triggers": { "crons": ["* * * * *"] }, + "vars": { + "KNOT_HOSTNAME": "knot.example.com", + "KNOT_ADMINS": "", + "KNOT_PLC_URL": "https://plc.directory", + "KNOT_OBJECT_FORMAT": "sha1", + "KNOT_OPEN_ADMISSION": "false" + } + // secrets: KNOT_SIGNING_KEY (hex secp256k1); KNOT_REMOTE_TOKEN only for + // repos registered against a non-artifacts remote +} -- 2.51.2