#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
pub mod auth;
pub mod bodies;
pub mod config;
pub mod error;
pub mod hosted;
pub mod html;
pub mod ownership;
pub mod repo;
pub mod request;
pub mod router;
pub mod spine;
pub mod token;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use solid_pod_rs_git::service::{GitHttpService, GitRequest};
pub use bodies::{HostedReader, LoopbackFetch};
pub use config::ForgeConfig;
pub use error::ForgeError;
pub use hosted::HostedStore;
pub use ownership::ForgeAgent;
pub use request::{esc, ForgeRequest, ForgeResponse};
pub use router::{parse_route, Route};
pub use spine::{FsSpineStore, SpineStore};
pub use token::TokenError;
pub mod prelude {
pub use crate::config::ForgeConfig;
pub use crate::error::ForgeError;
pub use crate::ownership::ForgeAgent;
pub use crate::request::{ForgeRequest, ForgeResponse};
pub use crate::ForgeService;
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn load_or_create_token_key(plugin_dir: &Path) -> Result<[u8; 32], ForgeError> {
let path = plugin_dir.join(".forge-token-key");
if let Ok(bytes) = std::fs::read(&path) {
if bytes.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(key);
}
}
let mut key = [0u8; 32];
key[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
key[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
std::fs::write(&path, key)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(key)
}
#[derive(Clone)]
pub struct ForgeService {
cfg: ForgeConfig,
plugin_dir: PathBuf,
repo_root: PathBuf,
git: Arc<GitHttpService>,
spine: Arc<dyn SpineStore>,
loopback: Option<Arc<dyn LoopbackFetch>>,
hosted: Arc<HostedStore>,
token_key: Arc<[u8; 32]>,
}
impl std::fmt::Debug for ForgeService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForgeService")
.field("prefix", &self.cfg.normalized_prefix())
.field("plugin_dir", &self.plugin_dir)
.finish()
}
}
impl ForgeService {
pub fn new(cfg: ForgeConfig, plugin_dir: impl Into<PathBuf>) -> Result<Self, ForgeError> {
let plugin_dir = plugin_dir.into();
let repo_root = plugin_dir.join("repos");
for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
std::fs::create_dir_all(plugin_dir.join(sub))?;
}
let git = Arc::new(GitHttpService::new(repo_root.clone()));
let spine: Arc<dyn SpineStore> = Arc::new(FsSpineStore::new(plugin_dir.clone()));
let hosted = Arc::new(HostedStore::new(plugin_dir.clone()));
let token_key = Arc::new(load_or_create_token_key(&plugin_dir)?);
Ok(Self {
cfg,
plugin_dir,
repo_root,
git,
spine,
loopback: None,
hosted,
token_key,
})
}
#[must_use]
pub fn with_loopback(mut self, lb: Arc<dyn LoopbackFetch>) -> Self {
self.loopback = Some(lb);
self
}
#[must_use]
pub fn with_spine(mut self, spine: Arc<dyn SpineStore>) -> Self {
self.spine = spine;
self
}
#[must_use]
pub fn token_key(&self) -> &[u8; 32] {
&self.token_key
}
#[must_use]
pub fn resolve_agent(&self, req: &ForgeRequest) -> ForgeAgent {
auth::resolve_agent(req, self.token_key.as_ref(), now_secs())
}
#[must_use]
pub fn prefix(&self) -> String {
self.cfg.normalized_prefix()
}
#[must_use]
pub fn plugin_dir(&self) -> &Path {
&self.plugin_dir
}
pub async fn handle(
&self,
req: ForgeRequest,
agent: ForgeAgent,
) -> Result<ForgeResponse, ForgeError> {
if req.method.eq_ignore_ascii_case("OPTIONS") {
return Ok(ForgeResponse {
status: 204,
headers: vec![
("access-control-allow-origin".into(), "*".into()),
(
"access-control-allow-methods".into(),
"GET, POST, PUT, DELETE, OPTIONS".into(),
),
(
"access-control-allow-headers".into(),
"Content-Type, Authorization".into(),
),
],
body: bytes::Bytes::new(),
});
}
let prefix = self.cfg.normalized_prefix();
let Some(rel) = router::strip_prefix(&prefix, &req.path) else {
return Ok(ForgeResponse::error(404, "not under forge prefix"));
};
let route = parse_route(&rel);
let result = self.dispatch(route, &req, &agent).await;
Ok(result.unwrap_or_else(|e| e.to_response()))
}
async fn dispatch(
&self,
route: Route,
req: &ForgeRequest,
_agent: &ForgeAgent,
) -> Result<ForgeResponse, ForgeError> {
match route {
Route::Index => self.h_index().await,
Route::OwnerIndex { owner } => self.h_owner(&owner).await,
Route::GitSmart { rel_path } => self.h_git_smart(req, _agent, &rel_path).await,
Route::RepoOverview { owner, repo } => self.h_overview(&owner, &repo).await,
Route::Tree {
owner,
repo,
rev,
path,
} => self.h_tree(&owner, &repo, &rev, &path).await,
Route::Blob {
owner,
repo,
rev,
path,
} => self.h_blob(&owner, &repo, &rev, &path).await,
Route::Raw {
owner,
repo,
rev,
path,
} => self.h_raw(&owner, &repo, &rev, &path).await,
Route::Commits { owner, repo, rev } => self.h_commits(req, &owner, &repo, &rev).await,
Route::Commit { owner, repo, sha } => self.h_commit(&owner, &repo, &sha).await,
Route::Branches { owner, repo } => self.h_branches(&owner, &repo).await,
Route::Tags { owner, repo } => self.h_tags(&owner, &repo).await,
Route::Issues { owner, repo } => {
if req.method.eq_ignore_ascii_case("POST") {
self.h_issue_create(req, _agent, &owner, &repo).await
} else {
self.h_issues_list(req, &owner, &repo).await
}
}
Route::IssueNew { owner, repo } => self.h_issue_new_form(&owner, &repo).await,
Route::IssueDetail { owner, repo, num } => {
if req.method.eq_ignore_ascii_case("POST") {
self.h_issue_comment(req, _agent, &owner, &repo, num).await
} else {
self.h_issue_detail(&owner, &repo, num).await
}
}
Route::ApiToken => self.h_api_token(req, _agent).await,
Route::ApiHosted { hex, id } => self.h_api_hosted(req, _agent, &hex, &id).await,
_ => Err(ForgeError::NotFound("not implemented".into())),
}
}
async fn resolve_repo(&self, owner: &str, repo: &str) -> Result<PathBuf, ForgeError> {
let dir = repo::repo_git_dir(&self.repo_root, owner, repo)?;
if !tokio::fs::metadata(&dir)
.await
.map(|m| m.is_dir())
.unwrap_or(false)
{
return Err(ForgeError::NotFound(format!("repo {owner}/{repo}")));
}
Ok(dir)
}
async fn h_index(&self) -> Result<ForgeResponse, ForgeError> {
let repos = repo::list_all(&self.repo_root).await;
Ok(ForgeResponse::html(
200,
html::index_page(&self.prefix(), &repos),
))
}
async fn h_owner(&self, owner: &str) -> Result<ForgeResponse, ForgeError> {
let repos = repo::list_owner(&self.repo_root, owner).await;
if repos.is_empty() {
return Err(ForgeError::NotFound(format!("owner {owner}")));
}
Ok(ForgeResponse::html(
200,
html::owner_page(&self.prefix(), owner, &repos),
))
}
async fn h_git_smart(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
rel_path: &str,
) -> Result<ForgeResponse, ForgeError> {
let git_req = GitRequest {
method: req.method.clone(),
path: rel_path.to_string(),
query: req.query.clone(),
headers: req.headers.clone(),
body: req.raw_body.clone(),
host_url: req.host_url.clone(),
};
if git_req.is_write() {
let owner = rel_path
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("");
if !agent.can_write_namespace(owner) {
return Err(ForgeError::Forbidden(format!(
"cannot push into namespace '{owner}'"
)));
}
}
let resp = self.git.handle(git_req).await?;
Ok(ForgeResponse {
status: resp.status,
headers: resp.headers,
body: resp.body,
})
}
async fn h_overview(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let prefix = self.prefix();
if !repo::browse::has_commits(&dir).await {
return Ok(ForgeResponse::html(
200,
html::repo_overview_page(&prefix, owner, repo, "main", &[], None),
));
}
let branch = repo::browse::default_branch(&dir).await;
let entries = repo::browse::list_tree(&dir, &branch, "").await?;
let readme = self.find_readme(&dir, &branch, &entries).await;
Ok(ForgeResponse::html(
200,
html::repo_overview_page(&prefix, owner, repo, &branch, &entries, readme.as_deref()),
))
}
async fn find_readme(
&self,
dir: &Path,
rev: &str,
entries: &[repo::browse::TreeEntry],
) -> Option<String> {
let candidate = entries.iter().find(|e| {
e.kind == repo::browse::EntryKind::File && {
let n = e.name.to_ascii_lowercase();
n == "readme" || n == "readme.md" || n == "readme.txt"
}
})?;
let bytes =
repo::browse::read_blob(dir, rev, &candidate.name, self.cfg.max_body_bytes as u64)
.await
.ok()?;
if request::looks_textual(&bytes) {
Some(String::from_utf8_lossy(&bytes).into_owned())
} else {
None
}
}
async fn h_tree(
&self,
owner: &str,
repo: &str,
rev: &str,
path: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let entries = repo::browse::list_tree(&dir, rev, path).await?;
Ok(ForgeResponse::html(
200,
html::tree_page(&self.prefix(), owner, repo, rev, path, &entries),
))
}
async fn h_blob(
&self,
owner: &str,
repo: &str,
rev: &str,
path: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
const BLOB_VIEW_MAX: u64 = 1024 * 1024;
let content = match repo::browse::read_blob(&dir, rev, path, BLOB_VIEW_MAX).await {
Ok(bytes) => {
if request::looks_textual(&bytes) {
Some(String::from_utf8_lossy(&bytes).into_owned())
} else {
None
}
}
Err(ForgeError::BadRequest(_)) => None,
Err(e) => return Err(e),
};
Ok(ForgeResponse::html(
200,
html::blob_page(&self.prefix(), owner, repo, rev, path, content.as_deref()),
))
}
async fn h_raw(
&self,
owner: &str,
repo: &str,
rev: &str,
path: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
const RAW_MAX: u64 = 25 * 1024 * 1024;
let bytes = repo::browse::read_blob(&dir, rev, path, RAW_MAX).await?;
let is_text = request::looks_textual(&bytes);
let filename = path.rsplit('/').next().unwrap_or("download");
Ok(ForgeResponse::raw_bytes(
bytes::Bytes::from(bytes),
is_text,
filename,
))
}
async fn h_commits(
&self,
req: &ForgeRequest,
owner: &str,
repo: &str,
rev: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let page: u32 = req
.query_param("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1)
.max(1);
let (commits, has_next) = repo::browse::commit_log(&dir, rev, page, 50).await?;
Ok(ForgeResponse::html(
200,
html::commits_page(&self.prefix(), owner, repo, rev, &commits, page, has_next),
))
}
async fn h_commit(
&self,
owner: &str,
repo: &str,
sha: &str,
) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let meta = solid_pod_rs_git::api::resolve_commit(&dir, sha).await?;
let patch = repo::browse::commit_patch(&dir, &meta.hash).await?;
Ok(ForgeResponse::html(
200,
html::commit_page(&self.prefix(), owner, repo, &meta, &patch),
))
}
async fn h_branches(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let info = solid_pod_rs_git::api::git_branches(&dir).await?;
Ok(ForgeResponse::html(
200,
html::refs_page(
&self.prefix(),
owner,
repo,
"Branches",
Some(&info.current),
&info.local,
"commits",
),
))
}
async fn h_tags(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
let dir = self.resolve_repo(owner, repo).await?;
let tags = repo::browse::list_tags(&dir).await?;
Ok(ForgeResponse::html(
200,
html::refs_page(&self.prefix(), owner, repo, "Tags", None, &tags, "tree"),
))
}
async fn h_issues_list(
&self,
req: &ForgeRequest,
owner: &str,
repo: &str,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let filter = match req.query_param("state").as_deref() {
Some("closed") => spine::issues::IssueState::Closed,
_ => spine::issues::IssueState::Open,
};
let issues = idx.by_state(filter);
Ok(ForgeResponse::html(
200,
html::issues_list_page(
&self.prefix(),
owner,
repo,
filter,
idx.count(spine::issues::IssueState::Open),
idx.count(spine::issues::IssueState::Closed),
&issues,
),
))
}
async fn h_issue_new_form(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
Ok(ForgeResponse::html(
200,
html::issue_new_page(&self.prefix(), owner, repo),
))
}
async fn h_issue_detail(
&self,
owner: &str,
repo: &str,
num: u64,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let entry = idx
.issues
.get(&num)
.ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
let truncated = entry.thread.len() > self.cfg.thread_cap;
let hosted: Arc<dyn HostedReader> = self.hosted.clone();
let threads = bodies::render_thread(
&entry.thread,
self.loopback.clone(),
Some(hosted),
&self.cfg,
)
.await;
Ok(ForgeResponse::html(
200,
html::issue_detail_page(&self.prefix(), owner, repo, entry, &threads, truncated),
))
}
fn parse_issue_body(req: &ForgeRequest) -> (Option<String>, Option<String>, Option<String>) {
let ct = req.header("content-type").unwrap_or("");
let raw = String::from_utf8_lossy(&req.raw_body);
let field_json = |v: &serde_json::Value, k: &str| {
v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string())
};
if ct.contains("application/json") {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
return (
field_json(&v, "title"),
field_json(&v, "resourceUrl"),
field_json(&v, "body"),
);
}
}
let pairs = request::parse_form(&raw);
let field = |k: &str| pairs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
(field("title"), field("resourceUrl"), field("body"))
}
async fn make_pointer(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
owner: &str,
repo: &str,
resource_url: Option<&str>,
body: Option<&str>,
) -> Result<spine::issues::ThreadPointer, ForgeError> {
match agent {
ForgeAgent::Anonymous => {
Err(ForgeError::Unauthorised("authentication required".into()))
}
ForgeAgent::Nostr { pubkey_hex } => {
let body = body
.map(str::trim)
.filter(|b| !b.is_empty())
.ok_or_else(|| ForgeError::BadRequest("body required".into()))?;
let rref = self.hosted.write(pubkey_hex, body.as_bytes()).await?;
Ok(spine::issues::ThreadPointer {
author: agent.author_id(),
resource_url: rref,
at: now_secs(),
hosted: true,
})
}
ForgeAgent::Pod { .. } => {
let url = resource_url
.ok_or_else(|| ForgeError::BadRequest("resourceUrl required".into()))?;
self.verify_pod_pointer(req, agent, owner, repo, url).await
}
}
}
async fn verify_pod_pointer(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
repo_owner: &str,
repo: &str,
resource_url: &str,
) -> Result<spine::issues::ThreadPointer, ForgeError> {
let caller = agent
.owner()
.ok_or_else(|| ForgeError::Unauthorised("authentication required".into()))?;
let host = req
.host_url
.as_deref()
.ok_or_else(|| ForgeError::BadRequest("missing host".into()))?;
bodies::own_area_ok(resource_url, host, caller, repo_owner, repo)?;
let lb = self
.loopback
.as_ref()
.ok_or_else(|| ForgeError::Unsupported("pod body verification unavailable".into()))?;
match lb
.get(
resource_url,
self.cfg.max_body_bytes,
self.cfg.fetch_timeout_secs,
)
.await
{
bodies::FetchResult::Body(_) => Ok(spine::issues::ThreadPointer {
author: agent.author_id(),
resource_url: resource_url.to_string(),
at: now_secs(),
hosted: false,
}),
bodies::FetchResult::Removed => Err(ForgeError::BadRequest(
"pod body is not publicly readable".into(),
)),
bodies::FetchResult::TooLarge => {
Err(ForgeError::BadRequest("pod body too large".into()))
}
bodies::FetchResult::Error(e) => {
Err(ForgeError::Backend(format!("pod body fetch failed: {e}")))
}
}
}
async fn h_issue_create(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
owner: &str,
repo: &str,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let (title, resource_url, body) = Self::parse_issue_body(req);
let title = title
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.ok_or_else(|| ForgeError::BadRequest("title required".into()))?;
let pointer = self
.make_pointer(
req,
agent,
owner,
repo,
resource_url.as_deref(),
body.as_deref(),
)
.await?;
let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let entry = spine::issues::IssueEntry {
number: 0,
title,
state: spine::issues::IssueState::Open,
author: agent.author_id(),
created_at: now_secs(),
thread: vec![pointer],
};
let num = idx.allocate(entry);
spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
Ok(ForgeResponse::redirect(303, &location))
}
async fn h_issue_comment(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
owner: &str,
repo: &str,
num: u64,
) -> Result<ForgeResponse, ForgeError> {
self.resolve_repo(owner, repo).await?;
let (_title, resource_url, body) = Self::parse_issue_body(req);
let pointer = self
.make_pointer(
req,
agent,
owner,
repo,
resource_url.as_deref(),
body.as_deref(),
)
.await?;
let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
let entry = idx
.issues
.get_mut(&num)
.ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
entry.thread.push(pointer);
spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
Ok(ForgeResponse::redirect(303, &location))
}
async fn h_api_token(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
) -> Result<ForgeResponse, ForgeError> {
if !req.method.eq_ignore_ascii_case("POST") {
return Err(ForgeError::NotFound("token requires POST".into()));
}
let iat = now_secs();
let ttl = self.cfg.token_ttl_secs;
let token = token::mint(self.token_key.as_ref(), agent, iat, ttl).ok_or_else(|| {
ForgeError::Unauthorised("authentication required to mint a token".into())
})?;
let v = serde_json::json!({
"token": token,
"tokenType": "Bearer",
"expiresIn": ttl,
"exp": iat + ttl,
"agent": agent.author_id(),
});
Ok(ForgeResponse::json(200, &v))
}
async fn h_api_hosted(
&self,
req: &ForgeRequest,
agent: &ForgeAgent,
hex: &str,
id: &str,
) -> Result<ForgeResponse, ForgeError> {
if req.method.eq_ignore_ascii_case("DELETE") {
if agent.owner() != Some(hex) {
return Err(ForgeError::Forbidden(
"only the owner may delete a hosted body".into(),
));
}
let removed = self.hosted.delete(hex, id).await?;
if removed {
return Ok(ForgeResponse::with_type(
204,
"application/json",
bytes::Bytes::new(),
));
}
return Err(ForgeError::NotFound(format!("hosted {hex}/{id}")));
}
match self.hosted.read(hex, id, self.cfg.max_body_bytes).await? {
Some(bytes) => Ok(ForgeResponse {
status: 200,
headers: vec![
("content-type".into(), "application/json".into()),
("x-content-type-options".into(), "nosniff".into()),
("access-control-allow-origin".into(), "*".into()),
],
body: bytes::Bytes::from(bytes),
}),
None => Err(ForgeError::NotFound(format!("hosted {hex}/{id}"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use tempfile::TempDir;
fn req(method: &str, path: &str) -> ForgeRequest {
ForgeRequest {
method: method.into(),
path: path.into(),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
}
}
fn service() -> (TempDir, ForgeService) {
let td = TempDir::new().unwrap();
let svc = ForgeService::new(ForgeConfig::default(), td.path()).unwrap();
(td, svc)
}
#[tokio::test]
async fn new_creates_layout() {
let (td, _svc) = service();
for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
assert!(td.path().join(sub).is_dir(), "{sub} dir must exist");
}
}
#[tokio::test]
async fn index_renders_empty() {
let (_td, svc) = service();
let r = svc
.handle(req("GET", "/forge"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 200);
let body = String::from_utf8(r.body.to_vec()).unwrap();
assert!(body.contains("No repositories yet"));
}
#[tokio::test]
async fn index_lists_pushed_repo() {
let (td, svc) = service();
tokio::fs::create_dir_all(td.path().join("repos/alice/demo.git"))
.await
.unwrap();
let r = svc
.handle(req("GET", "/forge"), ForgeAgent::Anonymous)
.await
.unwrap();
let body = String::from_utf8(r.body.to_vec()).unwrap();
assert!(body.contains("/forge/alice/demo"));
let r2 = svc
.handle(req("GET", "/forge/alice"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r2.status, 200);
}
#[tokio::test]
async fn unknown_owner_is_404() {
let (_td, svc) = service();
let r = svc
.handle(req("GET", "/forge/ghost"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 404);
}
#[tokio::test]
async fn options_preflight() {
let (_td, svc) = service();
let r = svc
.handle(req("OPTIONS", "/forge/x"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 204);
assert!(r
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("access-control-allow-methods")));
}
#[tokio::test]
async fn outside_prefix_is_404() {
let (_td, svc) = service();
let r = svc
.handle(req("GET", "/other/thing"), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 404);
}
#[tokio::test]
async fn unimplemented_route_is_404_for_now() {
let (_td, svc) = service();
let r = svc
.handle(
req("GET", "/forge/alice/repo/issues"),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(r.status, 404);
}
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_NOSYSTEM", "1")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "git {args:?} failed in {}", dir.display());
}
fn seed_repo(td: &TempDir, owner: &str, name: &str) {
let work = td.path().join(format!("work-{owner}-{name}"));
std::fs::create_dir_all(&work).unwrap();
run_git(&work, &["init", "-b", "main"]);
std::fs::write(work.join("README.md"), "# Demo\nhello forge\n").unwrap();
std::fs::create_dir_all(work.join("src")).unwrap();
std::fs::write(work.join("src/lib.rs"), "pub fn f() -> u8 { 42 }\n").unwrap();
run_git(&work, &["add", "-A"]);
run_git(
&work,
&[
"-c",
"user.email=t@e.st",
"-c",
"user.name=Tester",
"commit",
"-m",
"initial commit",
],
);
run_git(&work, &["tag", "v1.0"]);
let bare = td
.path()
.join("repos")
.join(owner)
.join(format!("{name}.git"));
std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
run_git(
td.path(),
&[
"clone",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
}
async fn body(svc: &ForgeService, path: &str) -> (u16, String) {
let r = svc
.handle(req("GET", path), ForgeAgent::Anonymous)
.await
.unwrap();
(r.status, String::from_utf8_lossy(&r.body).into_owned())
}
#[tokio::test]
async fn overview_lists_tree_and_readme() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (status, html) = body(&svc, "/forge/alice/demo").await;
assert_eq!(status, 200);
assert!(html.contains("alice/demo"));
assert!(html.contains("README.md"));
assert!(html.contains("src"));
assert!(html.contains("hello forge"));
}
#[tokio::test]
async fn tree_blob_raw_roundtrip() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (s1, tree) = body(&svc, "/forge/alice/demo/tree/main/src").await;
assert_eq!(s1, 200);
assert!(tree.contains("lib.rs"));
let (s2, blob) = body(&svc, "/forge/alice/demo/blob/main/src/lib.rs").await;
assert_eq!(s2, 200);
assert!(blob.contains("pub fn f"));
let r = svc
.handle(
req("GET", "/forge/alice/demo/raw/main/src/lib.rs"),
ForgeAgent::Anonymous,
)
.await
.unwrap();
assert_eq!(r.status, 200);
let ct = r
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.clone())
.unwrap();
assert!(ct.starts_with("text/plain"));
assert_eq!(
String::from_utf8_lossy(&r.body),
"pub fn f() -> u8 { 42 }\n"
);
}
#[tokio::test]
async fn commits_branches_tags_commit_view() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (s1, commits) = body(&svc, "/forge/alice/demo/commits/main").await;
assert_eq!(s1, 200);
assert!(commits.contains("initial commit"));
let (s2, branches) = body(&svc, "/forge/alice/demo/branches").await;
assert_eq!(s2, 200);
assert!(branches.contains("main"));
assert!(branches.contains("default"));
let (s3, tags) = body(&svc, "/forge/alice/demo/tags").await;
assert_eq!(s3, 200);
assert!(tags.contains("v1.0"));
let dir = td.path().join("repos/alice/demo.git");
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&dir)
.output()
.unwrap();
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
let (s4, commit) = body(&svc, &format!("/forge/alice/demo/commit/{sha}")).await;
assert_eq!(s4, 200);
assert!(commit.contains("initial commit"));
assert!(commit.contains("README.md"));
}
#[tokio::test]
async fn browse_rejects_bad_ref_and_missing_repo() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let (s1, _) = body(&svc, "/forge/alice/ghost").await;
assert_eq!(s1, 404);
let (s2, _) = body(&svc, "/forge/alice/demo/tree/--upload-pack/x").await;
assert_eq!(s2, 400);
let (s3, _) = body(&svc, "/forge/alice/demo/blob/main/nope.txt").await;
assert_eq!(s3, 404);
}
#[tokio::test]
async fn empty_repo_overview_renders() {
if !git_available() {
return;
}
let (td, svc) = service();
let bare = td.path().join("repos/bob/empty.git");
std::fs::create_dir_all(&bare).unwrap();
run_git(
td.path(),
&["init", "--bare", "-b", "main", bare.to_str().unwrap()],
);
let (status, html) = body(&svc, "/forge/bob/empty").await;
assert_eq!(status, 200);
assert!(html.contains("empty"));
}
use std::collections::HashMap;
use std::sync::Mutex;
struct MockLb {
present: Mutex<HashMap<String, Vec<u8>>>,
}
impl MockLb {
fn new() -> Self {
Self {
present: Mutex::new(HashMap::new()),
}
}
fn put(&self, url: &str, body: &[u8]) {
self.present
.lock()
.unwrap()
.insert(url.into(), body.to_vec());
}
fn remove(&self, url: &str) {
self.present.lock().unwrap().remove(url);
}
}
#[async_trait::async_trait]
impl bodies::LoopbackFetch for MockLb {
async fn get(&self, url: &str, _max: usize, _to: u64) -> bodies::FetchResult {
match self.present.lock().unwrap().get(url) {
Some(b) => bodies::FetchResult::Body(b.clone()),
None => bodies::FetchResult::Removed,
}
}
}
fn pod_agent(user: &str) -> ForgeAgent {
ForgeAgent::Pod {
webid: format!("https://pod.example/{user}/profile/card#me"),
username: user.to_string(),
}
}
fn post_form(path: &str, form: &str) -> ForgeRequest {
ForgeRequest {
method: "POST".into(),
path: path.into(),
query: String::new(),
headers: vec![(
"content-type".into(),
"application/x-www-form-urlencoded".into(),
)],
raw_body: Bytes::from(form.to_string()),
host_url: Some("https://pod.example".into()),
}
}
fn issues_service() -> (TempDir, ForgeService, Arc<MockLb>) {
let td = TempDir::new().unwrap();
let mock = Arc::new(MockLb::new());
let svc = ForgeService::new(ForgeConfig::default(), td.path())
.unwrap()
.with_loopback(mock.clone() as Arc<dyn bodies::LoopbackFetch>);
(td, svc, mock)
}
#[tokio::test]
async fn issue_create_list_detail_and_deletion() {
if !git_available() {
return;
}
let (td, svc, mock) = issues_service();
seed_repo(&td, "alice", "demo");
let url = "https://pod.example/alice/public/forge/alice--demo/issue-1.jsonld";
mock.put(url, b"the issue body text");
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=First+bug&resourceUrl={}",
url.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert_eq!(r.status, 303);
let loc = r
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("location"))
.map(|(_, v)| v.clone())
.unwrap();
assert_eq!(loc, "/forge/alice/demo/issues/1");
let (s, list) = body(&svc, "/forge/alice/demo/issues").await;
assert_eq!(s, 200);
assert!(list.contains("First bug"));
assert!(list.contains("1 open"));
let (s2, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
assert_eq!(s2, 200);
assert!(detail.contains("the issue body text"));
mock.remove(url);
let (_s3, detail2) = body(&svc, "/forge/alice/demo/issues/1").await;
assert!(detail2.contains("content removed by its author"));
}
#[tokio::test]
async fn issue_create_rejects_foreign_area_and_ssrf() {
if !git_available() {
return;
}
let (td, svc, mock) = issues_service();
seed_repo(&td, "alice", "demo");
mock.put(
"https://pod.example/bob/public/forge/alice--demo/x.jsonld",
b"body",
);
let bad = "https://pod.example/bob/public/forge/alice--demo/x.jsonld";
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=x&resourceUrl={}",
bad.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert_eq!(r.status, 403);
let ssrf = "http://169.254.169.254/alice/public/forge/alice--demo/x.jsonld";
let r2 = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=x&resourceUrl={}",
ssrf.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert!(r2.status == 403 || r2.status == 400);
}
#[tokio::test]
async fn comment_appends_to_thread() {
if !git_available() {
return;
}
let (td, svc, mock) = issues_service();
seed_repo(&td, "alice", "demo");
let open = "https://pod.example/alice/public/forge/alice--demo/i1.jsonld";
mock.put(open, b"opening");
svc.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=t&resourceUrl={}",
open.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
let c = "https://pod.example/bob/public/forge/alice--demo/c1.jsonld";
mock.put(c, b"a helpful comment");
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues/1",
&format!("resourceUrl={}", c.replace(':', "%3A").replace('/', "%2F")),
),
pod_agent("bob"),
)
.await
.unwrap();
assert_eq!(r.status, 303);
let (_s, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
assert!(detail.contains("opening"));
assert!(detail.contains("a helpful comment"));
assert!(detail.contains("commented"));
}
#[tokio::test]
async fn issue_create_without_loopback_fails_closed() {
if !git_available() {
return;
}
let (td, svc) = service();
seed_repo(&td, "alice", "demo");
let url = "https://pod.example/alice/public/forge/alice--demo/x.jsonld";
let r = svc
.handle(
post_form(
"/forge/alice/demo/issues",
&format!(
"title=x&resourceUrl={}",
url.replace(':', "%3A").replace('/', "%2F")
),
),
pod_agent("alice"),
)
.await
.unwrap();
assert_eq!(r.status, 501);
}
fn hex64() -> String {
"d".repeat(64)
}
fn nostr_agent(hex: &str) -> ForgeAgent {
ForgeAgent::Nostr {
pubkey_hex: hex.to_string(),
}
}
fn post_json(path: &str, json: &str) -> ForgeRequest {
ForgeRequest {
method: "POST".into(),
path: path.into(),
query: String::new(),
headers: vec![("content-type".into(), "application/json".into())],
raw_body: Bytes::from(json.to_string()),
host_url: Some("https://pod.example".into()),
}
}
#[tokio::test]
async fn token_mint_then_resolves() {
let (_td, svc) = service();
let agent = nostr_agent(&hex64());
let r = svc
.handle(post_form("/forge/api/token", ""), agent.clone())
.await
.unwrap();
assert_eq!(r.status, 200);
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
let token = v["token"].as_str().unwrap().to_string();
assert!(token.starts_with("f1."));
let follow = ForgeRequest {
method: "GET".into(),
path: "/forge/x".into(),
query: String::new(),
headers: vec![("authorization".into(), format!("Bearer {token}"))],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
assert_eq!(svc.resolve_agent(&follow), agent);
}
#[tokio::test]
async fn token_mint_anonymous_is_401() {
let (_td, svc) = service();
let r = svc
.handle(post_form("/forge/api/token", ""), ForgeAgent::Anonymous)
.await
.unwrap();
assert_eq!(r.status, 401);
}
#[tokio::test]
async fn podless_nostr_issue_uses_hosted_store() {
if !git_available() {
return;
}
let (td, svc) = service();
let hex = hex64();
seed_repo(&td, &hex, "proj");
let r = svc
.handle(
post_json(
&format!("/forge/{hex}/proj/issues"),
"{\"title\":\"podless bug\",\"body\":\"no pod here\"}",
),
nostr_agent(&hex),
)
.await
.unwrap();
assert_eq!(r.status, 303, "podless issue create should redirect");
let (s, detail) = body(&svc, &format!("/forge/{hex}/proj/issues/1")).await;
assert_eq!(s, 200);
assert!(detail.contains("no pod here"));
assert!(detail.contains("podless bug"));
}
#[tokio::test]
async fn hosted_api_get_and_owner_only_delete() {
let (td, svc) = service();
let hex = hex64();
let store = HostedStore::new(td.path());
let rref = store.write(&hex, b"{\"body\":\"hi\"}").await.unwrap();
let (h, u) = HostedStore::parse_ref(&rref).unwrap();
let (s, got) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
assert_eq!(s, 200);
assert!(got.contains("hi"));
let del_other = ForgeRequest {
method: "DELETE".into(),
path: format!("/forge/api/hosted/{h}/{u}"),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
let r1 = svc
.handle(del_other.clone(), nostr_agent(&"e".repeat(64)))
.await
.unwrap();
assert_eq!(r1.status, 403);
let r2 = svc.handle(del_other, nostr_agent(&hex)).await.unwrap();
assert_eq!(r2.status, 204);
let (s2, _) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
assert_eq!(s2, 404);
}
#[tokio::test]
async fn namespace_push_guard_blocks_foreign_push() {
let (_td, svc) = service();
let push = ForgeRequest {
method: "POST".into(),
path: "/forge/alice/demo.git/git-receive-pack".into(),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
let r = svc
.handle(push.clone(), nostr_agent(&hex64()))
.await
.unwrap();
assert_eq!(r.status, 403);
let r_anon = svc.handle(push, ForgeAgent::Anonymous).await.unwrap();
assert_eq!(r_anon.status, 403);
}
#[tokio::test]
async fn namespace_push_guard_allows_own_push() {
let (_td, svc) = service();
let hex = hex64();
let push = ForgeRequest {
method: "POST".into(),
path: format!("/forge/{hex}/demo.git/git-receive-pack"),
query: String::new(),
headers: vec![],
raw_body: Bytes::new(),
host_url: Some("https://pod.example".into()),
};
let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
assert_ne!(r.status, 403, "own-namespace push must pass the guard");
}
}