1use std::collections::BTreeMap;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TestamentFormat {
27 V1,
29 Strict,
31 Strict3,
33}
34
35impl TestamentFormat {
36 fn long_header(self) -> &'static str {
37 match self {
38 TestamentFormat::V1 => "bazaar-ng testament version 1\n",
39 TestamentFormat::Strict => "bazaar-ng testament version 2.1\n",
40 TestamentFormat::Strict3 => "bazaar testament version 3 strict\n",
41 }
42 }
43
44 fn short_header(self) -> &'static str {
45 match self {
46 TestamentFormat::V1 => "bazaar-ng testament short form 1\n",
47 TestamentFormat::Strict => "bazaar-ng testament short form 2.1\n",
48 TestamentFormat::Strict3 => "bazaar testament short form 3 strict\n",
49 }
50 }
51
52 fn include_root(self) -> bool {
53 matches!(self, TestamentFormat::Strict3)
54 }
55
56 fn strict(self) -> bool {
57 matches!(self, TestamentFormat::Strict | TestamentFormat::Strict3)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum EntryKind {
64 File,
65 Directory,
66 Symlink,
67 TreeReference,
68}
69
70impl EntryKind {
71 fn as_str(self) -> &'static str {
72 match self {
73 EntryKind::File => "file",
74 EntryKind::Directory => "directory",
75 EntryKind::Symlink => "symlink",
76 EntryKind::TreeReference => "tree-reference",
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
88pub struct TestamentEntry {
89 pub path: String,
90 pub kind: EntryKind,
91 pub file_id: Vec<u8>,
92 pub content: Vec<u8>,
94 pub revision: Vec<u8>,
95 pub executable: bool,
96}
97
98#[derive(Debug, PartialEq, Eq)]
100pub enum TestamentError {
101 WhitespaceNotAllowed(Vec<u8>),
104 LinebreakNotAllowed(String),
106 MissingFileSha1(Vec<u8>),
108 MissingSymlinkTarget(Vec<u8>),
110}
111
112impl std::fmt::Display for TestamentError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 TestamentError::WhitespaceNotAllowed(v) => {
116 write!(
117 f,
118 "whitespace not allowed in {:?}",
119 String::from_utf8_lossy(v)
120 )
121 }
122 TestamentError::LinebreakNotAllowed(s) => {
123 write!(f, "line break not allowed in {s:?}")
124 }
125 TestamentError::MissingFileSha1(v) => {
126 write!(f, "file {:?} has no text sha1", String::from_utf8_lossy(v))
127 }
128 TestamentError::MissingSymlinkTarget(v) => {
129 write!(f, "symlink {:?} has no target", String::from_utf8_lossy(v))
130 }
131 }
132 }
133}
134
135impl std::error::Error for TestamentError {}
136
137pub struct Testament {
144 pub revision_id: Vec<u8>,
145 pub committer: String,
146 pub timestamp: i64,
147 pub timezone: i32,
148 pub message: String,
149 pub parent_ids: Vec<Vec<u8>>,
150 pub revprops: BTreeMap<String, String>,
151 pub entries: Vec<TestamentEntry>,
152}
153
154impl Testament {
155 pub fn as_text_lines(&self, format: TestamentFormat) -> Result<Vec<Vec<u8>>, TestamentError> {
157 if contains_whitespace_bytes(&self.revision_id) {
158 return Err(TestamentError::WhitespaceNotAllowed(
159 self.revision_id.clone(),
160 ));
161 }
162 if crate::osutils::contains_linebreaks(&self.committer) {
163 return Err(TestamentError::LinebreakNotAllowed(self.committer.clone()));
164 }
165
166 let mut r: Vec<String> = Vec::new();
167 r.push(format.long_header().to_string());
168 r.push(format!(
169 "revision-id: {}\n",
170 String::from_utf8_lossy(&self.revision_id)
171 ));
172 r.push(format!("committer: {}\n", self.committer));
173 r.push(format!("timestamp: {}\n", self.timestamp));
174 r.push(format!("timezone: {}\n", self.timezone));
175
176 r.push("parents:\n".to_string());
177 let mut parents = self.parent_ids.clone();
178 parents.sort();
179 for parent in &parents {
180 if contains_whitespace_bytes(parent) {
181 return Err(TestamentError::WhitespaceNotAllowed(parent.clone()));
182 }
183 r.push(format!(" {}\n", String::from_utf8_lossy(parent)));
184 }
185
186 r.push("message:\n".to_string());
187 for line in splitlines(&self.message) {
188 r.push(format!(" {line}\n"));
189 }
190
191 r.push("inventory:\n".to_string());
192 for entry in &self.entries {
193 r.push(self.entry_to_line(format, entry)?);
194 }
195
196 r.extend(self.revprops_to_lines()?);
197
198 Ok(r.into_iter().map(String::into_bytes).collect())
199 }
200
201 fn entry_to_line(
202 &self,
203 format: TestamentFormat,
204 entry: &TestamentEntry,
205 ) -> Result<String, TestamentError> {
206 if contains_whitespace_bytes(&entry.file_id) {
207 return Err(TestamentError::WhitespaceNotAllowed(entry.file_id.clone()));
208 }
209 let (content, spacer) = match entry.kind {
210 EntryKind::File => {
211 if entry.content.is_empty() {
212 return Err(TestamentError::MissingFileSha1(entry.file_id.clone()));
213 }
214 (String::from_utf8_lossy(&entry.content).into_owned(), " ")
215 }
216 EntryKind::Symlink => {
217 if entry.content.is_empty() {
218 return Err(TestamentError::MissingSymlinkTarget(entry.file_id.clone()));
219 }
220 (
221 escape_path(&String::from_utf8_lossy(&entry.content), format)?,
222 " ",
223 )
224 }
225 _ => (String::new(), ""),
226 };
227
228 let mut line = format!(
229 " {} {} {}{}{}",
230 entry.kind.as_str(),
231 escape_path(&entry.path, format)?,
232 String::from_utf8_lossy(&entry.file_id),
233 spacer,
234 content,
235 );
236 if format.strict() {
237 line.push(' ');
238 line.push_str(&String::from_utf8_lossy(&entry.revision));
239 line.push_str(if entry.executable { " yes" } else { " no" });
240 }
241 line.push('\n');
242 Ok(line)
243 }
244
245 fn revprops_to_lines(&self) -> Result<Vec<String>, TestamentError> {
246 if self.revprops.is_empty() {
247 return Ok(Vec::new());
248 }
249 let mut r = vec!["properties:\n".to_string()];
250 for (name, value) in &self.revprops {
252 if crate::osutils::contains_whitespace(name) {
253 return Err(TestamentError::WhitespaceNotAllowed(
254 name.clone().into_bytes(),
255 ));
256 }
257 r.push(format!(" {name}:\n"));
258 for line in splitlines(value) {
259 r.push(format!(" {line}\n"));
260 }
261 }
262 Ok(r)
263 }
264
265 pub fn as_text(&self, format: TestamentFormat) -> Result<Vec<u8>, TestamentError> {
267 Ok(self.as_text_lines(format)?.concat())
268 }
269
270 pub fn as_sha1(&self, format: TestamentFormat) -> Result<Vec<u8>, TestamentError> {
272 Ok(crate::weave::sha_strings(&self.as_text_lines(format)?))
273 }
274
275 pub fn as_short_text(&self, format: TestamentFormat) -> Result<Vec<u8>, TestamentError> {
277 let sha1 = self.as_sha1(format)?;
278 let mut out = format.short_header().as_bytes().to_vec();
279 out.extend_from_slice(b"revision-id: ");
280 out.extend_from_slice(&self.revision_id);
281 out.push(b'\n');
282 out.extend_from_slice(b"sha1: ");
283 out.extend_from_slice(&sha1);
284 out.push(b'\n');
285 Ok(out)
286 }
287}
288
289fn escape_path(path: &str, format: TestamentFormat) -> Result<String, TestamentError> {
292 if crate::osutils::contains_linebreaks(path) {
293 return Err(TestamentError::LinebreakNotAllowed(path.to_string()));
294 }
295 let path = if format.include_root() && path.is_empty() {
296 "."
297 } else {
298 path
299 };
300 Ok(path.replace('\\', "/").replace(' ', "\\ "))
301}
302
303fn contains_whitespace_bytes(s: &[u8]) -> bool {
307 s.iter()
308 .any(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c))
309}
310
311fn splitlines(s: &str) -> Vec<&str> {
315 if s.is_empty() {
316 return Vec::new();
317 }
318 let trimmed = s.strip_suffix('\n').unwrap_or(s);
319 trimmed.split('\n').collect()
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn rev1() -> Testament {
327 let mut revprops = BTreeMap::new();
328 revprops.insert("branch-nick".to_string(), "test branch".to_string());
329 Testament {
330 revision_id: b"test@user-1".to_vec(),
331 committer: "test@user".to_string(),
332 timestamp: 1129025423,
333 timezone: 0,
334 message: "initial null commit".to_string(),
335 parent_ids: Vec::new(),
336 revprops,
337 entries: Vec::new(),
338 }
339 }
340
341 fn rev2() -> Testament {
342 let mut revprops = BTreeMap::new();
343 revprops.insert("branch-nick".to_string(), "test branch".to_string());
344 Testament {
345 revision_id: b"test@user-2".to_vec(),
346 committer: "test@user".to_string(),
347 timestamp: 1129025483,
348 timezone: 36000,
349 message: "add files and directories".to_string(),
350 parent_ids: vec![b"test@user-1".to_vec()],
351 revprops,
352 entries: vec![
353 TestamentEntry {
354 path: "hello".to_string(),
355 kind: EntryKind::File,
356 file_id: b"hello-id".to_vec(),
357 content: b"34dd0ac19a24bf80c4d33b5c8960196e8d8d1f73".to_vec(),
358 revision: b"test@user-2".to_vec(),
359 executable: true,
360 },
361 TestamentEntry {
362 path: "src".to_string(),
363 kind: EntryKind::Directory,
364 file_id: b"src-id".to_vec(),
365 content: Vec::new(),
366 revision: b"test@user-2".to_vec(),
367 executable: false,
368 },
369 TestamentEntry {
370 path: "src/foo.c".to_string(),
371 kind: EntryKind::File,
372 file_id: b"foo.c-id".to_vec(),
373 content: b"a2a049c20f908ae31b231d98779eb63c66448f24".to_vec(),
374 revision: b"test@user-2".to_vec(),
375 executable: false,
376 },
377 ],
378 }
379 }
380
381 const REV_1_V1: &[u8] = concat!(
384 "bazaar-ng testament version 1\n",
385 "revision-id: test@user-1\n",
386 "committer: test@user\n",
387 "timestamp: 1129025423\n",
388 "timezone: 0\n",
389 "parents:\n",
390 "message:\n",
391 " initial null commit\n",
392 "inventory:\n",
393 "properties:\n",
394 " branch-nick:\n",
395 " test branch\n",
396 )
397 .as_bytes();
398
399 const REV_2_V1: &[u8] = concat!(
400 "bazaar-ng testament version 1\n",
401 "revision-id: test@user-2\n",
402 "committer: test@user\n",
403 "timestamp: 1129025483\n",
404 "timezone: 36000\n",
405 "parents:\n",
406 " test@user-1\n",
407 "message:\n",
408 " add files and directories\n",
409 "inventory:\n",
410 " file hello hello-id 34dd0ac19a24bf80c4d33b5c8960196e8d8d1f73\n",
411 " directory src src-id\n",
412 " file src/foo.c foo.c-id a2a049c20f908ae31b231d98779eb63c66448f24\n",
413 "properties:\n",
414 " branch-nick:\n",
415 " test branch\n",
416 )
417 .as_bytes();
418
419 const REV_2_STRICT: &[u8] = concat!(
420 "bazaar-ng testament version 2.1\n",
421 "revision-id: test@user-2\n",
422 "committer: test@user\n",
423 "timestamp: 1129025483\n",
424 "timezone: 36000\n",
425 "parents:\n",
426 " test@user-1\n",
427 "message:\n",
428 " add files and directories\n",
429 "inventory:\n",
430 " file hello hello-id 34dd0ac19a24bf80c4d33b5c8960196e8d8d1f73 test@user-2 yes\n",
431 " directory src src-id test@user-2 no\n",
432 " file src/foo.c foo.c-id a2a049c20f908ae31b231d98779eb63c66448f24 test@user-2 no\n",
433 "properties:\n",
434 " branch-nick:\n",
435 " test branch\n",
436 )
437 .as_bytes();
438
439 #[test]
440 fn rev1_v1_matches_breezy() {
441 assert_eq!(rev1().as_text(TestamentFormat::V1).unwrap(), REV_1_V1);
442 }
443
444 #[test]
445 fn rev2_v1_matches_breezy() {
446 assert_eq!(rev2().as_text(TestamentFormat::V1).unwrap(), REV_2_V1);
447 }
448
449 #[test]
450 fn rev2_strict_matches_breezy() {
451 assert_eq!(
452 rev2().as_text(TestamentFormat::Strict).unwrap(),
453 REV_2_STRICT
454 );
455 }
456
457 #[test]
458 fn strict3_includes_root() {
459 let mut t = rev2();
461 t.entries.insert(
462 0,
463 TestamentEntry {
464 path: String::new(),
465 kind: EntryKind::Directory,
466 file_id: b"TREE_ROT".to_vec(),
467 content: Vec::new(),
468 revision: b"test@user-1".to_vec(),
469 executable: false,
470 },
471 );
472 let text = String::from_utf8(t.as_text(TestamentFormat::Strict3).unwrap()).unwrap();
473 assert!(text.starts_with("bazaar testament version 3 strict\n"));
474 assert!(text.contains(" directory . TREE_ROT test@user-1 no\n"));
475 }
476
477 #[test]
478 fn short_form_is_header_plus_sha() {
479 let t = rev1();
480 let sha = t.as_sha1(TestamentFormat::V1).unwrap();
481 let mut expected =
482 b"bazaar-ng testament short form 1\nrevision-id: test@user-1\nsha1: ".to_vec();
483 expected.extend_from_slice(&sha);
484 expected.push(b'\n');
485 assert_eq!(t.as_short_text(TestamentFormat::V1).unwrap(), expected);
486 }
487
488 #[test]
489 fn whitespace_in_revision_id_rejected() {
490 let mut t = rev1();
491 t.revision_id = b"bad id".to_vec();
492 assert!(matches!(
493 t.as_text_lines(TestamentFormat::V1),
494 Err(TestamentError::WhitespaceNotAllowed(_))
495 ));
496 }
497}