Skip to content
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,8 @@ config. Design:
capture) through F6 (acceptance on the first deployment); F0, F1 (server
skeleton, users, organizations, tokens, `admin` CLI), F2a (repositories API, lifecycle
and reconciler, collaborators, branches) and F2b (smart-HTTP git transport via `git http-backend`,
`pre-receive` ref policy, admission control, threat-model pass #1) are built. Hosted repos are
`pre-receive` ref policy, admission control, threat-model pass #1) and F3a (content reads: refs, tags, commits,
trees, contents, archives; writes are F3b) are built. Hosted repos are
bare Git and get none of the `.sc/` features.

Other follow-up work is tracked as GitHub issues and listed under Deferred
Expand Down
2 changes: 1 addition & 1 deletion crates/forge/conformance/forge-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ for _ in $(seq 50); do
done
python3 -I "$here/run.py" check --base-url "http://127.0.0.1:$port" --admin-token "$token" \
--fixtures "$here/fixtures/1.22.6" --departures "$here/departures.json" --pending "$here/slice-pending.json" \
--only A,B,C,J,K,L,M,N,R,V "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
--only A,B,C,D,E,J,K,L,M,N,R,V "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
64 changes: 60 additions & 4 deletions crates/forge/conformance/slice-pending.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,62 @@
{
"B": [25, "GET /api/v1/repos/alice/scoped/contents/README.md"],
"J": {"defer": [[56, "GET /api/v1/repos/acme/smart/tags"], [57, "GET /api/v1/repos/acme/smart/git/refs/tags"], [59, "GET /api/v1/repos/acme/smart/tags"]]},
"K": {"defer": [[21, "GET /api/v1/repos/acme/vis/contents"], [26, "GET /api/v1/repos/acme/ghost/contents"], [31, "GET /api/v1/repos/acme/vis/contents"], [36, "GET /api/v1/repos/acme/ghost/contents"], [55, "GET /api/v1/orgs/acme/repos"], [56, "GET /api/v1/orgs/acme/repos?limit=50"]]},
"N": {"defer": [[6, "GET /api/v1/repos/acme/dflt/contents"]]}
"B": {
"defer": [
[
30,
"POST /api/v1/repos/alice/scoped/statuses/<sha#2>"
]
]
},
"D": {
"defer": [
[
13,
"GET /api/v1/repos/acme/blank/pulls?state=all"
]
]
},
"E": {
"stop": [
42,
"KeyError: 'sha'"
],
"defer": [
[
36,
"POST /api/v1/repos/acme/hist/contents/new.txt"
],
[
37,
"POST /api/v1/repos/acme/hist/contents/new.txt"
],
[
38,
"PUT /api/v1/repos/acme/hist/contents/README.md"
],
[
39,
"PUT /api/v1/repos/acme/hist/contents/README.md"
],
[
40,
"PUT /api/v1/repos/acme/hist/contents/README.md"
],
[
41,
"POST /api/v1/repos/acme/hist/contents/branch-file.txt"
]
]
},
"K": {
"defer": [
[
55,
"GET /api/v1/orgs/acme/repos"
],
[
56,
"GET /api/v1/orgs/acme/repos?limit=50"
]
]
}
}
51 changes: 51 additions & 0 deletions crates/forge/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,57 @@ pub(crate) fn paged_with(
r
}

/// Like [`paged`], but a page link repeats *every* request parameter, alphabetically by name (what
/// Gitea does for the commits listing), with `limit` and `page` set to the link's own values.
pub(crate) fn paged_sorted(
ctx: &Ctx,
path: &str,
total: i64,
page: i64,
limit: i64,
items: Vec<Value>,
) -> Resp {
let mut q: std::collections::BTreeMap<String, String> = ctx
.req
.query
.split('&')
.filter(|kv| !kv.is_empty())
.map(|kv| {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
(k.to_string(), v.to_string())
})
.rev() // the request parser reads the first value of a repeated parameter; the last insert wins
.collect();
q.insert("limit".into(), limit.to_string());
let last = ((total + limit - 1) / limit).max(1);
// Gitea builds the links from the clamped page: asking past the end still points `prev` at the
// page before the last.
let page = page.min(last);
let mut url = |p: i64, rel: &str| {
q.insert("page".into(), p.to_string());
let query: Vec<String> = q.iter().map(|(k, v)| format!("{k}={v}")).collect();
format!(
"<{}/api/v1{path}?{}>; rel=\"{rel}\"",
ctx.app.base,
query.join("&")
)
};
let mut links = Vec::new();
if page < last {
links.push(url(page + 1, "next"));
links.push(url(last, "last"));
}
if page > 1 {
links.push(url(1, "first"));
links.push(url(page - 1, "prev"));
}
let mut r = Resp::json(200, &Value::Array(items)).with("x-total-count", total.to_string());
if !links.is_empty() {
r = r.with("link", links.join(","));
}
r
}

/// `GET /version`: the Gitea release whose behaviour this server reproduces.
pub fn version(_: &mut Ctx) -> Resp {
Resp::json(200, &json!({"version": GITEA_VERSION}))
Expand Down
81 changes: 81 additions & 0 deletions crates/forge/src/b64.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! Standard base64 (RFC 4648, padded), for the contents API's file bodies and uploads.

const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/// Encode `data` with padding.
pub fn encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (u32::from(c[0]) << 16)
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
| u32::from(*c.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= c.len() {
out.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}

/// Decode padded standard base64; ASCII whitespace (line breaks) is ignored. `None` for anything
/// else outside the alphabet, bad padding, or a length that is not a multiple of four.
pub fn decode(s: &str) -> Option<Vec<u8>> {
let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if !bytes.len().is_multiple_of(4) {
return None;
}
let val = |b: u8| T.iter().position(|&t| t == b).map(|p| p as u32);
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
let chunks = bytes.chunks(4).count();
for (i, c) in bytes.chunks(4).enumerate() {
let pad = c.iter().rev().take_while(|&&b| b == b'=').count();
if pad > 2 || (pad > 0 && i + 1 != chunks) {
return None;
}
let mut n = 0u32;
for (j, &b) in c.iter().enumerate() {
n <<= 6;
if j < 4 - pad {
n |= val(b)?;
} else if b != b'=' {
return None;
}
}
let b3 = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
out.extend_from_slice(&b3[..3 - pad]);
}
Some(out)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn round_trips_every_padding_length() {
for (raw, enc) in [
(&b""[..], ""),
(b"f", "Zg=="),
(b"fo", "Zm8="),
(b"foo", "Zm9v"),
(b"foob", "Zm9vYg=="),
(b"hello\n", "aGVsbG8K"),
] {
assert_eq!(encode(raw), enc);
assert_eq!(decode(enc).as_deref(), Some(raw));
}
let all: Vec<u8> = (0..=255).collect();
assert_eq!(decode(&encode(&all)), Some(all));
}

#[test]
fn decode_refuses_malformed_input_and_ignores_line_breaks() {
assert_eq!(decode("Zm9v\nYg==\n").as_deref(), Some(&b"foob"[..]));
for bad in ["Zg=", "Z", "Zg==Zg==", "Zm9v!", "=Zg=", "Zm=v", "Zg=a"] {
assert!(decode(bad).is_none(), "{bad}");
}
}
}
1 change: 1 addition & 0 deletions crates/forge/src/cgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ fn text(status: u16, body: &str) -> crate::http::Resp {
headers: vec![("content-type", "text/plain;charset=utf-8".into())],
body: body.as_bytes().to_vec(),
delay: Duration::ZERO,
hold: None,
}
}

Expand Down
Loading
Loading