···44 "crates/credential_helper",55 "crates/identity",66 "crates/knot",77+ "crates/lexicon",78 "crates/oauth",89 "crates/serve_git",910 "crates/xrpc"···21202221[workspace.dependencies]2322identity = { path = "crates/identity" }2323+lexicon = { path = "crates/lexicon" }2424oauth = { path = "crates/oauth" }2525serve_git = { path = "crates/serve_git"}2626xrpc = { path = "crates/xrpc" }27272828anyhow = "1.0.100"2929axum = "0.8.4"3030-gix = { version = "0.73.0", features = ["max-performance"] }3030+data-encoding = "2.9.0"3131+gix = { version = "0.74.0", features = ["max-performance"] }3132reqwest = { version = "0.12.23", features = ["json"] }3233serde = { version = "1.0.226", features = ["derive"] }3334serde_json = "1.0.145"3435thiserror = "2.0.16"3636+time = { version = "0.3.43", features = ["formatting", "macros", "parsing", "serde"] }3537tracing = "0.1.41"3638url = { version = "2.5.7", features = ["serde"] }3739
+3-2
crates/knot/Cargo.toml
···10101111[dependencies]1212identity.workspace = true1313+lexicon.workspace = true1314oauth.workspace = true1415serve_git.workspace = true1516xrpc.workspace = true···2827axum-extra = { version = "0.10.1", features = ["async-read-body"] }2928bytes = "1.10.1"3029clap = { version = "4.5.47", features = ["derive", "env", "string"] }3131-data-encoding = "2.9.0"3030+data-encoding.workspace = true3231futures-util = "0.3.31"3332hyper-util = { version = "0.1.17", features = ["client"] }3433rustc-hash = "2.1.1"3535-time = { version = "0.3.43", features = ["formatting", "macros", "parsing", "serde"] }3434+time.workspace = true3635tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "process", "signal", "rt-multi-thread"] }3736tokio-stream = { version = "0.1.17", features = ["time"] }3837tokio-tungstenite = "0.28.0"
-17
crates/knot/src/convert.rs
···11-//!22-//! Utility conversions.33-//!44-use gix::date::Time;55-use time::OffsetDateTime;66-use time::UtcOffset;77-use time::error::ComponentRange;88-99-/// Convert a [`gix::date::Time`] to a [`time::OffsetDateTime`].1010-///1111-#[allow(unused)]1212-pub fn time_to_offsetdatetime(time: &Time) -> Result<OffsetDateTime, ComponentRange> {1313- let odt = OffsetDateTime::from_unix_timestamp(time.seconds)?1414- .to_offset(UtcOffset::from_whole_seconds(time.offset)?);1515-1616- Ok(odt)1717-}
-6
crates/knot/src/lib.rs
···11pub(crate) mod atproto;22-pub(crate) mod convert;32pub mod model;43pub mod public;54pub mod types;66-77-mod objectid;88-pub use objectid::ObjectId;99-1010-pub mod repospec;
···11+use serde::{Deserialize, Serialize};22+use std::str::FromStr;33+44+/// Repository path specifier.55+#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]66+#[serde(try_from = "UnvalidatedRepoPath")]77+pub struct RepoPath {88+ /// Repository owner99+ pub owner: Box<str>,1010+1111+ /// Repository name1212+ pub name: Box<str>,1313+}1414+1515+#[derive(Deserialize)]1616+struct UnvalidatedRepoPath {1717+ owner: Box<str>,1818+ name: Box<str>,1919+}2020+2121+impl TryFrom<UnvalidatedRepoPath> for RepoPath {2222+ type Error = RepoPathError;2323+2424+ #[inline]2525+ fn try_from(2626+ UnvalidatedRepoPath { owner, name }: UnvalidatedRepoPath,2727+ ) -> Result<Self, Self::Error> {2828+ Self::from_parts(owner, name)2929+ }3030+}3131+3232+impl RepoPath {3333+ pub fn from_parts(3434+ owner: impl Into<Box<str>>,3535+ name: impl Into<Box<str>>,3636+ ) -> Result<Self, RepoPathError> {3737+ fn inner(owner: Box<str>, name: Box<str>) -> Result<RepoPath, RepoPathError> {3838+ validate(owner.as_ref())?;3939+ validate(name.as_ref())?;4040+ Ok(RepoPath { owner, name })4141+ }4242+ inner(owner.into(), name.into())4343+ }4444+4545+ pub const fn owner(&self) -> &str {4646+ &self.owner4747+ }4848+4949+ pub const fn name(&self) -> &str {5050+ &self.name5151+ }5252+}5353+5454+impl std::fmt::Display for RepoPath {5555+ #[inline]5656+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {5757+ write!(f, "{}/{}", self.owner, self.name)5858+ }5959+}6060+6161+impl FromStr for RepoPath {6262+ type Err = RepoPathError;6363+6464+ fn from_str(s: &str) -> Result<Self, Self::Err> {6565+ let Some((owner, name)) = s.split_once('/') else {6666+ return Err(RepoPathError::Format);6767+ };6868+6969+ validate(owner)?;7070+ validate(name)?;7171+7272+ Ok(Self {7373+ owner: owner.into(),7474+ name: name.into(),7575+ })7676+ }7777+}7878+7979+pub mod string {8080+ //! Serialize/Deserialize a `RepoSpec` from a string.8181+ //!8282+ //! # Example8383+ //!8484+ //! ```rust,no_run8585+ //! # use lexicon::extra::repopath::RepoPath;8686+ //! #[derive(serde::Deserialize)]8787+ //! struct Params {8888+ //! #[serde(with = "lexicon::extra::repopath::string")]8989+ //! repo: RepoPath,9090+ //! }9191+ //! ```9292+ //!9393+ use super::RepoPath;9494+ use serde::{Deserialize, Deserializer, Serializer};9595+ use std::{borrow::Cow, str::FromStr};9696+9797+ pub fn deserialize<'de, D>(deserializer: D) -> Result<RepoPath, D::Error>9898+ where9999+ D: Deserializer<'de>,100100+ {101101+ let s = <Cow<str>>::deserialize(deserializer)?;102102+ let repo = RepoPath::from_str(&s).map_err(serde::de::Error::custom)?;103103+ Ok(repo)104104+ }105105+106106+ #[allow(unused)]107107+ pub fn serialize<S>(v: &RepoPath, serializer: S) -> Result<S::Ok, S::Error>108108+ where109109+ S: Serializer,110110+ {111111+ serializer.serialize_str(&format!("{}/{}", v.owner(), v.name()))112112+ }113113+}114114+115115+fn validate(name: &str) -> Result<(), RepoPathError> {116116+ static FORBIDDEN_SUBSTRINGS: &[&str] = &[".."];117117+ static FORBIDDEN_PREFIXES: &[&str] = &[".", "-"];118118+ static ACCEPTED_SYMBOLS: &[char] = &['.', '-', '_', ':'];119119+120120+ if name.is_empty() {121121+ return Err(RepoPathError::Empty);122122+ }123123+124124+ for value in name.chars() {125125+ if !(value.is_ascii_alphanumeric() || ACCEPTED_SYMBOLS.contains(&value)) {126126+ return Err(RepoPathError::Character { value });127127+ }128128+ }129129+130130+ for substring in FORBIDDEN_SUBSTRINGS {131131+ if name.contains(substring) {132132+ return Err(RepoPathError::Substring { substring });133133+ }134134+ }135135+136136+ for prefix in FORBIDDEN_PREFIXES {137137+ if name.starts_with(prefix) {138138+ return Err(RepoPathError::Prefix { prefix });139139+ }140140+ }141141+142142+ Ok(())143143+}144144+145145+#[derive(Debug, thiserror::Error)]146146+pub enum RepoPathError {147147+ #[error("expected a repository specifier in form '{{owner}}/{{name}}'")]148148+ Format,149149+ #[error("repository name cannot be empty")]150150+ Empty,151151+ #[error("invalid character in repository name: '{value}'")]152152+ Character { value: char },153153+ #[error("invalid sub-string in repository name: '{substring}'")]154154+ Substring { substring: &'static str },155155+ #[error("invalid prefix for respository name: '{prefix}'")]156156+ Prefix { prefix: &'static str },157157+}
+11
crates/lexicon/src/lib.rs
···11+//!22+//! Artisinal, hand-written Lexicon definitions.33+//!44+//! When you're too lazy to setup codegen, not lazy enough to not write them55+//! all out manually.66+//!77+pub mod sh;88+99+/// Types which aren't part of the lexicon, but are used for serializing1010+/// and deserializing.1111+pub mod extra;
···11+use crate::extra::{22+ objectid::{Hex, ObjectId},33+ repopath::RepoPath,44+};55+use serde::{Deserialize, Serialize};66+use time::OffsetDateTime;77+88+use super::refs;99+1010+#[derive(Debug, Deserialize)]1111+pub struct Input {1212+ /// Repository identifier in the format `did:plc:.../repoName`1313+ #[serde(with = "crate::extra::repopath::string")]1414+ pub repo: RepoPath,1515+}1616+1717+/// Response type for the `sh.tangled.repo.getDefaultBranch` query.1818+//1919+// @NOTE This completely different to what the lexicon describes,2020+// but it is what knotserver outputs.2121+#[derive(Debug, Serialize)]2222+#[serde(rename_all = "camelCase")]2323+pub struct Output {2424+ /// Short-name of the default branch.2525+ pub name: String,2626+2727+ /// ID of the most recent commit on the default branch2828+ //2929+ // @NOTE Official knotserver always returns an empty string.3030+ #[serde(skip_serializing_if = "Option::is_none")]3131+ pub hash: Option<ObjectId>,3232+3333+ /// Timestamp of the most recent commit on the default branch3434+ //3535+ // @NOTE Official knotserver always returns the unix epoch.3636+ #[serde(3737+ with = "time::serde::rfc3339::option",3838+ skip_serializing_if = "Option::is_none"3939+ )]4040+ pub when: Option<OffsetDateTime>,4141+}4242+4343+/// Defined response type for the `sh.tangled.repo.getDefaultBranch` query.4444+//4545+#[derive(Debug, Serialize)]4646+#[serde(rename_all = "camelCase")]4747+pub struct LexiOutput {4848+ /// Default branch name.4949+ pub name: Box<str>,5050+5151+ /// Latest commit hash on default branch.5252+ pub hash: ObjectId<Hex>,5353+5454+ /// Short commit hash.5555+ pub short_hash: Box<str>,5656+5757+ /// Timestamp of the latest commit.5858+ #[serde(with = "time::serde::rfc3339")]5959+ pub when: OffsetDateTime,6060+6161+ /// Latest commit message.6262+ pub message: Option<Box<str>>,6363+6464+ pub author: Option<refs::Signature>,6565+}
+57
crates/lexicon/src/sh/tangled/repo/languages.rs
···11+//!22+//! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/languages.json>33+//!44+use crate::extra::repopath::RepoPath;55+use serde::{Deserialize, Serialize};66+77+#[derive(Debug, Deserialize)]88+pub struct Input {99+ /// Respository identifier in format `did:plc:.../repoName`1010+ #[serde(with = "crate::extra::repopath::string")]1111+ pub repo: RepoPath,1212+1313+ /// Git reference (branch, tag, or commit SHA)1414+ #[serde(rename = "ref")]1515+ pub rev: String,1616+}1717+1818+#[derive(Debug, Default, Serialize)]1919+#[serde(rename_all = "camelCase")]2020+pub struct Output {2121+ /// The git reference used2222+ #[serde(rename = "ref")]2323+ pub rev: String,2424+2525+ #[serde(skip_serializing_if = "Vec::is_empty")]2626+ pub languages: Vec<Language>,2727+2828+ /// Total size of all analyzed files in bytes2929+ #[serde(skip_serializing_if = "Option::is_none")]3030+ pub total_size: Option<u64>,3131+3232+ /// Total number of files analyzed3333+ #[serde(skip_serializing_if = "Option::is_none")]3434+ pub total_files: Option<u64>,3535+}3636+3737+#[derive(Debug, Serialize)]3838+#[serde(rename_all = "camelCase")]3939+pub struct Language {4040+ /// Programming language name4141+ pub name: String,4242+4343+ /// Total size of files in this language (bytes)4444+ pub size: u64,4545+4646+ /// Percentage of total codebase 0..=1004747+ pub percentage: u8,4848+4949+ /// Number of files in this language5050+ pub file_count: u64,5151+5252+ /// Hex color code for this language5353+ pub color: String,5454+5555+ /// File extensions associated with this language5656+ pub extensions: Vec<String>,5757+}
+38
crates/lexicon/src/sh/tangled/repo/log.rs
···11+//!22+//! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/log.json>33+//!44+use crate::extra::repopath::RepoPath;55+use serde::Deserialize;66+use std::path::PathBuf;77+88+/// Parameters for the `sh.tangled.repo.log` query.99+#[derive(Debug, Deserialize)]1010+pub struct Input {1111+ /// Repository identifier in the format `did:plc:.../repoName`1212+ #[serde(with = "crate::extra::repopath::string")]1313+ pub repo: RepoPath,1414+1515+ /// Git reference (branch, tag, or commit SHA)1616+ #[serde(rename = "ref")]1717+ pub rev: Option<String>,1818+1919+ /// Path to filter commits by2020+ pub path: Option<PathBuf>,2121+2222+ /// Maximum number of commits to return.2323+ ///2424+ /// (1..=100); Default: 502525+ #[serde(default = "log_limit_default")]2626+ pub limit: u16,2727+2828+ /// Pagination cursor (commit SHA)2929+ //3030+ // @NOTE AppView actually gives us an integer for the number of3131+ // commits to skip.3232+ #[serde(default)]3333+ pub cursor: usize,3434+}3535+3636+const fn log_limit_default() -> u16 {3737+ 503838+}
+28
crates/lexicon/src/sh/tangled/repo/tags.rs
···11+//!22+//! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/tags.json>33+//!44+use crate::extra::repopath::RepoPath;55+use serde::Deserialize;66+77+/// Parameters for the `sh.tangled.repo.tags` query.88+///99+/// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/tags.json>1010+#[derive(Debug, Deserialize)]1111+pub struct Input {1212+ /// Repository identifier in the format `did:plc:.../repoName`1313+ #[serde(with = "crate::extra::repopath::string")]1414+ pub repo: RepoPath,1515+1616+ /// Maximum number of commits to return.1717+ ///1818+ /// (1..=100); Default: 501919+ #[serde(default = "tag_limit_default")]2020+ pub limit: u16,2121+2222+ /// Pagnination cursor2323+ pub cursor: Option<String>,2424+}2525+2626+const fn tag_limit_default() -> u16 {2727+ 502828+}
+89
crates/lexicon/src/sh/tangled/repo/tree.rs
···11+//!22+//! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/tree.json>33+//!44+use std::{borrow::Cow, path::PathBuf};55+66+use crate::extra::{objectid::ObjectId, repopath::RepoPath};77+use serde::{Deserialize, Serialize};88+use time::OffsetDateTime;99+1010+/// Parameters for the `sh.tangled.repo.tree` query.1111+///1212+/// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/tree.json>1313+#[derive(Debug, Deserialize)]1414+pub struct Input {1515+ /// Repository identifier in the format `did:plc:.../repoName`1616+ #[serde(with = "crate::extra::repopath::string")]1717+ pub repo: RepoPath,1818+1919+ /// Git reference (branch, tag, or commit SHA)2020+ #[serde(rename = "ref")]2121+ pub rev: Option<String>,2222+2323+ /// Path within the repository tree2424+ pub path: Option<PathBuf>,2525+}2626+2727+/// Output of `sh.tangled.repo.tree` query.2828+#[derive(Debug, Serialize)]2929+pub struct Output {3030+ /// The git reference used3131+ #[serde(rename = "ref")]3232+ pub rev: String,3333+3434+ /// The parent path in the tree3535+ #[serde(skip_serializing_if = "Option::is_none")]3636+ pub parent: Option<PathBuf>,3737+3838+ /// Parent directory path3939+ #[serde(skip_serializing_if = "Option::is_none")]4040+ pub dotdot: Option<PathBuf>,4141+4242+ /// Readme for this file tree4343+ #[serde(skip_serializing_if = "Option::is_none")]4444+ pub readme: Option<Readme>,4545+4646+ pub files: Vec<TreeEntry>,4747+}4848+4949+#[derive(Debug, Default, Serialize)]5050+pub struct TreeEntry {5151+ /// Relative file or directory name5252+ pub name: String,5353+5454+ /// File mode5555+ pub mode: Cow<'static, str>,5656+5757+ /// File size in bytes5858+ pub size: usize,5959+6060+ /// Whether this entry is a file6161+ pub is_file: bool,6262+6363+ /// Whether this entry is a directory/subtree6464+ pub is_subtree: bool,6565+6666+ pub last_commit: Option<LastCommit>,6767+}6868+6969+#[derive(Debug, Serialize)]7070+pub struct Readme {7171+ /// Contents of the readme file7272+ pub filename: String,7373+7474+ /// Name of the readme file7575+ pub contents: String,7676+}7777+7878+#[derive(Debug, Serialize)]7979+pub struct LastCommit {8080+ /// Commit hash8181+ pub hash: ObjectId,8282+8383+ /// Commit message8484+ pub message: String,8585+8686+ /// Commit timestamp8787+ #[serde(with = "time::serde::rfc3339")]8888+ pub when: OffsetDateTime,8989+}