forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.0 KiB
1//! Shared repo resolution + authorization + git process construction for smart-HTTP and SSH.
2//! Both transports MUST go through these functions so the rules live in one place.
3
4use std::path::PathBuf;
5
6use forge_core::{Action, Denied, Repo, User, authorize};
7use forge_git::{GitCommand, GitService};
8
9use crate::state::AppState;
10
11#[derive(Debug, PartialEq, Eq)]
12pub enum AccessError {
13 /// No credentials were given and they're needed (HTTP: 401 + WWW-Authenticate).
14 Unauthorized,
15 /// Repo doesn't exist OR the actor can't see it. Never distinguish the two.
16 NotFound,
17 /// Actor can see the repo but lacks the right (e.g. push to read-only / archived).
18 Forbidden,
19 Internal(String),
20}
21
22pub enum Transport {
23 Http,
24 Ssh,
25}
26
27impl Transport {
28 pub fn as_str(&self) -> &'static str {
29 match self {
30 Transport::Http => "http",
31 Transport::Ssh => "ssh",
32 }
33 }
34}
35
36fn internal(e: impl std::fmt::Display) -> AccessError {
37 AccessError::Internal(e.to_string())
38}
39
40/// Anonymous callers get Unauthorized (so git prompts for credentials) for every
41/// "cannot use this repo" outcome, so missing and hidden repos look identical to them.
42fn hidden_or_missing(actor: Option<&User>) -> AccessError {
43 if actor.is_none() {
44 AccessError::Unauthorized
45 } else {
46 AccessError::NotFound
47 }
48}
49
50/// Look up `owner/name` (a trailing ".git" on `name` is stripped), then authorize `actor`:
51/// UploadPack needs Read and ReceivePack needs Write. Returns the repo and its on-disk path.
52pub async fn resolve_repo(
53 state: &AppState,
54 actor: Option<&User>,
55 owner: &str,
56 name: &str,
57 service: GitService,
58) -> Result<(Repo, PathBuf), AccessError> {
59 let action = match service {
60 GitService::UploadPack => Action::Read,
61 GitService::ReceivePack => Action::Write,
62 };
63 // `<repo>.wiki.git` (or `.wiki`) is the repo's wiki: same owner, same Read/Write rules, its own
64 // bare repo, and it is hidden (not found) while the wiki is switched off.
65 if let Some(base) = forge_core::names::wiki_base(name) {
66 let repo = resolve_repo_for(state, actor, owner, base, action).await?;
67 if !state.store.wiki_enabled(&repo.id).await.map_err(internal)? {
68 return Err(hidden_or_missing(actor));
69 }
70 let path =
71 forge_git::repo::wiki_path(&state.config.data_dir, &repo.id).map_err(internal)?;
72 return Ok((repo, path));
73 }
74 let repo = resolve_repo_for(state, actor, owner, name, action).await?;
75 let path = forge_git::repo::repo_path(&state.config.data_dir, &repo.id).map_err(internal)?;
76 Ok((repo, path))
77}
78
79/// The repo at `owner/name` (a trailing ".git" is stripped, and a past name is followed), or
80/// `None`. No authorization. Shared by the transports that check tokens instead of users.
81pub async fn find_repo(
82 state: &AppState,
83 owner: &str,
84 name: &str,
85) -> Result<Option<Repo>, AccessError> {
86 let name = name.strip_suffix(".git").unwrap_or(name);
87
88 // A past owner/name (left by a rename or transfer) is served as the repo it points at. The
89 // checks run on that repo, so the redirect grants nothing extra.
90 match state
91 .store
92 .repo_by_owner_and_name(owner, name)
93 .await
94 .map_err(internal)?
95 {
96 Some(repo) => Ok(Some(repo)),
97 None => state
98 .store
99 .repo_by_redirect(owner, name)
100 .await
101 .map_err(internal),
102 }
103}
104
105/// [`find_repo`], then authorize `actor` for `action`. A missing repo and a hidden one give the
106/// same answer.
107pub async fn resolve_repo_for(
108 state: &AppState,
109 actor: Option<&User>,
110 owner: &str,
111 name: &str,
112 action: Action,
113) -> Result<Repo, AccessError> {
114 let Some(repo) = find_repo(state, owner, name).await? else {
115 return Err(hidden_or_missing(actor));
116 };
117
118 let (role, org_role) = match actor {
119 Some(user) => (
120 state
121 .store
122 .collaborator_role(&repo.id, &user.id)
123 .await
124 .map_err(internal)?,
125 state
126 .store
127 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
128 .await
129 .map_err(internal)?,
130 ),
131 None => (None, None),
132 };
133
134 authorize(actor, &repo, role, org_role, action).map_err(|denied| match denied {
135 Denied::NotFound => hidden_or_missing(actor),
136 Denied::Forbidden if actor.is_none() => AccessError::Unauthorized,
137 Denied::Forbidden => AccessError::Forbidden,
138 })?;
139
140 Ok(repo)
141}
142
143/// Build the git process for `service` on `repo_path`. For ReceivePack it also sets the hook env
144/// (FORGE_INTERNAL_SOCKET, FORGE_INTERNAL_TOKEN, FORGE_ACTOR_ID, FORGE_REPO_ID, FORGE_PROTOCOL).
145/// HTTP passes stateless_rpc=true; SSH passes false. `advertise_refs` is used only by HTTP info/refs.
146// The signature is shared with the SSH backend, so the argument count stays.
147#[allow(clippy::too_many_arguments)]
148pub fn git_process(
149 state: &AppState,
150 repo: &Repo,
151 repo_path: &std::path::Path,
152 actor: Option<&User>,
153 service: GitService,
154 transport: Transport,
155 advertise_refs: bool,
156 git_protocol: Option<&str>,
157) -> GitCommand {
158 let stateless_rpc = matches!(transport, Transport::Http);
159 let cmd = forge_git::service_command(
160 &state.git,
161 service,
162 repo_path,
163 stateless_rpc,
164 advertise_refs,
165 git_protocol,
166 );
167
168 if service != GitService::ReceivePack {
169 return cmd;
170 }
171 let is_wiki = repo_path
172 .file_name()
173 .and_then(|n| n.to_str())
174 .is_some_and(|n| n.ends_with(".wiki.git"));
175
176 let actor_id = actor.map_or("", |user| user.id.as_str());
177 cmd.env("FORGE_INTERNAL_SOCKET", state.internal_socket.as_os_str())
178 .env("FORGE_INTERNAL_TOKEN", &*state.internal_token)
179 .env("FORGE_ACTOR_ID", actor_id)
180 .env("FORGE_REPO_ID", repo.id.as_str())
181 .env("FORGE_PROTOCOL", transport.as_str())
182 .env("FORGE_WIKI", if is_wiki { "1" } else { "0" })
183}