From 75c81aa0ded90fab03c77b9fb53c02ffeeefd066 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 18:16:48 -0700 Subject: [PATCH 01/36] Open database --- Cargo.lock | 10 +++++ Cargo.toml | 1 + src/hash.rs | 41 ++++++++++++++++++++ src/server.rs | 73 ++++++++++++++++++++++++++++++++++- src/subcommand/serve/tests.rs | 9 +++++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5379c27..f89dc316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,6 +1019,7 @@ dependencies = [ "owo-colors", "pretty_assertions", "rand 0.8.6", + "redb", "regex", "reqwest", "rust-embed", @@ -2268,6 +2269,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + [[package]] name = "redox_users" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 1d926c85..c934425b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ libc = "0.2.186" num-traits = "0.2.19" owo-colors = "4" rand = "0.8.5" +redb = "4.1.0" regex = "1.10.6" reqwest = { version = "0.13.3", features = ["blocking", "http2"] } rust-embed = { version = "8.11.0", features = ["mime-guess"] } diff --git a/src/hash.rs b/src/hash.rs index 02f834a2..fb964246 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -101,6 +101,47 @@ impl Encode for Hash { } } +impl redb::Key for Hash { + fn compare(a: &[u8], b: &[u8]) -> Ordering { + a.cmp(b) + } +} + +impl redb::Value for Hash { + type SelfType<'a> + = Hash + where + Self: 'a; + + type AsBytes<'a> + = &'a [u8; Self::LEN] + where + Self: 'a; + + fn fixed_width() -> Option { + Some(Self::LEN) + } + + fn from_bytes<'a>(data: &'a [u8]) -> Self + where + Self: 'a, + { + let array: [u8; Self::LEN] = data.try_into().unwrap(); + array.into() + } + + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> &'a [u8; Self::LEN] + where + Self: 'b, + { + value.as_bytes() + } + + fn type_name() -> redb::TypeName { + redb::TypeName::new("filepack-hash") + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/server.rs b/src/server.rs index f9e5b064..ea308fa8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,6 +1,41 @@ -use super::*; +use { + super::*, + redb::{Database, ReadableTable, TableDefinition}, +}; + +#[macro_export] +macro_rules! define_table { + ($name:ident, $key:ty, $value:ty) => { + const $name: TableDefinition<$key, $value> = TableDefinition::new(stringify!($name)); + }; +} + +#[macro_export] +macro_rules! define_multimap_table { + ($name:ident, $key:ty, $value:ty) => { + const $name: MultimapTableDefinition<$key, $value> = + MultimapTableDefinition::new(stringify!($name)); + }; +} + +const SCHEMA_VERSION: u64 = 0; + +const METADATA: TableDefinition = TableDefinition::new("metadata"); +const PACKAGES: TableDefinition = TableDefinition::new("packages"); + +#[derive(Copy, Clone)] +pub(crate) enum MetadataKey { + Schema = 0, +} + +impl MetadataKey { + fn key(self) -> u64 { + self as u64 + } +} pub(crate) struct Server { + database: Database, files: Utf8PathBuf, incoming: Utf8PathBuf, } @@ -56,13 +91,47 @@ impl Server { } pub(crate) fn with_data_dir(data_dir: &Utf8Path) -> Result { + let database = Database::create(data_dir.join("database.redb")).unwrap(); + + let tx = database.begin_write().unwrap(); + + if tx.list_tables().unwrap().count() == 0 { + { + let mut metadata = tx.open_table(METADATA).unwrap(); + + metadata + .insert(&MetadataKey::Schema.key(), &SCHEMA_VERSION) + .unwrap(); + + tx.open_table(PACKAGES).unwrap(); + } + + tx.commit().unwrap(); + } else { + let schema_version = tx + .open_table(METADATA) + .unwrap() + .get(&MetadataKey::Schema.key()) + .unwrap() + .unwrap() + .value(); + + assert_eq!(schema_version, SCHEMA_VERSION); + + drop(tx) + } + let files = data_dir.join("files"); filesystem::create_dir_all(&files)?; let incoming = data_dir.join("incoming"); filesystem::create_dir_all(&incoming)?; - Ok(Self { files, incoming }) + Ok(Self { + database, + files, + incoming, + }) } pub(crate) async fn write_file(&self, hash: Hash, body: Body) -> ServerResult { diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index b4bca15c..a22d9fac 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -7,6 +7,15 @@ use { tower::ServiceExt, }; +// todo: +// - we need packages +// - either a file in /packages/ (can be empty) or a database entry +// - does it verify anything? +// - directory deserializes, recursively +// - metadata deserializes +// - all file hashes are present +// - database could hold which files are valid directories and have all files present so we don't have to re-verify + struct TestRequestBuilder { body: Option, method: &'static str, From 977e12ce67a7bba341d9405cdcf332186d777920 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:03:33 -0700 Subject: [PATCH 02/36] Remove TODO --- src/server.rs | 15 --------------- src/subcommand/serve/tests.rs | 9 --------- 2 files changed, 24 deletions(-) diff --git a/src/server.rs b/src/server.rs index ea308fa8..21614685 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,21 +3,6 @@ use { redb::{Database, ReadableTable, TableDefinition}, }; -#[macro_export] -macro_rules! define_table { - ($name:ident, $key:ty, $value:ty) => { - const $name: TableDefinition<$key, $value> = TableDefinition::new(stringify!($name)); - }; -} - -#[macro_export] -macro_rules! define_multimap_table { - ($name:ident, $key:ty, $value:ty) => { - const $name: MultimapTableDefinition<$key, $value> = - MultimapTableDefinition::new(stringify!($name)); - }; -} - const SCHEMA_VERSION: u64 = 0; const METADATA: TableDefinition = TableDefinition::new("metadata"); diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index a22d9fac..b4bca15c 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -7,15 +7,6 @@ use { tower::ServiceExt, }; -// todo: -// - we need packages -// - either a file in /packages/ (can be empty) or a database entry -// - does it verify anything? -// - directory deserializes, recursively -// - metadata deserializes -// - all file hashes are present -// - database could hold which files are valid directories and have all files present so we don't have to re-verify - struct TestRequestBuilder { body: Option, method: &'static str, From eb38c67887e9e82fb0add30b7358ad79b68d3481 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:14:05 -0700 Subject: [PATCH 03/36] Implement directory verification --- src/server.rs | 92 ++++++++++++++++++++- src/server_error.rs | 20 ++++- src/subcommand/serve.rs | 11 ++- src/subcommand/serve/tests.rs | 147 ++++++++++++++++++++++++++++++++++ src/subcommand/upload.rs | 79 ++++++++++++------ 5 files changed, 321 insertions(+), 28 deletions(-) diff --git a/src/server.rs b/src/server.rs index 21614685..534badaa 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,10 +1,11 @@ use { super::*, - redb::{Database, ReadableTable, TableDefinition}, + redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}, }; const SCHEMA_VERSION: u64 = 0; +const DIRECTORIES: TableDefinition = TableDefinition::new("directories"); const METADATA: TableDefinition = TableDefinition::new("metadata"); const PACKAGES: TableDefinition = TableDefinition::new("packages"); @@ -75,6 +76,93 @@ impl Server { Ok((file, len)) } + pub(crate) async fn verify_directory(&self, hash: Hash) -> ServerResult { + let path = self.file_path(hash); + + let cbor = match tokio::fs::read(&path).await { + Err(err) => { + return if err.kind() == io::ErrorKind::NotFound { + Err(server_error::FileNotFound { hash }.into_error(err)) + } else { + Err(server_error::FilesystemIo { path }.into_error(err)) + }; + } + Ok(cbor) => cbor, + }; + + let directory = + Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash })?; + + { + let tx = self + .database + .begin_read() + .map_err(redb::Error::from) + .context(server_error::Database)?; + + let directories = tx + .open_table(DIRECTORIES) + .map_err(redb::Error::from) + .context(server_error::Database)?; + + for entry in directory.entries.values() { + match entry.ty { + EntryType::File => { + let file_path = self.file_path(entry.hash); + let exists = tokio::fs::try_exists(&file_path) + .await + .context(server_error::FilesystemIo { path: &file_path })?; + ensure!( + exists, + server_error::DirectoryFileMissing { + directory: hash, + file: entry.hash, + }, + ); + } + EntryType::Directory => { + let verified = directories + .get(&entry.hash) + .map_err(redb::Error::from) + .context(server_error::Database)? + .is_some(); + ensure!( + verified, + server_error::DirectorySubdirectoryUnverified { + directory: hash, + subdirectory: entry.hash, + }, + ); + } + } + } + } + + let tx = self + .database + .begin_write() + .map_err(redb::Error::from) + .context(server_error::Database)?; + + { + let mut directories = tx + .open_table(DIRECTORIES) + .map_err(redb::Error::from) + .context(server_error::Database)?; + + directories + .insert(&hash, &()) + .map_err(redb::Error::from) + .context(server_error::Database)?; + } + + tx.commit() + .map_err(redb::Error::from) + .context(server_error::Database)?; + + Ok(()) + } + pub(crate) fn with_data_dir(data_dir: &Utf8Path) -> Result { let database = Database::create(data_dir.join("database.redb")).unwrap(); @@ -88,6 +176,8 @@ impl Server { .insert(&MetadataKey::Schema.key(), &SCHEMA_VERSION) .unwrap(); + tx.open_table(DIRECTORIES).unwrap(); + tx.open_table(PACKAGES).unwrap(); } diff --git a/src/server_error.rs b/src/server_error.rs index 0ec83ff5..de835edf 100644 --- a/src/server_error.rs +++ b/src/server_error.rs @@ -9,6 +9,14 @@ pub(crate) enum ServerError { AuthorizationMalformed, #[snafu(display("missing authorization header"))] AuthorizationMissing, + #[snafu(display("database error"))] + Database { source: redb::Error }, + #[snafu(display("failed to decode directory {hash}"))] + DirectoryDecode { hash: Hash, source: DecodeError }, + #[snafu(display("directory {directory} references missing file {file}"))] + DirectoryFileMissing { directory: Hash, file: Hash }, + #[snafu(display("directory {directory} references unverified subdirectory {subdirectory}"))] + DirectorySubdirectoryUnverified { directory: Hash, subdirectory: Hash }, #[snafu(display("file with hash {hash} not found"))] FileNotFound { hash: Hash, source: io::Error }, #[snafu(display("I/O error at {path}"))] @@ -32,11 +40,15 @@ impl ServerError { Self::AuthorizationInvalid { .. } | Self::AuthorizationMalformed | Self::AuthorizationMissing + | Self::DirectoryDecode { .. } + | Self::DirectoryFileMissing { .. } + | Self::DirectorySubdirectoryUnverified { .. } | Self::FileNotFound { .. } | Self::PageNotFound | Self::UploadBodyRead { .. } | Self::UploadForbidden | Self::UploadHashMismatch { .. } => self.to_string(), + Self::Database { .. } => "database error".into(), Self::FilesystemIo { .. } => "filesystem I/O error".into(), } } @@ -46,10 +58,14 @@ impl ServerError { Self::AuthorizationInvalid { .. } | Self::AuthorizationMalformed | Self::AuthorizationMissing => StatusCode::UNAUTHORIZED, + Self::Database { .. } | Self::FilesystemIo { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::DirectoryDecode { .. } + | Self::DirectoryFileMissing { .. } + | Self::DirectorySubdirectoryUnverified { .. } + | Self::UploadBodyRead { .. } + | Self::UploadHashMismatch { .. } => StatusCode::BAD_REQUEST, Self::FileNotFound { .. } | Self::PageNotFound => StatusCode::NOT_FOUND, - Self::FilesystemIo { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::UploadForbidden => StatusCode::FORBIDDEN, - Self::UploadBodyRead { .. } | Self::UploadHashMismatch { .. } => StatusCode::BAD_REQUEST, } } } diff --git a/src/subcommand/serve.rs b/src/subcommand/serve.rs index 900af841..bc37c807 100644 --- a/src/subcommand/serve.rs +++ b/src/subcommand/serve.rs @@ -5,7 +5,7 @@ use { extract::{Extension, Path}, http::{HeaderValue, Uri, header}, response::Redirect, - routing::{get, put}, + routing::{get, post, put}, }, axum_server::Handle, rustls_acme::{ @@ -232,6 +232,7 @@ impl Serve { pub(crate) fn router(server: Arc, auth_config: Option>) -> Router { let router = Router::new() .route("/", get(Self::home)) + .route("/directory/{hash}", post(Self::verify_directory)) .route("/favicon.ico", get(Self::favicon)) .route("/file/{hash}", get(Self::download)) .route("/file/{hash}", put(Self::upload)) @@ -437,6 +438,14 @@ impl Serve { ) -> ServerResult { server.write_file(*hash, body).await } + + async fn verify_directory( + _: Authenticated, + server: ServerExtension, + Path(hash): Path, + ) -> ServerResult { + server.verify_directory(hash).await + } } impl Default for Serve { diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index b4bca15c..f7dcc1a6 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -150,6 +150,10 @@ impl TestServer { Self::with_auth(None) } + fn post(&self, path: impl Into) -> TestRequestBuilder { + TestRequestBuilder::new("POST", path, self.router.clone()) + } + fn put(&self, path: impl Into) -> TestRequestBuilder { TestRequestBuilder::new("PUT", path, self.router.clone()) } @@ -208,6 +212,26 @@ fn default_serve_matches_parsed() { ); } +fn directory_cbor(entries: &[(&str, EntryType, Hash, u64)]) -> Vec { + Directory { + version: Version::Zero, + entries: entries + .iter() + .map(|(name, ty, hash, size)| { + ( + name.parse().unwrap(), + Entry { + ty: *ty, + hash: *hash, + size: *size, + }, + ) + }) + .collect(), + } + .encode_to_vec() +} + #[test] fn domain_defaults_to_hostname() { assert_eq!( @@ -522,3 +546,126 @@ async fn upload_with_wrong_hash_fails() { server.assert_incoming_empty(); } + +#[tokio::test] +async fn verify_directory_decode_error() { + let server = TestServer::new(); + + let junk = b"junk"; + let hash = Hash::bytes(junk); + server.write_file(junk); + + server + .post(format!("/directory/{hash}")) + .status(StatusCode::BAD_REQUEST) + .assert_body(format!("failed to decode directory {hash}")) + .send() + .await; +} + +#[tokio::test] +async fn verify_directory_file_not_found() { + let server = TestServer::new(); + + let hash = Hash::bytes(b"foo"); + + server + .post(format!("/directory/{hash}")) + .status(StatusCode::NOT_FOUND) + .assert_body(format!("file with hash {hash} not found")) + .send() + .await; +} + +#[tokio::test] +async fn verify_directory_missing_file() { + let server = TestServer::new(); + + let missing = Hash::bytes(b"foo"); + let cbor = directory_cbor(&[("foo", EntryType::File, missing, 3)]); + let hash = Hash::bytes(&cbor); + server.write_file(&cbor); + + server + .post(format!("/directory/{hash}")) + .status(StatusCode::BAD_REQUEST) + .assert_body(format!( + "directory {hash} references missing file {missing}" + )) + .send() + .await; +} + +#[tokio::test] +async fn verify_directory_rejects_missing_auth_header() { + let admin = PrivateKey::generate(); + let server = TestServer::with_auth(Some(Arc::new(AuthConfig { + admin: Some(admin.public_key()), + audiences: vec!["filepack.example".into()], + }))); + + let hash = Hash::bytes(b"foo"); + + server + .post(format!("/directory/{hash}")) + .status(StatusCode::UNAUTHORIZED) + .assert_body("missing authorization header") + .send() + .await; +} + +#[tokio::test] +async fn verify_directory_succeeds() { + let server = TestServer::new(); + + let file = b"foo"; + let file_hash = Hash::bytes(file); + server.write_file(file); + + let child_cbor = directory_cbor(&[("foo", EntryType::File, file_hash, file.len() as u64)]); + let child_hash = Hash::bytes(&child_cbor); + server.write_file(&child_cbor); + + server.post(format!("/directory/{child_hash}")).send().await; + + let parent_cbor = directory_cbor(&[( + "child", + EntryType::Directory, + child_hash, + child_cbor.len() as u64, + )]); + let parent_hash = Hash::bytes(&parent_cbor); + server.write_file(&parent_cbor); + + server + .post(format!("/directory/{parent_hash}")) + .send() + .await; +} + +#[tokio::test] +async fn verify_directory_unverified_subdirectory() { + let server = TestServer::new(); + + let child_cbor = directory_cbor(&[]); + let child_hash = Hash::bytes(&child_cbor); + server.write_file(&child_cbor); + + let parent_cbor = directory_cbor(&[( + "child", + EntryType::Directory, + child_hash, + child_cbor.len() as u64, + )]); + let parent_hash = Hash::bytes(&parent_cbor); + server.write_file(&parent_cbor); + + server + .post(format!("/directory/{parent_hash}")) + .status(StatusCode::BAD_REQUEST) + .assert_body(format!( + "directory {parent_hash} references unverified subdirectory {child_hash}" + )) + .send() + .await; +} diff --git a/src/subcommand/upload.rs b/src/subcommand/upload.rs index ba6fc2b5..8ae77d72 100644 --- a/src/subcommand/upload.rs +++ b/src/subcommand/upload.rs @@ -13,6 +13,17 @@ pub(crate) struct Upload { } impl Upload { + fn post(&self, kind: &str, hash: Hash, key: Option<&PrivateKey>) -> Result { + let url = self.server.join(&format!("{kind}/{hash}")).unwrap(); + let mut request = Client::new().post(url); + if let Some(key) = key { + let host = self.server.host_str().unwrap().to_owned(); + request = request.bearer_auth(Token::encode(key, &host)?); + } + request.send().check_status()?; + Ok(()) + } + pub(crate) fn run(self, options: Options) -> Result { let key = if let Some(name) = &self.auth { let loopback = match self.server.host().unwrap() { @@ -55,6 +66,40 @@ impl Upload { Ok(()) } + fn upload_directory( + &self, + archive_path: &Utf8Path, + archive: &Archive, + hash: Hash, + path: &Utf8Path, + options: &Options, + key: Option<&PrivateKey>, + ) -> Result { + let context = error::UnarchiveManifest { path: archive_path }; + + let cbor = archive.file(hash).context(context)?; + + let directory = Directory::decode_from_slice(cbor) + .context(archive_error::DirectoryDecode) + .context(context)?; + + self.upload_body(hash, cbor.to_vec().into(), key)?; + + for (component, entry) in &directory.entries { + let child_path = path.join(component); + match entry.ty { + EntryType::Directory => { + self.upload_directory(archive_path, archive, entry.hash, &child_path, options, key)?; + } + EntryType::File => self.upload_package_file(&child_path, entry, options, key)?, + } + } + + self.post("directory", hash, key)?; + + Ok(()) + } + fn upload_file(&self, path: &Utf8Path, options: &Options, key: Option<&PrivateKey>) -> Result { let hash = options .hash_file(path) @@ -76,32 +121,18 @@ impl Upload { ) -> Result { let archive = Archive::load_with_path(archive_path, archive_path)?; - let context = error::UnarchiveManifest { path: archive_path }; - - let fingerprint = archive.fingerprint().context(context)?; + let fingerprint = archive + .fingerprint() + .context(error::UnarchiveManifest { path: archive_path })?; - let mut directories = vec![( + self.upload_directory( + archive_path, + &archive, fingerprint.into(), - archive_path.parent().unwrap().to_owned(), - )]; - - while let Some((hash, path)) = directories.pop() { - let cbor = archive.file(hash).context(context)?; - - let directory = Directory::decode_from_slice(cbor) - .context(archive_error::DirectoryDecode) - .context(context)?; - - self.upload_body(hash, cbor.to_vec().into(), key)?; - - for (component, entry) in directory.entries { - let path = path.join(component); - match entry.ty { - EntryType::Directory => directories.push((entry.hash, path)), - EntryType::File => self.upload_package_file(&path, &entry, options, key)?, - } - } - } + archive_path.parent().unwrap(), + options, + key, + )?; Ok(()) } From c86bdae9962059d9b5fbb804ba15768c193fdf31 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:26:45 -0700 Subject: [PATCH 04/36] Add error handling --- src/error.rs | 20 ++++++++++++ src/server.rs | 75 ++++++++++++++++----------------------------- src/server_error.rs | 29 +++++++++++++++--- 3 files changed, 72 insertions(+), 52 deletions(-) diff --git a/src/error.rs b/src/error.rs index d894f621..1cf0b157 100644 --- a/src/error.rs +++ b/src/error.rs @@ -67,6 +67,14 @@ pub enum Error { }, #[snafu(display("failed to get local data directory"))] DataLocalDir { backtrace: Option }, + #[snafu(display("database schema version `{actual}` does not match expected `{expected}`"))] + DatabaseSchemaVersionMismatch { + actual: u64, + backtrace: Option, + expected: u64, + }, + #[snafu(display("database is missing schema version"))] + DatabaseSchemaVersionMissing { backtrace: Option }, #[snafu(display("failed to decode manifest at `{path}`"))] DecodeManifest { backtrace: Option, @@ -310,6 +318,18 @@ pub enum Error { backtrace: Option, source: io::Error, }, + #[snafu(transparent)] + Redb { source: redb::Error }, + #[snafu(transparent)] + RedbCommit { source: redb::CommitError }, + #[snafu(transparent)] + RedbDatabase { source: redb::DatabaseError }, + #[snafu(transparent)] + RedbStorage { source: redb::StorageError }, + #[snafu(transparent)] + RedbTable { source: redb::TableError }, + #[snafu(transparent)] + RedbTransaction { source: redb::TransactionError }, #[snafu(display("request failed"))] Request { backtrace: Option, diff --git a/src/server.rs b/src/server.rs index 534badaa..18c27db0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -94,16 +94,9 @@ impl Server { Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash })?; { - let tx = self - .database - .begin_read() - .map_err(redb::Error::from) - .context(server_error::Database)?; + let tx = self.database.begin_read()?; - let directories = tx - .open_table(DIRECTORIES) - .map_err(redb::Error::from) - .context(server_error::Database)?; + let directories = tx.open_table(DIRECTORIES)?; for entry in directory.entries.values() { match entry.ty { @@ -121,11 +114,7 @@ impl Server { ); } EntryType::Directory => { - let verified = directories - .get(&entry.hash) - .map_err(redb::Error::from) - .context(server_error::Database)? - .is_some(); + let verified = directories.get(&entry.hash)?.is_some(); ensure!( verified, server_error::DirectorySubdirectoryUnverified { @@ -138,62 +127,52 @@ impl Server { } } - let tx = self - .database - .begin_write() - .map_err(redb::Error::from) - .context(server_error::Database)?; + let tx = self.database.begin_write()?; { - let mut directories = tx - .open_table(DIRECTORIES) - .map_err(redb::Error::from) - .context(server_error::Database)?; - - directories - .insert(&hash, &()) - .map_err(redb::Error::from) - .context(server_error::Database)?; + let mut directories = tx.open_table(DIRECTORIES)?; + + directories.insert(&hash, &())?; } - tx.commit() - .map_err(redb::Error::from) - .context(server_error::Database)?; + tx.commit()?; Ok(()) } pub(crate) fn with_data_dir(data_dir: &Utf8Path) -> Result { - let database = Database::create(data_dir.join("database.redb")).unwrap(); + let database = Database::create(data_dir.join("database.redb"))?; - let tx = database.begin_write().unwrap(); + let tx = database.begin_write()?; - if tx.list_tables().unwrap().count() == 0 { + if tx.list_tables()?.count() == 0 { { - let mut metadata = tx.open_table(METADATA).unwrap(); + let mut metadata = tx.open_table(METADATA)?; - metadata - .insert(&MetadataKey::Schema.key(), &SCHEMA_VERSION) - .unwrap(); + metadata.insert(&MetadataKey::Schema.key(), &SCHEMA_VERSION)?; - tx.open_table(DIRECTORIES).unwrap(); + tx.open_table(DIRECTORIES)?; - tx.open_table(PACKAGES).unwrap(); + tx.open_table(PACKAGES)?; } - tx.commit().unwrap(); + tx.commit()?; } else { let schema_version = tx - .open_table(METADATA) - .unwrap() - .get(&MetadataKey::Schema.key()) - .unwrap() - .unwrap() + .open_table(METADATA)? + .get(&MetadataKey::Schema.key())? + .context(error::DatabaseSchemaVersionMissing)? .value(); - assert_eq!(schema_version, SCHEMA_VERSION); + ensure!( + schema_version == SCHEMA_VERSION, + error::DatabaseSchemaVersionMismatch { + actual: schema_version, + expected: SCHEMA_VERSION, + }, + ); - drop(tx) + drop(tx); } let files = data_dir.join("files"); diff --git a/src/server_error.rs b/src/server_error.rs index de835edf..d02d1c7c 100644 --- a/src/server_error.rs +++ b/src/server_error.rs @@ -9,8 +9,6 @@ pub(crate) enum ServerError { AuthorizationMalformed, #[snafu(display("missing authorization header"))] AuthorizationMissing, - #[snafu(display("database error"))] - Database { source: redb::Error }, #[snafu(display("failed to decode directory {hash}"))] DirectoryDecode { hash: Hash, source: DecodeError }, #[snafu(display("directory {directory} references missing file {file}"))] @@ -26,6 +24,18 @@ pub(crate) enum ServerError { }, #[snafu(display("page not found"))] PageNotFound, + #[snafu(transparent)] + Redb { source: redb::Error }, + #[snafu(transparent)] + RedbCommit { source: redb::CommitError }, + #[snafu(transparent)] + RedbDatabase { source: redb::DatabaseError }, + #[snafu(transparent)] + RedbStorage { source: redb::StorageError }, + #[snafu(transparent)] + RedbTable { source: redb::TableError }, + #[snafu(transparent)] + RedbTransaction { source: redb::TransactionError }, #[snafu(display("error reading body of upload with hash {hash}"))] UploadBodyRead { hash: Hash, source: axum::Error }, #[snafu(display("uploads forbidden"))] @@ -48,7 +58,12 @@ impl ServerError { | Self::UploadBodyRead { .. } | Self::UploadForbidden | Self::UploadHashMismatch { .. } => self.to_string(), - Self::Database { .. } => "database error".into(), + Self::Redb { .. } + | Self::RedbCommit { .. } + | Self::RedbDatabase { .. } + | Self::RedbStorage { .. } + | Self::RedbTable { .. } + | Self::RedbTransaction { .. } => "database error".into(), Self::FilesystemIo { .. } => "filesystem I/O error".into(), } } @@ -58,7 +73,13 @@ impl ServerError { Self::AuthorizationInvalid { .. } | Self::AuthorizationMalformed | Self::AuthorizationMissing => StatusCode::UNAUTHORIZED, - Self::Database { .. } | Self::FilesystemIo { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::FilesystemIo { .. } + | Self::Redb { .. } + | Self::RedbCommit { .. } + | Self::RedbDatabase { .. } + | Self::RedbStorage { .. } + | Self::RedbTable { .. } + | Self::RedbTransaction { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::DirectoryDecode { .. } | Self::DirectoryFileMissing { .. } | Self::DirectorySubdirectoryUnverified { .. } From 07781372fc5d1cad46d0b0b7d460306a6b603bb0 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:28:23 -0700 Subject: [PATCH 05/36] Remove packages table --- src/server.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/server.rs b/src/server.rs index 18c27db0..d931671d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,7 +7,6 @@ const SCHEMA_VERSION: u64 = 0; const DIRECTORIES: TableDefinition = TableDefinition::new("directories"); const METADATA: TableDefinition = TableDefinition::new("metadata"); -const PACKAGES: TableDefinition = TableDefinition::new("packages"); #[derive(Copy, Clone)] pub(crate) enum MetadataKey { @@ -152,8 +151,6 @@ impl Server { metadata.insert(&MetadataKey::Schema.key(), &SCHEMA_VERSION)?; tx.open_table(DIRECTORIES)?; - - tx.open_table(PACKAGES)?; } tx.commit()?; From c8e3b4cda3e602370ccdb1bf794b528d997f7256 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:36:54 -0700 Subject: [PATCH 06/36] Rename DirectorySubdirectoryUnverified to DirectoryUnverified --- src/server.rs | 2 +- src/server_error.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server.rs b/src/server.rs index d931671d..24997e38 100644 --- a/src/server.rs +++ b/src/server.rs @@ -116,7 +116,7 @@ impl Server { let verified = directories.get(&entry.hash)?.is_some(); ensure!( verified, - server_error::DirectorySubdirectoryUnverified { + server_error::DirectoryUnverified { directory: hash, subdirectory: entry.hash, }, diff --git a/src/server_error.rs b/src/server_error.rs index d02d1c7c..7e53cc2c 100644 --- a/src/server_error.rs +++ b/src/server_error.rs @@ -14,7 +14,7 @@ pub(crate) enum ServerError { #[snafu(display("directory {directory} references missing file {file}"))] DirectoryFileMissing { directory: Hash, file: Hash }, #[snafu(display("directory {directory} references unverified subdirectory {subdirectory}"))] - DirectorySubdirectoryUnverified { directory: Hash, subdirectory: Hash }, + DirectoryUnverified { directory: Hash, subdirectory: Hash }, #[snafu(display("file with hash {hash} not found"))] FileNotFound { hash: Hash, source: io::Error }, #[snafu(display("I/O error at {path}"))] @@ -52,7 +52,7 @@ impl ServerError { | Self::AuthorizationMissing | Self::DirectoryDecode { .. } | Self::DirectoryFileMissing { .. } - | Self::DirectorySubdirectoryUnverified { .. } + | Self::DirectoryUnverified { .. } | Self::FileNotFound { .. } | Self::PageNotFound | Self::UploadBodyRead { .. } @@ -82,7 +82,7 @@ impl ServerError { | Self::RedbTransaction { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::DirectoryDecode { .. } | Self::DirectoryFileMissing { .. } - | Self::DirectorySubdirectoryUnverified { .. } + | Self::DirectoryUnverified { .. } | Self::UploadBodyRead { .. } | Self::UploadHashMismatch { .. } => StatusCode::BAD_REQUEST, Self::FileNotFound { .. } | Self::PageNotFound => StatusCode::NOT_FOUND, From 8eb6dd09676a119ecb738b8165b39f8d455bd4c8 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:39:49 -0700 Subject: [PATCH 07/36] Clean up redb errors --- src/server_error.rs | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/server_error.rs b/src/server_error.rs index 7e53cc2c..892318a9 100644 --- a/src/server_error.rs +++ b/src/server_error.rs @@ -25,17 +25,15 @@ pub(crate) enum ServerError { #[snafu(display("page not found"))] PageNotFound, #[snafu(transparent)] - Redb { source: redb::Error }, + Database { source: redb::DatabaseError }, #[snafu(transparent)] - RedbCommit { source: redb::CommitError }, + DatabaseCommit { source: redb::CommitError }, #[snafu(transparent)] - RedbDatabase { source: redb::DatabaseError }, + DatabaseStorage { source: redb::StorageError }, #[snafu(transparent)] - RedbStorage { source: redb::StorageError }, + DatabaseTable { source: redb::TableError }, #[snafu(transparent)] - RedbTable { source: redb::TableError }, - #[snafu(transparent)] - RedbTransaction { source: redb::TransactionError }, + DatabaseTransaction { source: redb::TransactionError }, #[snafu(display("error reading body of upload with hash {hash}"))] UploadBodyRead { hash: Hash, source: axum::Error }, #[snafu(display("uploads forbidden"))] @@ -58,12 +56,11 @@ impl ServerError { | Self::UploadBodyRead { .. } | Self::UploadForbidden | Self::UploadHashMismatch { .. } => self.to_string(), - Self::Redb { .. } - | Self::RedbCommit { .. } - | Self::RedbDatabase { .. } - | Self::RedbStorage { .. } - | Self::RedbTable { .. } - | Self::RedbTransaction { .. } => "database error".into(), + Self::Database { .. } + | Self::DatabaseCommit { .. } + | Self::DatabaseStorage { .. } + | Self::DatabaseTable { .. } + | Self::DatabaseTransaction { .. } => "database error".into(), Self::FilesystemIo { .. } => "filesystem I/O error".into(), } } @@ -73,13 +70,12 @@ impl ServerError { Self::AuthorizationInvalid { .. } | Self::AuthorizationMalformed | Self::AuthorizationMissing => StatusCode::UNAUTHORIZED, - Self::FilesystemIo { .. } - | Self::Redb { .. } - | Self::RedbCommit { .. } - | Self::RedbDatabase { .. } - | Self::RedbStorage { .. } - | Self::RedbTable { .. } - | Self::RedbTransaction { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::Database { .. } + | Self::DatabaseCommit { .. } + | Self::DatabaseStorage { .. } + | Self::DatabaseTable { .. } + | Self::DatabaseTransaction { .. } + | Self::FilesystemIo { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::DirectoryDecode { .. } | Self::DirectoryFileMissing { .. } | Self::DirectoryUnverified { .. } From 134ae913ff95d61e73a579763119001a0277ba36 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:41:56 -0700 Subject: [PATCH 08/36] Adjust --- src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 1cf0b157..2a946df4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -73,7 +73,7 @@ pub enum Error { backtrace: Option, expected: u64, }, - #[snafu(display("database is missing schema version"))] + #[snafu(display("database schema version missing"))] DatabaseSchemaVersionMissing { backtrace: Option }, #[snafu(display("failed to decode manifest at `{path}`"))] DecodeManifest { From 6a70405aaf0d19efb02f7b1681789b050bfa8d48 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:42:48 -0700 Subject: [PATCH 09/36] Clean up Database Error variants --- src/error.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/error.rs b/src/error.rs index 2a946df4..4aba5b9a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -319,17 +319,15 @@ pub enum Error { source: io::Error, }, #[snafu(transparent)] - Redb { source: redb::Error }, + Database { source: redb::DatabaseError }, #[snafu(transparent)] - RedbCommit { source: redb::CommitError }, + DatabaseCommit { source: redb::CommitError }, #[snafu(transparent)] - RedbDatabase { source: redb::DatabaseError }, + DatabaseStorage { source: redb::StorageError }, #[snafu(transparent)] - RedbStorage { source: redb::StorageError }, + DatabaseTable { source: redb::TableError }, #[snafu(transparent)] - RedbTable { source: redb::TableError }, - #[snafu(transparent)] - RedbTransaction { source: redb::TransactionError }, + DatabaseTransaction { source: redb::TransactionError }, #[snafu(display("request failed"))] Request { backtrace: Option, From b7ad35531d4523c57d29a581ffd85682e2fb62bb Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 19:45:36 -0700 Subject: [PATCH 10/36] Avoid type ascription --- src/hash.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hash.rs b/src/hash.rs index fb964246..d622c49b 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -126,8 +126,7 @@ impl redb::Value for Hash { where Self: 'a, { - let array: [u8; Self::LEN] = data.try_into().unwrap(); - array.into() + <[u8; Self::LEN]>::try_from(data).unwrap().into() } fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> &'a [u8; Self::LEN] From 19d0e1af6c84947e6883596f2025c4781be4b865 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:06:16 -0700 Subject: [PATCH 11/36] Use MetadataKey directly --- src/hash.rs | 2 +- src/server.rs | 51 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/hash.rs b/src/hash.rs index d622c49b..022dd685 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -126,7 +126,7 @@ impl redb::Value for Hash { where Self: 'a, { - <[u8; Self::LEN]>::try_from(data).unwrap().into() + <[u8; Self::LEN]>::from_bytes(data).into() } fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> &'a [u8; Self::LEN] diff --git a/src/server.rs b/src/server.rs index 24997e38..0b937d3c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,21 +1,56 @@ use { super::*, - redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}, + redb::{Database, Key, ReadableDatabase, ReadableTable, TableDefinition, TypeName, Value}, }; const SCHEMA_VERSION: u64 = 0; const DIRECTORIES: TableDefinition = TableDefinition::new("directories"); -const METADATA: TableDefinition = TableDefinition::new("metadata"); +const METADATA: TableDefinition = TableDefinition::new("metadata"); -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug, FromRepr)] +#[repr(u64)] pub(crate) enum MetadataKey { Schema = 0, } -impl MetadataKey { - fn key(self) -> u64 { - self as u64 +impl Key for MetadataKey { + fn compare(a: &[u8], b: &[u8]) -> Ordering { + u64::compare(a, b) + } +} + +impl Value for MetadataKey { + type SelfType<'a> + = Self + where + Self: 'a; + + type AsBytes<'a> + = ::AsBytes<'a> + where + Self: 'a; + + fn fixed_width() -> Option { + u64::fixed_width() + } + + fn from_bytes<'a>(data: &'a [u8]) -> Self + where + Self: 'a, + { + Self::from_repr(u64::from_bytes(data)).unwrap() + } + + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> ::AsBytes<'a> + where + Self: 'b, + { + u64::as_bytes(&(*value as u64)) + } + + fn type_name() -> TypeName { + TypeName::new("filepack-metadata-key") } } @@ -148,7 +183,7 @@ impl Server { { let mut metadata = tx.open_table(METADATA)?; - metadata.insert(&MetadataKey::Schema.key(), &SCHEMA_VERSION)?; + metadata.insert(MetadataKey::Schema, &SCHEMA_VERSION)?; tx.open_table(DIRECTORIES)?; } @@ -157,7 +192,7 @@ impl Server { } else { let schema_version = tx .open_table(METADATA)? - .get(&MetadataKey::Schema.key())? + .get(MetadataKey::Schema)? .context(error::DatabaseSchemaVersionMissing)? .value(); From 6a70c0ec2cf1475e6466d063dfd59dd4030c5092 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:24:45 -0700 Subject: [PATCH 12/36] Modify --- src/database.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ src/hash.rs | 40 ----------------------- src/lib.rs | 1 + src/server.rs | 50 +++-------------------------- 4 files changed, 91 insertions(+), 85 deletions(-) create mode 100644 src/database.rs diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 00000000..274a9b5e --- /dev/null +++ b/src/database.rs @@ -0,0 +1,85 @@ +use { + super::*, + crate::server::DatabaseMetadata, + redb::{Key, TypeName, Value}, +}; + +impl Key for DatabaseMetadata { + fn compare(a: &[u8], b: &[u8]) -> Ordering { + u64::compare(a, b) + } +} + +impl Value for DatabaseMetadata { + type SelfType<'a> + = Self + where + Self: 'a; + + type AsBytes<'a> + = ::AsBytes<'a> + where + Self: 'a; + + fn fixed_width() -> Option { + u64::fixed_width() + } + + fn from_bytes<'a>(data: &'a [u8]) -> Self + where + Self: 'a, + { + Self::from_repr(u64::from_bytes(data)).unwrap() + } + + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> ::AsBytes<'a> + where + Self: 'b, + { + u64::as_bytes(&(*value as u64)) + } + + fn type_name() -> TypeName { + TypeName::new("filepack-metadata-key") + } +} + +impl Key for Hash { + fn compare(a: &[u8], b: &[u8]) -> Ordering { + a.cmp(b) + } +} + +impl Value for Hash { + type SelfType<'a> + = Hash + where + Self: 'a; + + type AsBytes<'a> + = &'a [u8; Self::LEN] + where + Self: 'a; + + fn fixed_width() -> Option { + Some(Self::LEN) + } + + fn from_bytes<'a>(data: &'a [u8]) -> Self + where + Self: 'a, + { + <[u8; Self::LEN]>::try_from(data).unwrap().into() + } + + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> &'a [u8; Self::LEN] + where + Self: 'b, + { + value.as_bytes() + } + + fn type_name() -> TypeName { + TypeName::new("filepack-hash") + } +} diff --git a/src/hash.rs b/src/hash.rs index 022dd685..02f834a2 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -101,46 +101,6 @@ impl Encode for Hash { } } -impl redb::Key for Hash { - fn compare(a: &[u8], b: &[u8]) -> Ordering { - a.cmp(b) - } -} - -impl redb::Value for Hash { - type SelfType<'a> - = Hash - where - Self: 'a; - - type AsBytes<'a> - = &'a [u8; Self::LEN] - where - Self: 'a; - - fn fixed_width() -> Option { - Some(Self::LEN) - } - - fn from_bytes<'a>(data: &'a [u8]) -> Self - where - Self: 'a, - { - <[u8; Self::LEN]>::from_bytes(data).into() - } - - fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> &'a [u8; Self::LEN] - where - Self: 'b, - { - value.as_bytes() - } - - fn type_name() -> redb::TypeName { - redb::TypeName::new("filepack-hash") - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index cc428275..559fab95 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -206,6 +206,7 @@ mod component_error; mod context; mod count; mod dalek_signature_error; +mod database; mod date_time; mod decode; mod decode_error; diff --git a/src/server.rs b/src/server.rs index 0b937d3c..5872275f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,59 +1,19 @@ use { super::*, - redb::{Database, Key, ReadableDatabase, ReadableTable, TableDefinition, TypeName, Value}, + redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}, }; const SCHEMA_VERSION: u64 = 0; const DIRECTORIES: TableDefinition = TableDefinition::new("directories"); -const METADATA: TableDefinition = TableDefinition::new("metadata"); +const METADATA: TableDefinition = TableDefinition::new("metadata"); #[derive(Copy, Clone, Debug, FromRepr)] #[repr(u64)] -pub(crate) enum MetadataKey { +pub(crate) enum DatabaseMetadata { Schema = 0, } -impl Key for MetadataKey { - fn compare(a: &[u8], b: &[u8]) -> Ordering { - u64::compare(a, b) - } -} - -impl Value for MetadataKey { - type SelfType<'a> - = Self - where - Self: 'a; - - type AsBytes<'a> - = ::AsBytes<'a> - where - Self: 'a; - - fn fixed_width() -> Option { - u64::fixed_width() - } - - fn from_bytes<'a>(data: &'a [u8]) -> Self - where - Self: 'a, - { - Self::from_repr(u64::from_bytes(data)).unwrap() - } - - fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> ::AsBytes<'a> - where - Self: 'b, - { - u64::as_bytes(&(*value as u64)) - } - - fn type_name() -> TypeName { - TypeName::new("filepack-metadata-key") - } -} - pub(crate) struct Server { database: Database, files: Utf8PathBuf, @@ -183,7 +143,7 @@ impl Server { { let mut metadata = tx.open_table(METADATA)?; - metadata.insert(MetadataKey::Schema, &SCHEMA_VERSION)?; + metadata.insert(DatabaseMetadata::Schema, &SCHEMA_VERSION)?; tx.open_table(DIRECTORIES)?; } @@ -192,7 +152,7 @@ impl Server { } else { let schema_version = tx .open_table(METADATA)? - .get(MetadataKey::Schema)? + .get(DatabaseMetadata::Schema)? .context(error::DatabaseSchemaVersionMissing)? .value(); From dbaedfb51c75c098957d2ecbeecf105c777063f9 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:30:05 -0700 Subject: [PATCH 13/36] Improve error handling --- src/server.rs | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/server.rs b/src/server.rs index 5872275f..1d31fcb9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -50,16 +50,13 @@ impl Server { pub(crate) async fn open_file(&self, hash: Hash) -> ServerResult<(tokio::fs::File, u64)> { let path = self.file_path(hash); - let file = match tokio::fs::File::open(&path).await { - Err(err) => { - return if err.kind() == io::ErrorKind::NotFound { - Err(server_error::FileNotFound { hash }.into_error(err)) - } else { - Err(server_error::FilesystemIo { path }.into_error(err)) - }; + let file = tokio::fs::File::open(&path).await.map_err(|err| { + if err.kind() == io::ErrorKind::NotFound { + server_error::FileNotFound { hash }.into_error(err) + } else { + server_error::FilesystemIo { path: &path }.into_error(err) } - Ok(file) => file, - }; + })?; let len = file .metadata() @@ -73,16 +70,13 @@ impl Server { pub(crate) async fn verify_directory(&self, hash: Hash) -> ServerResult { let path = self.file_path(hash); - let cbor = match tokio::fs::read(&path).await { - Err(err) => { - return if err.kind() == io::ErrorKind::NotFound { - Err(server_error::FileNotFound { hash }.into_error(err)) - } else { - Err(server_error::FilesystemIo { path }.into_error(err)) - }; + let cbor = tokio::fs::read(&path).await.map_err(|err| { + if err.kind() == io::ErrorKind::NotFound { + server_error::FileNotFound { hash }.into_error(err) + } else { + server_error::FilesystemIo { path }.into_error(err) } - Ok(cbor) => cbor, - }; + })?; let directory = Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash })?; From 8be219e1123cfac724eabf0b42e74b4e1a130e8b Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:32:52 -0700 Subject: [PATCH 14/36] Placate clippy --- src/database.rs | 42 +++++++++++++++++++++--------------------- src/error.rs | 20 ++++++++++---------- src/server.rs | 3 +-- src/server_error.rs | 20 ++++++++++---------- 4 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/database.rs b/src/database.rs index 274a9b5e..5649e651 100644 --- a/src/database.rs +++ b/src/database.rs @@ -11,15 +11,22 @@ impl Key for DatabaseMetadata { } impl Value for DatabaseMetadata { + type AsBytes<'a> + = ::AsBytes<'a> + where + Self: 'a; + type SelfType<'a> = Self where Self: 'a; - type AsBytes<'a> - = ::AsBytes<'a> + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> ::AsBytes<'a> where - Self: 'a; + Self: 'b, + { + u64::as_bytes(&(*value as u64)) + } fn fixed_width() -> Option { u64::fixed_width() @@ -32,13 +39,6 @@ impl Value for DatabaseMetadata { Self::from_repr(u64::from_bytes(data)).unwrap() } - fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> ::AsBytes<'a> - where - Self: 'b, - { - u64::as_bytes(&(*value as u64)) - } - fn type_name() -> TypeName { TypeName::new("filepack-metadata-key") } @@ -51,34 +51,34 @@ impl Key for Hash { } impl Value for Hash { + type AsBytes<'a> + = &'a [u8; Self::LEN] + where + Self: 'a; + type SelfType<'a> = Hash where Self: 'a; - type AsBytes<'a> - = &'a [u8; Self::LEN] + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a> where - Self: 'a; + Self: 'b, + { + value.as_bytes() + } fn fixed_width() -> Option { Some(Self::LEN) } - fn from_bytes<'a>(data: &'a [u8]) -> Self + fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a> where Self: 'a, { <[u8; Self::LEN]>::try_from(data).unwrap().into() } - fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> &'a [u8; Self::LEN] - where - Self: 'b, - { - value.as_bytes() - } - fn type_name() -> TypeName { TypeName::new("filepack-hash") } diff --git a/src/error.rs b/src/error.rs index 4aba5b9a..b60f8844 100644 --- a/src/error.rs +++ b/src/error.rs @@ -67,6 +67,10 @@ pub enum Error { }, #[snafu(display("failed to get local data directory"))] DataLocalDir { backtrace: Option }, + #[snafu(transparent)] + Database { source: redb::DatabaseError }, + #[snafu(transparent)] + DatabaseCommit { source: redb::CommitError }, #[snafu(display("database schema version `{actual}` does not match expected `{expected}`"))] DatabaseSchemaVersionMismatch { actual: u64, @@ -75,6 +79,12 @@ pub enum Error { }, #[snafu(display("database schema version missing"))] DatabaseSchemaVersionMissing { backtrace: Option }, + #[snafu(transparent)] + DatabaseStorage { source: redb::StorageError }, + #[snafu(transparent)] + DatabaseTable { source: redb::TableError }, + #[snafu(transparent)] + DatabaseTransaction { source: redb::TransactionError }, #[snafu(display("failed to decode manifest at `{path}`"))] DecodeManifest { backtrace: Option, @@ -318,16 +328,6 @@ pub enum Error { backtrace: Option, source: io::Error, }, - #[snafu(transparent)] - Database { source: redb::DatabaseError }, - #[snafu(transparent)] - DatabaseCommit { source: redb::CommitError }, - #[snafu(transparent)] - DatabaseStorage { source: redb::StorageError }, - #[snafu(transparent)] - DatabaseTable { source: redb::TableError }, - #[snafu(transparent)] - DatabaseTransaction { source: redb::TransactionError }, #[snafu(display("request failed"))] Request { backtrace: Option, diff --git a/src/server.rs b/src/server.rs index 1d31fcb9..979a5d34 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,10 +3,9 @@ use { redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}, }; -const SCHEMA_VERSION: u64 = 0; - const DIRECTORIES: TableDefinition = TableDefinition::new("directories"); const METADATA: TableDefinition = TableDefinition::new("metadata"); +const SCHEMA_VERSION: u64 = 0; #[derive(Copy, Clone, Debug, FromRepr)] #[repr(u64)] diff --git a/src/server_error.rs b/src/server_error.rs index 892318a9..2240c964 100644 --- a/src/server_error.rs +++ b/src/server_error.rs @@ -9,6 +9,16 @@ pub(crate) enum ServerError { AuthorizationMalformed, #[snafu(display("missing authorization header"))] AuthorizationMissing, + #[snafu(transparent)] + Database { source: redb::DatabaseError }, + #[snafu(transparent)] + DatabaseCommit { source: redb::CommitError }, + #[snafu(transparent)] + DatabaseStorage { source: redb::StorageError }, + #[snafu(transparent)] + DatabaseTable { source: redb::TableError }, + #[snafu(transparent)] + DatabaseTransaction { source: redb::TransactionError }, #[snafu(display("failed to decode directory {hash}"))] DirectoryDecode { hash: Hash, source: DecodeError }, #[snafu(display("directory {directory} references missing file {file}"))] @@ -24,16 +34,6 @@ pub(crate) enum ServerError { }, #[snafu(display("page not found"))] PageNotFound, - #[snafu(transparent)] - Database { source: redb::DatabaseError }, - #[snafu(transparent)] - DatabaseCommit { source: redb::CommitError }, - #[snafu(transparent)] - DatabaseStorage { source: redb::StorageError }, - #[snafu(transparent)] - DatabaseTable { source: redb::TableError }, - #[snafu(transparent)] - DatabaseTransaction { source: redb::TransactionError }, #[snafu(display("error reading body of upload with hash {hash}"))] UploadBodyRead { hash: Hash, source: axum::Error }, #[snafu(display("uploads forbidden"))] From 2d689260167335ebf0b17a578c9cb7557b52980c Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:34:42 -0700 Subject: [PATCH 15/36] Adapt --- src/database.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database.rs b/src/database.rs index 5649e651..cf6a1851 100644 --- a/src/database.rs +++ b/src/database.rs @@ -21,7 +21,7 @@ impl Value for DatabaseMetadata { where Self: 'a; - fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> ::AsBytes<'a> + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a> where Self: 'b, { From 555f6b32b984a090be00ca1dd4742af545f0b315 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:39:41 -0700 Subject: [PATCH 16/36] Amend --- src/server.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/server.rs b/src/server.rs index 979a5d34..a01c3767 100644 --- a/src/server.rs +++ b/src/server.rs @@ -88,12 +88,11 @@ impl Server { for entry in directory.entries.values() { match entry.ty { EntryType::File => { - let file_path = self.file_path(entry.hash); - let exists = tokio::fs::try_exists(&file_path) - .await - .context(server_error::FilesystemIo { path: &file_path })?; + let path = self.file_path(entry.hash); ensure!( - exists, + tokio::fs::try_exists(&path) + .await + .context(server_error::FilesystemIo { path })?, server_error::DirectoryFileMissing { directory: hash, file: entry.hash, @@ -101,9 +100,8 @@ impl Server { ); } EntryType::Directory => { - let verified = directories.get(&entry.hash)?.is_some(); ensure!( - verified, + directories.get(&entry.hash)?.is_some(), server_error::DirectoryUnverified { directory: hash, subdirectory: entry.hash, From 36d246a0c5840f4aa254fc94fd365300b244fea2 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:43:12 -0700 Subject: [PATCH 17/36] Adapt --- src/server.rs | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/server.rs b/src/server.rs index a01c3767..fd37ee75 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,6 +1,6 @@ use { super::*, - redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}, + redb::{Database, ReadableTable, TableDefinition}, }; const DIRECTORIES: TableDefinition = TableDefinition::new("directories"); @@ -80,25 +80,32 @@ impl Server { let directory = Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash })?; - { - let tx = self.database.begin_read()?; + for entry in directory.entries.values() { + match entry.ty { + EntryType::File => { + let path = self.file_path(entry.hash); + ensure!( + tokio::fs::try_exists(&path) + .await + .context(server_error::FilesystemIo { path })?, + server_error::DirectoryFileMissing { + directory: hash, + file: entry.hash, + }, + ); + } + EntryType::Directory => {} + } + } + + let tx = self.database.begin_write()?; - let directories = tx.open_table(DIRECTORIES)?; + { + let mut directories = tx.open_table(DIRECTORIES)?; for entry in directory.entries.values() { match entry.ty { - EntryType::File => { - let path = self.file_path(entry.hash); - ensure!( - tokio::fs::try_exists(&path) - .await - .context(server_error::FilesystemIo { path })?, - server_error::DirectoryFileMissing { - directory: hash, - file: entry.hash, - }, - ); - } + EntryType::File => {} EntryType::Directory => { ensure!( directories.get(&entry.hash)?.is_some(), @@ -110,12 +117,6 @@ impl Server { } } } - } - - let tx = self.database.begin_write()?; - - { - let mut directories = tx.open_table(DIRECTORIES)?; directories.insert(&hash, &())?; } From f3f031afd2004b9a56823f65765509c7cb70836d Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:44:23 -0700 Subject: [PATCH 18/36] Adjust --- src/server.rs | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/src/server.rs b/src/server.rs index fd37ee75..5ee32b87 100644 --- a/src/server.rs +++ b/src/server.rs @@ -81,20 +81,17 @@ impl Server { Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash })?; for entry in directory.entries.values() { - match entry.ty { - EntryType::File => { - let path = self.file_path(entry.hash); - ensure!( - tokio::fs::try_exists(&path) - .await - .context(server_error::FilesystemIo { path })?, - server_error::DirectoryFileMissing { - directory: hash, - file: entry.hash, - }, - ); - } - EntryType::Directory => {} + if entry.ty == EntryType::File { + let path = self.file_path(entry.hash); + ensure!( + tokio::fs::try_exists(&path) + .await + .context(server_error::FilesystemIo { path })?, + server_error::DirectoryFileMissing { + directory: hash, + file: entry.hash, + }, + ); } } @@ -104,17 +101,14 @@ impl Server { let mut directories = tx.open_table(DIRECTORIES)?; for entry in directory.entries.values() { - match entry.ty { - EntryType::File => {} - EntryType::Directory => { - ensure!( - directories.get(&entry.hash)?.is_some(), - server_error::DirectoryUnverified { - directory: hash, - subdirectory: entry.hash, - }, - ); - } + if entry.ty == EntryType::Directory { + ensure!( + directories.get(&entry.hash)?.is_some(), + server_error::DirectoryUnverified { + directory: hash, + subdirectory: entry.hash, + }, + ); } } From d4781c2f1628ccf0b59e9191e0e0129eb5def59f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:46:00 -0700 Subject: [PATCH 19/36] Adjust --- src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.rs b/src/server.rs index 5ee32b87..f8acc65b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -125,7 +125,7 @@ impl Server { let tx = database.begin_write()?; - if tx.list_tables()?.count() == 0 { + if tx.list_tables()?.count() == 0 && tx.list_multimap_tables()?.count() == 0 { { let mut metadata = tx.open_table(METADATA)?; From 798129db5264163a406a190a508cd98a0e45f9af Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:47:47 -0700 Subject: [PATCH 20/36] Reform --- src/server.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server.rs b/src/server.rs index f8acc65b..94bb7749 100644 --- a/src/server.rs +++ b/src/server.rs @@ -127,9 +127,8 @@ impl Server { if tx.list_tables()?.count() == 0 && tx.list_multimap_tables()?.count() == 0 { { - let mut metadata = tx.open_table(METADATA)?; - - metadata.insert(DatabaseMetadata::Schema, &SCHEMA_VERSION)?; + tx.open_table(METADATA)? + .insert(DatabaseMetadata::Schema, &SCHEMA_VERSION)?; tx.open_table(DIRECTORIES)?; } From c6a36f519b6ee3a291f3c08e22d84a949d898d5f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:48:08 -0700 Subject: [PATCH 21/36] Revise --- src/server.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server.rs b/src/server.rs index 94bb7749..b10c3127 100644 --- a/src/server.rs +++ b/src/server.rs @@ -135,16 +135,16 @@ impl Server { tx.commit()?; } else { - let schema_version = tx + let actual = tx .open_table(METADATA)? .get(DatabaseMetadata::Schema)? .context(error::DatabaseSchemaVersionMissing)? .value(); ensure!( - schema_version == SCHEMA_VERSION, + actual == SCHEMA_VERSION, error::DatabaseSchemaVersionMismatch { - actual: schema_version, + actual, expected: SCHEMA_VERSION, }, ); From b3c354ecfc0861c618c3fc10e49047e74f254829 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:50:35 -0700 Subject: [PATCH 22/36] Amend --- src/subcommand/serve.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/subcommand/serve.rs b/src/subcommand/serve.rs index bc37c807..44272d60 100644 --- a/src/subcommand/serve.rs +++ b/src/subcommand/serve.rs @@ -442,9 +442,9 @@ impl Serve { async fn verify_directory( _: Authenticated, server: ServerExtension, - Path(hash): Path, + hash: Path, ) -> ServerResult { - server.verify_directory(hash).await + server.verify_directory(*hash).await } } From d6a269d55a7c362cdfacc6b1121e2c09447a37ee Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 20:54:18 -0700 Subject: [PATCH 23/36] Enhance --- src/error.rs | 7 ++----- src/subcommand/upload.rs | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/error.rs b/src/error.rs index b60f8844..5227f679 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,11 +11,6 @@ pub enum Error { actual: Hash, expected: Hash, }, - #[snafu(display("cannot use authentication with non-HTTPS server `{server}`"))] - AuthenticationOverHttp { - backtrace: Option, - server: Url, - }, #[snafu(display("failed to decode bech32 `{bech32}`"))] Bech32Decode { backtrace: Option, @@ -401,6 +396,8 @@ pub enum Error { backtrace: Option, source: SystemTimeError, }, + #[snafu(display("authentication tokens may only be used with server over HTTPS or loopback"))] + TokenOverHttp { backtrace: Option }, #[snafu(display("failed to unarchive manifest"))] UnarchiveManifest { backtrace: Option, diff --git a/src/subcommand/upload.rs b/src/subcommand/upload.rs index 8ae77d72..9a4c7d35 100644 --- a/src/subcommand/upload.rs +++ b/src/subcommand/upload.rs @@ -34,9 +34,7 @@ impl Upload { ensure!( self.server.scheme() == "https" || loopback, - error::AuthenticationOverHttp { - server: self.server.clone(), - }, + error::TokenOverHttp, ); let keychain = Keychain::load(&options)?; From 3ff1444ed31eb64fbbc6d2a47b342d4b19d7a3b6 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:02:32 -0700 Subject: [PATCH 24/36] Revise --- src/subcommand/upload.rs | 22 +++++++++++----------- tests/upload.rs | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/subcommand/upload.rs b/src/subcommand/upload.rs index 9a4c7d35..ac39dfb3 100644 --- a/src/subcommand/upload.rs +++ b/src/subcommand/upload.rs @@ -66,12 +66,12 @@ impl Upload { fn upload_directory( &self, - archive_path: &Utf8Path, archive: &Archive, + archive_path: &Utf8Path, + file_path: &Utf8Path, hash: Hash, - path: &Utf8Path, - options: &Options, key: Option<&PrivateKey>, + options: &Options, ) -> Result { let context = error::UnarchiveManifest { path: archive_path }; @@ -84,12 +84,12 @@ impl Upload { self.upload_body(hash, cbor.to_vec().into(), key)?; for (component, entry) in &directory.entries { - let child_path = path.join(component); + let file_path = file_path.join(component); match entry.ty { EntryType::Directory => { - self.upload_directory(archive_path, archive, entry.hash, &child_path, options, key)?; + self.upload_directory(archive, archive_path, &file_path, entry.hash, key, options)? } - EntryType::File => self.upload_package_file(&child_path, entry, options, key)?, + EntryType::File => self.upload_package_file(entry, key, options, &file_path)?, } } @@ -124,12 +124,12 @@ impl Upload { .context(error::UnarchiveManifest { path: archive_path })?; self.upload_directory( - archive_path, &archive, - fingerprint.into(), + archive_path, archive_path.parent().unwrap(), - options, + fingerprint.into(), key, + options, )?; Ok(()) @@ -137,10 +137,10 @@ impl Upload { fn upload_package_file( &self, - path: &Utf8Path, expected: &Entry, - options: &Options, key: Option<&PrivateKey>, + options: &Options, + path: &Utf8Path, ) -> Result { let actual = options .hash_file(path) diff --git a/tests/upload.rs b/tests/upload.rs index d72570bf..341d78fb 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -216,7 +216,7 @@ fn upload_auth_requires_https() { "--file", "foo", ]) - .stderr("error: cannot use authentication with non-HTTPS server `http://example.com/`\n") + .stderr("error: authentication tokens may only be used with HTTPS or loopback server\n") .failure(); } From bfb31305dd6b497fdf19a5b880b3b04c6079da8f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:02:45 -0700 Subject: [PATCH 25/36] Revise --- tests/upload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/upload.rs b/tests/upload.rs index 341d78fb..0ec69196 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -216,7 +216,7 @@ fn upload_auth_requires_https() { "--file", "foo", ]) - .stderr("error: authentication tokens may only be used with HTTPS or loopback server\n") + .stderr("error: authentication tokens may only be used over HTTPS or loopback\n") .failure(); } From 439e8e4914136dc0e940dd495e4ee836923e0606 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:03:16 -0700 Subject: [PATCH 26/36] Reform --- src/error.rs | 2 +- src/subcommand/upload.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/error.rs b/src/error.rs index 5227f679..d13ab7ab 100644 --- a/src/error.rs +++ b/src/error.rs @@ -396,7 +396,7 @@ pub enum Error { backtrace: Option, source: SystemTimeError, }, - #[snafu(display("authentication tokens may only be used with server over HTTPS or loopback"))] + #[snafu(display("authentication tokens may only be used over HTTPS or loopback"))] TokenOverHttp { backtrace: Option }, #[snafu(display("failed to unarchive manifest"))] UnarchiveManifest { diff --git a/src/subcommand/upload.rs b/src/subcommand/upload.rs index ac39dfb3..a818799a 100644 --- a/src/subcommand/upload.rs +++ b/src/subcommand/upload.rs @@ -87,7 +87,7 @@ impl Upload { let file_path = file_path.join(component); match entry.ty { EntryType::Directory => { - self.upload_directory(archive, archive_path, &file_path, entry.hash, key, options)? + self.upload_directory(archive, archive_path, &file_path, entry.hash, key, options)?; } EntryType::File => self.upload_package_file(entry, key, options, &file_path)?, } From 5bc9e5ec11493b48cd51a0064ded5d86dcf5dd60 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:10:41 -0700 Subject: [PATCH 27/36] Adjust --- src/subcommand/upload.rs | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/subcommand/upload.rs b/src/subcommand/upload.rs index a818799a..a0eda13d 100644 --- a/src/subcommand/upload.rs +++ b/src/subcommand/upload.rs @@ -1,4 +1,8 @@ -use {super::*, reqwest::blocking::Body, url::Host}; +use { + super::*, + reqwest::blocking::{Body, RequestBuilder}, + url::Host, +}; #[derive(Parser)] pub(crate) struct Upload { @@ -13,15 +17,17 @@ pub(crate) struct Upload { } impl Upload { - fn post(&self, kind: &str, hash: Hash, key: Option<&PrivateKey>) -> Result { - let url = self.server.join(&format!("{kind}/{hash}")).unwrap(); - let mut request = Client::new().post(url); + fn request_with_token( + &self, + mut builder: RequestBuilder, + key: Option<&PrivateKey>, + ) -> Result { if let Some(key) = key { let host = self.server.host_str().unwrap().to_owned(); - request = request.bearer_auth(Token::encode(key, &host)?); + builder = builder.bearer_auth(Token::encode(key, &host)?); } - request.send().check_status()?; - Ok(()) + + Ok(builder) } pub(crate) fn run(self, options: Options) -> Result { @@ -55,12 +61,11 @@ impl Upload { fn upload_body(&self, hash: Hash, body: Body, key: Option<&PrivateKey>) -> Result { let url = self.server.join(&format!("file/{hash}")).unwrap(); - let mut request = Client::new().put(url).body(body); - if let Some(key) = key { - let host = self.server.host_str().unwrap().to_owned(); - request = request.bearer_auth(Token::encode(key, &host)?); - } - request.send().check_status()?; + let request = Client::new().put(url).body(body); + self + .request_with_token(request, key)? + .send() + .check_status()?; Ok(()) } @@ -93,7 +98,12 @@ impl Upload { } } - self.post("directory", hash, key)?; + let url = self.server.join(&format!("directory/{hash}")).unwrap(); + let request = Client::new().post(url); + self + .request_with_token(request, key)? + .send() + .check_status()?; Ok(()) } From 242c1ff4a9100fcebb01afc93084129643df1d8c Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:14:02 -0700 Subject: [PATCH 28/36] Modify --- src/subcommand/serve/tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index f7dcc1a6..5dbc5db4 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -2,14 +2,14 @@ use { super::*, axum::{ body, - http::{Request, header::HeaderName}, + http::{Method, Request, header::HeaderName}, }, tower::ServiceExt, }; struct TestRequestBuilder { body: Option, - method: &'static str, + method: Method, path: String, response_body: Body, response_headers: BTreeMap, @@ -53,7 +53,7 @@ impl TestRequestBuilder { self } - fn new(method: &'static str, path: impl Into, router: Router) -> Self { + fn new(method: Method, path: impl Into, router: Router) -> Self { Self { body: None, method, @@ -143,7 +143,7 @@ impl TestServer { } fn get(&self, path: impl Into) -> TestRequestBuilder { - TestRequestBuilder::new("GET", path, self.router.clone()) + TestRequestBuilder::new(Method::GET, path, self.router.clone()) } fn new() -> Self { @@ -151,11 +151,11 @@ impl TestServer { } fn post(&self, path: impl Into) -> TestRequestBuilder { - TestRequestBuilder::new("POST", path, self.router.clone()) + TestRequestBuilder::new(Method::POST, path, self.router.clone()) } fn put(&self, path: impl Into) -> TestRequestBuilder { - TestRequestBuilder::new("PUT", path, self.router.clone()) + TestRequestBuilder::new(Method::PUT, path, self.router.clone()) } fn with_auth(auth_config: Option>) -> Self { From 92487931367e80813017f40dc3ec8aa2bd8fd91a Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:25:34 -0700 Subject: [PATCH 29/36] New test --- tests/upload.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/upload.rs b/tests/upload.rs index 0ec69196..54b1e857 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -446,6 +446,31 @@ error: failed to unarchive manifest .failure(); } +#[test] +fn upload_package_succeeds() { + let server = Test::new() + .serve() + .assert_file(&format!("files/{}", Hash::bytes(b"aaa")), "aaa") + .assert_file(&format!("files/{}", Hash::bytes(b"bbb")), "bbb") + .assert_file(&format!("files/{}", Hash::bytes(b"ccc")), "ccc") + .assert_file(&format!("files/{}", Hash::bytes(b"ddd")), "ddd") + .spawn(); + + Test::new() + .write("foo", "aaa") + .write("bar", "bbb") + .create_dir("empty") + .write("sub/baz", "ccc") + .write("sub/qux", "ddd") + .create_dir("sub/empty") + .args(["create", "."]) + .success() + .args(["upload", "--server", &server.address(), "manifest.filepack"]) + .success(); + + server.terminate().success(); +} + #[test] fn upload_package_uploads_files() { let server = Test::new() From 11093434f5f539d980150587b305f5567959475a Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:27:32 -0700 Subject: [PATCH 30/36] Add new test --- tests/upload.rs | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/tests/upload.rs b/tests/upload.rs index 54b1e857..422a614d 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -446,31 +446,6 @@ error: failed to unarchive manifest .failure(); } -#[test] -fn upload_package_succeeds() { - let server = Test::new() - .serve() - .assert_file(&format!("files/{}", Hash::bytes(b"aaa")), "aaa") - .assert_file(&format!("files/{}", Hash::bytes(b"bbb")), "bbb") - .assert_file(&format!("files/{}", Hash::bytes(b"ccc")), "ccc") - .assert_file(&format!("files/{}", Hash::bytes(b"ddd")), "ddd") - .spawn(); - - Test::new() - .write("foo", "aaa") - .write("bar", "bbb") - .create_dir("empty") - .write("sub/baz", "ccc") - .write("sub/qux", "ddd") - .create_dir("sub/empty") - .args(["create", "."]) - .success() - .args(["upload", "--server", &server.address(), "manifest.filepack"]) - .success(); - - server.terminate().success(); -} - #[test] fn upload_package_uploads_files() { let server = Test::new() @@ -487,6 +462,7 @@ fn upload_package_uploads_files() { .create_dir("empty") .write("sub/baz", "ccc") .write("sub/qux", "ddd") + .create_dir("sub/empty") .args(["create", "."]) .success() .args(["upload", "--server", &server.address(), "manifest.filepack"]) From 86cd2ce487caf19d8f93ec1e2c4681e5531653fa Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:45:36 -0700 Subject: [PATCH 31/36] Add directory listing --- src/subcommand/serve.rs | 7 ++++++- src/templates.rs | 6 ++++++ tests/test.rs | 15 +++++++++++++++ tests/upload.rs | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/subcommand/serve.rs b/src/subcommand/serve.rs index 44272d60..7f72ed19 100644 --- a/src/subcommand/serve.rs +++ b/src/subcommand/serve.rs @@ -13,7 +13,7 @@ use { }, std::net::TcpStream, sysinfo::System, - templates::FilesHtml, + templates::{DirectoryHtml, FilesHtml}, tokio::{net::TcpListener, runtime}, tokio_util::io::ReaderStream, tower_http::set_header::SetResponseHeaderLayer, @@ -229,9 +229,14 @@ impl Serve { )) } + async fn directory(server: ServerExtension) -> ServerResult { + todo!() + } + pub(crate) fn router(server: Arc, auth_config: Option>) -> Router { let router = Router::new() .route("/", get(Self::home)) + .route("/directory/{hash}", get(Self::directory)) .route("/directory/{hash}", post(Self::verify_directory)) .route("/favicon.ico", get(Self::favicon)) .route("/file/{hash}", get(Self::download)) diff --git a/src/templates.rs b/src/templates.rs index 54bcea76..7f45d85f 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,5 +1,11 @@ use super::*; +#[derive(Boilerplate)] +pub(crate) struct DirectoryHtml { + pub(crate) directory: Directory, + pub(crate) hash: Hash, +} + #[derive(Boilerplate)] pub(crate) struct FilesHtml { pub(crate) files: Vec, diff --git a/tests/test.rs b/tests/test.rs index 1b03f485..b205ff54 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -6,6 +6,7 @@ pub(crate) struct Test { data_dir: Option, directories: BTreeSet, env: BTreeMap>, + file_counts: BTreeMap, files: BTreeMap, manifests: BTreeMap, ready_address: bool, @@ -43,6 +44,11 @@ impl Test { self } + pub(crate) fn assert_file_count(mut self, path: &str, count: usize) -> Self { + assert!(self.file_counts.insert(path.into(), count).is_none()); + self + } + pub(crate) fn assert_file_regex(mut self, path: &str, pattern: &str) -> Self { assert!( self @@ -290,6 +296,14 @@ impl Test { expected.check(&actual, &format!("file `{path}`")); } + for (path, expected) in &self.file_counts { + let actual = fs::read_dir(self.join(path)).unwrap().count(); + assert_eq!( + actual, *expected, + "directory `{path}` has {actual} files, expected {expected}", + ); + } + for (path, expected) in &self.manifests { let manifest = Manifest::load(Some(&self.join(path))).unwrap(); let actual = format!("{}\n", serde_json::to_string_pretty(&manifest).unwrap()); @@ -403,6 +417,7 @@ impl Test { data_dir: None, directories: BTreeSet::new(), env: BTreeMap::new(), + file_counts: BTreeMap::new(), files: BTreeMap::new(), manifests: BTreeMap::new(), ready_address: false, diff --git a/tests/upload.rs b/tests/upload.rs index 422a614d..ea022c6f 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -454,6 +454,7 @@ fn upload_package_uploads_files() { .assert_file(&format!("files/{}", Hash::bytes(b"bbb")), "bbb") .assert_file(&format!("files/{}", Hash::bytes(b"ccc")), "ccc") .assert_file(&format!("files/{}", Hash::bytes(b"ddd")), "ddd") + .assert_file_count("files", 7) .spawn(); Test::new() From ff9fb87447c7300775b63322094a8c5d5ac8c434 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 21:56:07 -0700 Subject: [PATCH 32/36] Add directory endpoint --- src/server.rs | 25 ++++++++++++--- src/server_error.rs | 7 ++++- src/subcommand/serve.rs | 10 ++++-- src/subcommand/serve/tests.rs | 57 +++++++++++++++++++++++++++++------ templates/directory.html | 27 +++++++++++++++++ tests/upload.rs | 25 +++++++++++++-- 6 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 templates/directory.html diff --git a/src/server.rs b/src/server.rs index b10c3127..76c9f379 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,6 +1,6 @@ use { super::*, - redb::{Database, ReadableTable, TableDefinition}, + redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}, }; const DIRECTORIES: TableDefinition = TableDefinition::new("directories"); @@ -66,7 +66,7 @@ impl Server { Ok((file, len)) } - pub(crate) async fn verify_directory(&self, hash: Hash) -> ServerResult { + async fn read_directory(&self, hash: Hash) -> ServerResult { let path = self.file_path(hash); let cbor = tokio::fs::read(&path).await.map_err(|err| { @@ -77,8 +77,25 @@ impl Server { } })?; - let directory = - Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash })?; + Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash }) + } + + pub(crate) async fn directory(&self, hash: Hash) -> ServerResult { + ensure!( + self + .database + .begin_read()? + .open_table(DIRECTORIES)? + .get(&hash)? + .is_some(), + server_error::DirectoryNotFound { hash }, + ); + + self.read_directory(hash).await + } + + pub(crate) async fn verify_directory(&self, hash: Hash) -> ServerResult { + let directory = self.read_directory(hash).await?; for entry in directory.entries.values() { if entry.ty == EntryType::File { diff --git a/src/server_error.rs b/src/server_error.rs index 2240c964..3448aea9 100644 --- a/src/server_error.rs +++ b/src/server_error.rs @@ -23,6 +23,8 @@ pub(crate) enum ServerError { DirectoryDecode { hash: Hash, source: DecodeError }, #[snafu(display("directory {directory} references missing file {file}"))] DirectoryFileMissing { directory: Hash, file: Hash }, + #[snafu(display("directory {hash} not found"))] + DirectoryNotFound { hash: Hash }, #[snafu(display("directory {directory} references unverified subdirectory {subdirectory}"))] DirectoryUnverified { directory: Hash, subdirectory: Hash }, #[snafu(display("file with hash {hash} not found"))] @@ -50,6 +52,7 @@ impl ServerError { | Self::AuthorizationMissing | Self::DirectoryDecode { .. } | Self::DirectoryFileMissing { .. } + | Self::DirectoryNotFound { .. } | Self::DirectoryUnverified { .. } | Self::FileNotFound { .. } | Self::PageNotFound @@ -81,7 +84,9 @@ impl ServerError { | Self::DirectoryUnverified { .. } | Self::UploadBodyRead { .. } | Self::UploadHashMismatch { .. } => StatusCode::BAD_REQUEST, - Self::FileNotFound { .. } | Self::PageNotFound => StatusCode::NOT_FOUND, + Self::DirectoryNotFound { .. } | Self::FileNotFound { .. } | Self::PageNotFound => { + StatusCode::NOT_FOUND + } Self::UploadForbidden => StatusCode::FORBIDDEN, } } diff --git a/src/subcommand/serve.rs b/src/subcommand/serve.rs index 7f72ed19..f19db536 100644 --- a/src/subcommand/serve.rs +++ b/src/subcommand/serve.rs @@ -229,8 +229,14 @@ impl Serve { )) } - async fn directory(server: ServerExtension) -> ServerResult { - todo!() + async fn directory( + server: ServerExtension, + Path(hash): Path, + ) -> ServerResult { + Ok(DirectoryHtml { + directory: server.directory(hash).await?, + hash, + }) } pub(crate) fn router(server: Arc, auth_config: Option>) -> Router { diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index 5dbc5db4..d9d5c487 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -212,7 +212,7 @@ fn default_serve_matches_parsed() { ); } -fn directory_cbor(entries: &[(&str, EntryType, Hash, u64)]) -> Vec { +fn directory(entries: &[(&str, EntryType, Hash, u64)]) -> Directory { Directory { version: Version::Zero, entries: entries @@ -229,7 +229,6 @@ fn directory_cbor(entries: &[(&str, EntryType, Hash, u64)]) -> Vec { }) .collect(), } - .encode_to_vec() } #[test] @@ -325,6 +324,43 @@ async fn files_non_empty() { .await; } +#[tokio::test] +async fn get_directory_not_found() { + let server = TestServer::new(); + + let cbor = directory(&[]).encode_to_vec(); + let hash = Hash::bytes(&cbor); + server.write_file(&cbor); + + server + .get(format!("/directory/{hash}")) + .status(StatusCode::NOT_FOUND) + .assert_body(format!("directory {hash} not found")) + .send() + .await; +} + +#[tokio::test] +async fn get_directory_succeeds() { + let server = TestServer::new(); + + let dir = directory(&[]); + let cbor = dir.encode_to_vec(); + let hash = Hash::bytes(&cbor); + server.write_file(&cbor); + + server.post(format!("/directory/{hash}")).send().await; + + server + .get(format!("/directory/{hash}")) + .assert_response(DirectoryHtml { + directory: dir, + hash, + }) + .send() + .await; +} + #[tokio::test] async fn home() { TestServer::new() @@ -582,7 +618,7 @@ async fn verify_directory_missing_file() { let server = TestServer::new(); let missing = Hash::bytes(b"foo"); - let cbor = directory_cbor(&[("foo", EntryType::File, missing, 3)]); + let cbor = directory(&[("foo", EntryType::File, missing, 3)]).encode_to_vec(); let hash = Hash::bytes(&cbor); server.write_file(&cbor); @@ -622,18 +658,20 @@ async fn verify_directory_succeeds() { let file_hash = Hash::bytes(file); server.write_file(file); - let child_cbor = directory_cbor(&[("foo", EntryType::File, file_hash, file.len() as u64)]); + let child_cbor = + directory(&[("foo", EntryType::File, file_hash, file.len() as u64)]).encode_to_vec(); let child_hash = Hash::bytes(&child_cbor); server.write_file(&child_cbor); server.post(format!("/directory/{child_hash}")).send().await; - let parent_cbor = directory_cbor(&[( + let parent_cbor = directory(&[( "child", EntryType::Directory, child_hash, child_cbor.len() as u64, - )]); + )]) + .encode_to_vec(); let parent_hash = Hash::bytes(&parent_cbor); server.write_file(&parent_cbor); @@ -647,16 +685,17 @@ async fn verify_directory_succeeds() { async fn verify_directory_unverified_subdirectory() { let server = TestServer::new(); - let child_cbor = directory_cbor(&[]); + let child_cbor = directory(&[]).encode_to_vec(); let child_hash = Hash::bytes(&child_cbor); server.write_file(&child_cbor); - let parent_cbor = directory_cbor(&[( + let parent_cbor = directory(&[( "child", EntryType::Directory, child_hash, child_cbor.len() as u64, - )]); + )]) + .encode_to_vec(); let parent_hash = Hash::bytes(&parent_cbor); server.write_file(&parent_cbor); diff --git a/templates/directory.html b/templates/directory.html new file mode 100644 index 00000000..1b39df8f --- /dev/null +++ b/templates/directory.html @@ -0,0 +1,27 @@ + + + + + + directory {{ self.hash }} ยท filepack + + + +

Files

+
+%% for (name, entry) in &self.directory.entries { +
{{ name }}
+
+%% match entry.ty { +%% EntryType::Directory => { + {{ entry.hash }} +%% } +%% EntryType::File => { + {{ entry.hash }} +%% } +%% } +
+%% } +
+ + diff --git a/tests/upload.rs b/tests/upload.rs index ea022c6f..04579415 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -457,7 +457,7 @@ fn upload_package_uploads_files() { .assert_file_count("files", 7) .spawn(); - Test::new() + let test = Test::new() .write("foo", "aaa") .write("bar", "bbb") .create_dir("empty") @@ -465,9 +465,30 @@ fn upload_package_uploads_files() { .write("sub/qux", "ddd") .create_dir("sub/empty") .args(["create", "."]) - .success() + .success(); + + let root = Hash::from( + Manifest::load(Some(&test.path().join("manifest.filepack"))) + .unwrap() + .fingerprint(), + ); + + test .args(["upload", "--server", &server.address(), "manifest.filepack"]) .success(); + let response = reqwest::blocking::get(format!("{}/directory/{root}", server.address())).unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.text().unwrap(); + + for name in ["bar", "empty", "foo", "sub"] { + assert!( + body.contains(&format!("
{name}
")), + "body missing `{name}`: {body}" + ); + } + server.terminate().success(); } From ebfdd302a22b288953f93c2f0f315f5c57f5abd5 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 22:19:04 -0700 Subject: [PATCH 33/36] Tweak --- src/server.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/server.rs b/src/server.rs index 76c9f379..a231c088 100644 --- a/src/server.rs +++ b/src/server.rs @@ -241,3 +241,49 @@ impl Server { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn database_schema_version_mismatch() { + let (_tempdir, data_dir) = tempdir(); + + { + let database = Database::create(data_dir.join("database.redb")).unwrap(); + let tx = database.begin_write().unwrap(); + tx.open_table(METADATA) + .unwrap() + .insert(DatabaseMetadata::Schema, &SCHEMA_VERSION + 1) + .unwrap(); + tx.commit().unwrap(); + } + + assert_matches!( + Server::with_data_dir(&data_dir).map(drop), + Err(Error::DatabaseSchemaVersionMismatch { + actual, + backtrace: _, + expected: SCHEMA_VERSION, + }) if actual == SCHEMA_VERSION + 1, + ); + } + + #[test] + fn database_schema_version_missing() { + let (_tempdir, data_dir) = tempdir(); + + { + let database = Database::create(data_dir.join("database.redb")).unwrap(); + let tx = database.begin_write().unwrap(); + tx.open_table(DIRECTORIES).unwrap(); + tx.commit().unwrap(); + } + + assert_matches!( + Server::with_data_dir(&data_dir).map(drop), + Err(Error::DatabaseSchemaVersionMissing { backtrace: _ }), + ); + } +} From 1b60c5381fe5a4c00aa2e894cd53d22549be5619 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 22:22:05 -0700 Subject: [PATCH 34/36] Enhance --- src/subcommand/serve/tests.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index d9d5c487..dcdd6876 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -658,20 +658,29 @@ async fn verify_directory_succeeds() { let file_hash = Hash::bytes(file); server.write_file(file); - let child_cbor = - directory(&[("foo", EntryType::File, file_hash, file.len() as u64)]).encode_to_vec(); + let child = directory(&[("foo", EntryType::File, file_hash, file.len() as u64)]); + let child_cbor = child.encode_to_vec(); let child_hash = Hash::bytes(&child_cbor); server.write_file(&child_cbor); server.post(format!("/directory/{child_hash}")).send().await; - let parent_cbor = directory(&[( + server + .get(format!("/directory/{child_hash}")) + .assert_response(DirectoryHtml { + directory: child, + hash: child_hash, + }) + .send() + .await; + + let parent = directory(&[( "child", EntryType::Directory, child_hash, child_cbor.len() as u64, - )]) - .encode_to_vec(); + )]); + let parent_cbor = parent.encode_to_vec(); let parent_hash = Hash::bytes(&parent_cbor); server.write_file(&parent_cbor); @@ -679,6 +688,15 @@ async fn verify_directory_succeeds() { .post(format!("/directory/{parent_hash}")) .send() .await; + + server + .get(format!("/directory/{parent_hash}")) + .assert_response(DirectoryHtml { + directory: parent, + hash: parent_hash, + }) + .send() + .await; } #[tokio::test] From f77a75e735092c5ef09bc2384d6a2330248b46c7 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 22:25:29 -0700 Subject: [PATCH 35/36] Enhance --- src/server.rs | 28 ++++++++++++++-------------- src/subcommand/serve.rs | 20 ++++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/server.rs b/src/server.rs index a231c088..947af478 100644 --- a/src/server.rs +++ b/src/server.rs @@ -20,6 +20,20 @@ pub(crate) struct Server { } impl Server { + pub(crate) async fn directory(&self, hash: Hash) -> ServerResult { + ensure!( + self + .database + .begin_read()? + .open_table(DIRECTORIES)? + .get(&hash)? + .is_some(), + server_error::DirectoryNotFound { hash }, + ); + + self.read_directory(hash).await + } + fn file_path(&self, hash: Hash) -> Utf8PathBuf { self.files.join(hash.to_string()) } @@ -80,20 +94,6 @@ impl Server { Directory::decode_from_slice(&cbor).context(server_error::DirectoryDecode { hash }) } - pub(crate) async fn directory(&self, hash: Hash) -> ServerResult { - ensure!( - self - .database - .begin_read()? - .open_table(DIRECTORIES)? - .get(&hash)? - .is_some(), - server_error::DirectoryNotFound { hash }, - ); - - self.read_directory(hash).await - } - pub(crate) async fn verify_directory(&self, hash: Hash) -> ServerResult { let directory = self.read_directory(hash).await?; diff --git a/src/subcommand/serve.rs b/src/subcommand/serve.rs index f19db536..2d32609a 100644 --- a/src/subcommand/serve.rs +++ b/src/subcommand/serve.rs @@ -134,6 +134,16 @@ impl Serve { Ok(acceptor) } + async fn directory( + server: ServerExtension, + Path(hash): Path, + ) -> ServerResult { + Ok(DirectoryHtml { + directory: server.directory(hash).await?, + hash, + }) + } + fn domains(&self) -> Result> { if self.domains.is_empty() { Ok(vec![System::host_name().context(error::AcmeHostname)?]) @@ -229,16 +239,6 @@ impl Serve { )) } - async fn directory( - server: ServerExtension, - Path(hash): Path, - ) -> ServerResult { - Ok(DirectoryHtml { - directory: server.directory(hash).await?, - hash, - }) - } - pub(crate) fn router(server: Arc, auth_config: Option>) -> Router { let router = Router::new() .route("/", get(Self::home)) From f8f0d69dcf2f3d6e0efa734b361f4dbb77d451bd Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 20 May 2026 22:41:51 -0700 Subject: [PATCH 36/36] Address nits --- src/subcommand/serve/tests.rs | 22 ++++++++++++++++++++++ templates/directory.html | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index dcdd6876..229f8ca4 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -613,6 +613,28 @@ async fn verify_directory_file_not_found() { .await; } +#[tokio::test] +async fn verify_directory_idempotent() { + let server = TestServer::new(); + + let dir = directory(&[]); + let cbor = dir.encode_to_vec(); + let hash = Hash::bytes(&cbor); + server.write_file(&cbor); + + server.post(format!("/directory/{hash}")).send().await; + server.post(format!("/directory/{hash}")).send().await; + + server + .get(format!("/directory/{hash}")) + .assert_response(DirectoryHtml { + directory: dir, + hash, + }) + .send() + .await; +} + #[tokio::test] async fn verify_directory_missing_file() { let server = TestServer::new(); diff --git a/templates/directory.html b/templates/directory.html index 1b39df8f..3494d432 100644 --- a/templates/directory.html +++ b/templates/directory.html @@ -7,7 +7,7 @@ -

Files

+

Directory {{ self.hash }}

%% for (name, entry) in &self.directory.entries {
{{ name }}