···88/// Header containing the upload info.
99pub const ATTIC_NAR_INFO: &str = "X-Attic-Nar-Info";
10101111+/// Header containing the size of the upload info at the beginning of the body.
1212+pub const ATTIC_NAR_INFO_PREAMBLE_SIZE: &str = "X-Attic-Nar-Info-Preamble-Size";
1313+1114/// NAR information associated with a upload.
1215///
1313-/// This is JSON-serialized as the value of the `X-Attic-Nar-Info` header.
1414-/// The (client-compressed) NAR is the PUT body.
1616+/// There are two ways for the client to supply the NAR information:
1717+///
1818+/// 1. At the beginning of the PUT body. The `X-Attic-Nar-Info-Preamble-Size`
1919+/// header must be set to the size of the JSON.
2020+/// 2. Through the `X-Attic-Nar-Info` header.
2121+///
2222+/// The client is advised to use the first method if the serialized
2323+/// JSON is large (>4K).
1524///
1625/// Regardless of client compression, the server will always decompress
1726/// the NAR to validate the NAR hash before applying the server-configured
+30-9
client/src/api/mod.rs
···55use bytes::Bytes;
66use const_format::concatcp;
77use displaydoc::Display;
88-use futures::TryStream;
88+use futures::{
99+ future,
1010+ stream::{self, StreamExt, TryStream, TryStreamExt},
1111+};
912use reqwest::{
1013 header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT},
1114 Body, Client as HttpClient, Response, StatusCode, Url,
···1619use crate::version::ATTIC_DISTRIBUTOR;
1720use attic::api::v1::cache_config::{CacheConfig, CreateCacheRequest};
1821use attic::api::v1::get_missing_paths::{GetMissingPathsRequest, GetMissingPathsResponse};
1919-use attic::api::v1::upload_path::{UploadPathNarInfo, UploadPathResult, ATTIC_NAR_INFO};
2222+use attic::api::v1::upload_path::{
2323+ UploadPathNarInfo, UploadPathResult, ATTIC_NAR_INFO, ATTIC_NAR_INFO_PREAMBLE_SIZE,
2424+};
2025use attic::cache::CacheName;
2126use attic::nix_store::StorePathHash;
22272328/// The User-Agent string of Attic.
2429const ATTIC_USER_AGENT: &str =
2530 concatcp!("Attic/{} ({})", env!("CARGO_PKG_NAME"), ATTIC_DISTRIBUTOR);
3131+3232+/// The size threshold to send the upload info as part of the PUT body.
3333+const NAR_INFO_PREAMBLE_THRESHOLD: usize = 4 * 1024; // 4 KiB
26342735/// The Attic API client.
2836#[derive(Debug, Clone)]
···165173 &self,
166174 nar_info: UploadPathNarInfo,
167175 stream: S,
176176+ force_preamble: bool,
168177 ) -> Result<Option<UploadPathResult>>
169178 where
170170- S: TryStream + Send + Sync + 'static,
171171- S::Error: Into<Box<dyn StdError + Send + Sync>>,
172172- Bytes: From<S::Ok>,
179179+ S: TryStream<Ok = Bytes> + Send + Sync + 'static,
180180+ S::Error: Into<Box<dyn StdError + Send + Sync>> + Send + Sync,
173181 {
174182 let endpoint = self.endpoint.join("_api/v1/upload-path")?;
175183 let upload_info_json = serde_json::to_string(&nar_info)?;
176184177177- let req = self
185185+ let mut req = self
178186 .client
179187 .put(endpoint)
180180- .header(ATTIC_NAR_INFO, HeaderValue::from_str(&upload_info_json)?)
181181- .header(USER_AGENT, HeaderValue::from_str(ATTIC_USER_AGENT)?)
182182- .body(Body::wrap_stream(stream));
188188+ .header(USER_AGENT, HeaderValue::from_str(ATTIC_USER_AGENT)?);
189189+190190+ if force_preamble || upload_info_json.len() >= NAR_INFO_PREAMBLE_THRESHOLD {
191191+ let preamble = Bytes::from(upload_info_json);
192192+ let preamble_len = preamble.len();
193193+ let preamble_stream = stream::once(future::ok(preamble));
194194+195195+ let chained = preamble_stream.chain(stream.into_stream());
196196+ req = req
197197+ .header(ATTIC_NAR_INFO_PREAMBLE_SIZE, preamble_len)
198198+ .body(Body::wrap_stream(chained));
199199+ } else {
200200+ req = req
201201+ .header(ATTIC_NAR_INFO, HeaderValue::from_str(&upload_info_json)?)
202202+ .body(Body::wrap_stream(stream));
203203+ }
183204184205 let res = req.send().await?;
185206
+22-4
client/src/command/push.rs
···77use std::time::{Duration, Instant};
8899use anyhow::{anyhow, Result};
1010+use bytes::Bytes;
1011use clap::Parser;
1112use futures::future::join_all;
1212-use futures::stream::Stream;
1313+use futures::stream::{Stream, TryStreamExt};
1314use indicatif::{HumanBytes, MultiProgress, ProgressBar, ProgressState, ProgressStyle};
1415use tokio::sync::Semaphore;
1516···4142 /// The maximum number of parallel upload processes.
4243 #[clap(short = 'j', long, default_value = "5")]
4344 jobs: usize,
4545+4646+ /// Always send the upload info as part of the payload.
4747+ #[clap(long, hide = true)]
4848+ force_preamble: bool,
4449}
45504651struct PushPlan {
···7075 api: ApiClient,
7176 cache: &CacheName,
7277 mp: MultiProgress,
7878+ force_preamble: bool,
7379) -> Result<()> {
7480 let path = &path_info.path;
7581 let upload_info = {
···127133 );
128134 let bar = mp.add(ProgressBar::new(path_info.nar_size));
129135 bar.set_style(style);
130130- let nar_stream = NarStreamProgress::new(store.nar_from_path(path.to_owned()), bar.clone());
136136+ let nar_stream = NarStreamProgress::new(store.nar_from_path(path.to_owned()), bar.clone())
137137+ .map_ok(Bytes::from);
131138132139 let start = Instant::now();
133133- match api.upload_path(upload_info, nar_stream).await {
140140+ match api
141141+ .upload_path(upload_info, nar_stream, force_preamble)
142142+ .await
143143+ {
134144 Ok(r) => {
135145 let r = r.unwrap_or(UploadPathResult {
136146 kind: UploadPathResultKind::Uploaded,
···243253 async move {
244254 let permit = upload_limit.acquire().await?;
245255246246- upload_path(store.clone(), path_info, api, cache, mp.clone()).await?;
256256+ upload_path(
257257+ store.clone(),
258258+ path_info,
259259+ api,
260260+ cache,
261261+ mp.clone(),
262262+ sub.force_preamble,
263263+ )
264264+ .await?;
247265248266 drop(permit);
249267 Ok::<(), anyhow::Error>(())
+19-5
integration-tests/basic/default.nix
···99 atticd = ". /etc/atticd.env && export ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64 && atticd -f ${serverConfigFile}";
1010 };
11111212- testDrv = pkgs.writeText "test.nix" ''
1212+ makeTestDerivation = pkgs.writeShellScript "make-drv" ''
1313+ name=$1
1414+ base=$(basename $name)
1515+1616+ cat >$name <<EOF
1317 #!/bin/sh
1414- /*/sh -c "echo hello > $out"; exit 0; */
1818+ /*/sh -c "echo hello > \$out"; exit 0; */
1519 derivation {
1616- name = "hello.txt";
1717- builder = ./test.nix;
2020+ name = "$base";
2121+ builder = ./$name;
1822 system = builtins.currentSystem;
1923 preferLocalBuild = true;
2024 allowSubstitutes = false;
2125 }
2626+ EOF
2727+2828+ chmod +x $name
2229 '';
23302431 databaseModules = {
···171178 client.succeed("attic cache create test")
172179173180 with subtest("Check that we can push a path"):
174174- client.succeed("cat ${testDrv} >test.nix && chmod +x test.nix")
181181+ client.succeed("${makeTestDerivation} test.nix")
175182 test_file = client.succeed("nix-build --no-out-link test.nix")
176183 test_file_hash = test_file.removeprefix("/nix/store/")[:32]
177184···209216 print(f"Remaining files: {files}")
210217 assert files.strip() == ""
211218 ''}
219219+220220+ with subtest("Check that we can include the upload info in the payload"):
221221+ client.succeed("${makeTestDerivation} test2.nix")
222222+ test2_file = client.succeed("nix-build --no-out-link test2.nix")
223223+ client.succeed(f"attic push --force-preamble test {test2_file}")
224224+ client.succeed(f"nix-store --delete {test2_file}")
225225+ client.succeed(f"nix-store -r {test2_file}")
212226213227 with subtest("Check that we can destroy the cache"):
214228 client.succeed("attic cache info test")
+50-10
server/src/api/v1/upload_path.rs
···1111 extract::{BodyStream, Extension, Json},
1212 http::HeaderMap,
1313};
1414-use bytes::Bytes;
1414+use bytes::{Bytes, BytesMut};
1515use chrono::Utc;
1616use digest::Output as DigestOutput;
1717use futures::future::join_all;
···3434use crate::{RequestState, State};
3535use attic::api::v1::upload_path::{
3636 UploadPathNarInfo, UploadPathResult, UploadPathResultKind, ATTIC_NAR_INFO,
3737+ ATTIC_NAR_INFO_PREAMBLE_SIZE,
3738};
3839use attic::hash::Hash;
3939-use attic::stream::StreamHasher;
4040+use attic::stream::{read_chunk_async, StreamHasher};
4041use attic::util::Finally;
41424243use crate::chunking::chunk_stream;
···5253///
5354/// TODO: Make this configurable
5455const CONCURRENT_CHUNK_UPLOADS: usize = 10;
5656+5757+/// The maximum size of the upload info JSON.
5858+const MAX_NAR_INFO_SIZE: usize = 64 * 1024; // 64 KiB
55595660type CompressorFn<C> = Box<dyn FnOnce(C) -> Box<dyn AsyncRead + Unpin + Send> + Send>;
5761···116120 headers: HeaderMap,
117121 stream: BodyStream,
118122) -> ServerResult<Json<UploadPathResult>> {
123123+ let mut stream = StreamReader::new(
124124+ stream.map(|r| r.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))),
125125+ );
126126+119127 let upload_info: UploadPathNarInfo = {
120120- let header = headers
121121- .get(ATTIC_NAR_INFO)
122122- .ok_or_else(|| ErrorKind::RequestError(anyhow!("X-Attic-Nar-Info must be set")))?;
128128+ if let Some(preamble_size_bytes) = headers.get(ATTIC_NAR_INFO_PREAMBLE_SIZE) {
129129+ // Read from the beginning of the PUT body
130130+ let preamble_size: usize = preamble_size_bytes
131131+ .to_str()
132132+ .map_err(|_| {
133133+ ErrorKind::RequestError(anyhow!(
134134+ "{} has invalid encoding",
135135+ ATTIC_NAR_INFO_PREAMBLE_SIZE
136136+ ))
137137+ })?
138138+ .parse()
139139+ .map_err(|_| {
140140+ ErrorKind::RequestError(anyhow!(
141141+ "{} must be a valid unsigned integer",
142142+ ATTIC_NAR_INFO_PREAMBLE_SIZE
143143+ ))
144144+ })?;
123145124124- serde_json::from_slice(header.as_bytes()).map_err(ServerError::request_error)?
146146+ if preamble_size > MAX_NAR_INFO_SIZE {
147147+ return Err(ErrorKind::RequestError(anyhow!("Upload info is too large")).into());
148148+ }
149149+150150+ let buf = BytesMut::with_capacity(preamble_size);
151151+ let preamble = read_chunk_async(&mut stream, buf)
152152+ .await
153153+ .map_err(|e| ErrorKind::RequestError(e.into()))?;
154154+155155+ if preamble.len() != preamble_size {
156156+ return Err(ErrorKind::RequestError(anyhow!(
157157+ "Upload info doesn't match specified size"
158158+ ))
159159+ .into());
160160+ }
161161+162162+ serde_json::from_slice(&preamble).map_err(ServerError::request_error)?
163163+ } else if let Some(nar_info_bytes) = headers.get(ATTIC_NAR_INFO) {
164164+ // Read from X-Attic-Nar-Info header
165165+ serde_json::from_slice(nar_info_bytes.as_bytes()).map_err(ServerError::request_error)?
166166+ } else {
167167+ return Err(ErrorKind::RequestError(anyhow!("{} must be set", ATTIC_NAR_INFO)).into());
168168+ }
125169 };
126170 let cache_name = &upload_info.cache;
127171···133177 Ok(cache)
134178 })
135179 .await?;
136136-137137- let stream = StreamReader::new(
138138- stream.map(|r| r.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))),
139139- );
140180141181 let username = req_state.auth.username().map(str::to_string);
142182