stenoxide_core/pipeline/mod.rs
1//! Layer 5 — pipeline orchestration.
2//!
3//! Chains layers 1 to 4 with explicit ownership transfer at every step, so
4//! each sensitive buffer is dropped and zeroed at the earliest possible point.
5//! The extraction path needs no cost map: STC decoding operates on the
6//! syndrome of all pixels rather than on a stored position list.
7//!
8//! # The ownership chain
9//!
10//! Every step of [`EmbedPipeline::embed`] carries a comment explaining what it
11//! hands over and what the borrow checker guarantees at that point. Two of those
12//! guarantees are the reason this layer is written the way it is:
13//!
14//! - **Nothing sensitive outlives its use.** The password dies with the master
15//! key derivation, the master key with its expansion, the plaintext with its
16//! encryption, the ciphertext and the subkeys with the trellis pass. Each is
17//! moved into a scope that ends at that point rather than kept in a variable
18//! the rest of the function could still read.
19//! - **The cost map and the samples cannot disagree.** `CostMap<'img>` holds the
20//! borrow of the image, so `pixels_mut()` does not compile while the map is
21//! alive. Embedding into pixels whose costs were computed from different
22//! samples is exactly the mistake that would steer changes into the wrong
23//! regions, and here it is a compile error rather than a convention.
24//!
25//! # What travels in the container
26//!
27//! Nothing but bits. No metadata, no salt, no nonce, no length prefix outside
28//! the frame described in `frame`: the salt is recomputed from the image, the
29//! nonce and the permutation seed are derived from it, and the only thing the
30//! receiver is told is how many ciphertext bytes to decode.
31
32pub mod error;
33
34// Readable inside the crate rather than private to this module: `generate`
35// writes containers of its own and there is one function in this workspace that
36// turns a sample buffer into a PNG on disk.
37pub(crate) mod frame;
38
39use std::path::Path;
40
41use zeroize::Zeroizing;
42
43use crate::cost::hill::{CostError, HillCostProvider};
44use crate::cost::CostProvider;
45use crate::crypto::aead::{
46 compress_and_encrypt, decrypt_and_decompress, AEADCipher, AEADError, CryptoError,
47 XChaCha20Poly1305Cipher,
48};
49use crate::crypto::expand::{expand_master_key, DerivedKeys};
50use crate::crypto::kdf::{Argon2Kdf, KeyDeriver};
51use crate::generate::read_generated;
52use crate::image_io::buffer::{CoverSource, ImageBuffer};
53use crate::image_io::phash::{
54 compute_stable_phash, phash_salt_hypotheses, recover_phash_salt, PHashError, PHashSalt,
55};
56use crate::image_io::validate::load_and_validate;
57use crate::stego::permute::generate_pixel_permutation;
58use crate::stego::sizer::{compute_capacity, validate_payload_fits, EmbeddingMode, SizerError};
59use crate::stego::stc::{stc_decode_safe, stc_encode_safe, StcConfig};
60
61pub use crate::pipeline::error::{OutputError, PipelineError};
62
63/// Ciphertext bytes decoded provisionally to tell two salt hypotheses apart.
64///
65/// The figure comes from [`recover_phash_salt`], which decrypts the head of this
66/// prefix with the raw XChaCha20 keystream and looks for a Zstandard frame magic
67/// number. Sixty-four bytes is one XChaCha20 block, and nothing shorter would
68/// let the discriminator seek past the block the AEAD reserves for its one-time
69/// MAC key.
70const PROVISIONAL_PREFIX_BYTES: usize = 64;
71
72/// What one embedding operation did to the container.
73///
74/// A measurement of the finished stego image, not a receipt the receiver needs:
75/// none of these numbers travels with the payload, and the extraction path
76/// recomputes everything it needs from the image itself.
77#[derive(Debug)]
78pub struct EmbedReport {
79 /// Positions whose carrier bit the trellis flipped, across both regions of
80 /// the frame.
81 pub pixels_modified: usize,
82 /// Ciphertext bytes embedded, tag included.
83 ///
84 /// The compressed and encrypted size, not the length of the message the
85 /// caller handed in: the plaintext length is not something the container
86 /// carries, and reporting it here would suggest otherwise.
87 pub payload_bytes: usize,
88 /// Bits embedded per pixel of the container, frame header included.
89 ///
90 /// The figure the security of the scheme rests on. It is bounded by
91 /// [`crate::stego::stc::MAX_BPP`] on each region of the frame, so it can
92 /// never reach that ceiling over the image as a whole.
93 pub effective_bpp: f32,
94 /// Dimensions of the container as `(width, height)`, in pixels.
95 pub image_dimensions: (u32, u32),
96}
97
98/// What one extraction operation recovered.
99#[derive(Debug)]
100pub struct ExtractReport {
101 /// Ciphertext bytes recovered from the container, tag included.
102 ///
103 /// Counted before decryption and decompression, so it measures how much of
104 /// the container was in use rather than how long the message is — the caller
105 /// already holds the plaintext and can measure that itself.
106 pub payload_bytes: usize,
107}
108
109/// The orchestrator of layers 1 to 4.
110///
111/// Generic over the three components that have a choice of implementation, so
112/// that a test can substitute a cheap key deriver or a stub cost model without
113/// any of the code below knowing. The production instantiation is built by
114/// [`EmbedPipeline::default_secure`].
115pub struct EmbedPipeline<KDF, AEAD, COST> {
116 /// Password stretching. Argon2id in production.
117 kdf: KDF,
118 /// Authenticated encryption. XChaCha20-Poly1305 in production.
119 aead: AEAD,
120 /// Per-pixel embedding costs. HILL in production.
121 cost: COST,
122}
123
124impl<KDF, AEAD, COST> EmbedPipeline<KDF, AEAD, COST> {
125 /// Assembles a pipeline from its three components.
126 ///
127 /// Unconstrained on purpose: the bounds belong on the operations, not on
128 /// construction, so that a caller can hold a pipeline built from anything
129 /// and only meet the requirements when it embeds or extracts.
130 pub fn new(kdf: KDF, aead: AEAD, cost: COST) -> Self {
131 Self { kdf, aead, cost }
132 }
133}
134
135impl EmbedPipeline<Argon2Kdf, XChaCha20Poly1305Cipher, HillCostProvider> {
136 /// Builds the pipeline this project considers secure: Argon2id at 128 MiB
137 /// and four passes, XChaCha20-Poly1305, and the HILL cost model.
138 ///
139 /// There is no constructor that weakens any of the three. A pipeline whose
140 /// components were chosen at run time would look identical at the API
141 /// surface to this one and behave nothing like it.
142 pub fn default_secure() -> Self {
143 Self::new(
144 Argon2Kdf::default_secure(),
145 XChaCha20Poly1305Cipher::new(),
146 HillCostProvider::new(),
147 )
148 }
149}
150
151/// Result of one extraction attempt under a single salt hypothesis.
152///
153/// A rejection is not an error: with an uncertain hash bit there are two
154/// hypotheses, and the first one being wrong is an ordinary step of the
155/// protocol, not a failure to report.
156enum Attempt {
157 /// The payload authenticated and decompressed.
158 Recovered {
159 /// The recovered message.
160 plaintext: Zeroizing<Vec<u8>>,
161 /// Ciphertext bytes that were decoded to produce it.
162 ciphertext_bytes: usize,
163 },
164 /// The payload did not authenticate under this hypothesis.
165 ///
166 /// Carries the provisionally decoded ciphertext prefix, which is what
167 /// [`recover_phash_salt`] needs to decide whether the hypothesis or the
168 /// password was at fault. The prefix is empty when the length header itself
169 /// decoded to nonsense, in which case the discriminator rejects both
170 /// hypotheses and the caller moves on to the alternative.
171 Rejected(Zeroizing<Vec<u8>>),
172}
173
174impl<KDF, AEAD, COST> EmbedPipeline<KDF, AEAD, COST>
175where
176 KDF: KeyDeriver,
177 AEAD: AEADCipher,
178 COST: CostProvider<Error = CostError>,
179{
180 /// Hides `plaintext` in the container at `image_path` and writes the result
181 /// to `output_path`.
182 ///
183 /// Both secrets are taken by value in a [`Zeroizing`] wrapper: the pipeline
184 /// becomes their owner and wipes them at the point in the chain where they
185 /// stop being needed, which a borrow could not guarantee.
186 ///
187 /// # Errors
188 ///
189 /// Returns a [`PipelineError`] wrapping the error of whichever layer refused
190 /// the operation: an unusable container, an unstable perceptual hash, a
191 /// smooth image, a payload that does not fit, a failure of the coder, or a
192 /// file that could not be written.
193 pub fn embed(
194 &self,
195 image_path: &Path,
196 plaintext: Zeroizing<Vec<u8>>,
197 password: Zeroizing<Vec<u8>>,
198 output_path: &Path,
199 ) -> Result<EmbedReport, PipelineError> {
200 // Step 1 — the container enters the chain. `load_and_validate` is the
201 // only producer of an `ImageBuffer`, so from here on the type itself is
202 // the proof that every validation gate ran. The pipeline owns it, and
203 // will still own it when it is written back out.
204 let mut image_buffer = load_and_validate(image_path)?;
205 let image_dimensions = image_buffer.dimensions();
206 let pixel_count = image_buffer.pixel_count();
207
208 // Step 2 — the salt is a function of the container. Only a shared borrow
209 // is taken, so `image_buffer` is untouched and still owned by us.
210 let phash_salt = compute_stable_phash(&image_buffer)?;
211
212 // Step 3 — the password is consumed here and nowhere else. Both it and
213 // the salt are dropped as soon as the derivation returns: `Zeroizing`
214 // wipes the password bytes and `ZeroizeOnDrop` wipes the salt, so
215 // neither survives the statement that used it.
216 let master_key = self.kdf.derive(password.as_slice(), &phash_salt)?;
217 drop(password);
218 drop(phash_salt);
219
220 // Step 4 — the master key is expanded and immediately destroyed. It is
221 // passed by reference, so `expand_master_key` never owns key material it
222 // did not create and the wipe happens here, at the earliest point the
223 // chain allows.
224 let derived_keys = expand_master_key(&master_key)?;
225 drop(master_key);
226
227 // Step 5 — the message becomes ciphertext. The intermediate compressed
228 // buffer lives and dies inside `compress_and_encrypt`; the plaintext is
229 // dropped the moment it returns, which is the last instant it is needed.
230 let ciphertext = compress_and_encrypt(
231 plaintext.as_slice(),
232 derived_keys.enc_key(),
233 derived_keys.nonce(),
234 &self.aead,
235 )?;
236 drop(plaintext);
237
238 // Step 6 — the cost map borrows the image for as long as it lives.
239 // INVARIANT: from this line until `drop(cost_map)` the compiler refuses
240 // every call to `image_buffer.pixels_mut()`. The samples the map was
241 // computed from and the samples the coder will modify are therefore the
242 // same samples, and that is checked, not assumed.
243 let cost_map = self.cost.compute(&image_buffer)?;
244
245 // Step 7 — capacity is measured and the payload is checked against it
246 // before a single position is touched. The frame overhead is charged to
247 // the payload here because the sizer measures the container as a whole
248 // and knows nothing about the header region.
249 let capacity = compute_capacity(&cost_map, EmbeddingMode::Symmetric);
250 validate_payload_fits(ciphertext.len() + frame::FRAME_OVERHEAD_BYTES, &capacity)?;
251
252 // Step 8 — the secret visiting order. It depends only on the seed and on
253 // the pixel count, both of which the receiver can reproduce.
254 let permutation = generate_pixel_permutation(pixel_count, derived_keys.stc_seed());
255
256 // Step 9 — everything the coder needs is copied out of the image and the
257 // map, in embedding order. Both reads are shared borrows and coexist
258 // happily; what matters is that they are the *last* reads, because the
259 // next statement releases the map's borrow and the one after that takes
260 // a unique borrow of the samples.
261 let mut cover_symbols = frame::gather_cover_symbols(&image_buffer, &permutation);
262 let cost_reordered = frame::reorder_costs(cost_map.costs(), &permutation);
263
264 // The copy above is what makes this drop possible, and the drop is what
265 // makes `image_buffer` mutable again.
266 drop(cost_map);
267
268 // Step 10 — two trellis passes over disjoint regions of the permuted
269 // positions: the length header first, then the ciphertext. See
270 // [`frame`] for why the length cannot simply ride inside the payload.
271 let length_header = frame::encode_length_header(ciphertext.len()).ok_or_else(|| {
272 // Unreachable after the capacity check: a container able to carry a
273 // ciphertext this long does not exist. Reported as an oversized
274 // payload because that is exactly what it is.
275 SizerError::PayloadTooLarge {
276 payload: ciphertext.len(),
277 available: u32::MAX as usize,
278 deficit: ciphertext.len().saturating_sub(u32::MAX as usize),
279 }
280 })?;
281
282 let stc_config = StcConfig::new(*derived_keys.stc_seed());
283
284 let (header_costs, payload_costs) = frame::split_regions(&cost_reordered);
285 let (header_cover, payload_cover) = frame::split_regions_mut(&mut cover_symbols);
286
287 let header_changes =
288 stc_encode_safe(header_cover, header_costs, &length_header, &stc_config)?;
289 let payload_changes = stc_encode_safe(
290 payload_cover,
291 payload_costs,
292 ciphertext.as_slice(),
293 &stc_config,
294 )?;
295
296 let payload_bytes = ciphertext.len();
297
298 // The coder has taken everything it needed from them, so the ciphertext
299 // and every derived key leave memory here: `Zeroizing` wipes the first,
300 // `ZeroizeOnDrop` the other two.
301 drop(ciphertext);
302 drop(derived_keys);
303 drop(stc_config);
304 drop(cost_reordered);
305
306 // Step 11 — the stego symbols go back into the carrier bits. This is the
307 // unique borrow that the cost map was standing in the way of, and it is
308 // the only mutation of the container in the whole crate.
309 frame::apply_cover_symbols(&mut image_buffer, &permutation, &cover_symbols);
310 drop(permutation);
311 drop(cover_symbols);
312
313 frame::write_png(&image_buffer, output_path)?;
314
315 // Step 12 — the report is pure metadata. `image_buffer` is dropped as
316 // this returns and is deliberately not wiped: its contents are the file
317 // just written to disk, so there is nothing in it an attacker could not
318 // read there instead.
319 let embedded_bits = frame::LENGTH_HEADER_BITS + payload_bytes * 8;
320
321 Ok(EmbedReport {
322 pixels_modified: header_changes + payload_changes,
323 payload_bytes,
324 effective_bpp: embedded_bits as f32 / pixel_count.max(1) as f32,
325 image_dimensions,
326 })
327 }
328
329 /// Recovers the message hidden in the stego image at `stego_path`.
330 ///
331 /// # Why extraction needs no cost map
332 ///
333 /// STC decoding operates on the syndrome `H x stego (mod 2)` taken over
334 /// *all* the positions of a region. The receiver does not need to know which
335 /// pixels were modified, and there is no position list to transmit or store:
336 /// it only has to reproduce the permutation, which follows from the same
337 /// `stc_seed` derived from the same `MasterKey` derived from the same
338 /// password and the same image. That is the whole reason the container
339 /// carries no metadata at all — and the reason the expensive half of
340 /// embedding, the HILL analysis, has no counterpart here.
341 ///
342 /// # Why it reads two kinds of container
343 ///
344 /// A container may have been produced by [`crate::generate`] rather than by
345 /// [`EmbedPipeline::embed`], and nothing in the file says which — a marker
346 /// would be the one piece of metadata this design does not carry. Both
347 /// readings are therefore attempted under one key derivation, and every
348 /// failure is the single failure below. The second reading costs a stream
349 /// cipher over the container and no second Argon2id pass, because both
350 /// constructions derive from the same perceptual hash of the same image.
351 ///
352 /// # Errors
353 ///
354 /// Returns a [`PipelineError`] wrapping the error of whichever layer
355 /// refused: an unusable file, a hash too unstable to reproduce, a coder
356 /// failure, or [`AEADError::AuthenticationFailed`] — which collapses a wrong
357 /// password, a wrong image and a damaged payload into one answer on purpose.
358 pub fn extract(
359 &self,
360 stego_path: &Path,
361 password: Zeroizing<Vec<u8>>,
362 ) -> Result<(Zeroizing<Vec<u8>>, ExtractReport), PipelineError> {
363 // Step 1 — the stego image goes through the same gates as a cover. A
364 // container that would have been refused for embedding cannot be one
365 // this crate produced.
366 let stego_image = load_and_validate(stego_path)?;
367
368 // Step 2 — the salt hypotheses. One when every hash bit is stable, two
369 // when embedding may have pushed a coefficient across the median. The
370 // password is not consumed yet: with `k == 1` it may have to stretch
371 // more than once, so it is kept until every hypothesis is spent.
372 let hypotheses = phash_salt_hypotheses(&stego_image)?;
373
374 // Steps 3 to 8 under the hypothesis the image itself suggests.
375 match self.attempt_extract(&stego_image, &hypotheses.primary, password.as_slice())? {
376 Attempt::Recovered {
377 plaintext,
378 ciphertext_bytes,
379 } => {
380 drop(password);
381
382 Ok((
383 plaintext,
384 ExtractReport {
385 payload_bytes: ciphertext_bytes,
386 },
387 ))
388 }
389 Attempt::Rejected(prefix) => {
390 let Some(alternative) = hypotheses.alternative else {
391 // Every hash bit was stable, so the salt was certainly the
392 // right one and the failure is genuine.
393 drop(password);
394
395 return Err(PipelineError::Crypto(CryptoError::AEADError(
396 AEADError::AuthenticationFailed,
397 )));
398 };
399
400 // `k == 1`. The prefix was decoded under the primary hypothesis,
401 // so `recover_phash_salt` can only confirm that hypothesis — and
402 // that is precisely the question being asked. A confirmation
403 // means the seed was right and the payload really is unusable; a
404 // rejection means the uncertain bit measured the other way on
405 // the cover, and the alternative deserves a full attempt.
406 let verdict = recover_phash_salt(
407 &stego_image,
408 password.as_slice(),
409 &self.kdf,
410 prefix.as_slice(),
411 );
412 drop(prefix);
413
414 let outcome = match verdict {
415 Ok(confirmed) => {
416 drop(confirmed);
417 drop(password);
418
419 return Err(PipelineError::Crypto(CryptoError::AEADError(
420 AEADError::AuthenticationFailed,
421 )));
422 }
423 Err(PHashError::RecoveryFailed) => {
424 self.attempt_extract(&stego_image, &alternative, password.as_slice())
425 }
426 Err(err) => Err(PipelineError::PHash(err)),
427 };
428
429 drop(password);
430
431 match outcome? {
432 Attempt::Recovered {
433 plaintext,
434 ciphertext_bytes,
435 } => Ok((
436 plaintext,
437 ExtractReport {
438 payload_bytes: ciphertext_bytes,
439 },
440 )),
441 Attempt::Rejected(_) => Err(PipelineError::Crypto(CryptoError::AEADError(
442 AEADError::AuthenticationFailed,
443 ))),
444 }
445 }
446 }
447 }
448
449 /// Runs the extraction chain once, under one candidate salt.
450 ///
451 /// The inverse of steps 3 to 10 of [`EmbedPipeline::embed`], and the unit the
452 /// hypothesis search repeats. Everything it derives — master key, subkeys,
453 /// permutation — is local and dropped before it returns, so a failed attempt
454 /// leaves nothing behind for the next one to trip over.
455 ///
456 /// # Why two readings, and why one derivation
457 ///
458 /// A container may have been generated *around* its payload rather than
459 /// embedded into — see [`crate::generate`] — and the two are read by
460 /// completely different code. Nothing in the file says which it is, and
461 /// nothing may: a flag would be the marker this project has gone to some
462 /// trouble not to carry, and a distinct error would let an attacker holding
463 /// a candidate password learn which construction produced an image.
464 ///
465 /// So both are tried and both failures are the same failure. It costs
466 /// almost nothing because the expensive step is Argon2id and the two
467 /// readings derive from the same perceptual hash of the same image: one
468 /// derivation serves both, and the second reading is a stream cipher over
469 /// the container and nothing else.
470 ///
471 /// # Errors
472 ///
473 /// Returns a [`PipelineError`] only for failures that no other hypothesis
474 /// could repair. A payload that does not authenticate is reported as
475 /// [`Attempt::Rejected`], because with an uncertain hash bit that is a
476 /// question about the salt and not yet an error.
477 fn attempt_extract(
478 &self,
479 stego_image: &ImageBuffer,
480 salt: &PHashSalt,
481 password: &[u8],
482 ) -> Result<Attempt, PipelineError> {
483 // Steps 3 and 4 — the same derivation the sender ran, in the same order.
484 // Both intermediates are wiped as soon as the next value exists.
485 let master_key = self.kdf.derive(password, salt)?;
486 let derived_keys = expand_master_key(&master_key)?;
487 drop(master_key);
488
489 let outcome = match self.decode_trellis(stego_image, &derived_keys)? {
490 recovered @ Attempt::Recovered { .. } => recovered,
491 // The trellis found nothing. Under these very keys the container
492 // may still be one that was generated around its payload, and that
493 // reading is what the prefix would otherwise be discarded for.
494 Attempt::Rejected(prefix) => {
495 match read_generated(stego_image, &derived_keys, &self.aead) {
496 Ok((plaintext, ciphertext_bytes)) => Attempt::Recovered {
497 plaintext,
498 ciphertext_bytes,
499 },
500 // Every way of failing here is the way the trellis reading
501 // already failed, so the attempt ends exactly as it would
502 // have without this second try — prefix included, because
503 // the salt discriminator upstream still wants it.
504 Err(_) => Attempt::Rejected(prefix),
505 }
506 }
507 };
508
509 drop(derived_keys);
510
511 Ok(outcome)
512 }
513
514 /// The Syndrome-Trellis reading of a container, under keys already derived.
515 ///
516 /// Steps 5 to 10 of the inverse chain. Split from [`Self::attempt_extract`]
517 /// so that the derivation above it happens once and serves both readings.
518 ///
519 /// # Errors
520 ///
521 /// As [`Self::attempt_extract`]: only failures no other hypothesis could
522 /// repair.
523 fn decode_trellis(
524 &self,
525 stego_image: &ImageBuffer,
526 derived_keys: &DerivedKeys,
527 ) -> Result<Attempt, PipelineError> {
528 // Step 5 — the visiting order, reproduced rather than transmitted.
529 let permutation =
530 generate_pixel_permutation(stego_image.pixel_count(), derived_keys.stc_seed());
531 let cover_symbols = frame::gather_cover_symbols(stego_image, &permutation);
532 drop(permutation);
533
534 let stc_config = StcConfig::new(*derived_keys.stc_seed());
535 let (header_region, payload_region) = frame::split_regions(&cover_symbols);
536
537 // Step 6 — the length header. Its region is a constant number of
538 // positions, which is what makes this decode possible at all.
539 let header = stc_decode_safe(header_region, frame::LENGTH_HEADER_BITS, &stc_config)?;
540
541 // Step 7 — a header decoded under the wrong seed is uniformly random, so
542 // the length it announces has to be judged before it is acted on. An
543 // implausible one ends the attempt with an empty prefix, which the
544 // discriminator upstream reads as "this hypothesis explains nothing".
545 let announced = frame::decode_length_header(&header).unwrap_or(0);
546 if announced < frame::MIN_CIPHERTEXT_BYTES
547 || announced.saturating_mul(8) > stc_config.capacity_bits(payload_region.len())
548 {
549 return Ok(Attempt::Rejected(Zeroizing::new(Vec::new())));
550 }
551
552 // Step 8 — the payload region, decoded to the exact length announced.
553 let ciphertext =
554 Zeroizing::new(stc_decode_safe(payload_region, announced * 8, &stc_config)?);
555 drop(cover_symbols);
556 drop(stc_config);
557
558 // Step 9 — authentication, then decompression. Nothing reaches the
559 // Zstandard decoder that the Poly1305 tag has not already vouched for.
560 let outcome = decrypt_and_decompress(
561 ciphertext.as_slice(),
562 derived_keys.enc_key(),
563 derived_keys.nonce(),
564 &self.aead,
565 );
566
567 match outcome {
568 Ok(plaintext) => Ok(Attempt::Recovered {
569 plaintext,
570 ciphertext_bytes: announced,
571 }),
572 // Step 10 — the tag rejected the payload. Under a hypothesis that
573 // may be wrong this says nothing yet, so the head of the ciphertext
574 // is handed back for the discriminator to judge.
575 Err(CryptoError::AEADError(_)) => Ok(Attempt::Rejected(Zeroizing::new(
576 ciphertext
577 .iter()
578 .copied()
579 .take(PROVISIONAL_PREFIX_BYTES)
580 .collect(),
581 ))),
582 // Decompression failed *after* the tag verified: the key was right
583 // and the data is genuinely broken. No other hypothesis can help.
584 Err(err) => Err(PipelineError::Crypto(err)),
585 }
586 }
587}