Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,14 @@ administrative CAS is never automatically rebased.
oldest-first.
- After import, every advertised ref must resolve to a complete local Git
object graph before pins or remote-tracking refs move.
- Client-side `git merge-base --is-ancestor` enforces fast-forward updates.
- Explicit force pushes append a normal encrypted delta.
- Push destinations are limited to `refs/heads/*`; tags and deletion fail
before publication.
- The helper advertises `refs/heads/*` and `refs/tags/*`; every other ref
namespace is rejected before publication.
- Branch updates require fast-forward ancestry unless force is explicit. Tags
may be created freely, but an existing tag is moved only by a forced push.
Lightweight and annotated tag objects are included in the encrypted pack.
- Branch and tag deletion publish a signed generation with the complete ref set
minus the deleted ref and no pack delta. `refs/heads/main` cannot be deleted
while it is advertised as the remote `HEAD`.

A fresh clone traverses the manifest/key chain to genesis. A returning client
traverses to its pinned manifest and imports only unseen pack IDs. The client
Expand Down
17 changes: 17 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,23 @@ Rotation is one administrative transition that adds the replacement device and
revokes the old record. Other users keep their device private keys. No pack is
rewritten.

### 8.5 Inner Git refs

The remote helper advertises refs in `refs/heads/*` and `refs/tags/*`. All other
namespaces MUST be rejected before publication. Branch refs MUST resolve to
commits and retain the fast-forward check unless the writer explicitly forces
the update. Lightweight and annotated tags MAY point to any Git object; their
complete object graphs, including annotated tag objects, MUST be included in
the encrypted pack and verified by receiving clients. An existing tag MUST
remain unchanged unless the update is forced.

Deleting an existing branch or tag publishes a signed writer manifest with the
complete ref map minus that ref. A deletion introduces no pack and does not
increase the cumulative pack count. The helper advertises `refs/heads/main` as
the remote `HEAD` when present and MUST refuse to delete it while it is
advertised as the default branch. Returning clones can remove stale local
tracking refs with Git's `--prune` and `--prune-tags` fetch options.

## 9. Delta traversal and Git connectivity

A fresh clone traverses manifest and key links from `HEAD` to genesis,
Expand Down
13 changes: 8 additions & 5 deletions src/bin/git-remote-e2ee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ fn run_protocol<S: Storage>(repository: EncryptedRepository<S>, remote_name: Str
writeln!(output, "push")?;
writeln!(output, "option")?;
writeln!(output, "refspec refs/heads/*:refs/remotes/{remote_name}/*")?;
writeln!(output, "refspec refs/tags/*:refs/tags/*")?;
writeln!(output)?;
output.flush()?;
}
Expand Down Expand Up @@ -144,11 +145,13 @@ fn process_push_batch<S: git_remote_e2ee::storage::Storage>(
let (source, destination) = spec
.split_once(':')
.context("push refspec must contain ':'")?;
if source.is_empty() {
writeln!(output, "error {destination} ref deletion is not supported")?;
continue;
}
let result = if dry_run {
let result = if source.is_empty() && dry_run {
repository
.validate_delete_ref_for_remote(local_repository, remote_name, destination)
.map(|_| String::new())
} else if source.is_empty() {
repository.delete_ref_for_remote(local_repository, remote_name, destination)
} else if dry_run {
repository
.validate_push_update_for_remote(
local_repository,
Expand Down
176 changes: 156 additions & 20 deletions src/git.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use std::fs::File;
use std::io::{Read, Write};
use std::path::Path;
Expand All @@ -22,6 +22,41 @@ pub fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
git_text(repo, &["rev-parse", "--verify", reference])
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InnerRefKind {
Branch,
Tag,
}

pub fn validate_inner_ref(repo: &Path, reference: &str) -> Result<InnerRefKind> {
let kind = if reference.starts_with("refs/heads/") {
InnerRefKind::Branch
} else if reference.starts_with("refs/tags/") {
InnerRefKind::Tag
} else {
bail!(
"unsupported ref namespace for {reference}; only refs/heads/* and refs/tags/* are supported"
)
};
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["check-ref-format", reference])
.output()?;
if !output.status.success() {
bail!("invalid Git ref name {reference}")
}
Ok(kind)
}

pub fn ensure_branch_target(repo: &Path, reference: &str, object: &str) -> Result<()> {
let target = resolve_tag_chain(repo, reference, object, "pushed")?;
if target.kind != "commit" {
bail!("branch ref {reference} must resolve to a commit")
}
Ok(())
}

pub fn is_ancestor(repo: &Path, ancestor: &str, descendant: &str) -> Result<bool> {
let status = Command::new("git")
.arg("-C")
Expand Down Expand Up @@ -211,15 +246,18 @@ pub fn ensure_refs_connected_since(
if refs.is_empty() {
return Ok(());
}
let mut commit_roots = Vec::new();
let mut verified_commit_roots = Vec::new();
for (reference, object) in refs {
let expression = format!("{object}^{{commit}}");
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["cat-file", "-e", &expression])
.output()?;
if !output.status.success() {
bail!("fetched ref {reference} does not resolve to a complete commit")
let ref_kind = validate_inner_ref(repo, reference)?;
let target = resolve_tag_chain(repo, reference, object, "fetched")?;
match target.kind.as_str() {
"commit" => commit_roots.push(target.oid),
"tree" if ref_kind == InnerRefKind::Tag => {
ensure_tree_connected(repo, reference, &target.oid)?
}
"blob" if ref_kind == InnerRefKind::Tag => {}
_ => bail!("fetched ref {reference} does not resolve to a supported Git object"),
}
}

Expand All @@ -229,28 +267,32 @@ pub fn ensure_refs_connected_since(
// reachable beyond that frontier. Still require the frontier tips to be
// present locally so corruption or an unexpected local prune fails closed.
for (reference, object) in verified_refs {
let expression = format!("{object}^{{commit}}");
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["cat-file", "-e", &expression])
.output()?;
if !output.status.success() {
bail!("verified ref {reference} is missing locally")
let ref_kind = validate_inner_ref(repo, reference)?;
let target = resolve_tag_chain(repo, reference, object, "verified")?;
match target.kind.as_str() {
"commit" => verified_commit_roots.push(target.oid),
"tree" if ref_kind == InnerRefKind::Tag => {
ensure_tree_connected(repo, reference, &target.oid)?
}
"blob" if ref_kind == InnerRefKind::Tag => {}
_ => bail!("verified ref {reference} does not resolve to a supported Git object"),
}
}

if commit_roots.is_empty() {
return Ok(());
}
let mut command = Command::new("git");
command
.arg("-C")
.arg(repo)
.args(["rev-list", "--objects", "--missing=print"]);
for object in refs.values() {
for object in &commit_roots {
command.arg(object);
}
if !verified_refs.is_empty() {
if !verified_commit_roots.is_empty() {
command.arg("--not");
for object in verified_refs.values() {
for object in &verified_commit_roots {
command.arg(object);
}
}
Expand All @@ -270,6 +312,100 @@ pub fn ensure_refs_connected_since(
Ok(())
}

struct RefTarget {
oid: String,
kind: String,
}

fn resolve_tag_chain(repo: &Path, reference: &str, object: &str, label: &str) -> Result<RefTarget> {
let mut current = object.to_owned();
let mut seen = HashSet::new();
loop {
if !seen.insert(current.clone()) {
bail!("{label} ref {reference} contains a cyclic tag chain")
}
let kind = match cat_file_type(repo, &current) {
Ok(kind) => kind,
Err(_) if label == "verified" => {
bail!("verified ref {reference} is missing locally")
}
Err(_) => {
bail!("fetched ref {reference} does not resolve to a complete Git object graph")
}
};
if kind != "tag" {
return Ok(RefTarget { oid: current, kind });
}
let body = git_text(repo, &["cat-file", "-p", &current])?;
let mut target = None;
let mut target_kind = None;
for line in body.lines().take_while(|line| !line.is_empty()) {
if let Some(value) = line.strip_prefix("object ") {
if target.is_some() {
bail!("annotated tag {reference} has duplicate target objects")
}
target = Some(value);
} else if let Some(value) = line.strip_prefix("type ") {
if target_kind.is_some() {
bail!("annotated tag {reference} has duplicate target types")
}
target_kind = Some(value);
}
}
let target = target.context("annotated tag is missing its target object")?;
let expected_kind = target_kind.context("annotated tag is missing its target type")?;
if cat_file_type(repo, target)? != expected_kind {
bail!("annotated tag {reference} has a mismatched target type")
}
current = target.to_owned();
}
}

fn cat_file_type(repo: &Path, object: &str) -> Result<String> {
git_text(repo, &["cat-file", "-t", object])
}

fn ensure_tree_connected(repo: &Path, reference: &str, tree: &str) -> Result<()> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["ls-tree", "-r", "-t", "-z", "--full-tree", tree])
.output()?;
if !output.status.success() {
bail!("fetched tag {reference} contains an incomplete tree")
}
for entry in output
.stdout
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
{
let header = entry
.split(|byte| *byte == b'\t')
.next()
.context("Git ls-tree returned a malformed entry")?;
let header = std::str::from_utf8(header)?;
let mut fields = header.split_ascii_whitespace();
let mode = fields
.next()
.context("Git ls-tree returned an entry without a mode")?;
let _kind = fields
.next()
.context("Git ls-tree returned an entry without an object type")?;
let object = fields
.next()
.context("Git ls-tree returned an entry without an object ID")?;
if mode == "160000" {
// A gitlink names a submodule commit that belongs to another
// object database and is not part of this tree's connectivity.
continue;
}
if cat_file_type(repo, object).is_err() {
bail!("fetched tag {reference} contains missing Git object {object}")
}
}
Ok(())
}

pub fn update_ref(repo: &Path, reference: &str, object: &str) -> Result<()> {
let output = Command::new("git")
.arg("-C")
Expand Down
Loading
Loading