Compare commits

...
Author SHA1 Message Date
beardthelion 5338a51e12 docs(node): document the invalid_url webhook delivery outcome 2026-11-06 18:47:18 -06:00
beardthelion fe155e1ea8 fix(node): revalidate stored webhook URLs at delivery and cover the subdomain repeated-dot case 2026-11-06 18:16:17 -06:00
beardthelion f2d172432e test(node): track the Db pool field change in the mirror-state migration tests 2026-11-06 15:20:23 -06:00
beardthelion a7f32fbf01 Merge remote-tracking branch 'origin/main' into codex/b1a-mirror-persistence 2026-11-06 15:03:02 -06:00
beardthelion 5ed3381a6b fix(gl): truncate node-supplied display strings at a char boundary (#350) (#536)
* fix(gl): truncate node-supplied display strings at a char boundary (#350)

Every timestamp/id/SHA the CLI prints was cut with &s[..N] or
&s[..N.min(s.len())]. The first panics on a short value, the second
still panics when byte N is inside a multi-byte char. A shared
text::truncate cuts at a char boundary and covers all sites, including
two the issue did not name (node events, agent did) and the same shape
in bounty, changelog, ipfs pin listing, and the merge-commit print.

* fix(gl): bound truncate by bytes, not chars (#460)

Address PR review feedback: char_indices().nth(max) capped characters,
so a multibyte char near the cut could exceed the byte limit. Start at
max.min(len) and walk back to the nearest char boundary instead.

* fix(gl): bound the bounty leaderboard did suffix at a char boundary
2026-11-06 20:38:05 +00:00
beardthelion 9b915b09a7 fix(attest): bound did:key method-id length before base58 decode (#360) (#535)
gitlawb-core added the same guard in 73bf132c; the second copy of the
parser in gitlawb-attest never got it, so an untrusted signer string of
arbitrary length reaches a decoder whose cost is quadratic in input.
2026-11-06 18:14:06 +00:00
77ac1037cb fix(icaptcha-client): parse GITLAWB_ICAPTCHA_INSECURE as truthy only (#227) (#533)
* fix(icaptcha-client): parse GITLAWB_ICAPTCHA_INSECURE as truthy only

Presence checks treated =0/=false/empty as enabled. Only explicit 1/true
now enable loopback HTTP trust relaxation (#227).

* fix(icaptcha-client): address #246 review (trim test, fmt, lock)

Pin trim with a whitespace truthy case, recover from a poisoned
ICAPTCHA_ENV_LOCK, document trim + warn visibility, and cargo fmt.

* fix(icaptcha-client): do not log raw ICAPTCHA_INSECURE value

The non-truthy warn path previously recorded the full trimmed env
value. That is arbitrary deployment input; report only that the
setting is invalid (#246 review).

* fix(icaptcha-client): pure insecure flag helpers for #246 review

Warn on present non-Unicode GITLAWB_ICAPTCHA_INSECURE (fail closed).
Pass insecure mode into is_https_with / resolve_solver_url_with so
tests no longer mutate process-global env (removes InsecureEnv race).

* fix(icaptcha-client): cover real env read + fix warn spacing

Add a serialized test that drives icaptcha_insecure_enabled() against
the process env so presence-check / typo / always-true mutations fail.
Collapse the mid-sentence space run in the non-truthy warning (#246).

* fix(icaptcha-client): put the non-truthy warning on one literal line

---------

Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-11-06 17:26:16 +00:00
db98b3957a fix(attest): make verify_all lenient under RequireAll (#359) (#532)
* fix(attest): make verify_all lenient under RequireAll (#359)

Registry::verify_all propagated every per-attestation error via ?, so a
single malformed or unknown attestation aborted the whole batch. Under
Policy::RequireAll that is a DoS vector: any third party can attach an
attestation, so an unrelated attacker/spam/v1 could block an otherwise
valid cert.

Under RequireAll, drop entries that fail to verify instead of aborting;
the required_types check remains the gate and only counts fully_verified
entries, so a required type present only as a malformed attestation still
fails with RequiredMissing. AcceptKnown and RejectUnknown stay strict.

Adds tests for both: a malformed extra is dropped without aborting, and a
malformed-only required type is still rejected.

Closes #359

* test(attest): lock the strict batch policies with an error-propagation test

AcceptKnown and RejectUnknown were only exercised per-attestation, so a
cleanup applying RequireAll's filter_map(Result::ok) shape to the strict
arms would fail open with every test green. Drive both policies through
verify_all with a wrong-cert-hash member and assert the batch surfaces
the underlying error. Also correct the RequireAll doc: unknown valid
types are retained as unverified, not dropped — only real Err paths are.

---------

Co-authored-by: andreolf <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-11-06 16:54:42 +00:00
beardthelionandAyush7614 8c9e4adc2d fix(node): canonicalize and 404-on-no-match in remove_label (#344) (#530)
* fix(node): canonicalize and 404-on-no-match in remove_label (#344)

add_label stored labels trimmed and lowercased, but remove_label passed the
path segment through as-is, so a label stored as 'bug' did not match
DELETE /repos/{owner}/{repo}/labels/Bug. Worse, remove_label's DB call
discarded the row count and the handler always returned 200 with
{removed: true}, including when the delete matched nothing — the
no-op-rendered-as-success shape the rest of the API rejects.

Move trim+lowercase and charset/length validation into a shared
canonicalize_label helper used by both handlers, and have
db::remove_label return the affected row count so the handler can return
404 when no label was deleted. add_label's behavior is unchanged
(idempotent semantics preserved); the only externally observable change is
that mixed-case and surrounding-whitespace path segments now match the
stored label, and a delete that matches no row returns 404 instead of
{removed: true}.

Tests:
  * remove_label_canonicalizes_and_404s_on_no_match exercises the
    mixed-case path, the trimmed path, the empty-after-trim rejection,
    the disallowed-charset rejection, and the no-op-returns-404 case.
  * remove_label_denies_non_owner re-pins the 403 owner-gated shape
    (matches AGENTS.md: owner-gated mutations return 403 after the repo
    lookup, distinct from the not-found shape).

* test(node): pin len>50 arm in remove_label canonicalize

The helper's length branch had no coverage — 51 chars must 400 and
50 chars must pass validation (404 when absent). This pins the arm
lifted into canonicalize_label and decouples the no-match 404 from the
double-delete above, as requested in review of #390.

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-06 15:08:34 +00:00
beardthelion 8eedb935c3 fix(node): gate bounty mutations on repo read before status checks (#529)
* fix(node): gate id-keyed bounty mutations on repo read before status checks

submit, approve, cancel, and dispute on /api/v1/bounties/{id}/* looked the
bounty up and ran status and participant checks without gating on the
bounty's repo read visibility. A signed stranger holding an id got a
distinguishable 400/403 on a private-repo bounty where an absent id gets
404, so the routes were an existence and lifecycle oracle.

Insert the same authorize_repo_read block get_bounty and claim_bounty
already carry, right after the lookup, so a denied caller sees the
not-found shape either way. Participant checks are unchanged; on a public
repo a stranger still reaches them.

Closes #341

* Address PR review feedback (#448)

- assert the exact not_found body shape for both held and absent bounty
  ids in the mutation-gate helper, so a denial carrying lifecycle or
  participant detail fails the test
2026-11-06 15:06:01 +00:00
beardthelion 47c6e6ce22 fix(node): fail pin_git_object when the add response carries no Hash (#528)
* fix(node): fail pin_git_object when the add response carries no Hash

A 200 from /api/v0/add without a parseable Hash fell back to the
caller-computed raw CID, recording an address the backend never
confirmed stored. For payloads above the backend's chunk threshold
(262144 bytes on Kubo) that raw CID is not even a stored block, so
every later /api/v0/cat on it fails permanently: get_encrypted_blob
errors on every fetch and mirrors retry the envelope on every sync.

A Hash-less 200 is now an add error carrying a truncated body excerpt.
The delaying_endpoint test fixture, which relied on the fallback by
returning an empty 200 body, now answers with a conformant NDJSON line.

* fix(node): reject a non-CID Hash in the add response

Address PR review feedback (#453): a string-valued Hash that does not
parse as a CID would be recorded into encrypted_blobs.cid and fail on
every later cat, the same failure class as a missing Hash. Validate the
returned Hash before accepting it and cover the malformed case with a
regression test.

* test(node): return real CIDs from the add-response test mocks

The pin_git_object CID validation rejects non-CID Hash strings, and a
dozen test_support fixtures answered 200 with placeholder values like
"bafyprovider" or "x". Replace them with a valid CID so the fixtures
model a healthy backend; no test asserts on the literal value.

* fix(node): bound the invalid Hash echoed in the add error
2026-11-06 06:32:13 +00:00
beardthelionandMystic 317a1550c2 fix(node): scope bounty aggregates to anonymously-listable repos (#477) (#527)
* fix(#477): scope bounty aggregates to anonymously-listable repos

bounty_stats and agent_bounty_stats ran unfiltered aggregates over all
bounties, leaking private-repo bounty activity to anonymous callers.

Mirror the stats() pattern from server.rs (#104):

1. Batch-load all deduped repos + visibility rules (2 SQL round-trips)
2. Filter to listable_at_root(rules, is_public, owner_did, None)
3. Pass visible (owner, name) pairs into SQL aggregates via EXISTS/unnest
   with owner key normalization on both sides (BOUNTY_OWNER_CASE_SQL).
4. Normalize claimant DID in agent_bounty_stats_visible and group by
   normalized claimant in bounty_leaderboard_visible (BOUNTY_CLAIMANT_CASE_SQL).
5. Remove orphaned unfiltered aggregate methods to pass dead_code lints.
6. In tests, seed completed bounty claimant state via the real claim/approve
   path, and verify root-rule-decided visibility overrides.

Aggregates are intentionally anonymous-scoped for all callers (matching the
#104 stats pattern).

Fail-closed: DB errors collapse the visible set to empty, so all counts
return 0 — an under-count never leaks existence.

Fixes #477

* style(node): apply rustfmt to the bounty visibility change

* test(node): pin bounty aggregate visibility for mirror rows

* docs(node): record why mirror rows are handled in the bounty visibility filter

* perf(node): keep the claimant index usable in per-agent bounty stats

* fix(node): return DB errors from bounty stats instead of zero counts

---------

Co-authored-by: Mystic <[email protected]>
2026-11-06 03:06:15 +00:00
JATMNandClaude Opus 5.5 30637193a6 fix(node): resolve issue ids from git refnames, not request input (#526)
* fix(node): resolve issue ids from git refnames, not request input

resolve_issue_id answered an exact match by echoing the request-path id
back to callers, so get_issue and close_issue spliced caller input into
`git cat-file blob refs/gitlawb/issues/<id>`. #521 restricted the input
to [A-Za-z0-9-], but the value handed to git was still the request
string (CodeQL rust/command-line-injection #83).

Resolve exact and prefix lookups through one for-each-ref listing and
return the refname git reports. An exact refname still wins over longer
ids that share it as a prefix; unique-prefix, ambiguous, not-found, and
revision-syntax behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(node): keep a dangling exact issue ref from winning over longer ids

for-each-ref lists a ref even when its blob is missing, so the exact-id
precedence added in the previous commit picked a dangling exact ref over a
readable longer sibling: resolve_issue_id returned the dangling id where
main reported an ambiguous prefix, get_issue then returned None, and
close_issue hit its expect().

Let the exact id win only when `git cat-file -e` finds its object, run on
git's own refname so no request input returns to the argv. Otherwise fall
through to the unchanged prefix rules, matching main for every layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-06 03:04:20 +00:00
Kevin Codex 0bae56f5ca Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/gitlawb-node/src/db/mod.rs
2026-08-31 14:03:54 +08:00
KRATOS a6d8459002 fix(node): close remaining mirror review findings
Origin-Session: local-d6a143 | Codex | 21 prompts
2026-08-15 09:47:18 +05:30
KRATOS 1b7639f0d7 fix(node): align peer URL validation message
Origin-Session: local-d6a143 | Codex | 20 prompts
2026-08-15 00:33:01 +05:30
KRATOS 5b9e06d501 fix(node): close mirror review blockers
Origin-Session: local-d6a143 | Codex | 20 prompts
2026-08-15 00:25:14 +05:30
KRATOS 92997b3695 fix(node): address mirror state review findings
Origin-Session: local-d6a143 | Codex | 16 prompts
2026-08-14 22:22:19 +05:30
KRATOS ea34d84c3a fix(node): reject null mirror transition state
Origin-Session: local-d6a143 | Codex | 14 prompts
2026-08-14 21:44:27 +05:30
KRATOS 5cc7e7a564 feat(node): persist upstream mirror transition state
Origin-Session: local-d6a143 | Codex | 12 prompts
2026-08-14 21:28:17 +05:30
26 changed files with 2967 additions and 200 deletions

No files matched your search

+17
View File
@@ -504,6 +504,23 @@ mod tests {
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_oversized_method_id_before_decoding() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
// A well-formed prefix on an absurdly long id must fail the length
// bound, not the decoder: base58 decode cost is quadratic in input
// length, so reaching it at all is the defect.
att.signer = format!("did:key:z{}", "1".repeat(256));
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
// Match the bound's own message: any other Did error (a decode or
// length mismatch downstream) means the oversized input was decoded.
match err {
Error::Did(m) => assert!(m.contains("too long"), "unexpected: {m}"),
e => panic!("expected Error::Did, got {e:?}"),
}
}
#[test]
fn verify_rejects_bad_signature_base64() {
let sk = fresh();
+127 -11
View File
@@ -150,23 +150,48 @@ impl Registry {
}
/// Verify a batch, then enforce `RequireAll`.
///
/// Under [`Policy::RequireAll`] this never short-circuits: an attestation
/// that fails to verify — bad signature, cert-hash mismatch, or malformed
/// payload, the actual `Err` paths of [`Registry::verify`] — is dropped
/// from the result rather than aborting the batch. A valid attestation of
/// a type with NO registered verifier is not one of those: under
/// `RequireAll` it verifies to `Ok` with `fully_verified: false` and stays
/// in the result, preserving the distinction between *untrusted* and
/// *invalid* (see `require_all_is_lenient_on_unknown_types_in_the_batch`).
/// Any third party can attach an attestation, so a hard error here would
/// let an unrelated `attacker/spam/v1` deny-of-service the whole cert (the
/// DoS vector the module docstring warns about). The `required_types`
/// check below is the real gate, and it only counts `fully_verified`
/// entries — so a required type represented solely by a malformed
/// attestation still fails with [`Error::RequiredMissing`].
///
/// Under `AcceptKnown` and `RejectUnknown` the batch stays strict: the
/// first verification error is surfaced to the caller.
pub fn verify_all(
&self,
attestations: &[Attestation],
expected_cert_hash: [u8; 32],
) -> Result<Vec<VerifiedAttestation>> {
let mut verified = Vec::with_capacity(attestations.len());
for a in attestations {
verified.push(self.verify(a, expected_cert_hash)?);
if self.policy != Policy::RequireAll {
let mut verified = Vec::with_capacity(attestations.len());
for a in attestations {
verified.push(self.verify(a, expected_cert_hash)?);
}
return Ok(verified);
}
if self.policy == Policy::RequireAll {
for required in &self.required_types {
let present = verified
.iter()
.any(|v| &v.type_ == required && v.fully_verified);
if !present {
return Err(Error::RequiredMissing(required.clone()));
}
// RequireAll: drop entries that fail to verify instead of aborting.
let verified: Vec<VerifiedAttestation> = attestations
.iter()
.filter_map(|a| self.verify(a, expected_cert_hash).ok())
.collect();
for required in &self.required_types {
let present = verified
.iter()
.any(|v| &v.type_ == required && v.fully_verified);
if !present {
return Err(Error::RequiredMissing(required.clone()));
}
}
Ok(verified)
@@ -324,6 +349,38 @@ mod tests {
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
}
/// The strict policies must surface a batch member's verification error
/// through `verify_all`, not launder it into a partial result. This binds
/// the POLICY BOUNDARY itself: only the `RequireAll` arm may drop failing
/// entries, and an apparently harmless cleanup that applies its
/// `filter_map(Result::ok)` shape to the strict arms would silently turn
/// the fail-closed contracts into fail-open — with every per-attestation
/// `verify` test still green, because none of them go through the batch
/// path. The failing entry is a wrong-cert-hash attestation (signed over a
/// different hash than the batch expects), a genuine `Err` under every
/// policy.
#[test]
fn strict_policies_propagate_a_batch_members_error() {
let sk = fresh();
let cert_hash = sample_hash();
let mut wrong_hash = sample_hash();
wrong_hash[0] ^= 0xff;
for policy in [Policy::AcceptKnown, Policy::RejectUnknown] {
let mut reg = Registry::new().with_policy(policy);
reg.register(DemoVerifier);
let good = signed_demo(&sk, cert_hash, "ok");
let bad = signed_demo(&sk, wrong_hash, "ok");
let err = reg
.verify_all(&[good, bad], cert_hash)
.expect_err("a strict policy must fail the whole batch");
assert!(
matches!(err, Error::Signature(_) | Error::CertHashMismatch),
"expected the underlying verification error, got {err:?}"
);
}
}
/// A required type that is present but unverified (no verifier registered
/// even though the type appears in `required_types`) must fail
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
@@ -341,6 +398,65 @@ mod tests {
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
}
/// A cert-hash that does not match `sample_hash()`, so an attestation
/// signed against it fails `verify_signature` when the batch is checked
/// against `sample_hash()`.
fn other_hash() -> [u8; 32] {
let mut h = sample_hash();
h[0] ^= 0xff;
h
}
/// Under `RequireAll`, a malformed attestation (here: signed against the
/// wrong cert hash, so it fails signature verification) attached alongside a
/// valid required one must not abort the batch. The junk is dropped and the
/// cert still verifies. Before the fix, `verify_all` propagated the error
/// via `?` and the whole batch failed — the DoS vector the docstring warns
/// about.
#[test]
fn require_all_drops_a_malformed_extra_and_still_accepts() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
// Signed against `other_hash()`, so it will not verify under `cert_hash`.
let malformed = signed_demo(&sk, other_hash(), "boom");
let real = signed_demo(&sk, cert_hash, "ok");
let verified = reg
.verify_all(&[malformed, real], cert_hash)
.expect("a malformed extra must not abort the batch under RequireAll");
assert_eq!(verified.len(), 1, "the malformed entry should be dropped");
assert!(verified
.iter()
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
}
/// Leniency must not open a hole: if the *only* attestation of a required
/// type is malformed, dropping it leaves the required type absent and
/// `verify_all` must still fail with `RequiredMissing`. Guards against a fix
/// that skips errors so eagerly it lets an unverified required type pass.
#[test]
fn require_all_rejects_when_only_a_malformed_required_type_is_present() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
// The sole demo/v1 is bound to the wrong cert hash → fails verification.
let malformed_required = signed_demo(&sk, other_hash(), "ok");
let err = reg
.verify_all(&[malformed_required], cert_hash)
.unwrap_err();
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
}
#[test]
fn payload_check_failure_rejects() {
let sk = fresh();
+123 -29
View File
@@ -285,11 +285,22 @@ pub async fn submit_bounty(
Path(id): Path<String>,
Json(req): Json<SubmitBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
if bounty.status != "claimed" {
return Err(AppError::BadRequest(format!(
@@ -325,11 +336,22 @@ pub async fn approve_bounty(
Path(id): Path<String>,
Json(req): Json<ApproveBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
if bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
@@ -372,11 +394,22 @@ pub async fn cancel_bounty(
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
@@ -407,11 +440,22 @@ pub async fn dispute_bounty(
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
if bounty.status != "claimed" && bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
@@ -456,21 +500,68 @@ pub async fn dispute_bounty(
Ok(Json(updated))
}
/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller
/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load
/// all deduped repos, batch-load their visibility rules, keep only those that
/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no
/// per-repo authorization queries.
///
/// Both bounty stats handlers mount behind `optional_signature` (see
/// `bounty_read_routes` in server.rs), so the caller is always `None` for
/// anonymous access. Every caller, signed or not, currently receives the
/// anonymous-scoped aggregates (matching the #104 pattern); per-caller
/// visibility can be threaded through here when that work lands.
///
/// A database error is returned to the caller, never collapsed to an empty
/// set: an empty set would render as HTTP 200 with zero counts, which a client
/// reads as confirmed absence of activity.
async fn visible_repo_pairs(state: &AppState) -> Result<Vec<(String, String)>> {
let rows = state.db.list_all_repos_deduped().await?;
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
let pairs = rows
.iter()
.filter(|r| {
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
// mirror-rows-handled: a mirror with a canonical twin is deduped to the
// canonical row and its rules; a mirror-only repo counts as public, the
// same answer authorize_repo_read and /stats give. Pinned by
// bounty_stats_mirror_rows_follow_canonical_twin_or_count_when_alone.
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
})
.map(|r| {
(
crate::db::normalize_owner_key(&r.owner_did).to_string(),
r.name.clone(),
)
})
.collect();
Ok(pairs)
}
/// GET /api/v1/bounties/stats
///
/// Aggregates are restricted to anonymously-listable repos so private-repo
/// bounty activity is not exposed (#477). The visible-repo set is resolved
/// once per request via `visible_repo_pairs` (two SQL round-trips), then
/// passed into the filtered aggregate queries.
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
let visible = visible_repo_pairs(&state).await?;
let open = state
.db
.count_bounties_by_status_visible("open", &visible)
.await?;
let claimed = state
.db
.count_bounties_by_status("claimed")
.await
.unwrap_or(0);
.count_bounties_by_status_visible("claimed", &visible)
.await?;
let completed = state
.db
.count_bounties_by_status("completed")
.await
.unwrap_or(0);
.count_bounties_by_status_visible("completed", &visible)
.await?;
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
let leaders = state.db.bounty_leaderboard_visible(10, &visible).await?;
let leaderboard = leaders
.into_iter()
.map(|(did, cnt, total)| AgentBountyEntry {
@@ -489,11 +580,14 @@ pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountySt
}
/// GET /api/v1/agents/{did}/bounties
///
/// Per-agent earnings restricted to anonymously-listable repos (#477).
pub async fn agent_bounty_stats(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<serde_json::Value>> {
let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0));
let visible = visible_repo_pairs(&state).await?;
let (count, total) = state.db.agent_bounty_stats_visible(&did, &visible).await?;
Ok(Json(serde_json::json!({
"did": did,
"completed_bounties": count,
+24 -9
View File
@@ -14,14 +14,13 @@ pub struct LabelRequest {
pub label: String,
}
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = req.label.trim().to_lowercase();
/// Canonicalize and validate a label string. `add_label` and `remove_label`
/// must agree on the form stored in the DB: `add_label` already trims and
/// lowercases before storing, so `remove_label` has to do the same to find
/// the row. Validation is shared so neither handler can drift on charset or
/// length (#344).
fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
let label = raw.trim().to_lowercase();
if label.is_empty() || label.len() > 50 {
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
}
@@ -33,6 +32,17 @@ pub async fn add_label(
"label must contain only alphanumeric characters, hyphens, and colons".into(),
));
}
Ok(label)
}
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = canonicalize_label(&req.label)?;
let record = state
.db
@@ -59,6 +69,8 @@ pub async fn remove_label(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, label)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let label = canonicalize_label(&label)?;
let record = state
.db
.get_repo(&owner, &name)
@@ -66,7 +78,10 @@ pub async fn remove_label(
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
state.db.remove_label(&record.id, &label).await?;
let removed = state.db.remove_label(&record.id, &label).await?;
if !removed {
return Err(AppError::NotFound(format!("label '{label}'")));
}
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
}
+42 -10
View File
@@ -70,6 +70,8 @@ pub struct PeerResponse {
pub reachable: bool,
}
pub(crate) const PUBLIC_HTTP_URL_REQUIREMENT: &str = "must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)";
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
@@ -104,8 +106,8 @@ fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
/// and by the boot-time prune of already-poisoned rows.
/// and `localhost` / `.localhost` / `.local` / `.internal` hostnames. Used at
/// announce time and by the boot-time prune of already-poisoned rows.
pub fn is_public_http_url(raw: &str) -> bool {
let url = match reqwest::Url::parse(raw) {
Ok(u) => u,
@@ -125,6 +127,7 @@ pub fn is_public_http_url(raw: &str) -> bool {
host.truncate(host.trim_end_matches('.').len());
if host.is_empty()
|| host == "localhost"
|| host.ends_with(".localhost")
|| host.ends_with(".local")
|| host.ends_with(".internal")
{
@@ -260,9 +263,9 @@ pub async fn announce(
// and turn our outbound sync-notify fan-out into an SSRF probe — and bury
// the real peers under junk so node-origin repos stop replicating.
if !is_public_http_url(&req.http_url) {
return Err(AppError::BadRequest(
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
));
return Err(AppError::BadRequest(format!(
"http_url {PUBLIC_HTTP_URL_REQUIREMENT}"
)));
}
// Reject self-announcements: a peer row whose http_url is our own public
@@ -541,6 +544,7 @@ mod tests {
fn rejects_loopback_private_and_internal() {
for bad in [
"http://localhost:7545",
"http://node.localhost:7545",
"http://127.0.0.1:5432/",
"http://localhost:22/",
"http://0.0.0.0:7545",
@@ -620,11 +624,14 @@ mod tests {
#[test]
fn rejects_repeated_trailing_root_dots() {
// Stripping a single root dot leaves `localhost.`, which matches
// neither the equality nor the suffix check. The subdomain form
// (`node.localhost..`) is not asserted here: `.localhost` is not yet a
// rejected suffix on this base, so that case belongs to whichever
// change adds it rather than to this one.
for bad in ["http://localhost../", "http://localhost...:7545"] {
// neither the equality nor the suffix check. The subdomain form now
// exercises the `.localhost` suffix rejection together with the extra
// trailing dot.
for bad in [
"http://localhost../",
"http://localhost...:7545",
"http://node.localhost../",
] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
}
@@ -1582,6 +1589,31 @@ mod tests {
)
}
#[sqlx::test]
async fn announce_rejects_localhost_subdomains_with_an_accurate_error(pool: PgPool) {
let state = test_state(pool).await;
let did = Keypair::generate().did().to_string();
let resp = announce_only(state.clone())
.oneshot(announce_as(
&did,
&announce_body(&did, "https://node.localhost:7545"),
))
.await
.unwrap();
let (status, error, message) = status_and_error(resp).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(error, "bad_request");
assert_eq!(
message,
"http_url must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)"
);
assert!(
snapshot(&state.db, &did).await.is_none(),
"a rejected .localhost announce must leave no row behind"
);
}
/// U4 scenario 1, and the first test the keyid branch has ever had: a caller
/// who proved control of one DID must not announce another's. Kills
/// neutralizing the handler's keyid comparison, and kills demoting this
+1 -1
View File
@@ -8289,7 +8289,7 @@ mod tests {
let add = server
.mock("POST", mockito::Matcher::Any)
.with_status(200)
.with_body(r#"{"Hash":"QmShouldNotHappen"}"#)
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
.expect(0)
.create_async()
.await;
+67 -4
View File
@@ -46,9 +46,10 @@ pub async fn create_webhook(
// endpoints (SSRF). Delivery runs on the shared no-redirect client
// (main.rs), which closes the 3xx-to-internal bounce.
if !crate::api::peers::is_public_http_url(&req.url) {
return Err(AppError::BadRequest(
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
));
return Err(AppError::BadRequest(format!(
"webhook URL {}",
crate::api::peers::PUBLIC_HTTP_URL_REQUIREMENT
)));
}
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
@@ -141,7 +142,19 @@ pub async fn delete_webhook(
#[cfg(test)]
mod tests {
use crate::api::peers::is_public_http_url;
use axum::extract::{Extension, Path, State};
use axum::Json;
use chrono::Utc;
use gitlawb_core::identity::Keypair;
use sqlx::PgPool;
use uuid::Uuid;
use super::{create_webhook, CreateWebhookRequest};
use crate::api::peers::{is_public_http_url, PUBLIC_HTTP_URL_REQUIREMENT};
use crate::auth::AuthenticatedDid;
use crate::db::RepoRecord;
use crate::error::AppError;
use crate::test_support::test_state;
// create_webhook gates req.url through is_public_http_url. Pin the exact
// SSRF targets from issue #81 so the webhook path can never regress to the
@@ -152,6 +165,7 @@ mod tests {
"http://127.0.0.1:5432/",
"http://169.254.169.254/latest/meta-data/",
"http://localhost/",
"http://node.localhost/",
"http://10.0.0.5/",
"http://[::1]/",
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
@@ -169,4 +183,53 @@ mod tests {
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
assert!(is_public_http_url("http://203.0.113.10:7545/"));
}
#[sqlx::test]
async fn webhook_localhost_rejection_uses_shared_public_url_contract(pool: PgPool) {
let state = test_state(pool).await;
let owner = Keypair::generate().did().to_string();
let repo_id = Uuid::new_v4().to_string();
state
.db
.create_repo(&RepoRecord {
id: repo_id.clone(),
name: "webhook-contract".to_string(),
owner_did: owner.clone(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: "/tmp/webhook-contract.git".to_string(),
forked_from: None,
machine_id: None,
})
.await
.unwrap();
let result = create_webhook(
State(state.clone()),
Extension(AuthenticatedDid(owner.clone())),
Path((owner, "webhook-contract".to_string())),
Json(CreateWebhookRequest {
url: "https://node.localhost/hook".to_string(),
secret: None,
events: None,
}),
)
.await;
match result {
Err(AppError::BadRequest(message)) => assert_eq!(
message,
format!("webhook URL {PUBLIC_HTTP_URL_REQUIREMENT}")
),
Err(error) => panic!("unexpected webhook rejection: {error}"),
Ok(_) => panic!("a .localhost webhook must be rejected"),
}
assert!(
state.db.list_webhooks(&repo_id).await.unwrap().is_empty(),
"a rejected .localhost webhook must leave no row behind"
);
}
}
File diff suppressed because it is too large. Load diff
+76 -29
View File
@@ -141,19 +141,10 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
return Ok(None);
}
// Try exact match first — fast path for callers passing the full UUID.
// List all refs that start with the given string; the glob also matches the exact
// ref. The id handed back is always git's own refname, never the request input.
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
let check = Command::new("git")
.args(["cat-file", "-e", &exact_ref])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
if check.status.success() {
return Ok(Some(id_or_prefix.to_string()));
}
// Prefix search: list all refs that start with the given string.
let prefix_glob = format!("refs/gitlawb/issues/{id_or_prefix}*");
let prefix_glob = format!("{exact_ref}*");
let list = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", &prefix_glob])
.current_dir(repo_path)
@@ -165,25 +156,44 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
}
let output = String::from_utf8_lossy(&list.stdout);
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
let matches: Vec<&str> = output
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
match matches.len() {
0 => Ok(None),
1 => {
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = matches[0]
.trim()
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(matches[0].trim())
.to_string();
Ok(Some(full_id))
// An exact id wins over longer ids that share it as a prefix, but only if its
// object exists: for-each-ref also lists a ref whose blob is missing, so a
// dangling exact ref falls through to the prefix rules below.
let exact = match matches.iter().find(|m| **m == exact_ref) {
Some(m) => {
let check = Command::new("git")
.args(["cat-file", "-e", m])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
check.status.success().then_some(*m)
}
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
}
None => None,
};
let found = match exact {
Some(m) => m,
None => match matches.as_slice() {
[] => return Ok(None),
[m] => *m,
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
},
};
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = found
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(found)
.to_string();
Ok(Some(full_id))
}
/// Close an issue by updating its status to "closed" in the git ref.
@@ -267,6 +277,43 @@ mod tests {
assert_eq!(resolved, Some(full_id.to_string()));
}
#[test]
fn test_resolve_exact_id_wins_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open"}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
assert_eq!(resolved, Some("abc12345".to_string()));
}
#[test]
fn test_resolve_dangling_exact_id_does_not_win_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open","n":1}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open","n":2}"#).unwrap();
// Dangle the exact ref: the ref stays listed by for-each-ref, but its blob is gone.
let out = Command::new("git")
.args(["rev-parse", "refs/gitlawb/issues/abc12345"])
.current_dir(dir.path())
.output()
.unwrap();
let hash = String::from_utf8(out.stdout).unwrap();
let hash = hash.trim();
std::fs::remove_file(
dir.path()
.join(".git/objects")
.join(&hash[..2])
.join(&hash[2..]),
)
.unwrap();
let result = resolve_issue_id(dir.path(), "abc12345");
assert!(result.unwrap_err().to_string().contains("ambiguous"));
assert!(close_issue(dir.path(), "abc12345").is_err());
}
#[test]
fn test_resolve_prefix_matches_unique() {
let dir = TempDir::new().unwrap();
+97 -16
View File
@@ -1508,9 +1508,6 @@ pub async fn pin_git_object(
return Ok(String::new());
}
// Compute the expected CIDv1 from the content bytes
let expected_cid = Cid::from_git_object_bytes(data).to_string();
let url = format!(
"{}/api/v0/add?cid-version=1&raw-leaves=true&pin=true",
ipfs_api.trim_end_matches('/')
@@ -1557,6 +1554,10 @@ pub async fn pin_git_object(
.text()
.await
.map_err(|e| anyhow::anyhow!("IPFS add response body read failed: {e}"))?;
// A 200 without a parseable Hash is not a successful pin. Fabricating the
// caller-computed CID would record an address the backend never confirmed;
// above its chunk threshold that raw CID is not even a stored block, so
// every later cat fails permanently.
let cid = body
.lines()
.filter(|l| !l.trim().is_empty())
@@ -1565,7 +1566,17 @@ pub async fn pin_git_object(
v["Hash"].as_str().map(|s| s.to_string())
})
.next_back()
.unwrap_or(expected_cid.clone());
.ok_or_else(|| {
anyhow::anyhow!(
"IPFS /api/v0/add returned 200 without a usable Hash: {:.200}",
body
)
})?;
// The string still has to be a CID. A garbage Hash would be recorded and
// later cat-ed the same way a missing one was, so reject it here.
Cid::from_str(&cid).map_err(|e| {
anyhow::anyhow!("IPFS /api/v0/add returned a Hash that is not a CID ({cid:.200}): {e}")
})?;
tracing::debug!(sha256 = %sha256_hex, %cid, "pinned git object to IPFS");
Ok(cid)
@@ -2223,16 +2234,16 @@ mod tests {
endpoint
}
/// A sleeping-but-live endpoint. Answers `200` with an empty body after
/// `delays[i]` for the i-th request it accepts (the last entry repeats), so
/// a test can make one add slow and the next fast. Drains the full request,
/// headers plus the declared `Content-Length` body, before sleeping: exactly
/// as in `rejecting_endpoint`, answering early and closing would surface as
/// a write failure on the client and turn a slow-but-healthy add into a
/// different failure shape.
/// A sleeping-but-live endpoint. Answers `200` with a conformant NDJSON add
/// response after `delays[i]` for the i-th request it accepts (the last
/// entry repeats), so a test can make one add slow and the next fast.
/// Drains the full request, headers plus the declared `Content-Length`
/// body, before sleeping: exactly as in `rejecting_endpoint`, answering
/// early and closing would surface as a write failure on the client and
/// turn a slow-but-healthy add into a different failure shape.
///
/// An empty body is a successful pin: `pin_git_object` falls back to the CID
/// it computed from the bytes when the response carries no `Hash`.
/// The Hash is a fixed, real CID: the timing tests assert on pin counts,
/// not on the recorded address.
async fn delaying_endpoint(delays: Vec<Duration>) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -2269,9 +2280,14 @@ mod tests {
}
}
tokio::time::sleep(delay).await;
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
let body = br#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4","Size":"12"}
"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {}\r\n\r\n",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.write_all(body).await;
let _ = sock.flush().await;
});
}
@@ -2373,6 +2389,71 @@ mod tests {
);
}
/// A 200 add response with no parseable Hash must surface as an add
/// failure. Falling back to the caller-computed CID records an
/// unverifiable address: above the backend's chunk threshold that raw
/// CID is not a stored block, so every later cat 500s.
#[tokio::test]
async fn pin_git_object_rejects_a_200_without_a_hash() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
.with_status(200)
.with_body(r#"{"Name":"object","Size":"19"}"#)
.create_async()
.await;
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
assert!(
result.is_err(),
"a 200 without a Hash must be an add failure, not a fabricated CID: {result:?}"
);
}
/// A Hash that is a string but not a CID must also fail. It would be
/// recorded into `encrypted_blobs.cid` and every later cat would miss.
#[tokio::test]
async fn pin_git_object_rejects_a_malformed_hash() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
.with_status(200)
.with_body(r#"{"Hash":"not-a-cid","Size":"19"}"#)
.create_async()
.await;
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
assert!(
result.is_err(),
"a Hash that is not a CID must be an add failure: {result:?}"
);
}
/// The Hash text goes into the error and `encrypted_pin` logs that error,
/// so a backend answering with a huge invalid Hash must not produce an
/// unbounded log entry.
#[tokio::test]
async fn pin_git_object_bounds_an_oversized_invalid_hash_in_the_error() {
let long = "x".repeat(5000);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
.with_status(200)
.with_body(format!(r#"{{"Hash":"{long}","Size":"19"}}"#))
.create_async()
.await;
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
let err = result.expect_err("an invalid Hash must be an add failure");
let msg = err.to_string();
assert!(
msg.len() < 1000,
"the error must bound the echoed Hash, got {} bytes",
msg.len()
);
assert!(
!msg.contains(&long),
"the error must not carry the full invalid Hash"
);
}
/// The permit-hold bound. `pin_new_objects` runs under a deferring
/// `pin_semaphore`, so without a batch deadline the hold is O(N) with N
/// chosen by the pusher. Five objects against an endpoint that takes 2s
+3 -1
View File
@@ -314,7 +314,9 @@ pub fn record_sync_processed(status: &str) {
}
/// Record one webhook delivery attempt outcome. `result` ∈
/// {ok, http_error, network_error, skipped}.
/// {ok, http_error, network_error, skipped, invalid_url}.
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
/// check at delivery time and the POST was never sent.
pub fn record_webhook_delivery(result: &str) {
if let Some(c) = WEBHOOK_DELIVERIES.get() {
c.with_label_values(&[result]).inc();
File diff suppressed because it is too large. Load diff
+13
View File
@@ -79,6 +79,19 @@ async fn fire_event_async(
let delivery_id = uuid::Uuid::new_v4().to_string();
tokio::spawn(async move {
// Policy skip, not a delivery failure: the stored URL predates the
// create-time gate. The "invalid_url" label is documented on
// record_webhook_delivery in metrics.rs.
if !crate::api::peers::is_public_http_url(&hook.url) {
crate::metrics::record_webhook_delivery("invalid_url");
tracing::warn!(
url = %hook.url,
event = %event_name,
"webhook delivery skipped: stored URL is not a public http(s) target"
);
return;
}
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
let mut req = client
+1 -1
View File
@@ -88,7 +88,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
let short = did
.split(':')
.next_back()
.map(|s| &s[..s.len().min(16)])
.map(|s| crate::text::truncate(s, 16))
.unwrap_or("?");
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
let caps = agent["capabilities"]
+2 -2
View File
@@ -275,7 +275,7 @@ async fn cmd_list(
let title = b["title"].as_str().unwrap_or("?");
let amount = b["amount"].as_i64().unwrap_or(0);
let st = b["status"].as_str().unwrap_or("?");
let short_id = &id[..8.min(id.len())];
let short_id = crate::text::truncate(id, 8);
println!("{short_id} {st:<10} {amount:>12} $GITLAWB {title}");
}
}
@@ -403,7 +403,7 @@ async fn cmd_stats(node: String) -> Result<()> {
let did = entry["did"].as_str().unwrap_or("?");
let count = entry["completed"].as_i64().unwrap_or(0);
let earned = entry["total_earned"].as_i64().unwrap_or(0);
let short = &did[did.len().saturating_sub(8)..];
let short = crate::text::truncate_suffix(did, 8);
println!(
" {}. ...{short} {count} bounties {earned} $GITLAWB",
i + 1
+4 -1
View File
@@ -117,7 +117,10 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let issued_at = cert["issued_at"].as_str().map(|s| &s[..19]).unwrap_or("?");
let issued_at = cert["issued_at"]
.as_str()
.map(|s| crate::text::truncate(s, 19))
.unwrap_or("?");
println!(" {id:.8} {issued_at} {ref_name} {new_sha:.12}");
}
Ok(())
+2 -2
View File
@@ -75,14 +75,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
for event in &events {
let ts = event["timestamp"].as_str().unwrap_or("?");
let date = &ts[..ts.len().min(10)];
let date = crate::text::truncate(ts, 10);
match event["type"].as_str().unwrap_or("") {
"commit" => {
let sha = event["sha"].as_str().unwrap_or("?");
let msg = event["message"].as_str().unwrap_or("?");
let first_line = msg.lines().next().unwrap_or(msg);
let short_sha = &sha[..sha.len().min(8)];
let short_sha = crate::text::truncate(sha, 8);
println!(" {date} commit {short_sha} {first_line}");
}
"pr_merged" => {
+1 -5
View File
@@ -131,11 +131,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
// Trim pinned_at to date+time without subseconds
let ts = if pinned_at.len() >= 19 {
&pinned_at[..19]
} else {
pinned_at
};
let ts = crate::text::truncate(pinned_at, 19);
println!(" {cid}");
println!(" sha256: {sha}");
println!(" pinned: {ts}");
+34 -3
View File
@@ -251,7 +251,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let status = issue["status"].as_str().unwrap_or("?");
let created = issue["created_at"]
.as_str()
.map(|s| &s[..10])
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
let icon = match status {
"open" => "○",
@@ -365,10 +365,13 @@ async fn cmd_issue_comments(
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.map(|s| crate::text::truncate(s, 8))
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
let created = c["created_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
println!(" · {author_short} ({created})");
println!(" {cbody}");
println!();
@@ -439,6 +442,34 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn test_cmd_list_survives_malformed_timestamps() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// A short timestamp used to panic on `&s[..10]`; a timestamp whose
// byte 10 is mid-char panics even with a len() guard.
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"issues":[{"id":"a","title":"short ts","status":"open","created_at":"2026"},{"id":"b","title":"mb ts","status":"open","created_at":"2026-08-1é5T00:00:00Z"},{"id":"c","title":"empty ts","status":"open","created_at":""}]}"#)
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_create_success() {
let dir = TempDir::new().unwrap();
+1
View File
@@ -30,6 +30,7 @@ mod star;
mod status;
mod sync;
mod task;
mod text;
mod ucan_cmd;
mod visibility;
mod webhook;
+1 -1
View File
@@ -413,7 +413,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
let ref_name = ev["ref"].as_str().unwrap_or("?");
let ts = ev["timestamp"]
.as_str()
.map(|s| &s[..10.min(s.len())])
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
println!(" {ts} {repo} {ref_name}");
}
+1 -1
View File
@@ -97,7 +97,7 @@ async fn cmd_list(node: String) -> Result<()> {
let reachable = peer["reachable"].as_bool().unwrap_or(false);
let last_seen = peer["last_seen"]
.as_str()
.map(|s| &s[..10])
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("never");
let status = if reachable { "✓" } else { "✗" };
println!(" {status} {url}");
+13 -7
View File
@@ -304,7 +304,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.map(|s| crate::text::truncate(s, 8))
.unwrap_or("?");
let status_icon = match status {
"open" => "○",
@@ -374,7 +374,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let reviewer_short = reviewer
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.map(|s| crate::text::truncate(s, 8))
.unwrap_or("?");
let rstatus = r["status"].as_str().unwrap_or("?");
let rbody = r["body"].as_str().unwrap_or("");
@@ -414,10 +414,13 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.map(|s| crate::text::truncate(s, 8))
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
let created = c["created_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
println!(" · {author_short} ({created})");
println!(" {cbody}");
}
@@ -464,7 +467,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
let sha = result["merge_sha"].as_str().unwrap_or("?");
println!("✓ Merged PR #{number}");
println!(" Merge commit: {}", &sha[..sha.len().min(12)]);
println!(" Merge commit: {}", crate::text::truncate(sha, 12));
Ok(())
}
@@ -555,10 +558,13 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.map(|s| crate::text::truncate(s, 8))
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
let created = c["created_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
println!(" · {author_short} ({created})");
println!(" {cbody}");
println!();
+7 -4
View File
@@ -337,7 +337,10 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
let name = r["name"].as_str().unwrap_or("?");
let desc = r["description"].as_str().unwrap_or("");
let public = r["is_public"].as_bool().unwrap_or(true);
let updated = r["updated_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
let updated = r["updated_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
let vis = if public { "public" } else { "private" };
println!(" {name:<24} {vis:<8} {updated} {desc}");
}
@@ -514,7 +517,7 @@ async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Resul
let url = r["replica_url"].as_str().unwrap_or("?");
let registered = r["registered_at"]
.as_str()
.map(|s| &s[..10.min(s.len())])
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
println!(" {registered} {did} → {url}");
}
@@ -556,7 +559,7 @@ pub(crate) async fn cmd_commits(
.or_else(|| c["sha"].as_str())
.or_else(|| c["oid"].as_str())
.unwrap_or("?");
let short_sha = &sha[..sha.len().min(10)];
let short_sha = crate::text::truncate(sha, 10);
let msg = c["message"].as_str().unwrap_or("(no message)");
let first_line = msg.lines().next().unwrap_or(msg);
let author = c["author_name"]
@@ -566,7 +569,7 @@ pub(crate) async fn cmd_commits(
let date = c["date"]
.as_str()
.or_else(|| c["committer_date"].as_str())
.map(|s| &s[..10.min(s.len())])
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
println!(" {short_sha} {date} {first_line} ({author})");
}
+63
View File
@@ -0,0 +1,63 @@
//! Display helpers for strings that come off the wire unchecked.
/// Truncate `s` to at most `max` bytes, cutting at a char boundary.
///
/// `&s[..max]` panics when `s` is shorter than `max` bytes and when `max`
/// lands inside a multi-byte char; `s.len().min(max)` guards only the first
/// case. Node-supplied timestamps and ids hit both.
pub(crate) fn truncate(s: &str, max: usize) -> &str {
let mut end = max.min(s.len());
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
/// Keep at most the last `max` bytes of `s`, cutting at a char boundary.
///
/// `&s[s.len().saturating_sub(max)..]` panics when the start index lands
/// inside a multi-byte char; node-supplied ids hit that case.
pub(crate) fn truncate_suffix(s: &str, max: usize) -> &str {
let mut start = s.len().saturating_sub(max);
while !s.is_char_boundary(start) {
start += 1;
}
&s[start..]
}
#[cfg(test)]
mod tests {
use super::{truncate, truncate_suffix};
#[test]
fn cuts_ascii_at_max() {
assert_eq!(truncate("2026-08-15T12:34:56Z", 10), "2026-08-15");
}
#[test]
fn short_and_empty_inputs_return_whole() {
assert_eq!(truncate("2026", 10), "2026");
assert_eq!(truncate("", 10), "");
}
#[test]
fn multibyte_cut_does_not_panic() {
// Byte index 10 lands inside 'é'; the cut must land on the boundary
// before it (9 bytes) rather than panic or exceed the byte limit.
assert_eq!(truncate("2026-08-1\u{e9}5T12:34:56Z", 10), "2026-08-1");
// A string that is entirely multi-byte and shorter than max.
assert_eq!(truncate("\u{e9}\u{e9}", 10), "\u{e9}\u{e9}");
}
#[test]
fn suffix_cut_does_not_panic() {
// 'é' is two bytes; a start index of len-8 lands inside it.
assert_eq!(truncate_suffix("did:key:\u{e9}1234567", 8), "1234567");
}
#[test]
fn suffix_short_input_returns_whole() {
assert_eq!(truncate_suffix("short", 8), "short");
assert_eq!(truncate_suffix("", 8), "");
}
}
+213 -24
View File
@@ -88,20 +88,81 @@ fn sanitize_excerpt(s: &str) -> String {
out
}
/// Whether `u` parses as a trusted URL.
/// Fixed warning when `GITLAWB_ICAPTCHA_INSECURE` is present but not an
/// explicit truthy value. Does not include the raw env value (arbitrary
/// deployment input — secrets / terminal controls).
fn warn_icaptcha_insecure_non_truthy() {
// One literal line on purpose: a continuation escape here keeps emitting
// correct prose (Rust strips the next line's leading whitespace), but the
// single-line form makes that property visible without knowing the escape
// semantics, which is what the review asked for.
tracing::warn!(
"GITLAWB_ICAPTCHA_INSECURE is set but not truthy (expected 1 or true); loopback HTTP trust relaxation remains disabled"
);
}
/// Parse a possibly-present `GITLAWB_ICAPTCHA_INSECURE` string.
///
/// Production trusts only `https`. When `GITLAWB_ICAPTCHA_INSECURE` is set (a
/// runtime escape hatch for integration tests) the function also trusts
/// `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry path
/// can be exercised against a local mockito server.
fn is_https(u: &str) -> bool {
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
/// enables the loopback-HTTP trust relaxation. Presence alone is not enough:
/// `=0`, `=false`, or an empty value leave it off. A non-empty non-truthy
/// value emits [`warn_icaptcha_insecure_non_truthy`].
fn icaptcha_insecure_from_str(raw: Option<&str>) -> bool {
let Some(v) = raw else {
return false;
};
let t = v.trim();
if t.eq_ignore_ascii_case("1") || t.eq_ignore_ascii_case("true") {
true
} else {
if !t.is_empty() {
warn_icaptcha_insecure_non_truthy();
}
false
}
}
/// Interpret `std::env::var("GITLAWB_ICAPTCHA_INSECURE")` without reading the
/// environment again — used by production and by unit tests.
///
/// `NotPresent` → disabled. `NotUnicode` → disabled + the same fixed warning
/// as a present non-truthy value (fail closed; do not treat as unset).
fn icaptcha_insecure_from_var(result: Result<String, std::env::VarError>) -> bool {
match result {
Ok(v) => icaptcha_insecure_from_str(Some(&v)),
Err(std::env::VarError::NotUnicode(_)) => {
warn_icaptcha_insecure_non_truthy();
false
}
Err(std::env::VarError::NotPresent) => false,
}
}
/// Whether `GITLAWB_ICAPTCHA_INSECURE` explicitly enables the loopback-HTTP
/// trust relaxation used by integration tests.
///
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
/// enables it. Presence alone is not enough: `=0`, `=false`, or an empty
/// value leave the relaxation off. A non-empty non-truthy value (including a
/// present non-Unicode value) emits a `tracing::warn!` (visible when the
/// `icaptcha_client` target is at WARN+, e.g. `RUST_LOG=icaptcha_client=warn`).
fn icaptcha_insecure_enabled() -> bool {
icaptcha_insecure_from_var(std::env::var("GITLAWB_ICAPTCHA_INSECURE"))
}
/// Whether `u` parses as a trusted URL, given a pre-parsed insecure flag.
///
/// Production trusts only `https`. When `insecure` is true the function also
/// trusts `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry
/// path can be exercised against a local mockito server.
fn is_https_with(u: &str, insecure: bool) -> bool {
let Ok(parsed) = reqwest::Url::parse(u) else {
return false;
};
if parsed.scheme() == "https" {
return true;
}
if std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some()
if insecure
&& parsed.scheme() == "http"
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
{
@@ -139,7 +200,15 @@ fn origin_key(u: &str) -> Option<String> {
/// explicitly-configured host (never a node-discovered one), so a key is never
/// exfiltrated to an origin the operator did not choose.
fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) {
let operator = operator.filter(|u| is_https(u));
resolve_solver_url_with(advertised, operator, icaptcha_insecure_enabled())
}
fn resolve_solver_url_with(
advertised: Option<&str>,
operator: Option<&str>,
insecure: bool,
) -> (String, bool) {
let operator = operator.filter(|u| is_https_with(u, insecure));
let operator_origin = operator.as_ref().and_then(|u| origin_key(u));
let default_origin = origin_key(DEFAULT_URL);
@@ -157,7 +226,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
};
let chosen = match advertised {
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
Some(a) if is_https_with(a, insecure) && allowed(&origin_key(a)) => a.to_string(),
Some(a) => {
tracing::warn!(
advertised = %a,
@@ -389,27 +458,36 @@ mod tests {
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard};
/// Serializes tests that touch the process-global
/// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race.
/// Serializes the real-env reader test so it never races with other
/// process-global env mutations (and recovers from a poisoned lock).
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
/// prior value on drop.
struct InsecureEnv {
struct EnvGuard {
_lock: MutexGuard<'static, ()>,
prev: Option<OsString>,
}
impl InsecureEnv {
fn new() -> Self {
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
impl EnvGuard {
fn lock() -> MutexGuard<'static, ()> {
ICAPTCHA_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn with_value(val: &str) -> Self {
let lock = Self::lock();
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
InsecureEnv { _lock: lock, prev }
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", val);
EnvGuard { _lock: lock, prev }
}
fn unset() -> Self {
let lock = Self::lock();
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE");
EnvGuard { _lock: lock, prev }
}
}
impl Drop for InsecureEnv {
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.prev.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
@@ -418,6 +496,118 @@ mod tests {
}
}
/// #227 / #246: cover the production env read in `icaptcha_insecure_enabled`.
/// Pure-helper tests alone leave mutations of this function green.
#[test]
fn icaptcha_insecure_enabled_reads_real_env() {
{
let _g = EnvGuard::unset();
assert!(!icaptcha_insecure_enabled(), "unset must disable");
}
{
let _g = EnvGuard::with_value("0");
assert!(!icaptcha_insecure_enabled(), "=0 must disable");
}
{
let _g = EnvGuard::with_value("false");
assert!(!icaptcha_insecure_enabled(), "=false must disable");
}
{
let _g = EnvGuard::with_value("");
assert!(!icaptcha_insecure_enabled(), "empty must disable");
}
{
let _g = EnvGuard::with_value("1");
assert!(icaptcha_insecure_enabled(), "=1 must enable");
}
{
let _g = EnvGuard::with_value("true");
assert!(icaptcha_insecure_enabled(), "=true must enable");
}
{
let _g = EnvGuard::with_value("TRUE");
assert!(icaptcha_insecure_enabled(), "=TRUE must enable");
}
{
let _g = EnvGuard::with_value(" 1 ");
assert!(icaptcha_insecure_enabled(), "\" 1 \" (trimmed) must enable");
}
}
/// #227: `=0` / `=false` / empty / unset must NOT enable loopback HTTP;
/// only explicit truthy `1` / `true` may. Uses pure helpers so tests do
/// not mutate process-global environment (avoids races with parallel
/// resolver tests that also call `resolve_solver_url`).
#[test]
fn insecure_env_only_truthy_enables_loopback_http() {
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("0"))),
"=0 must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("0")));
assert!(
!is_https_with(
"http://127.0.0.1",
icaptcha_insecure_from_str(Some("false"))
),
"=false must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("false")));
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(""))),
"empty value must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("")));
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(None)),
"unset must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(None));
assert!(!icaptcha_insecure_from_var(Err(
std::env::VarError::NotPresent
)));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("1"))),
"=1 must enable loopback HTTP"
);
assert!(is_https_with(
"http://localhost",
icaptcha_insecure_from_str(Some("1"))
));
assert!(icaptcha_insecure_from_str(Some("1")));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("true"))),
"=true must enable loopback HTTP"
);
assert!(icaptcha_insecure_from_str(Some("true")));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("TRUE"))),
"=TRUE (case-insensitive) must enable loopback HTTP"
);
// Trim is load-bearing: whitespace around a truthy token must still enable.
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(" 1 "))),
"\" 1 \" (trimmed) must enable loopback HTTP"
);
assert!(icaptcha_insecure_from_str(Some(" 1 ")));
// Present non-Unicode: fail closed + same fixed warning path as non-truthy.
assert!(!icaptcha_insecure_from_var(Err(
std::env::VarError::NotUnicode(OsString::from("x"))
)));
// https remains trusted regardless of the insecure flag.
assert!(is_https_with("https://icaptcha.gitlawb.com", false));
assert!(is_https_with("https://icaptcha.gitlawb.com", true));
}
// ── P1: hostile error bodies must not reach the terminal raw ──────────
#[test]
@@ -543,13 +733,12 @@ mod tests {
#[test]
fn rejects_insecure_advertised_url_on_different_port() {
// With GITLAWB_ICAPTCHA_INSECURE=1, two loopback URLs on different
// With insecure mode enabled, two loopback URLs on different
// ports must be treated as distinct origins so a hostile node cannot
// redirect the bearer key to a different listener on localhost.
let _env = InsecureEnv::new();
let op = "http://localhost:3000";
let (url, key_trusted) = resolve_solver_url(Some("http://localhost:9000"), Some(op));
let (url, key_trusted) =
resolve_solver_url_with(Some("http://localhost:9000"), Some(op), true);
assert_eq!(
url, op,
"must fall back to operator origin, not use different port"