Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829//! What atgc's exit status means, and how a failure comes to carry one.//!//! # Why this exists//!//! `main` returned `anyhow::Result<()>`, so every failure atgc has ever had//! exited `1`. A caller could tell that something went wrong and nothing else://! *not logged in*, *no such pull*, *the knot is down* and *the record moved//! under us* were one status, distinguishable only by matching on prose that//! is written for people and changes when the wording improves.//!//! Each of those wants a different next move, and for a tool this often//! driven by scripts and agents, "which next move" is the answer worth//! returning://!//! | code | [`Exit`] | what a caller should do |//! |---|---|---|//! | 0 | [`Ok`](Exit::Ok) | nothing; it worked |//! | 1 | [`Failure`](Exit::Failure) | read the message — it is not one of the below |//! | 2 | [`Usage`](Exit::Usage) | fix the command line; do not retry it |//! | 3 | [`NoSession`](Exit::NoSession) | `atgc auth login` |//! | 4 | [`Denied`](Exit::Denied) | a different account, or log in again for the scope |//! | 5 | [`NotFound`](Exit::NotFound) | check the identifier; it resolved to nothing |//! | 6 | [`Unreachable`](Exit::Unreachable) | retry later; a host did not answer |//! | 7 | [`Conflict`](Exit::Conflict) | re-read and try again; state moved underneath |//! | 8 | [`Bug`](Exit::Bug) | report it; atgc panicked and the message is a bug report |//!//! `2` is not a free choice: clap exits `2` on a parse error by itself, before//! any of this runs. Naming it here is what stops something else being given//! that number and two unrelated failures sharing a status.//!//! # How a failure gets a code//!//! Not by matching on its message. [`fail`] builds an error that *is* its//! message and also carries a code, and [`classify`] asks for the code back. A//! failure that carries none still exits `1`, exactly as it did before, so//! this is additive rather than a rule every call site now has to obey at//! once.//!//! The code rides inside one error rather than as a separate link in the//! chain, and that is not a detail. Both obvious alternatives break the//! message: a marker error carried *above* it makes `err.to_string()` the//! empty string, so every caller formatting with `{e}` prints a blank line//! instead of the reason; carried *below* it, `{e:#}` joins the marker's empty//! `Display` in and leaves a trailing `": "`. Both matter here — `pr read`,//! `stack write` and `backfill` all render errors with `{e:#}` today. One//! error with a field on it has neither problem, and needs no special-casing//! anywhere that prints.//!//! There is deliberately no way to tag an error that is merely being passed//! on, because that is the case with no good answer: inserting a link into a//! chain that already exists is exactly what the two broken shapes above do. A//! caller that wants a code says so where the error is made.//!//! # The one code nobody attaches//!//! [`Unreachable`](Exit::Unreachable) is mostly inferred rather than tagged.//! Every request in atgc goes through one `reqwest` client, and a connect//! failure or a timeout arrives as a `reqwest::Error` that says so about//! itself — so [`classify`] asks it, and several hundred call sites get the//! right status without a line of their own. Tagging is still available where//! a caller knows better.
use std::fmt;
/// The status atgc exits with. See the module docs for the table.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Exit { /// It worked. Ok, /// Something went wrong that is none of the below. The message is the /// only description; that is not a failure of this enum, it is what /// "unclassified" means. Failure, /// The command line does not make sense. clap's own code for a parse /// error, reused for the refusals clap cannot make itself — an argument /// that only means something inside a checkout, a combination that /// parses but contradicts itself. /// /// The largest of those is the argument nobody types: nearly every /// command takes the repo it acts on from the working directory, so a /// directory that is not a checkout, has no such remote, or has no /// branch is a command line that does not mean anything from here. See /// [`NoCheckout`](crate::clients::git::run::NoCheckout). Usage, /// No usable session for the account this command would act as. NoSession, /// There is a session and it was refused: a scope this login predates, a /// record belonging to somebody else, a knot that would not take the /// call. Denied, /// An identifier resolved to nothing. NotFound, /// A host did not answer — DNS, connect, or a stall past the client's /// deadline. The one code that means *try again unchanged*. Unreachable, /// State moved underneath the command: a `swapRecord` precondition that /// no longer holds, another atgc holding the config lock, a working tree /// that changed mid-flight. Conflict, /// atgc panicked. Not a failure it can describe — a defect in atgc /// itself, and the next move is to report it rather than to change /// anything about the command. /// /// **Why this is its own code rather than [`Exit::Failure`].** Before the panic /// hook, a panic exited `101`, which is Rust's number and appears nowhere /// in this table — so a script branching on these statuses saw something /// unclassified. Folding it into `1` would have fixed that by throwing /// information away: `1` means "read the message, it is one of the /// ordinary failures", and a caller that retried on it would be retrying /// a crash. `101` was at least *distinguishable*, by accident. This keeps /// the distinction and documents it. /// /// Added deliberately before 1.0, when the table is still free to grow; /// afterwards a ninth code is a breaking change to everything that /// enumerates them. Bug,}
impl Exit { /// The number itself. Spelled as a match rather than as `as u8` over a /// `repr(u8)` enum so that adding a variant in the middle cannot silently /// renumber the ones after it — these are a public interface the moment /// anybody writes `if [ $? -eq 3 ]`. pub fn code(self) -> u8 { match self { Exit::Ok => 0, Exit::Failure => 1, Exit::Usage => 2, Exit::NoSession => 3, Exit::Denied => 4, Exit::NotFound => 5, Exit::Unreachable => 6, Exit::Conflict => 7, Exit::Bug => 8, } }}
impl From<Exit> for std::process::ExitCode { fn from(exit: Exit) -> Self { std::process::ExitCode::from(exit.code()) }}
/// A failure that knows what kind it is: the message, and the status it/// should exit with.////// One error rather than a message with a marker beside it, so that nothing/// which prints an error has to know this type exists — `Display` is the/// message and only the message.#[derive(Debug)]struct Coded { exit: Exit, message: String,}
impl fmt::Display for Coded { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.message) }}
impl std::error::Error for Coded {}
/// Build a failure that carries a status, in place of `bail!`/`anyhow!`.////// The message behaves exactly as it would have: it is what `{e}` prints,/// what `{e:#}` leads with, and what a caller's `.context(…)` stacks on top/// of. The status is a field nobody printing it will ever see.pub fn fail(exit: Exit, message: impl fmt::Display) -> anyhow::Error { anyhow::Error::new(Coded { exit, message: message.to_string(), })}
/// The status this failure should exit with.////// A [`fail`] anywhere in the chain wins, because somebody said so at the/// call site and that outranks anything derived here. Failing that, a knot's/// [`Refused`](crate::clients::tangled::knot::Refused) says what it earns —/// from its error *tag* where the knot's own statuses for one refusal/// disagree with each other, and from [`from_status`] otherwise. Failing/// *that*, a `reqwest::Error` anywhere in the chain/// reporting a connect failure or a timeout means the host never answered,/// which is [`Unreachable`](Exit::Unreachable) whether or not anyone said so./// Anything else is [`Failure`](Exit::Failure) — the status every failure had/// before this module existed.////// The knot arm is here rather than at the call sites, which was the other/// way it could have gone. Every knot call in the tree goes through one/// funnel and none of them wants a *different* answer from the table, so a/// conversion per call site would be the same mapping written five times and/// forgotten on the sixth — `pr merge`, `stack merge`, `repo delete-branch`/// and `api` all exited `1` with the knot's status sitting unread in a field./// This is not a new kind of knowledge for this function either: it has/// always reached into the chain for `reqwest::Error`.pub fn classify(err: &anyhow::Error) -> Exit { if let Some(coded) = err.downcast_ref::<Coded>() { return coded.exit; } if let Some(refused) = err .chain() .filter_map(|e| e.downcast_ref::<crate::clients::tangled::knot::Refused>()) .next() { return refused.exit(); } let unreachable = err .chain() .filter_map(|e| e.downcast_ref::<reqwest::Error>()) .any(|e| e.is_connect() || e.is_timeout()); if unreachable { Exit::Unreachable } else { Exit::Failure }}
/// How to report a defect in atgc, as lines to print under one.////// **One place, because a bug has one answer wherever it surfaces.** A panic/// and an internal invariant that did not hold are the same event from the/// user's side — atgc is broken and there is nothing they can change — and/// before this they were told two different things, or nothing at all.////// It names `atgc report bug` rather than a URL because the command is the/// better route by some distance: it attaches atgc's version, the platform,/// git's version and the PDS host without anybody having to be asked for/// them, and the record it writes — a `sh.tangled.repo.issue` on atgc's own/// repo — lives in the reporter's own PDS, public and theirs to edit or/// delete. A URL asks somebody to assemble by hand exactly the facts that/// make a report actionable.////// The line carries a `--body` because the command requires one: tangled.org/// drops an issue whose body is empty, so a command line printed here/// without it would be one that refuses. What it suggests writing there is/// the one thing this process cannot supply.////// `title` becomes the report's one-line subject, so it is quoted ready to/// paste. It is the caller's own description of what broke, which is the most/// specific thing anybody has at that moment.pub fn how_to_report(title: &str) -> String { // Quotes inside the title would end the shell's argument early. A report // whose subject is truncated at a quote is worse than one whose subject // has apostrophes flattened out of it. let subject = title.replace(['"', '\n'], " "); let subject = subject.trim(); format!( "this is a bug in atgc, not something you did. Report it with\n\ \x20 atgc report bug --title {subject:?} --body 'what you were doing'\n\ \x20 which attaches atgc's version, your platform, git's version and your\n\ \x20 PDS host — no paths and no environment. The issue is public." )}
/// An error that is a defect in atgc rather than a failure it can describe.////// For the invariants that are supposed to hold and one day will not: a loop/// that must have set a value before it exits, a chain that must have at/// least one member. Written as `expect` they abort the process; written as/// an ordinary `bail!` they read as though the user did something. This says/// which it is, exits [`Exit::Bug`], and tells them what to do about it.////// Prefer this to `expect` anywhere the surrounding code returns a `Result`./// Where it does not, the panic hook in `main` says the same thing, from/// [`how_to_report`], and the two are one sentence on purpose.pub fn bug(what: impl fmt::Display) -> anyhow::Error { let what = what.to_string(); let report = how_to_report(&what); fail(Exit::Bug, format!("{what}\n {report}"))}
/// Which status an HTTP refusal deserves.////// One table for the whole tree. It was four: `cmd/api.rs` had this one and/// kept it private, `cmd/repo/`'s knot funnel had a second copy for that/// family, and the DID-document and appview-page readers each matched inline./// They agreed, and nothing made them keep agreeing — two readers disagreeing/// about what `403` means is exactly the ambiguity these numbers exist to/// remove.////// Each arm is a next move rather than a restatement of the status: `401` is/// *log in*, `403` is *not as this account*, `404` and `410` are *that/// resolved to nothing*, `409` is *state moved underneath*, and a gateway/// that never reached the service behind it is worth retrying unchanged. XRPC/// puts most refusals in `400`, and a request a service will not accept is a/// command line to fix rather than one to retry, so `400`, `405` and `422`/// are [`Usage`](Exit::Usage). Anything else keeps the unclassified `1`,/// which is what "the message is the only description" is for.////// For a refusal, that is — the caller has already decided the request failed/// before asking. A success reaching here answers [`Failure`](Exit::Failure)/// and not [`Ok`](Exit::Ok), because nothing should read a `200` as a reason/// to exit non-zero and nothing here can repair a caller that does.////// A caller that authenticated nothing wants [`from_public_status`].pub fn from_status(status: reqwest::StatusCode) -> Exit { match status.as_u16() { 401 => Exit::NoSession, 403 => Exit::Denied, 404 | 410 => Exit::NotFound, 409 => Exit::Conflict, 400 | 405 | 422 => Exit::Usage, 502..=504 => Exit::Unreachable, _ => Exit::Failure, }}
/// Which status an XRPC error *name* deserves, falling back to the status.////// The name is read first because it is the field that means the same thing/// at every service in the stack, while the number beside it is whatever the/// handler that raised it happened to pass: a PDS answers `RecordNotFound`/// with **400**, and reading that number alone tells a caller to fix a/// command line that is fine.////// **One table, for the same reason [`from_status`] is one table.** This was/// two — `cmd/api.rs` mapped the PDS vocabulary and/// [`Refused::exit`](crate::clients::tangled::knot::Refused::exit) mapped the/// knot's. They agreed on the two names they shared and nothing made them/// keep agreeing, which is the ambiguity these numbers exist to remove: a/// knot and a PDS disagreeing about what `AccessControl` means is worse than/// either answer. Merging them is safe because every name here is a *next/// move* rather than a fact about one service — nothing about "this record/// already exists" changes depending on who said it — so a service growing a/// name its neighbour already uses gets the answer its neighbour got.////// Only names whose meaning is unambiguous are listed. This is a mapping of/// somebody else's vocabulary and worth no more than that reading, so a name/// not here — `RateLimitExceeded`, anything newer than this build — is left/// to its status rather than guessed at.pub fn from_error_name(name: Option<&str>, status: reqwest::StatusCode) -> Exit { match name { // Nothing usable was presented. A knot and a PDS spell this // differently and mean the same thing. Some("ExpiredToken" | "InvalidToken" | "AuthMissing" | "AuthenticationRequired") => { Exit::NoSession } // Something was presented and refused. A knot spells "you may not do // that here" `AccessControl`; a PDS spells the scope half of it the // other two ways. Some("AccessControl" | "Forbidden" | "ScopeMissingError") => Exit::Denied, Some( "RecordNotFound" | "RepoNotFound" | "NotFound" | "AccountNotFound" | "OwnerNotFound" | "RefNotFound", ) => Exit::NotFound, // The compare-and-swap preconditions, and the two "already there" // refusals: both mean the caller's picture of the state is stale. Some("InvalidSwap" | "InvalidSwapError" | "RepoExists" | "RecordExists") => Exit::Conflict, _ => from_status(status), }}
/// [`from_status`] read for a request that carried no credentials.////// The same table with the four session-shaped answers taken back out. A/// public GET — a DID document from plc.directory, a pull's page from the/// appview — can still come back `401` or `403` when something in front of/// the service decides it dislikes the client, and [`Denied`](Exit::Denied)/// would then be a lie about the specific thing it means: *there is a session/// and it was refused*, whose next move is "a different account, or log in/// again for the scope". There is no account here to change./// [`Conflict`](Exit::Conflict) and [`Usage`](Exit::Usage) go the same way —/// nothing was written, so no state moved underneath, and the URL was built/// in atgc rather than typed by anyone.////// What survives is what a status can still mean when nobody is logged in:/// the identifier resolved to nothing, or a host in the path did not answer.////// Derived from [`from_status`] rather than written as a second table, so a/// row added there is considered here too instead of quietly applying to/// authenticated callers alone.pub fn from_public_status(status: reqwest::StatusCode) -> Exit { match from_status(status) { Exit::NoSession | Exit::Denied | Exit::Conflict | Exit::Usage => Exit::Failure, settled => settled, }}
#[cfg(test)]mod tests { use super::*; use anyhow::anyhow;
/// The numbers are an interface. A caller writes `if [ $? -eq 3 ]` once /// and never looks again, so a variant added in the middle must not /// renumber the rest — this pins every one of them. /// A bug names the command that reports it, not a URL. /// /// `atgc report bug` is the route that works: it attaches the version, /// the platform, git's version and the PDS host without anybody being /// asked for them. A URL asks the reporter to assemble by hand exactly /// the facts that make a report actionable, which is how bug reports /// arrive without them. #[test] fn a_bug_says_how_to_report_itself() { let err = bug("the retry loop finished without a response"); let text = format!("{err:#}"); assert!(text.contains("atgc report bug --title"), "{text}"); assert!( text.contains("the retry loop finished without a response"), "the report has to carry what actually broke: {text}" ); assert_eq!(classify(&err), Exit::Bug); }
/// A title with a quote in it does not end the shell argument early. /// /// The subject is a message this build wrote, but panic payloads and /// error strings quote things — a record key, a branch name, a header — /// and a report whose subject is cut at the first quote is worse than /// one whose quotes were flattened. #[test] fn a_report_title_survives_quotes_and_newlines() { let line = how_to_report("slice index 7 out of range for \"abc\"\nsecond line"); let subject = line .split("--title ") .nth(1) .expect("the command names a title") .lines() .next() .expect("the title is on one line"); assert_eq!( subject.matches('"').count(), 2, "unbalanced quoting: {line}" ); assert!(!subject.contains('\n')); }
/// A panic exits `8`, which is in the table, rather than Rust's `101`, /// which is not. /// /// The status is the half of the panic hook a script can see. Before it, /// the one failure a caller most wants to tell apart from the ordinary /// ones answered with a number the documented interface does not define. #[test] fn a_panic_has_a_status_of_its_own() { assert_eq!(Exit::Bug.code(), 8); assert_ne!( Exit::Bug, Exit::Failure, "a crash and an ordinary failure must not be one status: a caller \ that retries on `1` would be retrying a bug" ); }
#[test] fn the_numbers_do_not_move() { assert_eq!(Exit::Ok.code(), 0); assert_eq!(Exit::Failure.code(), 1); assert_eq!(Exit::Usage.code(), 2); assert_eq!(Exit::NoSession.code(), 3); assert_eq!(Exit::Denied.code(), 4); assert_eq!(Exit::NotFound.code(), 5); assert_eq!(Exit::Unreachable.code(), 6); assert_eq!(Exit::Conflict.code(), 7); }
/// Every code is distinct. Two variants sharing a number would make one /// of them undiagnosable from a shell. #[test] fn no_two_codes_collide() { let mut codes: Vec<u8> = [ Exit::Ok, Exit::Failure, Exit::Usage, Exit::NoSession, Exit::Denied, Exit::NotFound, Exit::Unreachable, Exit::Conflict, ] .into_iter() .map(Exit::code) .collect(); let total = codes.len(); codes.sort_unstable(); codes.dedup(); assert_eq!(codes.len(), total, "two variants share an exit status"); }
/// An untagged failure exits the way every failure did before this /// existed. This is what makes the change additive rather than a rule /// every call site has to obey at once. #[test] fn an_untagged_failure_is_still_one() { assert_eq!(classify(&anyhow!("something went wrong")), Exit::Failure); }
/// The code survives the context its callers stack on top, which is the /// ordinary case: a helper knows the kind of failure, and each caller /// above it adds a sentence about what it was doing. #[test] fn a_code_survives_the_context_stacked_above_it() { let err = fail(Exit::NoSession, "no session for did:plc:abc") .context("could not act as @someone.example.com") .context("pr create"); assert_eq!(classify(&err), Exit::NoSession); assert_eq!( format!("{err:#}"), "pr create: could not act as @someone.example.com: \ no session for did:plc:abc" ); }
/// **The regression this module's shape exists to prevent.** A marker /// carried above the message makes `to_string()` empty; carried below it, /// `{e:#}` gains a trailing `": "`. `pr read`, `stack write` and /// `backfill` all format errors with `{e:#}` today, so both would be /// visible bugs rather than theoretical ones. #[test] fn a_coded_error_still_prints_as_its_message() { let err = fail(Exit::Denied, "the knot refused it"); assert_eq!(err.to_string(), "the knot refused it"); assert_eq!(format!("{err:#}"), "the knot refused it"); assert!(!err.to_string().is_empty()); }
/// The status is not part of the message, and leaves no gap where it /// sits: the chain reads exactly as an untagged one would. #[test] fn the_status_is_invisible_in_the_message() { let coded = fail(Exit::NoSession, "no session for did:plc:abc").context("pr create"); let plain = anyhow!("no session for did:plc:abc").context("pr create"); assert_eq!(format!("{coded:#}"), format!("{plain:#}")); assert_eq!( format!("{coded:#}"), "pr create: no session for did:plc:abc" ); }
/// `fail` builds the same thing `bail!` would, plus the label. #[test] fn fail_carries_its_message_and_its_code() { let err = fail(Exit::NotFound, "no pull numbered 23 in this repo"); assert_eq!(classify(&err), Exit::NotFound); assert_eq!(err.to_string(), "no pull numbered 23 in this repo"); }
/// A host that never answered is `Unreachable` without anyone tagging /// it, which is how several hundred call sites get the right status for /// free. Driven through a real `reqwest::Error` — built by asking a /// client with a 1ms timeout for an unroutable TEST-NET-3 address /// (RFC 5737), so nothing leaves this machine. /// A knot's refusal exits as its status says, not as an unclassified 1. /// /// The knot funnel was the last error in the tree carrying a status /// nothing read. `pr merge` on a repo you have no push access to answered /// `403 AccessControl` and exited `1`, which is the code for "the message /// is the only description" — on a failure whose whole point is that it /// is a permissions problem somebody else can fix. #[test] fn a_knot_refusal_exits_as_its_status() { use crate::clients::tangled::knot::Refused; let refused = |status: reqwest::StatusCode| { classify(&anyhow::Error::new(Refused { knot: "knot1.tangled.sh".to_string(), nsid: "sh.tangled.repo.merge".to_string(), status, tag: "AccessControl".to_string(), detail: "no push access".to_string(), })) }; // One tag, three statuses, one answer. A knot spells `AccessControl` // 401 from its shared push guard, 403 from the membership handlers // and 400 from delete_repo, and "you do not have access" is the same // problem in all three. for status in [ reqwest::StatusCode::UNAUTHORIZED, reqwest::StatusCode::FORBIDDEN, reqwest::StatusCode::BAD_REQUEST, ] { assert_eq!(refused(status), Exit::Denied, "{status}"); } }
/// A tag the mapping does not name falls through to the status table. /// /// The table is the general answer and the tags are the exceptions to it, /// not a replacement: a knot error this build has never heard of still /// gets whatever its status earns. #[test] fn an_unmapped_tag_is_left_to_its_status() { use crate::clients::tangled::knot::Refused; let refused = |tag: &str, status: reqwest::StatusCode| { classify(&anyhow::Error::new(Refused { knot: "knot1.tangled.sh".to_string(), nsid: "sh.tangled.repo.merge".to_string(), status, tag: tag.to_string(), detail: "no".to_string(), })) }; assert_eq!( refused("Git", reqwest::StatusCode::CONFLICT), Exit::Conflict ); assert_eq!( refused( "SomethingNewerThanThisBuild", reqwest::StatusCode::NOT_FOUND ), Exit::NotFound ); // And a status the table has no answer for keeps the unclassified 1, // which is what "the message is the only description" is for. assert_eq!( refused("Git", reqwest::StatusCode::INTERNAL_SERVER_ERROR), Exit::Failure ); }
/// And it survives the context a caller wraps it in. /// /// Every real refusal reaches `main` under at least one `.context`, so a /// check that only looked at the outermost error would pass this module's /// tests and fix nothing. #[test] fn a_wrapped_knot_refusal_still_exits_as_its_status() { use anyhow::Context; let err = Err::<(), _>(anyhow::Error::new(crate::clients::tangled::knot::Refused { knot: "knot1.tangled.sh".to_string(), nsid: "sh.tangled.repo.merge".to_string(), status: reqwest::StatusCode::FORBIDDEN, tag: String::new(), detail: "no".to_string(), })) .context("merging pull 12") .unwrap_err(); assert_eq!(classify(&err), Exit::Denied); }
/// A `fail` still outranks it. `stack merge` turns an `AccessControl` /// refusal into a sentence about push access and codes it itself; that /// decision is made at a call site that knows more than the table does, /// and must not be re-derived here. #[test] fn an_explicit_code_outranks_the_knots_status() { let err = fail( Exit::Usage, anyhow::Error::new(crate::clients::tangled::knot::Refused { knot: "knot1.tangled.sh".to_string(), nsid: "sh.tangled.repo.merge".to_string(), status: reqwest::StatusCode::FORBIDDEN, tag: String::new(), detail: "no".to_string(), }), ); assert_eq!(classify(&err), Exit::Usage); }
#[tokio::test] async fn a_host_that_never_answered_is_unreachable_untagged() { let slow = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_millis(1)) .build() .expect("build a client"); let failed = slow .get("http://203.0.113.1/") .send() .await .expect_err("TEST-NET-3 is not routable"); assert!( failed.is_connect() || failed.is_timeout(), "expected a connect/timeout error, got {failed:?}" ); let err = anyhow::Error::new(failed).context("reading the repo record"); assert_eq!(classify(&err), Exit::Unreachable); }
/// One status per next move. This is the table four call sites used to /// keep privately, so it is pinned here rather than at any one of them. #[test] fn every_refusal_maps_to_the_next_move_it_earns() { let code = |n: u16| from_status(reqwest::StatusCode::from_u16(n).expect("a real status")); assert_eq!(code(401), Exit::NoSession); assert_eq!(code(403), Exit::Denied); assert_eq!(code(404), Exit::NotFound); assert_eq!(code(410), Exit::NotFound); assert_eq!(code(409), Exit::Conflict); assert_eq!(code(400), Exit::Usage); assert_eq!(code(405), Exit::Usage); assert_eq!(code(422), Exit::Usage); assert_eq!(code(502), Exit::Unreachable); assert_eq!(code(503), Exit::Unreachable); assert_eq!(code(504), Exit::Unreachable); // The two neighbours of that range, so it cannot quietly widen: a // service that answered 500 or 505 reached the request itself. assert_eq!(code(500), Exit::Failure); assert_eq!(code(505), Exit::Failure); assert_eq!(code(418), Exit::Failure); }
/// The statuses, which are the interface a script reads. The XRPC error /// name wins over the HTTP status, because it is the field that means the /// same thing at a PDS, a knot and an appview: a PDS answers /// `RecordNotFound` with 400, and exiting `2` for it would tell a caller /// to fix a command line that is fine. #[test] fn a_refusal_exits_as_the_kind_of_refusal_it_is() { let named = |status: reqwest::StatusCode, name: &str| from_error_name(Some(name), status); assert_eq!( named(reqwest::StatusCode::BAD_REQUEST, "RecordNotFound"), Exit::NotFound ); assert_eq!( named(reqwest::StatusCode::BAD_REQUEST, "ExpiredToken"), Exit::NoSession ); assert_eq!( named(reqwest::StatusCode::BAD_REQUEST, "InvalidSwap"), Exit::Conflict ); // The knot's spelling of "not you". assert_eq!( named(reqwest::StatusCode::UNAUTHORIZED, "AccessControl"), Exit::Denied );
// With no name to go on — a proxy's HTML error page, say — the status // decides. let bare = |n: u16| from_error_name(None, reqwest::StatusCode::from_u16(n).expect("real")); assert_eq!(bare(400), Exit::Usage); assert_eq!(bare(401), Exit::NoSession); assert_eq!(bare(403), Exit::Denied); assert_eq!(bare(404), Exit::NotFound); assert_eq!(bare(409), Exit::Conflict); assert_eq!(bare(502), Exit::Unreachable); assert_eq!(bare(503), Exit::Unreachable); // And anything nobody classified is 1, which is what unclassified is. assert_eq!(bare(500), Exit::Failure); assert_eq!( named(reqwest::StatusCode::TOO_MANY_REQUESTS, "RateLimitExceeded"), Exit::Failure ); }
/// **The regression the merge exists to prevent.** This table was two — /// `cmd/api.rs` over the PDS's vocabulary and `Refused::exit` over the /// knot's — and every name either of them classified must still get the /// answer it got, or unifying them was a silent behaviour change on the /// interface a script reads. /// /// Driven at `400`, which is the status that would decide these if the /// name layer stopped answering: every row below would read `Usage` /// instead, so a name that quietly fell out of the table fails here /// rather than in somebody's shell. #[test] fn one_table_answers_for_both_vocabularies() { let name = |name: &str| from_error_name(Some(name), reqwest::StatusCode::BAD_REQUEST); // What `cmd/api.rs` used to answer. for token in [ "ExpiredToken", "InvalidToken", "AuthMissing", "AuthenticationRequired", ] { assert_eq!(name(token), Exit::NoSession, "{token}"); } for denied in ["AccessControl", "Forbidden", "ScopeMissingError"] { assert_eq!(name(denied), Exit::Denied, "{denied}"); } for missing in [ "RecordNotFound", "RepoNotFound", "NotFound", "AccountNotFound", ] { assert_eq!(name(missing), Exit::NotFound, "{missing}"); } for moved in ["InvalidSwap", "InvalidSwapError"] { assert_eq!(name(moved), Exit::Conflict, "{moved}"); } // And what the knot funnel used to answer on its own. for missing in ["RepoNotFound", "OwnerNotFound", "RefNotFound"] { assert_eq!(name(missing), Exit::NotFound, "{missing}"); } for taken in ["RepoExists", "RecordExists"] { assert_eq!(name(taken), Exit::Conflict, "{taken}"); } // A refusal that carried no name at all is the status alone, which is // the case `Refused` has to hand this function every time a knot // answers with an empty tag. assert_eq!( from_error_name(None, reqwest::StatusCode::BAD_REQUEST), Exit::Usage ); assert_eq!(name(""), Exit::Usage, "the empty string is not a name"); }
/// A public read keeps the answers that do not presuppose a session and /// drops the ones that do. `403` is the case worth naming: a proxy in /// front of a public GET says it, and `4` would tell the reader to try a /// different account when there was never an account in the request. #[test] fn a_public_read_drops_the_answers_that_presuppose_a_session() { let code = |n: u16| from_public_status(reqwest::StatusCode::from_u16(n).expect("a real status")); assert_eq!(code(401), Exit::Failure); assert_eq!(code(403), Exit::Failure); assert_eq!(code(409), Exit::Failure); assert_eq!(code(400), Exit::Failure); // What is left is what a status still means with nobody logged in. assert_eq!(code(404), Exit::NotFound); assert_eq!(code(410), Exit::NotFound); assert_eq!(code(503), Exit::Unreachable); }
/// The public reading is the same table minus rows, never a second table /// with an answer of its own — every status is either what /// [`from_status`] said or the unclassified `1`. Written as a sweep so /// that a row added above cannot invent a third behaviour down here. #[test] fn a_public_read_never_invents_an_answer_the_table_does_not_have() { for n in 400..600u16 { let Ok(status) = reqwest::StatusCode::from_u16(n) else { continue; }; let public = from_public_status(status); assert!( public == from_status(status) || public == Exit::Failure, "{n} answers {public:?}, which is neither the table's answer nor 1" ); } }}