g1t/services/repos/src/commit_file.rs
| 1 | //! One file, committed on a new branch without a sandbox: how g1t proposes |
| 2 | //! a change on someone's behalf, such as a starter workflow, which then |
| 3 | //! becomes a pull request they can read, change and merge. |
| 4 | //! |
| 5 | //! The new tree is the default branch's head with the file put in place. |
| 6 | //! Only the trees on the way to it are rewritten (as catching up does), and |
| 7 | //! the blob, those trees and one commit by the person asking are pushed as |
| 8 | //! a pack to a branch that must not exist yet. |
| 9 | |
| 10 | use std::collections::{BTreeSet, HashMap}; |
| 11 | |
| 12 | use g1t_contracts::access::Capability; |
| 13 | use g1t_contracts::audit::{AuditActor, NewAuditEntry, Surface}; |
| 14 | use g1t_contracts::credentials::Decision; |
| 15 | use g1t_contracts::repos::{CommitFileArgs, CommittedFile, EntryKind, TreeEntry, is_valid_branch_name}; |
| 16 | use g1t_contracts::{FailureCode, Outcome}; |
| 17 | use g1t_kit::now_ms; |
| 18 | use g1t_scan::pack::{ObjectKind, object_id, write_pack}; |
| 19 | use worker::Result; |
| 20 | |
| 21 | use crate::catch_up::{Change, Signature, ancestors, commit_object, merge_tree, read_dirs}; |
| 22 | use crate::registry::{can_write, store_key}; |
| 23 | use crate::store::{GitRepo, GitStore, Scope}; |
| 24 | use crate::{Repos, UNVERIFIED, land, not_found}; |
| 25 | |
| 26 | /// The largest file this writes. |
| 27 | const MAX_CONTENT_BYTES: usize = 64 * 1024; |
| 28 | |
| 29 | /// Whether `path` is somewhere a file can be written: relative, without |
| 30 | /// empty, `.` or `..` parts, and not inside `.git`. |
| 31 | pub(crate) fn valid_path(path: &str) -> bool { |
| 32 | !path.is_empty() |
| 33 | && path.len() <= 400 |
| 34 | && !path.starts_with('/') |
| 35 | && !path.ends_with('/') |
| 36 | && path.split('/').all(|part| !part.is_empty() && part != "." && part != ".." && part != ".git") |
| 37 | && !path.chars().any(|c| c.is_control() || c == '\\') |
| 38 | } |
| 39 | |
| 40 | impl<S: GitStore> Repos<S> { |
| 41 | pub(crate) async fn commit_file(&self, a: CommitFileArgs) -> Result<Outcome<CommittedFile>> { |
| 42 | let actor = Some(a.actor.clone()); |
| 43 | let Some(repo) = self.readable(&a.repo, &actor).await? else { |
| 44 | return Ok(not_found()); |
| 45 | }; |
| 46 | if !can_write(&repo, &actor) { |
| 47 | return Ok(Outcome::fail( |
| 48 | FailureCode::Forbidden, |
| 49 | g1t_contracts::access::needs(Capability::Push, &format!("{}/{}", repo.namespace, repo.name)), |
| 50 | )); |
| 51 | } |
| 52 | if !a.actor.verified { |
| 53 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); |
| 54 | } |
| 55 | if let Some((code, message)) = crate::lifecycle::archived_refusal(&repo) { |
| 56 | return Ok(Outcome::fail(code, message)); |
| 57 | } |
| 58 | if !is_valid_branch_name(&a.branch) || a.branch == repo.default_branch { |
| 59 | return Ok(Outcome::fail(FailureCode::Invalid, format!("{} cannot be the new branch's name.", a.branch))); |
| 60 | } |
| 61 | if !valid_path(&a.path) { |
| 62 | return Ok(Outcome::fail(FailureCode::Invalid, format!("{} is not a path a file can be written to.", a.path))); |
| 63 | } |
| 64 | if a.content.len() > MAX_CONTENT_BYTES { |
| 65 | return Ok(Outcome::fail(FailureCode::Invalid, "The file is too large to write this way.")); |
| 66 | } |
| 67 | let message = a.message.trim(); |
| 68 | if message.is_empty() { |
| 69 | return Ok(Outcome::fail(FailureCode::Invalid, "A commit needs a message.")); |
| 70 | } |
| 71 | |
| 72 | let git = self.store.open(&store_key(&repo)).await?; |
| 73 | if git.branches().await?.iter().any(|branch| branch.name == a.branch) { |
| 74 | return Ok(Outcome::fail(FailureCode::Conflict, format!("A branch named {} already exists.", a.branch))); |
| 75 | } |
| 76 | let history = git.log(&repo.default_branch, 1).await?; |
| 77 | let Some(head) = history.first() else { |
| 78 | return Ok(Outcome::fail( |
| 79 | FailureCode::Conflict, |
| 80 | format!("{} has no commits yet. Push a first commit, then try again.", repo.default_branch), |
| 81 | )); |
| 82 | }; |
| 83 | let dirs: BTreeSet<String> = ancestors(&a.path).map(str::to_owned).collect(); |
| 84 | let read = read_dirs(&git, &head.tree_hash, &dirs).await?; |
| 85 | let (parent, name) = a.path.rsplit_once('/').unwrap_or(("", a.path.as_str())); |
| 86 | let exists = read |
| 87 | .get(parent) |
| 88 | .is_some_and(|(_, entries)| entries.iter().any(|entry| entry.name == name)); |
| 89 | if exists { |
| 90 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{} already exists on {}.", a.path, repo.default_branch))); |
| 91 | } |
| 92 | |
| 93 | let blob = a.content.clone().into_bytes(); |
| 94 | let blob_id = object_id(ObjectKind::Blob, &blob); |
| 95 | let trees: HashMap<String, Vec<TreeEntry>> = read.into_values().collect(); |
| 96 | let change = Change { |
| 97 | path: a.path.clone(), |
| 98 | entry: Some((EntryKind::Blob, blob_id)), |
| 99 | }; |
| 100 | let merged = match merge_tree(&head.tree_hash, &trees, &[change]) { |
| 101 | Ok(merged) => merged, |
| 102 | Err(why) => { |
| 103 | return Ok(Outcome::fail(FailureCode::Conflict, format!("g1t could not write {}: {why}.", a.path))); |
| 104 | } |
| 105 | }; |
| 106 | |
| 107 | let author = self.commit_identity(&a.actor).await; |
| 108 | let commit = commit_object( |
| 109 | &merged.tree, |
| 110 | &[&head.hash], |
| 111 | &Signature { |
| 112 | name: &author.name, |
| 113 | email: &author.email, |
| 114 | seconds: now_ms() / 1000, |
| 115 | }, |
| 116 | message, |
| 117 | ); |
| 118 | let commit_id = object_id(ObjectKind::Commit, &commit); |
| 119 | let mut objects: Vec<(ObjectKind, Vec<u8>)> = vec![(ObjectKind::Blob, blob)]; |
| 120 | objects.extend(merged.objects.into_iter().map(|bytes| (ObjectKind::Tree, bytes))); |
| 121 | objects.push((ObjectKind::Commit, commit)); |
| 122 | let access = git.access(Scope::Write).await?; |
| 123 | // Only if the branch is still not there. |
| 124 | let pushed = land::push_pack(&access, &a.branch, None, &commit_id, write_pack(&objects)).await?; |
| 125 | self.refs_moved(&repo.id).await; |
| 126 | |
| 127 | let git_ref = format!("refs/heads/{}", a.branch); |
| 128 | let mut target = self.audit_target(&a.repo).await?; |
| 129 | target.git_ref = Some(git_ref.clone()); |
| 130 | let mut entry = NewAuditEntry::new( |
| 131 | AuditActor::of(&a.actor), |
| 132 | "git.push", |
| 133 | Surface::Git, |
| 134 | target, |
| 135 | &Decision::allow("person"), |
| 136 | g1t_contracts::new_id("req", now_ms()), |
| 137 | ); |
| 138 | if let Err(reason) = pushed { |
| 139 | entry.result = Some("conflict".to_owned()); |
| 140 | entry.message = Some(reason.clone()); |
| 141 | self.record_git(entry).await; |
| 142 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{} could not be created: {reason}", a.branch))); |
| 143 | } |
| 144 | entry.result = Some("ok".to_owned()); |
| 145 | self.record_git(entry).await; |
| 146 | self.publish_push(&repo, &git_ref, None, &commit_id, Some(a.actor.id.clone())).await?; |
| 147 | Ok(Outcome::Ok(CommittedFile { |
| 148 | branch: a.branch, |
| 149 | commit: commit_id, |
| 150 | })) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | #[cfg(test)] |
| 155 | mod tests { |
| 156 | use super::valid_path; |
| 157 | |
| 158 | #[test] |
| 159 | fn only_plain_relative_paths_are_written() { |
| 160 | assert!(valid_path(".g1t/workflows/ci.yml")); |
| 161 | assert!(valid_path("README.md")); |
| 162 | for path in ["", "/etc/passwd", "a/../b", "a//b", ".git/config", "a/./b", "dir/", "a\\b"] { |
| 163 | assert!(!valid_path(path), "{path}"); |
| 164 | } |
| 165 | } |
| 166 | } |