Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
212 linesCodeBlameRaw
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
10use std::collections::{BTreeSet, HashMap};
11
12use g1t_contracts::access::Capability;
13use g1t_contracts::audit::{AuditActor, NewAuditEntry, Surface};
14use g1t_contracts::credentials::Decision;
15use g1t_contracts::repos::{CommitFileArgs, CommittedFile, EntryKind, TreeEntry, is_valid_branch_name};
16use g1t_contracts::{FailureCode, Outcome};
17use g1t_kit::now_ms;
18use g1t_scan::pack::{ObjectKind, object_id, write_pack};
19use worker::Result;
20
21use crate::catch_up::{Change, Signature, ancestors, commit_object, merge_tree, read_dirs};
22use crate::registry::{can_write, store_key};
23use crate::store::{GitRepo, GitStore, Scope};
24use crate::{Repos, UNVERIFIED, land, not_found};
25
26/// The largest file this writes.
27const 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`.
31pub(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
40impl<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::read_only_refusal(&repo) {
56 return Ok(Outcome::fail(code, message));
57 }
58 // Moving between namespaces: wait for it (moves.rs).
59 let repo = match self.unpaused(repo).await? {
60 Ok(repo) => repo,
61 Err((code, message)) => return Ok(Outcome::fail(code, message)),
62 };
63 if !is_valid_branch_name(&a.branch) || a.branch == repo.default_branch {
64 return Ok(Outcome::fail(FailureCode::Invalid, format!("{} cannot be the new branch's name.", a.branch)));
65 }
66 if !valid_path(&a.path) {
67 return Ok(Outcome::fail(FailureCode::Invalid, format!("{} is not a path a file can be written to.", a.path)));
68 }
69 // A workflow file, written for a token without the scope for it.
70 if let Some(refused) = g1t_contracts::scopes::decide_workflow_files(a.actor.token.as_deref(), [a.path.as_str()]) {
71 return Ok(Outcome::fail(FailureCode::Forbidden, refused.reason.unwrap_or_default()));
72 }
73 if a.content.len() > MAX_CONTENT_BYTES {
74 return Ok(Outcome::fail(FailureCode::Invalid, "The file is too large to write this way."));
75 }
76 let message = a.message.trim();
77 if message.is_empty() {
78 return Ok(Outcome::fail(FailureCode::Invalid, "A commit needs a message."));
79 }
80
81 let git = self.store.open(&store_key(&repo)).await?;
82 if git.branches().await?.iter().any(|branch| branch.name == a.branch) {
83 return Ok(Outcome::fail(FailureCode::Conflict, format!("A branch named {} already exists.", a.branch)));
84 }
85 let history = git.log(&repo.default_branch, 1).await?;
86 let Some(head) = history.first() else {
87 return Ok(Outcome::fail(
88 FailureCode::Conflict,
89 format!("{} has no commits yet. Push a first commit, then try again.", repo.default_branch),
90 ));
91 };
92 let dirs: BTreeSet<String> = ancestors(&a.path).map(str::to_owned).collect();
93 let read = read_dirs(&git, &head.tree_hash, &dirs).await?;
94 let (parent, name) = a.path.rsplit_once('/').unwrap_or(("", a.path.as_str()));
95 let exists = read
96 .get(parent)
97 .is_some_and(|(_, entries)| entries.iter().any(|entry| entry.name == name));
98 if exists {
99 return Ok(Outcome::fail(FailureCode::Conflict, format!("{} already exists on {}.", a.path, repo.default_branch)));
100 }
101
102 let blob = a.content.clone().into_bytes();
103 let blob_id = object_id(ObjectKind::Blob, &blob);
104 let trees: HashMap<String, Vec<TreeEntry>> = read.into_values().collect();
105 let change = Change {
106 path: a.path.clone(),
107 entry: Some((EntryKind::Blob, blob_id)),
108 };
109 let merged = match merge_tree(&head.tree_hash, &trees, &[change]) {
110 Ok(merged) => merged,
111 Err(why) => {
112 return Ok(Outcome::fail(FailureCode::Conflict, format!("g1t could not write {}: {why}.", a.path)));
113 }
114 };
115
116 let author = self.commit_identity(&a.actor).await;
117 let signed;
118 let message = if a.sign_off {
119 signed = crate::merge_methods::with_trailers(message, &[crate::merge_methods::sign_off_trailer(&author.name, &author.email)]);
120 signed.as_str()
121 } else {
122 message
123 };
124 let commit = commit_object(
125 &merged.tree,
126 &[&head.hash],
127 &Signature {
128 name: &author.name,
129 email: &author.email,
130 seconds: now_ms() / 1000,
131 },
132 message,
133 );
134 let commit_id = object_id(ObjectKind::Commit, &commit);
135 // Push protection, as for git: a secret nobody let through stops it.
136 if let Some(refusal) = self.protect_file(&repo, &a.actor, &a.path, a.content.as_bytes(), &commit_id).await {
137 return Ok(Outcome::fail(FailureCode::Forbidden, refusal));
138 }
139 // The rules of the new branch, as for a push of this commit.
140 let change = g1t_rules::push::RefChange {
141 git_ref: format!("refs/heads/{}", a.branch),
142 old: None,
143 new: Some(commit_id.clone()),
144 fast_forward: None,
145 commits: vec![crate::rules::made_commit(
146 &commit_id,
147 message,
148 &author.email,
149 1,
150 vec![g1t_contracts::rules::FileChange { path: a.path.clone(), size: Some(blob.len() as u64), deleted: false }],
151 )],
152 complete: true,
153 };
154 if let crate::rules::Ruled::Refused { message, .. } =
155 self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::Commit, vec![change]).await?
156 {
157 return Ok(Outcome::fail(FailureCode::Forbidden, message));
158 }
159 let mut objects: Vec<(ObjectKind, Vec<u8>)> = vec![(ObjectKind::Blob, blob)];
160 objects.extend(merged.objects.into_iter().map(|bytes| (ObjectKind::Tree, bytes)));
161 objects.push((ObjectKind::Commit, commit));
162 let access = git.access(Scope::Write).await?;
163 // Only if the branch is still not there.
164 let pack = write_pack(&objects);
165 if !self.registry.reserve_public(&repo, pack.len() as u64, self.free_public_bytes).await? {
166 return Ok(Outcome::fail(FailureCode::Forbidden, "Public repository storage is full. Make room before adding more data."));
167 }
168 let bytes = pack.len() as u64;
169 let pushed = land::push_pack(&access, &a.branch, None, &commit_id, pack).await?;
170 self.registry.finish_public(&repo, bytes, bytes).await?;
171 self.refs_moved(&repo.id).await;
172
173 let git_ref = format!("refs/heads/{}", a.branch);
174 let mut target = self.audit_target(&a.repo).await?;
175 target.git_ref = Some(git_ref.clone());
176 let mut entry = NewAuditEntry::new(
177 AuditActor::of(&a.actor),
178 "git.push",
179 Surface::Git,
180 target,
181 &Decision::allow("person"),
182 g1t_contracts::new_id("req", now_ms()),
183 );
184 if let Err(reason) = pushed {
185 entry.result = Some("conflict".to_owned());
186 entry.message = Some(reason.clone());
187 self.record_git(entry).await;
188 return Ok(Outcome::fail(FailureCode::Conflict, format!("{} could not be created: {reason}", a.branch)));
189 }
190 entry.result = Some("ok".to_owned());
191 self.record_git(entry).await;
192 self.publish_push(&repo, &git_ref, None, &commit_id, Some(&a.actor), Some(1)).await?;
193 Ok(Outcome::Ok(CommittedFile {
194 branch: a.branch,
195 commit: commit_id,
196 }))
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::valid_path;
203
204 #[test]
205 fn only_plain_relative_paths_are_written() {
206 assert!(valid_path(".g1t/workflows/ci.yml"));
207 assert!(valid_path("README.md"));
208 for path in ["", "/etc/passwd", "a/../b", "a//b", ".git/config", "a/./b", "dir/", "a\\b"] {
209 assert!(!valid_path(path), "{path}");
210 }
211 }
212}