| 1 | //! Extraction of jj change ids from raw Git commit objects. |
| 2 | //! |
| 3 | //! This is the single most format-sensitive function in Dogfood. Everything in |
| 4 | //! the change index keys off its output, so it is deliberately strict: it |
| 5 | //! either returns an id it is certain about, or `None`. |
| 6 | //! |
| 7 | //! The wire format is documented, with the observations that established it, in |
| 8 | //! `docs/change-id-format.md`. The short version: |
| 9 | //! |
| 10 | //! - The header key is `change-id`, lowercase and hyphenated. |
| 11 | //! - The value is 32 characters in jj's reverse-hex alphabet (`k`..=`z`) — the |
| 12 | //! same string the CLI prints. It is **not** hex. Do not decode it. |
| 13 | //! - Its position among the headers is not fixed. It appears before `gpgsig` on |
| 14 | //! signed commits and after the multi-line `jj:conflict-labels` header on |
| 15 | //! conflicted ones. |
| 16 | //! |
| 17 | //! That last point is why this is a real header parser rather than a substring |
| 18 | //! search: a `gpgsig` signature body is base64, and a continuation line inside |
| 19 | //! it can begin with any byte sequence at all, including `change-id `. |
| 20 | |
| 21 | /// A jj change id, in the canonical reverse-hex letter encoding jj stores and |
| 22 | /// displays. |
| 23 | /// |
| 24 | /// Stored and compared as the letter string. Constructing one guarantees it is |
| 25 | /// well-formed, so downstream code never has to re-validate. |
| 26 | #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] |
| 27 | pub struct ChangeId(String); |
| 28 | |
| 29 | /// Length of a jj change id: 16 bytes rendered as 32 reverse-hex characters. |
| 30 | const CHANGE_ID_LEN: usize = 32; |
| 31 | |
| 32 | /// jj's reverse-hex alphabet. `0123456789abcdef` maps to `zyxwvutsrqponmlk`, |
| 33 | /// so every character of a well-formed change id falls in `k..=z`. |
| 34 | const fn is_reverse_hex(b: u8) -> bool { |
| 35 | b >= b'k' && b <= b'z' |
| 36 | } |
| 37 | |
| 38 | impl ChangeId { |
| 39 | /// Parse a change id from its canonical letter encoding. |
| 40 | /// |
| 41 | /// Returns `None` unless the input is exactly 32 characters drawn from |
| 42 | /// jj's reverse-hex alphabet. Being strict here is what keeps malformed |
| 43 | /// ids out of the index, where they would be indistinguishable from real |
| 44 | /// ones forever. |
| 45 | pub fn parse(s: &str) -> Option<Self> { |
| 46 | let b = s.as_bytes(); |
| 47 | if b.len() != CHANGE_ID_LEN || !b.iter().copied().all(is_reverse_hex) { |
| 48 | return None; |
| 49 | } |
| 50 | Some(ChangeId(s.to_owned())) |
| 51 | } |
| 52 | |
| 53 | pub fn as_str(&self) -> &str { |
| 54 | &self.0 |
| 55 | } |
| 56 | |
| 57 | /// The abbreviated form shown in the UI, matching how the CLI abbreviates. |
| 58 | /// |
| 59 | /// This is presentation only. The shortest *unique* prefix within a repo is |
| 60 | /// a database question, not a property of the id, and is resolved by the |
| 61 | /// change lookup query rather than here. |
| 62 | pub fn short(&self) -> &str { |
| 63 | &self.0[..12.min(self.0.len())] |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | impl std::fmt::Display for ChangeId { |
| 68 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 69 | f.write_str(&self.0) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// One header of a Git commit object, with continuation lines folded in. |
| 74 | struct Header<'a> { |
| 75 | key: &'a [u8], |
| 76 | /// Raw value. For multi-line headers this is the first line only; callers |
| 77 | /// that need the folded value use [`Headers::folded`]. Every header |
| 78 | /// Dogfood cares about is single-line, so this avoids allocating for the |
| 79 | /// large `gpgsig` values that make up most of a signed commit. |
| 80 | value: &'a [u8], |
| 81 | } |
| 82 | |
| 83 | /// Iterator over the header block of a raw commit object. |
| 84 | /// |
| 85 | /// Stops at the first empty line, which per the Git object format terminates |
| 86 | /// the headers and begins the message. Lines beginning with a single space are |
| 87 | /// continuations of the preceding header and are skipped rather than being |
| 88 | /// mistaken for new headers — the hazard this parser exists to avoid. |
| 89 | struct Headers<'a> { |
| 90 | rest: &'a [u8], |
| 91 | done: bool, |
| 92 | } |
| 93 | |
| 94 | impl<'a> Headers<'a> { |
| 95 | fn new(raw: &'a [u8]) -> Self { |
| 96 | Headers { rest: raw, done: false } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | impl<'a> Iterator for Headers<'a> { |
| 101 | type Item = Header<'a>; |
| 102 | |
| 103 | fn next(&mut self) -> Option<Header<'a>> { |
| 104 | loop { |
| 105 | if self.done || self.rest.is_empty() { |
| 106 | return None; |
| 107 | } |
| 108 | |
| 109 | // Split off one line. |
| 110 | let (line, tail) = match memchr(b'\n', self.rest) { |
| 111 | Some(i) => (&self.rest[..i], &self.rest[i + 1..]), |
| 112 | // No trailing newline: this is the last line of the object. |
| 113 | None => (self.rest, &self.rest[self.rest.len()..]), |
| 114 | }; |
| 115 | self.rest = tail; |
| 116 | |
| 117 | // An empty line ends the header block; the message follows. |
| 118 | if line.is_empty() { |
| 119 | self.done = true; |
| 120 | return None; |
| 121 | } |
| 122 | |
| 123 | // A leading space marks a continuation of the previous header's |
| 124 | // value (gpgsig, jj:conflict-labels). Skip it: it is never the |
| 125 | // start of a new header, no matter what bytes it contains. |
| 126 | if line[0] == b' ' { |
| 127 | continue; |
| 128 | } |
| 129 | |
| 130 | // `key SP value`. A header with no space is malformed; skip it |
| 131 | // rather than aborting, so one odd header cannot cost us the |
| 132 | // change id. |
| 133 | return match memchr(b' ', line) { |
| 134 | Some(i) => Some(Header { key: &line[..i], value: &line[i + 1..] }), |
| 135 | None => continue, |
| 136 | }; |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | /// Minimal byte search, to avoid pulling in `memchr` for two call sites. |
| 142 | fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> { |
| 143 | haystack.iter().position(|&b| b == needle) |
| 144 | } |
| 145 | |
| 146 | /// Extract the jj change id from a raw git commit object. |
| 147 | /// |
| 148 | /// Returns `None` for commits authored by plain git, which carry no such |
| 149 | /// header, and for commits whose `change-id` value is not a well-formed id. |
| 150 | /// Callers treat `None` as "this commit needs a synthetic identity" (spec §4). |
| 151 | /// |
| 152 | /// `raw_commit` is the object payload — the bytes `git cat-file commit` prints, |
| 153 | /// without the `commit <len>\0` prefix that precedes them in the object store. |
| 154 | pub fn extract_change_id(raw_commit: &[u8]) -> Option<ChangeId> { |
| 155 | for h in Headers::new(raw_commit) { |
| 156 | if h.key == b"change-id" { |
| 157 | // Trailing `\r` would mean a mangled object; `from_utf8` on the |
| 158 | // trimmed value rejects anything non-ASCII anyway, and `parse` |
| 159 | // rejects anything outside the alphabet. |
| 160 | return std::str::from_utf8(h.value).ok().and_then(ChangeId::parse); |
| 161 | } |
| 162 | } |
| 163 | None |
| 164 | } |
| 165 | |
| 166 | /// Conflict information carried in a jj commit's `jj:trees` header. |
| 167 | /// |
| 168 | /// jj stores a conflicted state as an alternating list of tree ids: |
| 169 | /// `side_0, base_0, side_1, base_1, side_2, …` — always one more side than |
| 170 | /// base. See `docs/change-id-format.md` §6. |
| 171 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 172 | pub struct ConflictTrees { |
| 173 | pub sides: Vec<String>, |
| 174 | pub bases: Vec<String>, |
| 175 | } |
| 176 | |
| 177 | /// Extract conflict tree structure from a raw commit object. |
| 178 | /// |
| 179 | /// Returns `None` for unconflicted commits. Detection uses the `jj:trees` |
| 180 | /// header rather than looking for `.jjconflict-*` entries in the tree, because |
| 181 | /// the header is authoritative and requires no object loads. |
| 182 | pub fn extract_conflict_trees(raw_commit: &[u8]) -> Option<ConflictTrees> { |
| 183 | let value = Headers::new(raw_commit).find(|h| h.key == b"jj:trees")?.value; |
| 184 | let ids: Vec<String> = std::str::from_utf8(value) |
| 185 | .ok()? |
| 186 | .split_ascii_whitespace() |
| 187 | .map(str::to_owned) |
| 188 | .collect(); |
| 189 | |
| 190 | // Must be a non-empty odd count: sides = bases + 1. Anything else is not a |
| 191 | // shape we understand, and guessing at it would produce a wrong conflict |
| 192 | // view rather than an honest failure. |
| 193 | if ids.is_empty() || ids.len() % 2 == 0 { |
| 194 | return None; |
| 195 | } |
| 196 | |
| 197 | let mut sides = Vec::with_capacity(ids.len() / 2 + 1); |
| 198 | let mut bases = Vec::with_capacity(ids.len() / 2); |
| 199 | for (i, id) in ids.into_iter().enumerate() { |
| 200 | if i % 2 == 0 { |
| 201 | sides.push(id); |
| 202 | } else { |
| 203 | bases.push(id); |
| 204 | } |
| 205 | } |
| 206 | Some(ConflictTrees { sides, bases }) |
| 207 | } |
| 208 | |
| 209 | /// Tree entry names jj uses for conflict storage. These are implementation |
| 210 | /// detail of the storage format and must never appear in a user-facing file |
| 211 | /// listing. |
| 212 | pub fn is_conflict_artifact(name: &str) -> bool { |
| 213 | name.starts_with(".jjconflict-") || name == "JJ-CONFLICT-README" |
| 214 | } |
| 215 | |
| 216 | #[cfg(test)] |
| 217 | mod tests { |
| 218 | use super::*; |
| 219 | |
| 220 | /// A jj commit exactly as observed from `git cat-file commit`. |
| 221 | const JJ_SIMPLE: &[u8] = b"\ |
| 222 | tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1 |
| 223 | author Fixture Author <fixture@dogfood.sh> 1785482369 +0000 |
| 224 | committer Fixture Author <fixture@dogfood.sh> 1785482369 +0000 |
| 225 | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu |
| 226 | |
| 227 | first change |
| 228 | "; |
| 229 | |
| 230 | const PLAIN_GIT: &[u8] = b"\ |
| 231 | tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd |
| 232 | author Plain Git <pg@dogfood.sh> 1785482410 +0000 |
| 233 | committer Plain Git <pg@dogfood.sh> 1785482410 +0000 |
| 234 | |
| 235 | plain git commit |
| 236 | "; |
| 237 | |
| 238 | const JJ_MERGE: &[u8] = b"\ |
| 239 | tree 6349276c93aa7281b3ea997c1ed9eee4fca779ca |
| 240 | parent 0e6a26973ecf5056359d2b82f39ad9901675bd7f |
| 241 | parent f4017227300e3da7b8b05c461bc1bc24047707c8 |
| 242 | author Fixture Author <fixture@dogfood.sh> 1785482416 +0000 |
| 243 | committer Fixture Author <fixture@dogfood.sh> 1785482416 +0000 |
| 244 | change-id vokuuvrqvyoksvpnxznztskrpwnstmqt |
| 245 | |
| 246 | merge of left and right |
| 247 | "; |
| 248 | |
| 249 | /// Signed commit: `change-id` precedes a multi-line `gpgsig`. |
| 250 | const JJ_SIGNED: &[u8] = b"\ |
| 251 | tree ec67420ed747b72ce94854190b4c59deff01b9db |
| 252 | author Fixture Author <fixture@dogfood.sh> 1785482470 +0000 |
| 253 | committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000 |
| 254 | change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn |
| 255 | gpgsig -----BEGIN SSH SIGNATURE----- |
| 256 | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl |
| 257 | P4iz0WatcsQcBg98kAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 |
| 258 | AAAAQLPQ/lWPdf2zmi261EFYVcLDTOT1CpY8cEWRpjQXTPAkOOsVexrIzZypFgM4yTtgyU |
| 259 | pwfjmHzZ3D9uEeK1H3Ygg= |
| 260 | -----END SSH SIGNATURE----- |
| 261 | |
| 262 | signed via jj |
| 263 | "; |
| 264 | |
| 265 | /// Conflicted commit: `change-id` comes AFTER a multi-line `jj:` header. |
| 266 | const JJ_CONFLICTED: &[u8] = b"\ |
| 267 | tree 125bc2eed773e5eacbb11544e9966dab850ed4f9 |
| 268 | parent 79976290ff8d8ad397f41d423a469b26d225f157 |
| 269 | parent 47129393c3d17b7031ff88faa27ffe1b8180c225 |
| 270 | author Fixture Author <fixture@dogfood.sh> 1785497562 +0000 |
| 271 | committer Fixture Author <fixture@dogfood.sh> 1785497562 +0000 |
| 272 | jj:conflict-labels qtmrpxym 79976290 \"side A\" |
| 273 | qmksvpxn 691a639f \"conflict base\" |
| 274 | syuzyywq 47129393 \"side B\" |
| 275 | jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21 |
| 276 | change-id psxoyvqkymplnoqklzrotrqopkxqkynz |
| 277 | |
| 278 | conflicted merge |
| 279 | "; |
| 280 | |
| 281 | #[test] |
| 282 | fn extracts_from_simple_jj_commit() { |
| 283 | let id = extract_change_id(JJ_SIMPLE).expect("jj commit must yield an id"); |
| 284 | assert_eq!(id.as_str(), "qstvwxmpkvovsosxxmrnxpqmuuyllsqu"); |
| 285 | } |
| 286 | |
| 287 | #[test] |
| 288 | fn plain_git_commit_has_no_change_id() { |
| 289 | assert_eq!(extract_change_id(PLAIN_GIT), None); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn extracts_from_merge_commit() { |
| 294 | let id = extract_change_id(JJ_MERGE).unwrap(); |
| 295 | assert_eq!(id.as_str(), "vokuuvrqvyoksvpnxznztskrpwnstmqt"); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn extracts_when_followed_by_multiline_signature() { |
| 300 | let id = extract_change_id(JJ_SIGNED).unwrap(); |
| 301 | assert_eq!(id.as_str(), "rwsosmnkorymmtvvzwkuuzzqqmuonwnn"); |
| 302 | } |
| 303 | |
| 304 | #[test] |
| 305 | fn extracts_when_preceded_by_multiline_jj_header() { |
| 306 | // The case a naive "first N lines" parser gets wrong. |
| 307 | let id = extract_change_id(JJ_CONFLICTED).unwrap(); |
| 308 | assert_eq!(id.as_str(), "psxoyvqkymplnoqklzrotrqopkxqkynz"); |
| 309 | } |
| 310 | |
| 311 | #[test] |
| 312 | fn continuation_lines_are_never_read_as_headers() { |
| 313 | // The specific attack/corruption shape this parser exists to prevent: a |
| 314 | // signature body whose base64 happens to start a line with |
| 315 | // "change-id ". It must be ignored as a continuation, and the real |
| 316 | // header below it must win. |
| 317 | let raw = b"\ |
| 318 | tree 0000000000000000000000000000000000000000 |
| 319 | author A <a@b.c> 1 +0000 |
| 320 | committer A <a@b.c> 1 +0000 |
| 321 | gpgsig -----BEGIN SSH SIGNATURE----- |
| 322 | change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk |
| 323 | -----END SSH SIGNATURE----- |
| 324 | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu |
| 325 | |
| 326 | msg |
| 327 | "; |
| 328 | let id = extract_change_id(raw).unwrap(); |
| 329 | assert_eq!( |
| 330 | id.as_str(), |
| 331 | "qstvwxmpkvovsosxxmrnxpqmuuyllsqu", |
| 332 | "a continuation line must not be parsed as a change-id header" |
| 333 | ); |
| 334 | } |
| 335 | |
| 336 | #[test] |
| 337 | fn body_text_after_headers_is_not_scanned() { |
| 338 | // A commit message that mentions a change-id header must not be |
| 339 | // mistaken for one. The header block ends at the first blank line. |
| 340 | let raw = b"\ |
| 341 | tree 0000000000000000000000000000000000000000 |
| 342 | author A <a@b.c> 1 +0000 |
| 343 | committer A <a@b.c> 1 +0000 |
| 344 | |
| 345 | see also: |
| 346 | change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk |
| 347 | "; |
| 348 | assert_eq!(extract_change_id(raw), None); |
| 349 | } |
| 350 | |
| 351 | #[test] |
| 352 | fn rejects_malformed_values() { |
| 353 | let cases: &[(&str, &[u8])] = &[ |
| 354 | ("too short", b"tree x\nchange-id qstvwxmp\n\nm\n"), |
| 355 | ( |
| 356 | "too long", |
| 357 | b"tree x\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsquz\n\nm\n", |
| 358 | ), |
| 359 | ( |
| 360 | "hex, not reverse-hex", |
| 361 | b"tree x\nchange-id 0123456789abcdef0123456789abcdef\n\nm\n", |
| 362 | ), |
| 363 | ("empty", b"tree x\nchange-id \n\nm\n"), |
| 364 | ( |
| 365 | "uppercase", |
| 366 | b"tree x\nchange-id QSTVWXMPKVOVSOSXXMRNXPQMUUYLLSQU\n\nm\n", |
| 367 | ), |
| 368 | ]; |
| 369 | for (name, raw) in cases { |
| 370 | assert_eq!(extract_change_id(raw), None, "should reject: {name}"); |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | #[test] |
| 375 | fn header_key_match_is_exact() { |
| 376 | // `jj:change-id`, `Change-Id` (Gerrit's, in the message trailer) and |
| 377 | // similar must not be picked up. |
| 378 | let raw = b"\ |
| 379 | tree 0000000000000000000000000000000000000000 |
| 380 | author A <a@b.c> 1 +0000 |
| 381 | Change-Id qstvwxmpkvovsosxxmrnxpqmuuyllsqu |
| 382 | jj:change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu |
| 383 | |
| 384 | m |
| 385 | "; |
| 386 | assert_eq!(extract_change_id(raw), None); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn tolerates_unknown_extra_headers() { |
| 391 | let raw = b"\ |
| 392 | tree 0000000000000000000000000000000000000000 |
| 393 | author A <a@b.c> 1 +0000 |
| 394 | committer A <a@b.c> 1 +0000 |
| 395 | mergetag something |
| 396 | encoding ISO-8859-1 |
| 397 | some-future-jj-header whatever |
| 398 | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu |
| 399 | |
| 400 | m |
| 401 | "; |
| 402 | assert_eq!( |
| 403 | extract_change_id(raw).unwrap().as_str(), |
| 404 | "qstvwxmpkvovsosxxmrnxpqmuuyllsqu" |
| 405 | ); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn handles_truncated_object_without_panicking() { |
| 410 | // Robustness: the indexer must never panic on a malformed object. |
| 411 | for n in 0..JJ_SIMPLE.len() { |
| 412 | let _ = extract_change_id(&JJ_SIMPLE[..n]); |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | #[test] |
| 417 | fn root_change_id_is_all_z() { |
| 418 | // jj renders the root change as 32 'z' (= all-zero bytes). It is a |
| 419 | // well-formed id and must parse, so the indexer can recognise and skip |
| 420 | // it rather than treating it as a user change. |
| 421 | let id = ChangeId::parse("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap(); |
| 422 | assert_eq!(id.short(), "zzzzzzzzzzzz"); |
| 423 | } |
| 424 | |
| 425 | #[test] |
| 426 | fn parses_conflict_trees() { |
| 427 | let c = extract_conflict_trees(JJ_CONFLICTED).expect("conflicted commit"); |
| 428 | assert_eq!( |
| 429 | c.sides, |
| 430 | vec![ |
| 431 | "ada380c77b2f1923458d0b93cbfee42f297a58e9".to_string(), |
| 432 | "14a356d6227b63a22f94c18817912d5e37b77c21".to_string(), |
| 433 | ] |
| 434 | ); |
| 435 | assert_eq!( |
| 436 | c.bases, |
| 437 | vec!["892cad2b4d1f22ae36e2107a6a187a5dc227f965".to_string()] |
| 438 | ); |
| 439 | // The invariant jj's conflict representation guarantees. |
| 440 | assert_eq!(c.sides.len(), c.bases.len() + 1); |
| 441 | } |
| 442 | |
| 443 | #[test] |
| 444 | fn unconflicted_commits_have_no_conflict_trees() { |
| 445 | assert_eq!(extract_conflict_trees(JJ_SIMPLE), None); |
| 446 | assert_eq!(extract_conflict_trees(PLAIN_GIT), None); |
| 447 | assert_eq!(extract_conflict_trees(JJ_SIGNED), None); |
| 448 | } |
| 449 | |
| 450 | #[test] |
| 451 | fn rejects_even_length_tree_lists() { |
| 452 | // sides == bases + 1 always, so an even count is a shape we do not |
| 453 | // understand and must not guess at. |
| 454 | let raw = b"tree x\njj:trees aaaa bbbb\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu\n\nm\n"; |
| 455 | assert_eq!(extract_conflict_trees(raw), None); |
| 456 | } |
| 457 | |
| 458 | #[test] |
| 459 | fn filters_conflict_artifacts_from_listings() { |
| 460 | assert!(is_conflict_artifact(".jjconflict-side-0")); |
| 461 | assert!(is_conflict_artifact(".jjconflict-base-0")); |
| 462 | assert!(is_conflict_artifact("JJ-CONFLICT-README")); |
| 463 | assert!(!is_conflict_artifact("src")); |
| 464 | assert!(!is_conflict_artifact("c.txt")); |
| 465 | // Not a jj artifact: a user file that merely starts similarly. |
| 466 | assert!(!is_conflict_artifact(".jjconflict")); |
| 467 | } |
| 468 | } |
468 lines · Rust