Skip to content

Commit a393021

Browse files
committed
Use base58 encoded UUIDs for document IDs
Problem: the automerge-repo JS implementation uses base68 encoded UUIDs for it's document IDs. In this library we accept arbitrary strings. Solution: In preparation for full compatibility with the JS implementation constrain document IDs to be a UUID. This shouldn't have any compatibility problems for most users because by default when we create a document ID we use a UUID.
1 parent 3ecdcdb commit a393021

File tree

7 files changed

+84
-19
lines changed

7 files changed

+84
-19
lines changed

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ tracing = "0.1.37"
4444
ring = "0.16.20"
4545
hex = "0.4.3"
4646
tempfile = "3.6.0"
47+
bs58 = { version = "0.5.0", features = ["check"] }
4748

4849
[dev-dependencies]
4950
clap = { version = "4.2.5", features = ["derive"] }

src/fs_store.rs

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use std::{
33
fs::File,
44
io::Write,
55
path::{Path, PathBuf},
6-
str,
76
};
87

98
use crate::DocumentId;
@@ -314,8 +313,10 @@ impl DocIdPaths {
314313

315314
let level2 = level2.file_name()?.to_str()?;
316315
let doc_id_bytes = hex::decode(level2).ok()?;
317-
let doc_id_str = str::from_utf8(&doc_id_bytes).ok()?;
318-
let doc_id = DocumentId::from(doc_id_str);
316+
let Ok(doc_id) = DocumentId::try_from(doc_id_bytes) else {
317+
tracing::error!(level2_path=%level2, "invalid document ID");
318+
return None;
319+
};
319320
let result = Self::from(&doc_id);
320321
if result.prefix != prefix {
321322
None

src/interfaces.rs

+60-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use futures::future::BoxFuture;
22
use serde::{Deserialize, Serialize};
3-
use std::fmt::{Display, Formatter};
3+
use std::{
4+
fmt::{Display, Formatter},
5+
str::FromStr,
6+
};
47

58
#[derive(Debug, Eq, Hash, PartialEq, Clone)]
69
pub struct RepoId(pub String);
@@ -17,24 +20,72 @@ impl<'a> From<&'a str> for RepoId {
1720
}
1821
}
1922

20-
#[derive(Debug, Eq, Hash, PartialEq, Clone, Deserialize, Serialize)]
21-
pub struct DocumentId(pub String);
23+
#[derive(Eq, Hash, PartialEq, Clone, Deserialize, Serialize)]
24+
pub struct DocumentId([u8; 16]);
25+
26+
impl DocumentId {
27+
pub fn random() -> Self {
28+
Self(uuid::Uuid::new_v4().into_bytes())
29+
}
30+
31+
// This is necessary to make the interop tests work, we'll remove it once
32+
// we upgrade to the latest version of automerge-repo for the interop tests
33+
pub fn as_uuid_str(&self) -> String {
34+
uuid::Uuid::from_slice(self.0.as_ref()).unwrap().to_string()
35+
}
36+
}
2237

2338
impl AsRef<[u8]> for DocumentId {
2439
fn as_ref(&self) -> &[u8] {
2540
self.0.as_ref()
2641
}
2742
}
2843

29-
impl Display for DocumentId {
30-
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
31-
write!(f, "{}", self.0)
44+
#[derive(Debug, thiserror::Error)]
45+
#[error("Invalid document ID: {0}")]
46+
pub struct BadDocumentId(String);
47+
48+
impl TryFrom<Vec<u8>> for DocumentId {
49+
type Error = BadDocumentId;
50+
51+
fn try_from(v: Vec<u8>) -> Result<Self, Self::Error> {
52+
match uuid::Uuid::from_slice(v.as_slice()) {
53+
Ok(id) => Ok(Self(id.into_bytes())),
54+
Err(e) => Err(BadDocumentId(format!("invalid uuid: {}", e))),
55+
}
3256
}
3357
}
3458

35-
impl<'a> From<&'a str> for DocumentId {
36-
fn from(s: &'a str) -> Self {
37-
Self(s.to_string())
59+
impl FromStr for DocumentId {
60+
type Err = BadDocumentId;
61+
62+
fn from_str(s: &str) -> Result<Self, Self::Err> {
63+
match bs58::decode(s).with_check(None).into_vec() {
64+
Ok(bytes) => Self::try_from(bytes),
65+
Err(_) => {
66+
// attempt to parse legacy UUID format
67+
let uuid = uuid::Uuid::parse_str(s).map_err(|_| {
68+
BadDocumentId(
69+
"expected either a bs58-encoded document ID or a UUID".to_string(),
70+
)
71+
})?;
72+
Ok(Self(uuid.into_bytes()))
73+
}
74+
}
75+
}
76+
}
77+
78+
impl std::fmt::Debug for DocumentId {
79+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80+
let as_string = bs58::encode(&self.0).with_check().into_string();
81+
write!(f, "{}", as_string)
82+
}
83+
}
84+
85+
impl Display for DocumentId {
86+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
87+
let as_string = bs58::encode(&self.0).with_check().into_string();
88+
write!(f, "{}", as_string)
3889
}
3990
}
4091

src/message.rs

+14-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,17 @@ impl Message {
1717
"targetId" => target_id = Some(decoder.str()?.into()),
1818
"channelId" => {
1919
if decoder.probe().str().is_ok() {
20-
document_id = Some(decoder.str()?.into());
20+
let doc_str = decoder.str()?;
21+
if doc_str == "sync" {
22+
// automerge-repo-network-websocket encodes the channel id as "sync"
23+
// for join messages, we just ignore this
24+
continue;
25+
}
26+
document_id = Some(
27+
doc_str
28+
.parse()
29+
.map_err(|_| DecodeError::InvalidDocumentId)?,
30+
);
2131
}
2232
}
2333
"type" => type_name = Some(decoder.str()?),
@@ -77,7 +87,7 @@ impl Message {
7787
encoder.str("targetId").unwrap();
7888
encoder.str(to_repo_id.0.as_str()).unwrap();
7989
encoder.str("channelId").unwrap();
80-
encoder.str(document_id.0.as_str()).unwrap();
90+
encoder.str(document_id.as_uuid_str().as_str()).unwrap();
8191
encoder.str("message").unwrap();
8292
encoder.tag(minicbor::data::Tag::Unassigned(64)).unwrap();
8393
encoder.bytes(message.as_slice()).unwrap();
@@ -117,6 +127,8 @@ pub enum DecodeError {
117127
MissingBroadcast,
118128
#[error("unknown type {0}")]
119129
UnknownType(String),
130+
#[error("invalid document id")]
131+
InvalidDocumentId,
120132
}
121133

122134
impl From<minicbor::decode::Error> for DecodeError {

src/repo.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ impl RepoHandle {
135135

136136
/// Create a new document.
137137
pub fn new_document(&self) -> DocHandle {
138-
let document_id = DocumentId(Uuid::new_v4().to_string());
138+
let document_id = DocumentId::random();
139139
let document = new_document();
140140
let doc_info = self.new_document_info(document, DocState::LocallyCreatedNotEdited);
141141
let handle = DocHandle::new(

tests/fs_storage/main.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ fn fs_store_crud() {
2929
doc.put(automerge::ObjId::Root, "key", "value").unwrap();
3030
let mut change1 = doc.get_last_local_change().unwrap().clone();
3131

32-
let doc_id = automerge_repo::DocumentId::from("somedoc");
32+
let doc_id = automerge_repo::DocumentId::random();
3333
store.append(&doc_id, change1.bytes().as_ref()).unwrap();
3434
let result = store.get(&doc_id).unwrap().unwrap();
3535
assert_eq!(&result, change1.bytes().as_ref());
@@ -55,7 +55,7 @@ fn fs_store_crud() {
5555
assert_eq!(result, expected);
5656

5757
// check nonexistent docs don't upset anyone
58-
let nonexistent_doc_id = automerge_repo::DocumentId::from("nonexistent");
58+
let nonexistent_doc_id = automerge_repo::DocumentId::random();
5959
let result = store.get(&nonexistent_doc_id).unwrap();
6060
assert!(result.is_none());
6161
}

tests/network/document_load.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async fn test_loading_document_immediately_not_found() {
126126
let repo_handle = repo.run();
127127

128128
// Spawn a task that awaits the requested doc handle.
129-
let doc_id = DocumentId::from("doc1");
129+
let doc_id = DocumentId::random();
130130
assert!(repo_handle.load(doc_id).await.unwrap().is_none());
131131
// Shut down the repo.
132132
repo_handle.stop().unwrap();
@@ -142,7 +142,7 @@ async fn test_loading_document_not_found_async() {
142142
let repo_handle = repo.run();
143143

144144
// Spawn a task that awaits the requested doc handle.
145-
let doc_id = DocumentId::from("doc1");
145+
let doc_id = DocumentId::random();
146146
assert!(repo_handle.load(doc_id).await.unwrap().is_none());
147147
// Shut down the repo.
148148
tokio::task::spawn_blocking(|| {

0 commit comments

Comments
 (0)