This repository has no description
1mod template;
2
3use confique::Config;
4
5pub use template::{Key, Line, Lines, NoKeys, Segment, Shape, Template, TemplateError};
6
7// Each msg field defines the enum of placeholders it accepts,
8// so a typo'ed `{handel}` in a given config gets caught at startup,
9// rather than printed at some poor pusher mid-push.
10macro_rules! keys {
11 ( $( $name:ident { $( $variant:ident = $placeholder:literal ),+ $(,)? } )+ ) => {
12 $(
13 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14 pub enum $name {
15 $( $variant, )+
16 }
17
18 impl Key for $name {
19 const PLACEHOLDERS: &'static [(&'static str, Self)] =
20 &[ $( ($placeholder, Self::$variant), )+ ];
21 }
22 )+
23 };
24}
25
26macro_rules! config_type {
27 (Lines) => { Vec<String> };
28 (Line) => { String };
29}
30
31macro_rules! parse_field {
32 (Lines, $field:expr, $value:expr) => {
33 Template::parse_lines($field, $value)
34 };
35 (Line, $field:expr, $value:expr) => {
36 Template::parse($field, $value)
37 };
38}
39
40macro_rules! message_group {
41 (
42 $config:ident => $catalog:ident @ $prefix:literal {
43 $( $field:ident : $shape:ident<$keys:ty> = $default:tt ),+ $(,)?
44 }
45 ) => {
46 #[derive(Debug, ::confique::Config)]
47 pub struct $config {
48 $(
49 #[config(default = $default)]
50 pub $field: config_type!($shape),
51 )+
52 }
53
54 #[derive(Debug)]
55 pub struct $catalog {
56 $( pub $field: Template<$keys, $shape>, )+
57 }
58
59 impl $catalog {
60 pub fn parse(config: &$config) -> Result<Self, TemplateError> {
61 Ok(Self {
62 $(
63 $field: parse_field!(
64 $shape,
65 concat!($prefix, ".", stringify!($field)),
66 &config.$field
67 )?,
68 )+
69 })
70 }
71 }
72 };
73}
74
75keys! {
76 KnotKey { Knot = "knot" }
77 PushAckKey { Knot = "knot", Refs = "refs" }
78 UrlKey { Url = "url" }
79 CiLogsKey { Host = "host", Port = "port", Repo = "repo", Sha = "sha" }
80 GreetingKey { User = "user", Knot = "knot" }
81 AuthorizedKey { Authorized = "authorized" }
82 CountKey { Count = "count" }
83 RefKey { Ref = "ref" }
84 ErrorKey { Error = "error" }
85 CommandKey { Command = "command" }
86 VersionKey { Version = "version" }
87 AlgorithmKey { Algorithm = "algorithm" }
88 ValueKey { Value = "value" }
89 OidKey { Oid = "oid" }
90 DetailKey { Detail = "detail" }
91 DeclaredComputedKey { Declared = "declared", Computed = "computed" }
92 DeclaredReceivedKey { Declared = "declared", Received = "received" }
93 DeclaredLimitKey { Declared = "declared", Limit = "limit" }
94 FreeFloorKey { Free = "free", Floor = "floor" }
95 WhatLimitKey { What = "what", Limit = "limit" }
96}
97
98message_group! {
99 PushConfig => PushMessages @ "messages.push" {
100 ack: Lines<PushAckKey> = ["{knot} received {refs}."],
101 pull_request: Lines<UrlKey> = [
102 "",
103 "-> Open stinky pull request for this branch:",
104 " {url}",
105 ""
106 ],
107 pipeline_clean: Lines<NoKeys> = ["pipeline compiled with no diagnostics"],
108 pipeline_none: Lines<NoKeys> = ["no pipelines to compile"],
109 ci_logs: Lines<CiLogsKey> = [
110 "-> Browse CI logs in your terminal:",
111 " ssh -t -p {port} {host} {repo} {sha}"
112 ],
113 }
114}
115
116message_group! {
117 FetchConfig => FetchMessages @ "messages.fetch" {
118 motd: Lines<KnotKey> = ["Thanks for using {knot}!"],
119 enumerating: Lines<CountKey> = ["Enumerating objects: {count}, done."],
120 total: Lines<CountKey> = ["Total {count}, done."],
121 fatal: Line<ErrorKey> = "knot: {error}",
122 }
123}
124
125message_group! {
126 RejectConfig => RejectMessages @ "messages.reject" {
127 reserved_refs: Line<NoKeys> = "refs/cobs/* and refs/hidden/* are reserved and cannot be pushed",
128 cob_create_only: Line<NoKeys> = "existing refs/cobs/* object cannot be modified or deleted over the wire",
129 cob_delete: Line<NoKeys> = "refs/cobs/* stores append-only collaborative objects and cannot be deleted",
130 hidden_reserved: Line<NoKeys> = "refs/hidden/* is reserved for server-side fork staging and cannot be pushed",
131 cob_verification: Line<ErrorKey> = "collaborative-object verification failed: {error}",
132 ref_exists: Line<NoKeys> = "reference already exists",
133 stale_old_value: Line<NoKeys> = "stale info: old value doesn't match",
134 missing_objects: Line<NoKeys> = "missing necessary objects",
135 missing_objects_for: Line<RefKey> = "missing necessary objects for {ref}",
136 atomic_failed: Line<NoKeys> = "atomic transaction failed",
137 atomic_aborted: Line<NoKeys> = "atomic push aborted",
138 authorization_unavailable: Line<NoKeys> = "authorization unavailable",
139 unpacker_error: Line<NoKeys> = "unpacker error",
140 ref_snapshot_unavailable: Line<NoKeys> = "ref snapshot unavailable",
141 object_migration_failed: Line<NoKeys> = "object migration failed",
142 }
143}
144
145message_group! {
146 SshConfig => SshMessages @ "messages.ssh" {
147 greeting: Lines<GreetingKey> = [
148 "Hi {user}! You're authenticated to {knot} knot.",
149 "This knot serves git over ssh, so there's no shell here. :P",
150 "Clone repo with: git clone {knot}:<repoDID>"
151 ],
152 greeting_unknown: Lines<KnotKey> = [
153 "Hi there! This is the {knot} knot.",
154 "This knot serves git over ssh, so there's no shell here. :P",
155 "Clone repo with: git clone {knot}:<repoDID>",
156 "Publish your ssh key to your atproto account so this knot can identify your pushes.",
157 "Put your handle in the url, as in yourhandle@{knot}:<repoDID>, so your ssh client can find your registered key on its own."
158 ],
159 unsupported_command: Line<NoKeys> = "knot: unsupported command",
160 too_many_operations: Line<NoKeys> = "knot: too many concurrent operations from your address, try again shortly",
161 repo_not_found: Line<NoKeys> = "knot: repository not found",
162 index_warming: Line<NoKeys> = "knot: repository index is warming, retry shortly",
163 lfs_disabled: Line<NoKeys> = "knot: LFS isn't enabled on this knot",
164 key_not_registered: Line<AuthorizedKey> = "knot: this ssh key doesn't match any key published by the accounts that may push here. Authorized: {authorized}. If your agent offers several keys, add -o IdentitiesOnly=yes so it offers your registered key.",
165 identity_unavailable: Line<NoKeys> = "knot: couldn't read the account records needed to check your ssh key, retry shortly",
166 push_denied: Line<NoKeys> = "knot: you aren't authorized to push to this repository.",
167 shutting_down: Line<NoKeys> = "knot: server is shutting down",
168 archive_malformed: Line<NoKeys> = "knot: malformed upload-archive request",
169 archive_timeout: Line<NoKeys> = "knot: upload-archive request timed out",
170 archive_failed: Line<NoKeys> = "knot: upload-archive failed",
171 advertise_failed: Line<NoKeys> = "knot: cannot advertise refs",
172 push_too_large: Line<NoKeys> = "knot: push exceeds configured size limit",
173 receive_deadline: Line<NoKeys> = "knot: receive exceeded its time budget",
174 malformed_pack: Line<NoKeys> = "knot: malformed pack stream",
175 receive_read_error: Line<NoKeys> = "knot: receive read error",
176 receive_ended_early: Line<NoKeys> = "knot: receive stream ended early",
177 receive_failed: Line<NoKeys> = "knot: receive-pack failed",
178 }
179}
180
181message_group! {
182 HttpConfig => HttpMessages @ "messages.http" {
183 push_denied: Line<NoKeys> = "you aren't authorized to push to this repository",
184 repo_not_found: Line<NoKeys> = "repository not found",
185 push_too_large: Line<NoKeys> = "push exceeds the configured size limit",
186 malformed_pack: Line<ErrorKey> = "malformed pack stream: {error}",
187 receive_ended_early: Line<NoKeys> = "receive stream ended early",
188 }
189}
190
191message_group! {
192 LfsConfig => LfsMessages @ "messages.lfs" {
193 invalid_oid: Line<ValueKey> = "invalid LFS oid {value}",
194 hash_mismatch: Line<DeclaredComputedKey> = "oid mismatch, declared {declared}, computed {computed}",
195 size_mismatch: Line<DeclaredReceivedKey> = "size mismatch, declared {declared}, received {received}",
196 size_limit_exceeded: Line<DeclaredLimitKey> = "object size {declared} exceeds limit {limit}",
197 free_space_denied: Line<FreeFloorKey> = "free space {free} below floor {floor}",
198 not_found: Line<OidKey> = "object {oid} not found",
199 framing: Line<DetailKey> = "protocol framing fault: {detail}",
200 too_many: Line<WhatLimitKey> = "too many {what} in one message, limit {limit}",
201 unknown_command: Line<CommandKey> = "unknown command {command}",
202 unsupported_version: Line<VersionKey> = "unsupported version {version}",
203 unsupported_hash: Line<AlgorithmKey> = "unsupported hash algorithm {algorithm}",
204 put_on_download: Line<NoKeys> = "put-object isn't allowed on a download channel",
205 verify_on_download: Line<NoKeys> = "verify-object isn't allowed on a download channel",
206 get_on_upload: Line<NoKeys> = "get-object isn't allowed on an upload channel",
207 put_no_body: Line<NoKeys> = "put-object is missing its object body",
208 }
209}
210
211#[derive(Debug, Config)]
212pub struct MessagesConfig {
213 #[config(nested)]
214 pub push: PushConfig,
215 #[config(nested)]
216 pub fetch: FetchConfig,
217 #[config(nested)]
218 pub reject: RejectConfig,
219 #[config(nested)]
220 pub ssh: SshConfig,
221 #[config(nested)]
222 pub http: HttpConfig,
223 #[config(nested)]
224 pub lfs: LfsConfig,
225}
226
227impl MessagesConfig {
228 pub fn defaults() -> Self {
229 Self::builder()
230 .load()
231 .expect("message defaults satisfy every field")
232 }
233}
234
235#[derive(Debug)]
236pub struct Catalog {
237 pub push: PushMessages,
238 pub fetch: FetchMessages,
239 pub reject: RejectMessages,
240 pub ssh: SshMessages,
241 pub http: HttpMessages,
242 pub lfs: LfsMessages,
243}
244
245impl Catalog {
246 pub fn parse(config: &MessagesConfig) -> Result<Self, TemplateError> {
247 Ok(Self {
248 push: PushMessages::parse(&config.push)?,
249 fetch: FetchMessages::parse(&config.fetch)?,
250 reject: RejectMessages::parse(&config.reject)?,
251 ssh: SshMessages::parse(&config.ssh)?,
252 http: HttpMessages::parse(&config.http)?,
253 lfs: LfsMessages::parse(&config.lfs)?,
254 })
255 }
256
257 pub fn defaults() -> Self {
258 Self::parse(&MessagesConfig::defaults()).expect("built-in message templates parse")
259 }
260}
261
262pub fn default_catalog() -> &'static Catalog {
263 static DEFAULTS: std::sync::LazyLock<Catalog> = std::sync::LazyLock::new(Catalog::defaults);
264 &DEFAULTS
265}
266
267pub fn count_refs(applied: usize) -> String {
268 match applied {
269 1 => "1 ref".to_string(),
270 n => format!("{n} refs"),
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn the_defaults_parse_into_a_full_catalog() {
280 let catalog = Catalog::defaults();
281 assert_eq!(catalog.reject.ref_exists.text(), "reference already exists");
282 assert_eq!(
283 catalog.ssh.repo_not_found.text(),
284 "knot: repository not found"
285 );
286 }
287
288 #[test]
289 fn the_pull_request_block_matches_the_shipped_shape() {
290 let catalog = Catalog::defaults();
291 let url = "https://oyster.cafe/nel.pet/anemone/pulls/new";
292 let block = catalog
293 .push
294 .pull_request
295 .lines(|UrlKey::Url| url.to_string());
296 assert_eq!(
297 block,
298 vec![
299 "\u{200b}".to_string(),
300 "-> Open stinky pull request for this branch:".to_string(),
301 format!(" {url}"),
302 "\u{200b}".to_string(),
303 ]
304 );
305 }
306
307 #[test]
308 fn the_greeting_names_the_user_and_the_knot() {
309 let catalog = Catalog::defaults();
310 let lines = catalog.ssh.greeting.lines(|key| match key {
311 GreetingKey::User => "@nel.pet".to_string(),
312 GreetingKey::Knot => "oyster.cafe".to_string(),
313 });
314 assert!(lines[0].contains("@nel.pet"));
315 assert!(lines.iter().any(|line| line.contains("oyster.cafe")));
316 }
317
318 #[test]
319 fn an_unidentified_visitor_is_greeted_and_shown_what_a_push_needs() {
320 let catalog = Catalog::defaults();
321 let lines = catalog
322 .ssh
323 .greeting_unknown
324 .lines(|KnotKey::Knot| "oyster.cafe".to_string());
325 assert!(lines[0].contains("oyster.cafe"));
326 assert!(
327 lines.iter().any(|line| line.contains("ssh key")),
328 "a visitor the knot can't identify learns what a push needs: {lines:?}"
329 );
330
331 let denial = catalog
332 .ssh
333 .key_not_registered
334 .line(|AuthorizedKey::Authorized| "@nel.pet".to_string());
335 assert!(
336 denial.contains("@nel.pet"),
337 "the denial lists who may push instead: {denial}"
338 );
339 }
340
341 #[test]
342 fn an_empty_lines_template_mutes_the_message() {
343 let template: Template<NoKeys, Lines> =
344 Template::parse_lines("messages.test", &[]).unwrap();
345 assert!(template.text_lines().is_empty());
346 }
347
348 #[test]
349 fn an_unknown_placeholder_is_a_parse_error() {
350 let error = Template::<KnotKey, Lines>::parse_lines(
351 "messages.fetch.motd",
352 &["hi {handle}".to_string()],
353 )
354 .unwrap_err();
355 assert_eq!(
356 error,
357 TemplateError::UnknownPlaceholder {
358 field: "messages.fetch.motd",
359 name: "handle".to_string(),
360 }
361 );
362 }
363
364 #[test]
365 fn doubled_braces_render_as_literal_braces() {
366 let template: Template<NoKeys, Line> =
367 Template::parse("messages.test", "a {{literal}} brace").unwrap();
368 assert_eq!(template.text(), "a {literal} brace");
369 }
370
371 #[test]
372 fn line_templates_reject_empty_and_multiline_text() {
373 assert_eq!(
374 Template::<NoKeys, Line>::parse("messages.test", "").unwrap_err(),
375 TemplateError::Empty {
376 field: "messages.test"
377 }
378 );
379 assert_eq!(
380 Template::<NoKeys, Line>::parse("messages.test", "a\nb").unwrap_err(),
381 TemplateError::Multiline {
382 field: "messages.test"
383 }
384 );
385 }
386
387 #[test]
388 fn unbalanced_braces_are_parse_errors() {
389 assert_eq!(
390 Template::<NoKeys, Line>::parse("messages.test", "open {").unwrap_err(),
391 TemplateError::UnclosedBrace {
392 field: "messages.test"
393 }
394 );
395 assert_eq!(
396 Template::<NoKeys, Line>::parse("messages.test", "close }").unwrap_err(),
397 TemplateError::StrayBrace {
398 field: "messages.test"
399 }
400 );
401 }
402
403 #[test]
404 fn ref_counts_pluralize() {
405 assert_eq!(count_refs(1), "1 ref");
406 assert_eq!(count_refs(3), "3 refs");
407 }
408}