archive: Various Fixes

1. When `git archive` errors, do not require an exit code, but just use
    the `ExitStatus` we already have. This requires less unwrapping.
    Also, the previous default of 0 was misleading, since 0 is returned
    upon successful termination.
 2. Use "refname" instead of "refspec" as these are different concepts.
    Refer to <https://git-scm.com/docs/git-check-ref-format> and in
    particular
    <https://git-scm.com/docs/git-check-ref-format#Documentation/git-check-ref-format.txt---refspec-pattern>.
 3. Only allow appending ".tar.gz" to commit IDs, not to refnames,
    because refnames may contain the suffix ".tar.gz". It might look
    weird, but it is not forbidden to do `git branch foo.tar.gz`.
 4. `char::is_ascii` does not help sanitize refnames to file names. In
    particular we have `'/'.is_ascii() == true`. Replace forward slashes
    with two underscores.
This commit is contained in:
lorenz
2025-04-23 16:29:58 +02:00
parent 2fe3566848
commit af334bb0e4
2 changed files with 78 additions and 32 deletions
+2 -2
View File
@@ -86,8 +86,8 @@ pub enum RawError {
Io(#[from] std::io::Error),
/// Archive error.
#[error("git archive error: {0}")]
Archive(String),
#[error("`git archive` exited with status {0}:\n{1}")]
Archive(ExitStatus, String),
/// Git error.
#[error(transparent)]
+76 -30
View File
@@ -22,6 +22,8 @@ use crate::error::RawError as Error;
const MAX_BLOB_SIZE: usize = 10_485_760;
const ARCHIVE_SUFFIX: &str = ".tar.gz";
static MIMES: &[(&str, &str)] = &[
("3gp", "video/3gpp"),
("7z", "application/x-7z-compressed"),
@@ -96,13 +98,39 @@ static MIMES: &[(&str, &str)] = &[
pub fn router(profile: Arc<Profile>) -> Router {
Router::new()
.route("/:rid/:sha", get(commit_handler))
.route("/:rid/:sha/*path", get(file_by_commit_handler))
.route("/:rid/head/*path", get(file_by_canonical_head_handler))
.route("/:rid/archive/*refspec", get(archive_by_refspec_handler))
.route("/:rid/archive/*refname", get(archive_by_refname_handler))
.route("/:rid/blobs/:oid", get(file_by_oid_handler))
.with_state(profile)
}
async fn commit_handler(
Path((rid, sha)): Path<(RepoId, String)>,
State(profile): State<Arc<Profile>>,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
let storage = &profile.storage;
let repo = storage.repository(rid)?;
// Don't allow accessing private repos.
if repo.identity_doc()?.visibility().is_private() {
return Err(Error::NotFound);
}
if !sha.ends_with(ARCHIVE_SUFFIX) {
return Err(Error::NotFound);
}
let sha = sha.trim_end_matches(ARCHIVE_SUFFIX);
if Oid::from_str(sha).is_err() {
return Err(Error::NotFound);
}
archive_by_refname(rid, sha.to_string(), profile).await
}
async fn file_by_commit_handler(
Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
State(profile): State<Arc<Profile>>,
@@ -121,10 +149,18 @@ async fn file_by_commit_handler(
blob_response(blob, path)
}
async fn archive_by_refspec_handler(
Path((rid, refspec)): Path<(RepoId, String)>,
async fn archive_by_refname_handler(
Path((rid, refname)): Path<(RepoId, String)>,
State(profile): State<Arc<Profile>>,
) -> impl IntoResponse {
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
archive_by_refname(rid, refname, profile).await
}
async fn archive_by_refname(
rid: RepoId,
refname: String,
profile: Arc<Profile>,
) -> Result<(StatusCode, HeaderMap, Vec<u8>), Error> {
let storage = &profile.storage;
let repo = storage.repository(rid)?;
@@ -137,24 +173,18 @@ async fn archive_by_refspec_handler(
let project = doc.project()?;
let repo_name = project.name();
// Remove possible .tar.gz suffix
let refspec = if refspec.ends_with(".tar.gz") {
refspec.trim_end_matches(".tar.gz")
} else {
&refspec
let (oid, via_refname) = match Oid::from_str(&refname) {
Ok(oid) => (oid, false),
Err(_) => (
repo.backend
.resolve_reference_from_short_name(&refname)
.map(|reference| reference.target())?
.ok_or(Error::NotFound)?
.into(),
true,
),
};
let oid = match Oid::from_str(&refspec) {
Ok(oid) => oid,
Err(_) => repo
.backend
.resolve_reference_from_short_name(&refspec)
.map(|reference| reference.target())?
.ok_or(Error::NotFound)?
.into(),
};
// SAFETY: Git command is available on the system, so we can safely unwrap.
let output = Command::new("git")
.arg("archive")
.arg("--format=tar.gz")
@@ -163,22 +193,38 @@ async fn archive_by_refspec_handler(
.output()?;
if !output.status.success() {
return Err(Error::Archive(format!(
"{}; code={}",
String::from_utf8_lossy(&output.stderr).trim(),
output.status.code().unwrap_or(0)
)));
return Err(Error::Archive(
output.status,
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
// Build a filename for the archive, which includes the
// refname (if one was given):
//
// Without refname: <repo-name>-<oid>.tar.gz
// With refname: <repo-name>-<refname>--<oid>.tar.gz
let filename = {
let mut build = String::from(repo_name);
build.push('-');
if via_refname {
// NOTE: Sanitize refnames according to
// <https://git-scm.com/docs/git-check-ref-format>
build.push_str(&refname.replace("/", "__"));
build.push('-');
}
build.push_str(oid.to_string().as_str());
build.push_str(ARCHIVE_SUFFIX);
build
};
let mut response_headers = HeaderMap::new();
response_headers.insert("Content-Type", HeaderValue::from_str("application/gzip")?);
response_headers.insert(
"Content-Disposition",
HeaderValue::from_str(&format!(
"attachment; filename={}-{}.tar.gz",
repo_name,
refspec.replace(|c: char| !c.is_ascii(), "_")
))?,
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))?,
);
Ok::<_, Error>((StatusCode::OK, response_headers, output.stdout))
}