//! 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 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::() { return coded.exit; } if let Some(refused) = err .chain() .filter_map(|e| e.downcast_ref::()) .next() { return refused.exit(); } let unreachable = err .chain() .filter_map(|e| e.downcast_ref::()) .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 = [ 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" ); } } }