about summary refs log tree commit diff
path: root/tvix/store/src/proto/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tvix/store/src/proto/tests')
-rw-r--r--tvix/store/src/proto/tests/directory.rs289
-rw-r--r--tvix/store/src/proto/tests/directory_nodes_iterator.rs80
-rw-r--r--tvix/store/src/proto/tests/grpc_blobservice.rs102
-rw-r--r--tvix/store/src/proto/tests/grpc_directoryservice.rs241
-rw-r--r--tvix/store/src/proto/tests/grpc_pathinfoservice.rs67
-rw-r--r--tvix/store/src/proto/tests/mod.rs6
-rw-r--r--tvix/store/src/proto/tests/pathinfo.rs207
7 files changed, 992 insertions, 0 deletions
diff --git a/tvix/store/src/proto/tests/directory.rs b/tvix/store/src/proto/tests/directory.rs
new file mode 100644
index 000000000000..8d6ca7241d7a
--- /dev/null
+++ b/tvix/store/src/proto/tests/directory.rs
@@ -0,0 +1,289 @@
+use crate::{
+    proto::{Directory, DirectoryNode, FileNode, SymlinkNode, ValidateDirectoryError},
+    B3Digest,
+};
+use lazy_static::lazy_static;
+
+lazy_static! {
+    static ref DUMMY_DIGEST: [u8; 32] = [
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00,
+    ];
+}
+#[test]
+fn size() {
+    {
+        let d = Directory::default();
+        assert_eq!(d.size(), 0);
+    }
+    {
+        let d = Directory {
+            directories: vec![DirectoryNode {
+                name: String::from("foo"),
+                digest: DUMMY_DIGEST.to_vec(),
+                size: 0,
+            }],
+            ..Default::default()
+        };
+        assert_eq!(d.size(), 1);
+    }
+    {
+        let d = Directory {
+            directories: vec![DirectoryNode {
+                name: String::from("foo"),
+                digest: DUMMY_DIGEST.to_vec(),
+                size: 4,
+            }],
+            ..Default::default()
+        };
+        assert_eq!(d.size(), 5);
+    }
+    {
+        let d = Directory {
+            files: vec![FileNode {
+                name: String::from("foo"),
+                digest: DUMMY_DIGEST.to_vec(),
+                size: 42,
+                executable: false,
+            }],
+            ..Default::default()
+        };
+        assert_eq!(d.size(), 1);
+    }
+    {
+        let d = Directory {
+            symlinks: vec![SymlinkNode {
+                name: String::from("foo"),
+                target: String::from("bar"),
+            }],
+            ..Default::default()
+        };
+        assert_eq!(d.size(), 1);
+    }
+}
+
+#[test]
+fn digest() {
+    let d = Directory::default();
+
+    assert_eq!(
+        d.digest(),
+        B3Digest::from_vec(vec![
+            0xaf, 0x13, 0x49, 0xb9, 0xf5, 0xf9, 0xa1, 0xa6, 0xa0, 0x40, 0x4d, 0xea, 0x36, 0xdc,
+            0xc9, 0x49, 0x9b, 0xcb, 0x25, 0xc9, 0xad, 0xc1, 0x12, 0xb7, 0xcc, 0x9a, 0x93, 0xca,
+            0xe4, 0x1f, 0x32, 0x62
+        ])
+        .unwrap()
+    )
+}
+
+#[test]
+fn validate_empty() {
+    let d = Directory::default();
+    assert_eq!(d.validate(), Ok(()));
+}
+
+#[test]
+fn validate_invalid_names() {
+    {
+        let d = Directory {
+            directories: vec![DirectoryNode {
+                name: "".to_string(),
+                digest: DUMMY_DIGEST.to_vec(),
+                size: 42,
+            }],
+            ..Default::default()
+        };
+        match d.validate().expect_err("must fail") {
+            ValidateDirectoryError::InvalidName(n) => {
+                assert_eq!(n, "")
+            }
+            _ => panic!("unexpected error"),
+        };
+    }
+
+    {
+        let d = Directory {
+            directories: vec![DirectoryNode {
+                name: ".".to_string(),
+                digest: DUMMY_DIGEST.to_vec(),
+                size: 42,
+            }],
+            ..Default::default()
+        };
+        match d.validate().expect_err("must fail") {
+            ValidateDirectoryError::InvalidName(n) => {
+                assert_eq!(n, ".")
+            }
+            _ => panic!("unexpected error"),
+        };
+    }
+
+    {
+        let d = Directory {
+            files: vec![FileNode {
+                name: "..".to_string(),
+                digest: DUMMY_DIGEST.to_vec(),
+                size: 42,
+                executable: false,
+            }],
+            ..Default::default()
+        };
+        match d.validate().expect_err("must fail") {
+            ValidateDirectoryError::InvalidName(n) => {
+                assert_eq!(n, "..")
+            }
+            _ => panic!("unexpected error"),
+        };
+    }
+
+    {
+        let d = Directory {
+            symlinks: vec![SymlinkNode {
+                name: "\x00".to_string(),
+                target: "foo".to_string(),
+            }],
+            ..Default::default()
+        };
+        match d.validate().expect_err("must fail") {
+            ValidateDirectoryError::InvalidName(n) => {
+                assert_eq!(n, "\x00")
+            }
+            _ => panic!("unexpected error"),
+        };
+    }
+
+    {
+        let d = Directory {
+            symlinks: vec![SymlinkNode {
+                name: "foo/bar".to_string(),
+                target: "foo".to_string(),
+            }],
+            ..Default::default()
+        };
+        match d.validate().expect_err("must fail") {
+            ValidateDirectoryError::InvalidName(n) => {
+                assert_eq!(n, "foo/bar")
+            }
+            _ => panic!("unexpected error"),
+        };
+    }
+}
+
+#[test]
+fn validate_invalid_digest() {
+    let d = Directory {
+        directories: vec![DirectoryNode {
+            name: "foo".to_string(),
+            digest: vec![0x00, 0x42], // invalid length
+            size: 42,
+        }],
+        ..Default::default()
+    };
+    match d.validate().expect_err("must fail") {
+        ValidateDirectoryError::InvalidDigestLen(n) => {
+            assert_eq!(n, 2)
+        }
+        _ => panic!("unexpected error"),
+    }
+}
+
+#[test]
+fn validate_sorting() {
+    // "b" comes before "a", bad.
+    {
+        let d = Directory {
+            directories: vec![
+                DirectoryNode {
+                    name: "b".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+                DirectoryNode {
+                    name: "a".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+            ],
+            ..Default::default()
+        };
+        match d.validate().expect_err("must fail") {
+            ValidateDirectoryError::WrongSorting(s) => {
+                assert_eq!(s, "a".to_string());
+            }
+            _ => panic!("unexpected error"),
+        }
+    }
+
+    // "a" exists twice, bad.
+    {
+        let d = Directory {
+            directories: vec![
+                DirectoryNode {
+                    name: "a".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+                DirectoryNode {
+                    name: "a".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+            ],
+            ..Default::default()
+        };
+        match d.validate().expect_err("must fail") {
+            ValidateDirectoryError::DuplicateName(s) => {
+                assert_eq!(s, "a".to_string());
+            }
+            _ => panic!("unexpected error"),
+        }
+    }
+
+    // "a" comes before "b", all good.
+    {
+        let d = Directory {
+            directories: vec![
+                DirectoryNode {
+                    name: "a".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+                DirectoryNode {
+                    name: "b".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+            ],
+            ..Default::default()
+        };
+
+        d.validate().expect("validate shouldn't error");
+    }
+
+    // [b, c] and [a] are both properly sorted.
+    {
+        let d = Directory {
+            directories: vec![
+                DirectoryNode {
+                    name: "b".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+                DirectoryNode {
+                    name: "c".to_string(),
+                    digest: DUMMY_DIGEST.to_vec(),
+                    size: 42,
+                },
+            ],
+            symlinks: vec![SymlinkNode {
+                name: "a".to_string(),
+                target: "foo".to_string(),
+            }],
+            ..Default::default()
+        };
+
+        d.validate().expect("validate shouldn't error");
+    }
+}
diff --git a/tvix/store/src/proto/tests/directory_nodes_iterator.rs b/tvix/store/src/proto/tests/directory_nodes_iterator.rs
new file mode 100644
index 000000000000..9a283f72bd45
--- /dev/null
+++ b/tvix/store/src/proto/tests/directory_nodes_iterator.rs
@@ -0,0 +1,80 @@
+use crate::proto::node::Node;
+use crate::proto::Directory;
+use crate::proto::DirectoryNode;
+use crate::proto::FileNode;
+use crate::proto::SymlinkNode;
+
+#[test]
+fn iterator() {
+    let d = Directory {
+        directories: vec![
+            DirectoryNode {
+                name: "c".to_string(),
+                ..DirectoryNode::default()
+            },
+            DirectoryNode {
+                name: "d".to_string(),
+                ..DirectoryNode::default()
+            },
+            DirectoryNode {
+                name: "h".to_string(),
+                ..DirectoryNode::default()
+            },
+            DirectoryNode {
+                name: "l".to_string(),
+                ..DirectoryNode::default()
+            },
+        ],
+        files: vec![
+            FileNode {
+                name: "b".to_string(),
+                ..FileNode::default()
+            },
+            FileNode {
+                name: "e".to_string(),
+                ..FileNode::default()
+            },
+            FileNode {
+                name: "g".to_string(),
+                ..FileNode::default()
+            },
+            FileNode {
+                name: "j".to_string(),
+                ..FileNode::default()
+            },
+        ],
+        symlinks: vec![
+            SymlinkNode {
+                name: "a".to_string(),
+                ..SymlinkNode::default()
+            },
+            SymlinkNode {
+                name: "f".to_string(),
+                ..SymlinkNode::default()
+            },
+            SymlinkNode {
+                name: "i".to_string(),
+                ..SymlinkNode::default()
+            },
+            SymlinkNode {
+                name: "k".to_string(),
+                ..SymlinkNode::default()
+            },
+        ],
+    };
+
+    let mut node_names: Vec<String> = vec![];
+
+    for node in d.nodes() {
+        match node {
+            Node::Directory(n) => node_names.push(n.name),
+            Node::File(n) => node_names.push(n.name),
+            Node::Symlink(n) => node_names.push(n.name),
+        };
+    }
+
+    assert_eq!(
+        vec!["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"],
+        node_names
+    );
+}
diff --git a/tvix/store/src/proto/tests/grpc_blobservice.rs b/tvix/store/src/proto/tests/grpc_blobservice.rs
new file mode 100644
index 000000000000..02e04e7d723f
--- /dev/null
+++ b/tvix/store/src/proto/tests/grpc_blobservice.rs
@@ -0,0 +1,102 @@
+use crate::blobservice::BlobService;
+use crate::proto::blob_service_server::BlobService as GRPCBlobService;
+use crate::proto::{BlobChunk, GRPCBlobServiceWrapper, ReadBlobRequest, StatBlobRequest};
+use crate::tests::fixtures::{BLOB_A, BLOB_A_DIGEST};
+use crate::tests::utils::gen_blob_service;
+use tokio_stream::StreamExt;
+
+fn gen_grpc_blob_service(
+) -> GRPCBlobServiceWrapper<impl BlobService + Send + Sync + Clone + 'static> {
+    let blob_service = gen_blob_service();
+    GRPCBlobServiceWrapper::from(blob_service)
+}
+
+/// Trying to read a non-existent blob should return a not found error.
+#[tokio::test]
+async fn not_found_read() {
+    let service = gen_grpc_blob_service();
+
+    let resp = service
+        .read(tonic::Request::new(ReadBlobRequest {
+            digest: BLOB_A_DIGEST.to_vec(),
+        }))
+        .await;
+
+    // We can't use unwrap_err here, because the Ok value doesn't implement
+    // debug.
+    if let Err(e) = resp {
+        assert_eq!(e.code(), tonic::Code::NotFound);
+    } else {
+        panic!("resp is not err")
+    }
+}
+
+/// Trying to stat a non-existent blob should return a not found error.
+#[tokio::test]
+async fn not_found_stat() {
+    let service = gen_grpc_blob_service();
+
+    let resp = service
+        .stat(tonic::Request::new(StatBlobRequest {
+            digest: BLOB_A_DIGEST.to_vec(),
+            ..Default::default()
+        }))
+        .await
+        .expect_err("must fail");
+
+    // The resp should be a status with Code::NotFound
+    assert_eq!(resp.code(), tonic::Code::NotFound);
+}
+
+/// Put a blob in the store, get it back.
+#[tokio::test]
+async fn put_read_stat() {
+    let service = gen_grpc_blob_service();
+
+    // Send blob A.
+    let put_resp = service
+        .put(tonic_mock::streaming_request(vec![BlobChunk {
+            data: BLOB_A.clone(),
+        }]))
+        .await
+        .expect("must succeed")
+        .into_inner();
+
+    assert_eq!(BLOB_A_DIGEST.to_vec(), put_resp.digest);
+
+    // Stat for the digest of A.
+    // We currently don't ask for more granular chunking data, as we don't
+    // expose it yet.
+    let _resp = service
+        .stat(tonic::Request::new(StatBlobRequest {
+            digest: BLOB_A_DIGEST.to_vec(),
+            ..Default::default()
+        }))
+        .await
+        .expect("must succeed")
+        .into_inner();
+
+    // Read the blob. It should return the same data.
+    let resp = service
+        .read(tonic::Request::new(ReadBlobRequest {
+            digest: BLOB_A_DIGEST.to_vec(),
+        }))
+        .await;
+
+    let mut rx = resp.ok().unwrap().into_inner();
+
+    // the stream should contain one element, a BlobChunk with the same contents as BLOB_A.
+    let item = rx
+        .next()
+        .await
+        .expect("must be some")
+        .expect("must succeed");
+
+    assert_eq!(BLOB_A.to_vec(), item.data);
+
+    // … and no more elements
+    assert!(rx.next().await.is_none());
+
+    // TODO: we rely here on the blob being small enough to not get broken up into multiple chunks.
+    // Test with some bigger blob too
+}
diff --git a/tvix/store/src/proto/tests/grpc_directoryservice.rs b/tvix/store/src/proto/tests/grpc_directoryservice.rs
new file mode 100644
index 000000000000..069e82f6463e
--- /dev/null
+++ b/tvix/store/src/proto/tests/grpc_directoryservice.rs
@@ -0,0 +1,241 @@
+use crate::directoryservice::DirectoryService;
+use crate::proto::directory_service_server::DirectoryService as GRPCDirectoryService;
+use crate::proto::get_directory_request::ByWhat;
+use crate::proto::{Directory, DirectoryNode, SymlinkNode};
+use crate::proto::{GRPCDirectoryServiceWrapper, GetDirectoryRequest};
+use crate::tests::fixtures::{DIRECTORY_A, DIRECTORY_B, DIRECTORY_C};
+use crate::tests::utils::gen_directory_service;
+use tokio_stream::StreamExt;
+use tonic::Status;
+
+fn gen_grpc_service(
+) -> GRPCDirectoryServiceWrapper<impl DirectoryService + Send + Sync + Clone + 'static> {
+    let directory_service = gen_directory_service();
+    GRPCDirectoryServiceWrapper::from(directory_service)
+}
+
+/// Send the specified GetDirectoryRequest.
+/// Returns an error in the case of an error response, or an error in one of
+// the items in the stream, or a Vec<Directory> in the case of a successful
+/// request.
+async fn get_directories<S: GRPCDirectoryService>(
+    svc: &S,
+    get_directory_request: GetDirectoryRequest,
+) -> Result<Vec<Directory>, Status> {
+    let resp = svc.get(tonic::Request::new(get_directory_request)).await;
+
+    // if the response is an error itself, return the error, otherwise unpack
+    let stream = match resp {
+        Ok(resp) => resp,
+        Err(status) => return Err(status),
+    }
+    .into_inner();
+
+    let directory_results: Vec<Result<Directory, Status>> = stream.collect().await;
+
+    // turn Vec<Result<Directory, Status> into Result<Vec<Directory>,Status>
+    directory_results.into_iter().collect()
+}
+
+/// Trying to get a non-existent Directory should return a not found error.
+#[tokio::test]
+async fn not_found() {
+    let service = gen_grpc_service();
+
+    let resp = service
+        .get(tonic::Request::new(GetDirectoryRequest {
+            by_what: Some(ByWhat::Digest(DIRECTORY_A.digest().to_vec())),
+            ..Default::default()
+        }))
+        .await;
+
+    let mut rx = resp.expect("must succeed").into_inner().into_inner();
+
+    // The stream should contain one element, an error with Code::NotFound.
+    let item = rx
+        .recv()
+        .await
+        .expect("must be some")
+        .expect_err("must be err");
+    assert_eq!(item.code(), tonic::Code::NotFound);
+
+    // … and nothing else
+    assert!(rx.recv().await.is_none());
+}
+
+/// Put a Directory into the store, get it back.
+#[tokio::test]
+async fn put_get() {
+    let service = gen_grpc_service();
+
+    let streaming_request = tonic_mock::streaming_request(vec![DIRECTORY_A.clone()]);
+    let put_resp = service
+        .put(streaming_request)
+        .await
+        .expect("must succeed")
+        .into_inner();
+
+    // the sent root_digest should match the calculated digest
+    assert_eq!(put_resp.root_digest, DIRECTORY_A.digest().to_vec());
+
+    // get it back
+    let items = get_directories(
+        &service,
+        GetDirectoryRequest {
+            by_what: Some(ByWhat::Digest(DIRECTORY_A.digest().to_vec())),
+            ..Default::default()
+        },
+    )
+    .await
+    .expect("must not error");
+
+    assert_eq!(vec![DIRECTORY_A.clone()], items);
+}
+
+/// Put multiple Directories into the store, and get them back
+#[tokio::test]
+async fn put_get_multiple() {
+    let service = gen_grpc_service();
+
+    // sending "b" (which refers to "a") without sending "a" first should fail.
+    let put_resp = service
+        .put(tonic_mock::streaming_request(vec![DIRECTORY_B.clone()]))
+        .await
+        .expect_err("must fail");
+
+    assert_eq!(tonic::Code::InvalidArgument, put_resp.code());
+
+    // sending "a", then "b" should succeed, and the response should contain the digest of b.
+    let put_resp = service
+        .put(tonic_mock::streaming_request(vec![
+            DIRECTORY_A.clone(),
+            DIRECTORY_B.clone(),
+        ]))
+        .await
+        .expect("must succeed");
+
+    assert_eq!(
+        DIRECTORY_B.digest().to_vec(),
+        put_resp.into_inner().root_digest
+    );
+
+    // now, request b, first in non-recursive mode.
+    let items = get_directories(
+        &service,
+        GetDirectoryRequest {
+            recursive: false,
+            by_what: Some(ByWhat::Digest(DIRECTORY_B.digest().to_vec())),
+        },
+    )
+    .await
+    .expect("must not error");
+
+    // We expect to only get b.
+    assert_eq!(vec![DIRECTORY_B.clone()], items);
+
+    // now, request b, but in recursive mode.
+    let items = get_directories(
+        &service,
+        GetDirectoryRequest {
+            recursive: true,
+            by_what: Some(ByWhat::Digest(DIRECTORY_B.digest().to_vec())),
+        },
+    )
+    .await
+    .expect("must not error");
+
+    // We expect to get b, and then a, because that's how we traverse down.
+    assert_eq!(vec![DIRECTORY_B.clone(), DIRECTORY_A.clone()], items);
+}
+
+/// Put multiple Directories into the store, and omit duplicates.
+#[tokio::test]
+async fn put_get_dedup() {
+    let service = gen_grpc_service();
+
+    // Send "A", then "C", which refers to "A" two times
+    // Pretend we're a dumb client sending A twice.
+    let put_resp = service
+        .put(tonic_mock::streaming_request(vec![
+            DIRECTORY_A.clone(),
+            DIRECTORY_A.clone(),
+            DIRECTORY_C.clone(),
+        ]))
+        .await
+        .expect("must succeed");
+
+    assert_eq!(
+        DIRECTORY_C.digest().to_vec(),
+        put_resp.into_inner().root_digest
+    );
+
+    // Ask for "C" recursively. We expect to only get "A" once, as there's no point sending it twice.
+    let items = get_directories(
+        &service,
+        GetDirectoryRequest {
+            recursive: true,
+            by_what: Some(ByWhat::Digest(DIRECTORY_C.digest().to_vec())),
+        },
+    )
+    .await
+    .expect("must not error");
+
+    // We expect to get C, and then A (once, as the second A has been deduplicated).
+    assert_eq!(vec![DIRECTORY_C.clone(), DIRECTORY_A.clone()], items);
+}
+
+/// Trying to upload a Directory failing validation should fail.
+#[tokio::test]
+async fn put_reject_failed_validation() {
+    let service = gen_grpc_service();
+
+    // construct a broken Directory message that fails validation
+    let broken_directory = Directory {
+        symlinks: vec![SymlinkNode {
+            name: "".to_string(),
+            target: "doesntmatter".to_string(),
+        }],
+        ..Default::default()
+    };
+    assert!(broken_directory.validate().is_err());
+
+    // send it over, it must fail
+    let put_resp = service
+        .put(tonic_mock::streaming_request(vec![broken_directory]))
+        .await
+        .expect_err("must fail");
+
+    assert_eq!(put_resp.code(), tonic::Code::InvalidArgument);
+}
+
+/// Trying to upload a Directory with wrong size should fail.
+#[tokio::test]
+async fn put_reject_wrong_size() {
+    let service = gen_grpc_service();
+
+    // Construct a directory referring to DIRECTORY_A, but with wrong size.
+    let broken_parent_directory = Directory {
+        directories: vec![DirectoryNode {
+            name: "foo".to_string(),
+            digest: DIRECTORY_A.digest().to_vec(),
+            size: 42,
+        }],
+        ..Default::default()
+    };
+    // Make sure we got the size wrong.
+    assert_ne!(
+        broken_parent_directory.directories[0].size,
+        DIRECTORY_A.size()
+    );
+
+    // now upload both (first A, then the broken parent). This must fail.
+    let put_resp = service
+        .put(tonic_mock::streaming_request(vec![
+            DIRECTORY_A.clone(),
+            broken_parent_directory,
+        ]))
+        .await
+        .expect_err("must fail");
+
+    assert_eq!(put_resp.code(), tonic::Code::InvalidArgument);
+}
diff --git a/tvix/store/src/proto/tests/grpc_pathinfoservice.rs b/tvix/store/src/proto/tests/grpc_pathinfoservice.rs
new file mode 100644
index 000000000000..11cab2c264cc
--- /dev/null
+++ b/tvix/store/src/proto/tests/grpc_pathinfoservice.rs
@@ -0,0 +1,67 @@
+use crate::nar::NonCachingNARCalculationService;
+use crate::proto::get_path_info_request::ByWhat::ByOutputHash;
+use crate::proto::node::Node::Symlink;
+use crate::proto::path_info_service_server::PathInfoService as GRPCPathInfoService;
+use crate::proto::GRPCPathInfoServiceWrapper;
+use crate::proto::PathInfo;
+use crate::proto::{GetPathInfoRequest, Node, SymlinkNode};
+use crate::tests::fixtures::DUMMY_OUTPUT_HASH;
+use crate::tests::utils::{gen_blob_service, gen_directory_service, gen_pathinfo_service};
+use tonic::Request;
+
+/// generates a GRPCPathInfoService out of blob, directory and pathinfo services.
+///
+/// We only interact with it via the PathInfo GRPC interface.
+/// It uses the NonCachingNARCalculationService NARCalculationService to
+/// calculate NARs.
+fn gen_grpc_service() -> impl GRPCPathInfoService {
+    GRPCPathInfoServiceWrapper::new(
+        gen_pathinfo_service(),
+        NonCachingNARCalculationService::new(gen_blob_service(), gen_directory_service()),
+    )
+}
+
+/// Trying to get a non-existent PathInfo should return a not found error.
+#[tokio::test]
+async fn not_found() {
+    let service = gen_grpc_service();
+
+    let resp = service
+        .get(Request::new(GetPathInfoRequest {
+            by_what: Some(ByOutputHash(DUMMY_OUTPUT_HASH.to_vec())),
+        }))
+        .await;
+
+    let resp = resp.expect_err("must fail");
+    assert_eq!(resp.code(), tonic::Code::NotFound);
+}
+
+/// Put a PathInfo into the store, get it back.
+#[tokio::test]
+async fn put_get() {
+    let service = gen_grpc_service();
+
+    let path_info = PathInfo {
+        node: Some(Node {
+            node: Some(Symlink(SymlinkNode {
+                name: "00000000000000000000000000000000-foo".to_string(),
+                target: "doesntmatter".to_string(),
+            })),
+        }),
+        ..Default::default()
+    };
+
+    let resp = service.put(Request::new(path_info.clone())).await;
+
+    assert!(resp.is_ok());
+    assert_eq!(resp.expect("must succeed").into_inner(), path_info);
+
+    let resp = service
+        .get(Request::new(GetPathInfoRequest {
+            by_what: Some(ByOutputHash(DUMMY_OUTPUT_HASH.to_vec())),
+        }))
+        .await;
+
+    assert!(resp.is_ok());
+    assert_eq!(resp.expect("must succeed").into_inner(), path_info);
+}
diff --git a/tvix/store/src/proto/tests/mod.rs b/tvix/store/src/proto/tests/mod.rs
new file mode 100644
index 000000000000..0a96ea3a0d59
--- /dev/null
+++ b/tvix/store/src/proto/tests/mod.rs
@@ -0,0 +1,6 @@
+mod directory;
+mod directory_nodes_iterator;
+mod grpc_blobservice;
+mod grpc_directoryservice;
+mod grpc_pathinfoservice;
+mod pathinfo;
diff --git a/tvix/store/src/proto/tests/pathinfo.rs b/tvix/store/src/proto/tests/pathinfo.rs
new file mode 100644
index 000000000000..54a76fc6c554
--- /dev/null
+++ b/tvix/store/src/proto/tests/pathinfo.rs
@@ -0,0 +1,207 @@
+use crate::proto::{self, Node, PathInfo, ValidatePathInfoError};
+use lazy_static::lazy_static;
+use nix_compat::store_path::{self, StorePath};
+use test_case::test_case;
+
+lazy_static! {
+    static ref DUMMY_DIGEST: Vec<u8> = vec![
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00,
+    ];
+    static ref DUMMY_DIGEST_2: Vec<u8> = vec![
+        0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00,
+    ];
+}
+
+const DUMMY_NAME: &str = "00000000000000000000000000000000-dummy";
+
+#[test_case(
+    None,
+    Err(ValidatePathInfoError::NoNodePresent()) ;
+    "No node"
+)]
+#[test_case(
+    Some(Node { node: None }),
+    Err(ValidatePathInfoError::NoNodePresent());
+    "No node 2"
+)]
+fn validate_no_node(
+    t_node: Option<proto::Node>,
+    t_result: Result<StorePath, ValidatePathInfoError>,
+) {
+    // construct the PathInfo object
+    let p = PathInfo {
+        node: t_node,
+        ..Default::default()
+    };
+    assert_eq!(t_result, p.validate());
+}
+
+#[test_case(
+    proto::DirectoryNode {
+        name: DUMMY_NAME.to_string(),
+        digest: DUMMY_DIGEST.to_vec(),
+        size: 0,
+    },
+    Ok(StorePath::from_string(DUMMY_NAME).expect("must succeed"));
+    "ok"
+)]
+#[test_case(
+    proto::DirectoryNode {
+        name: DUMMY_NAME.to_string(),
+        digest: vec![],
+        size: 0,
+    },
+    Err(ValidatePathInfoError::InvalidDigestLen(0));
+    "invalid digest length"
+)]
+#[test_case(
+    proto::DirectoryNode {
+        name: "invalid".to_string(),
+        digest: DUMMY_DIGEST.to_vec(),
+        size: 0,
+    },
+    Err(ValidatePathInfoError::InvalidNodeName(
+        "invalid".to_string(),
+        store_path::Error::InvalidName(store_path::NameError::InvalidName("".to_string()))
+    ));
+    "invalid node name"
+)]
+fn validate_directory(
+    t_directory_node: proto::DirectoryNode,
+    t_result: Result<StorePath, ValidatePathInfoError>,
+) {
+    // construct the PathInfo object
+    let p = PathInfo {
+        node: Some(Node {
+            node: Some(proto::node::Node::Directory(t_directory_node)),
+        }),
+        ..Default::default()
+    };
+    assert_eq!(t_result, p.validate());
+}
+
+#[test_case(
+    proto::FileNode {
+        name: DUMMY_NAME.to_string(),
+        digest: DUMMY_DIGEST.to_vec(),
+        size: 0,
+        executable: false,
+    },
+    Ok(StorePath::from_string(DUMMY_NAME).expect("must succeed"));
+    "ok"
+)]
+#[test_case(
+    proto::FileNode {
+        name: DUMMY_NAME.to_string(),
+        digest: vec![],
+        ..Default::default()
+    },
+    Err(ValidatePathInfoError::InvalidDigestLen(0));
+    "invalid digest length"
+)]
+#[test_case(
+    proto::FileNode {
+        name: "invalid".to_string(),
+        digest: DUMMY_DIGEST.to_vec(),
+        ..Default::default()
+    },
+    Err(ValidatePathInfoError::InvalidNodeName(
+        "invalid".to_string(),
+        store_path::Error::InvalidName(store_path::NameError::InvalidName("".to_string()))
+    ));
+    "invalid node name"
+)]
+fn validate_file(t_file_node: proto::FileNode, t_result: Result<StorePath, ValidatePathInfoError>) {
+    // construct the PathInfo object
+    let p = PathInfo {
+        node: Some(Node {
+            node: Some(proto::node::Node::File(t_file_node)),
+        }),
+        ..Default::default()
+    };
+    assert_eq!(t_result, p.validate());
+}
+
+#[test_case(
+    proto::SymlinkNode {
+        name: DUMMY_NAME.to_string(),
+        ..Default::default()
+    },
+    Ok(StorePath::from_string(DUMMY_NAME).expect("must succeed"));
+    "ok"
+)]
+#[test_case(
+    proto::SymlinkNode {
+        name: "invalid".to_string(),
+        ..Default::default()
+    },
+    Err(ValidatePathInfoError::InvalidNodeName(
+        "invalid".to_string(),
+        store_path::Error::InvalidName(store_path::NameError::InvalidName("".to_string()))
+    ));
+    "invalid node name"
+)]
+fn validate_symlink(
+    t_symlink_node: proto::SymlinkNode,
+    t_result: Result<StorePath, ValidatePathInfoError>,
+) {
+    // construct the PathInfo object
+    let p = PathInfo {
+        node: Some(Node {
+            node: Some(proto::node::Node::Symlink(t_symlink_node)),
+        }),
+        ..Default::default()
+    };
+    assert_eq!(t_result, p.validate());
+}
+
+#[test]
+fn validate_references() {
+    // create a PathInfo without narinfo field.
+    let path_info = PathInfo {
+        node: Some(Node {
+            node: Some(proto::node::Node::Directory(proto::DirectoryNode {
+                name: DUMMY_NAME.to_string(),
+                digest: DUMMY_DIGEST.to_vec(),
+                size: 0,
+            })),
+        }),
+        references: vec![DUMMY_DIGEST_2.to_vec()],
+        narinfo: None,
+    };
+    assert!(path_info.validate().is_ok());
+
+    // create a PathInfo with a narinfo field, but an inconsistent set of references
+    let path_info_with_narinfo_missing_refs = PathInfo {
+        narinfo: Some(proto::NarInfo {
+            nar_size: 0,
+            nar_sha256: DUMMY_DIGEST.to_vec(),
+            signatures: vec![],
+            reference_names: vec![],
+        }),
+        ..path_info.clone()
+    };
+    match path_info_with_narinfo_missing_refs
+        .validate()
+        .expect_err("must_fail")
+    {
+        ValidatePathInfoError::InconsistentNumberOfReferences(_, _) => {}
+        _ => panic!("unexpected error"),
+    };
+
+    // create a pathinfo with the correct number of references, should suceed
+    let path_info_with_narinfo = PathInfo {
+        narinfo: Some(proto::NarInfo {
+            nar_size: 0,
+            nar_sha256: DUMMY_DIGEST.to_vec(),
+            signatures: vec![],
+            reference_names: vec![format!("/nix/store/{}", DUMMY_NAME)],
+        }),
+        ..path_info
+    };
+    assert!(path_info_with_narinfo.validate().is_ok());
+}