stuffr_core/error.rs
1use crate::format::FormatId;
2
3pub type Result<T> = std::result::Result<T, Error>;
4
5#[derive(Debug, thiserror::Error)]
6#[non_exhaustive]
7pub enum Error {
8 /// Stuffr's environment failed: a disk error, a permission failure, a
9 /// broken pipe. Exit 1, which this project reserves for "stuffr itself
10 /// failed" and nothing else.
11 ///
12 /// **Constructed through a hand-written `From` impl, not a derived one**
13 /// — see below `Error`'s own `impl` block. The derived `#[from]` was
14 /// blind to whether the failing read sat above or below a codec decoder,
15 /// which made a bare `?` below one wrong by default; four sites had it
16 /// wrong at once. [`crate::source::decode_side`] carries the whole
17 /// argument.
18 #[error("i/o error: {0}")]
19 Io(#[source] std::io::Error),
20
21 #[error("usage error: {0}")]
22 Usage(String),
23
24 #[error("format `{0}` is not available in this build")]
25 FormatNotEnabled(FormatId),
26
27 #[error("could not detect format; leading bytes were {seen}")]
28 UnknownFormat { seen: String },
29
30 #[error("format is ambiguous; candidates: {candidates}. Pass --format to choose.")]
31 AmbiguousFormat { candidates: String },
32
33 #[error("input is not seekable and `{format}` requires random access")]
34 NotSeekable { format: FormatId },
35
36 #[error("spill limit of {limit} bytes exceeded; raise --max-spill or use a seekable input")]
37 SpillLimitExceeded { limit: u64 },
38
39 #[error("resource limit exceeded: {0}")]
40 ResourceLimit(String),
41
42 #[error("archive is corrupt: {0}")]
43 Corrupt(String),
44
45 #[error("unsafe entry path `{path}` refused: {reason}")]
46 UnsafePath { path: String, reason: &'static str },
47
48 #[error("fidelity degraded under strict mode: {0} warning(s)")]
49 FidelityDegraded(usize),
50
51 #[error("entry `{0}` not found")]
52 EntryNotFound(String),
53
54 #[error("unsupported: {0}")]
55 Unsupported(String),
56
57 #[error("`{format}` can be {available} but not {requested} by this build")]
58 CapabilityUnavailable {
59 format: FormatId,
60 /// The direction that IS available, e.g. "read".
61 available: &'static str,
62 /// The direction that was asked for and is not, e.g. "written".
63 requested: &'static str,
64 },
65
66 /// Nesting exceeded [`crate::probe::MAX_CHAIN_DEPTH`]. Names the bound so
67 /// the message is actionable rather than merely a refusal.
68 #[error("archive nesting exceeds the {depth}-layer limit")]
69 ChainTooDeep { depth: usize },
70
71 /// The chain resolved to raw bytes with no container to open — e.g.
72 /// `list`/`test` pointed at a plain codec stream, or at a file whose
73 /// magic and extension both name none. Names what the input actually
74 /// resolved to (`chain.describe()`), so "unpack this .gz" is actionable
75 /// rather than a bare refusal.
76 #[error("{chain} is not an archive — it has no entries to list")]
77 NotAnArchive { chain: String },
78}
79
80impl Error {
81 /// "Entry number `index` does not exist; here is what does."
82 ///
83 /// One constructor rather than a `format!` at each raiser, because there
84 /// are two routes to an out-of-range index and they must report it
85 /// IDENTICALLY: a container's own [`crate::ArchiveRead::by_index`] finds
86 /// it immediately (zip, which has a central directory to check against),
87 /// while a caller counting a forward walk finds it only at the end of the
88 /// archive (tar, ar, cpio, and anything on a pipe). A script that could
89 /// tell those apart from the message would be reading an implementation
90 /// detail of the source it was handed.
91 ///
92 /// [`Self::EntryNotFound`] rather than [`Self::Usage`] because the variant
93 /// exists for exactly this — "you named an entry this archive does not
94 /// have" — and carries the same exit code, 2. The payload is shaped to
95 /// read inside that variant's own `entry \`{0}\` not found` wording.
96 ///
97 /// Indices are 0-based throughout, so the valid range is stated
98 /// explicitly rather than left to be derived from the count: `0-5` is
99 /// harder to get wrong than "6 entries" is.
100 pub fn entry_index_out_of_range(index: usize, entries: usize) -> Self {
101 Error::EntryNotFound(match entries {
102 0 => format!("#{index} (this archive has no entries)"),
103 n => format!(
104 "#{index} (valid indices are 0-{} in this {n}-entry archive)",
105 n - 1
106 ),
107 })
108 }
109
110 /// Maps to the process exit code, per the spec's table. Lives here rather
111 /// than in the CLI so it is unit-testable without a process spawn.
112 ///
113 /// # Exit 5 or exit 6 on an absurd header field: the rule the guards actually follow
114 ///
115 /// Five of this workspace's guards refuse a declared header field before
116 /// it can do damage, and they split across two codes. The table is
117 /// exhaustive of the five it names, NOT of every bounding check in the
118 /// tree — see the known exception below it:
119 ///
120 /// | guard | error | exit |
121 /// |---|---|---|
122 /// | `cpio.rs`'s `c_namesize` ceiling | `ResourceLimit` | 6 |
123 /// | `ar.rs`'s `refuse_if_over` (GNU name table, BSD identifier) | `ResourceLimit` | 6 |
124 /// | `ar.rs`'s `/N` index past the declared name table | `Corrupt` | 5 |
125 /// | `zip.rs`'s entry payload that never delivers its declared size | `Corrupt` | 5 |
126 /// | `zip.rs`'s index that reaches no entry over a file that opens with one | `Corrupt` | 5 |
127 ///
128 /// **The rule is a fact about what stuffr DID, not about what the input
129 /// declared:**
130 ///
131 /// * **Exit 6 — stuffr declined to ask the allocator.** The field was
132 /// measured against a structural ceiling and refused before anything was
133 /// sized from it. Nothing was read past the header.
134 /// * **Exit 5 — stuffr read the bytes and they contradict each other.** No
135 /// allocation was ever in question; what is wrong is the file's
136 /// agreement with itself.
137 ///
138 /// **It is NOT "could a bigger machine honour it".** Three of those sites
139 /// used to argue that locally, and their own reproducers falsify it:
140 /// cpio's is a **274-byte file declaring a 2.6 GiB name**, which no
141 /// machine anywhere can deliver, and it answers 6; ar's two guards split
142 /// across 5 and 6 on the **same 68-byte file**. Nothing about the input's
143 /// plausibility separates them, so a caller cannot predict the code from
144 /// the file — only from which guard stopped first. Each site links here
145 /// rather than re-deriving a local justification, because four local
146 /// derivations produced four different rules for one contract.
147 ///
148 /// **One live pair does NOT follow this rule, and saying so here is the
149 /// point of writing the rule down.** `cpio.rs`'s and `zip.rs`'s
150 /// `read_symlink_target` apply the SAME predicate against the SAME
151 /// constant — `declared > MAX_SYMLINK_TARGET_LEN`, 65,536 — and answer
152 /// differently: cpio `ResourceLimit`/6, zip `Corrupt`/5. By the rule above
153 /// both are 6: the field is measured against a structural ceiling and
154 /// refused before anything is sized from it. zip's own message even reads
155 /// "refusing to allocate", which is this rule's definition of exit 6.
156 ///
157 /// It is left inconsistent rather than fixed silently because changing it
158 /// is a user-visible exit-code change and **no test covers either site's
159 /// code today** — an untested behaviour change is not something to slip in
160 /// beside a documentation fix. Whoever aligns them should write that test
161 /// first. Two earlier attempts to state this contract were each falsified
162 /// by a live site; this is the third, and it names its own exception
163 /// rather than waiting to be caught by a fourth reader.
164 ///
165 /// What a caller can still read off the code: exit 6 says a LIMIT decided
166 /// the outcome, exit 5 says the FILE did and no budget changes that. The
167 /// container ceilings above are fixed rather than tunable (`OpenOpts`
168 /// carries no memory field, unlike `DecodeOpts::memory_limit`), so exit 6
169 /// there is not an invitation to retry with a bigger budget — it is still
170 /// the honest code, because a ceiling and not the bytes is what decided.
171 pub fn exit_code(&self) -> i32 {
172 match self {
173 Error::Usage(_) => 2,
174 Error::NotAnArchive { .. } => 2,
175 // The caller named an entry the archive does not contain — the
176 // same shape as naming a format this build does not have, and
177 // the same answer `NotAnArchive` above gives for "you pointed
178 // this verb at the wrong thing". Explicit, not the wildcard
179 // below: `stuffr unpack a.tar -C out nosuch.txt` extracting
180 // nothing must be distinguishable from an i/o failure.
181 Error::EntryNotFound(_) => 2,
182 // The same shape again, and the fourth wrong code this
183 // wildcard has produced. `stuffr list plain.txt.gz` (not an
184 // archive) exited 2; `stuffr list plain.txt` (no format at all)
185 // exited 1, as if stuffr had failed rather than the caller
186 // having pointed a verb at the wrong file. `AmbiguousFormat`'s
187 // own message literally says "Pass `--format` to choose" —
188 // actionable advice with an exit code that says "internal
189 // failure". Both are usage, both are 2.
190 Error::UnknownFormat { .. } | Error::AmbiguousFormat { .. } => 2,
191 Error::FormatNotEnabled(_) => 3,
192 Error::CapabilityUnavailable { .. } => 3,
193 // Exit 3 is "this build cannot do that", and every raiser of
194 // `Unsupported` in this workspace is exactly that — a capability
195 // or expressiveness limit, never an internal failure:
196 //
197 // * tar's, ar's and cpio's `by_index` refusal on a seekable
198 // source ("this container carries no entry index, by design").
199 // * cpio's 4 GiB per-entry ceiling, which is its `u32` size
200 // field and not a bug.
201 // * zip's unsupported compression method — an encrypted entry, a
202 // pre-deflate legacy method, or the pure tier's zstd gap, whose
203 // message names `--features c-backed`.
204 // * a `Chain` variant added upstream that this build does not
205 // know how to open, decode or describe.
206 // * `create_symlink` on a non-unix host.
207 //
208 // Explicit, not the wildcard below, and it did fall through it
209 // until Phase 2's Task 11: every one of those was reported as a
210 // generic failure, indistinguishable from an internal error, so a
211 // user told "rebuild with `--features c-backed`" got the same
212 // exit code as a panic-adjacent bug. That is the THIRD wrong code
213 // this wildcard has produced in this phase, after
214 // `ChainTooDeep` and `EntryNotFound`.
215 Error::Unsupported(_) => 3,
216 // "You asked for random access and this source cannot do it" is
217 // the same answer as the two arms above — a capability limit,
218 // actionable, and never an internal failure. It is the *required*
219 // reply to `by_index` on a forward-only source (container
220 // conformance property 6, and SEVEN raisers: `tar.rs`, `ar.rs`,
221 // `cpio.rs` and `zip.rs` each raise it once for exactly this, and
222 // `ladder.rs` (twice) and `probe.rs` (once) raise it from below
223 // the container layer, when the ladder itself cannot supply
224 // seek), so a valid archive on a pipe produced it on every single
225 // read — and the wildcard below
226 // reported that by-design refusal as exit 1, "stuffr failed".
227 // `entries.rs:453` already treats it as interchangeable with
228 // `Unsupported` when deciding whether to fall back to a forward
229 // walk; the exit code now agrees with that. The FIFTH wrong code
230 // this wildcard has produced, after `ChainTooDeep`,
231 // `EntryNotFound`, `Unsupported` and the
232 // `UnknownFormat`/`AmbiguousFormat` pair. Found by Phase 3a's
233 // honesty oracle (`honesty.rs`), before the fuzzer had run once.
234 Error::NotSeekable { .. } => 3,
235 Error::FidelityDegraded(_) => 4,
236 Error::Corrupt(_) => 5,
237 // A nesting bound is a bound on WORK, the same family as the
238 // spill and ratio limits — never exit 5, so a refused nesting
239 // bomb stays distinguishable from a corrupt file. Explicit, not
240 // the wildcard below: falling through it would make a refused
241 // nesting bomb indistinguishable from an internal error.
242 Error::SpillLimitExceeded { .. }
243 | Error::ResourceLimit(_)
244 | Error::ChainTooDeep { .. } => 6,
245 Error::UnsafePath { .. } => 7,
246 // A wrong exit code landing here is not always this match's own
247 // fault: it can equally mean the wrong `Error` variant was
248 // constructed upstream, before this function ever ran. See
249 // `Error::from_decode_io` — Task 4b's fix lives there, not here.
250 _ => 1,
251 }
252 }
253
254 /// Classifies an io error that arose while decoding.
255 ///
256 /// Malformed input becomes [`Error::Corrupt`] (exit 5); everything else
257 /// stays [`Error::Io`] (exit 1). `InvalidData` is the convention every
258 /// codec's decoder is expected to normalise onto for malformed input; a
259 /// codec whose backend disagrees adapts at its own boundary rather than
260 /// this classification changing. gzip is one such codec: flate2's
261 /// pure-Rust backend actually raises `InvalidInput` and `UnexpectedEof`,
262 /// never `InvalidData`, so gzip's decoder wraps it in an adapter that
263 /// folds both onto `InvalidData` before this function ever sees them.
264 ///
265 /// `OutOfMemory` becomes [`Error::ResourceLimit`] (exit 6), not
266 /// `Corrupt`: a codec whose dictionary allocation would exceed
267 /// `DecodeOpts::memory_limit` is refusing to allocate, not reporting a
268 /// damaged file, and exit 5 would actively mislead the caller about
269 /// which of those happened. A codec that raises this kind is expected to
270 /// have NOT routed it through the same `InvalidData`-folding adapter it
271 /// uses for malformed input, the same way `InvalidData` above is a
272 /// convention the codec opts into deliberately.
273 ///
274 /// One rule here rather than a helper each codec calls: putting the
275 /// decision in nine places means the natural code — a bare `?` on an
276 /// `io::Error` — silently bypasses it, and a convention whose failure mode
277 /// is invisible is not a convention. Conformance property 9 enforces the
278 /// codec half, that malformed input surfaces as `InvalidData` at all.
279 ///
280 /// Lives in `stuffr-core` rather than in `ops` because Phase 2's containers
281 /// need the identical classification on their own decode paths.
282 ///
283 /// **Task 4b (Phase 3a's fuzzing harness) added a fourth call site:**
284 /// `probe.rs`'s `resolve_chain_deep_with` re-probes a DECODED stream
285 /// whenever a codec has no container above it and the path named
286 /// nothing further (stdin, or an extension that ran out) — the loop
287 /// documented on `resolve_chain_deep`'s own doc comment. That re-probe
288 /// used to read through a bare `?`, so a truncated zlib stream
289 /// (`\x78\xda\x0a`, three bytes) or an empty file named `.lz` reported
290 /// `Error::Io` — exit 1, "stuffr failed" — for input that is simply
291 /// corrupt, while `stuffr cat` on the IDENTICAL bytes (via
292 /// `ops::decompress`, whose decode read was already wired to this
293 /// function) correctly exited 5. This is deliberately NOT filed as a
294 /// sixth instance of `exit_code`'s `_ => 1` wildcard misfiling a KNOWN
295 /// variant (see that match's own comment for the five it has produced):
296 /// `Error::Io` reaching that wildcard is correct, by construction —
297 /// it has no dedicated arm because the wildcard IS its arm. The bug
298 /// here was upstream of `exit_code` entirely: the wrong variant got
299 /// constructed in the first place, at a read that passed through a
300 /// decoder without this classifier's protection.
301 pub fn from_decode_io(e: std::io::Error) -> Self {
302 match e.kind() {
303 std::io::ErrorKind::InvalidData => Error::Corrupt(e.to_string()),
304 std::io::ErrorKind::OutOfMemory => Error::ResourceLimit(e.to_string()),
305 _ => Error::Io(e),
306 }
307 }
308}
309
310/// The single conversion every `?` on a read path passes through.
311///
312/// Hand-written rather than derived with thiserror's `#[from]`, because the
313/// derived one could not see the one fact that decides the answer: whether the
314/// read that failed sat BELOW a codec decoder (the archive's fault) or above
315/// one (stuffr's environment failing). [`crate::source::mark_decode_side`]
316/// records that at the read, and this is where it is spent.
317///
318/// Before this existed, the distinction was carried by whether the author of a
319/// given `?` remembered to write `map_err(Error::from_decode_io)` instead — so
320/// the natural code was wrong by default, and four sites had it wrong at once
321/// (`spill.rs`'s ladder rung 3, `lha.rs`'s `Lha::open`, `cpio.rs`'s per-header
322/// read-ahead, `zip.rs`'s `read_fixed`/`skip`). A 21-byte zstd stream whose
323/// decoded bytes opened with ARJ's `60 EA` magic reached the first of them and
324/// reported `i/o error` — exit 1, "stuffr failed" — where `stuffr cat` on the
325/// identical bytes correctly said the archive was corrupt.
326///
327/// [`Error::from_decode_io`] folds only `InvalidData` and `OutOfMemory`, so
328/// this cannot mistake an environment failure for corruption even when one
329/// propagates up through a decoder: it keeps its own kind, so it stays
330/// [`Error::Io`].
331impl From<std::io::Error> for Error {
332 fn from(e: std::io::Error) -> Self {
333 if crate::source::is_decode_side(&e) {
334 Error::from_decode_io(e)
335 } else {
336 Error::Io(e)
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::format::FormatId;
345
346 #[test]
347 fn exit_codes_match_the_spec_table() {
348 assert_eq!(Error::Usage("bad flag".into()).exit_code(), 2);
349 assert_eq!(
350 Error::FormatNotEnabled(FormatId::new("zstd")).exit_code(),
351 3
352 );
353 assert_eq!(
354 Error::Unsupported("no zstd in this build".into()).exit_code(),
355 3
356 );
357 assert_eq!(
358 Error::NotSeekable {
359 format: FormatId::new("squashfs")
360 }
361 .exit_code(),
362 3
363 );
364 assert_eq!(Error::FidelityDegraded(2).exit_code(), 4);
365 assert_eq!(Error::Corrupt("bad crc".into()).exit_code(), 5);
366 assert_eq!(Error::EntryNotFound("nosuch.txt".into()).exit_code(), 2);
367 assert_eq!(Error::SpillLimitExceeded { limit: 42 }.exit_code(), 6);
368 assert_eq!(Error::ResourceLimit("oom".into()).exit_code(), 6);
369 assert_eq!(
370 Error::UnsafePath {
371 path: "../x".into(),
372 reason: "path traversal above the destination",
373 }
374 .exit_code(),
375 7
376 );
377 }
378
379 /// The refusal has to say what IS valid, not merely that the asked-for
380 /// index is not — and the zero-entry case must not underflow its way to
381 /// "valid indices are 0-18446744073709551615".
382 #[test]
383 fn an_out_of_range_index_names_the_valid_range_and_survives_an_empty_archive() {
384 let e = Error::entry_index_out_of_range(9, 6);
385 assert_eq!(e.exit_code(), 2);
386 let text = e.to_string();
387 assert!(text.contains("#9"), "{text}");
388 assert!(
389 text.contains("0-5"),
390 "the valid range must be stated, not left to be derived: {text}"
391 );
392
393 let empty = Error::entry_index_out_of_range(0, 0).to_string();
394 assert!(
395 empty.contains("no entries") && !empty.contains("0-1844"),
396 "an empty archive must not underflow into a nonsense range: {empty}"
397 );
398 }
399
400 #[test]
401 fn from_decode_io_keys_resource_limit_off_out_of_memory_not_corrupt() {
402 // A codec refusing to allocate a declared dictionary is not reporting
403 // a damaged file — exit 6, never exit 5. See `DecodeOpts::memory_limit`.
404 let io_err = std::io::Error::new(std::io::ErrorKind::OutOfMemory, "too big");
405 let classified = Error::from_decode_io(io_err);
406 assert!(matches!(classified, Error::ResourceLimit(_)));
407 assert_eq!(classified.exit_code(), 6);
408 }
409
410 #[test]
411 fn not_seekable_names_the_format_so_callers_can_act() {
412 let e = Error::NotSeekable {
413 format: FormatId::new("squashfs"),
414 };
415 assert!(e.to_string().contains("squashfs"));
416 // Exit 3, not 1. A source that cannot seek is a capability limit in
417 // exactly the sense `Unsupported` and `CapabilityUnavailable` are,
418 // and it is the mandated answer to `by_index` on a forward-only
419 // source — so exit 1 meant every valid archive read from a pipe
420 // reported "stuffr failed". See `exit_code`'s own arm for why this
421 // is the fifth variant the wildcard has misfiled.
422 assert_eq!(e.exit_code(), 3);
423 assert_ne!(
424 e.exit_code(),
425 1,
426 "a by-design refusal must stay distinguishable from an internal failure"
427 );
428 }
429
430 #[test]
431 fn unsupported_is_a_capability_limit_not_a_generic_failure() {
432 // The three exit-3 variants say the same thing at different
433 // granularities — "this build cannot do that" — so they must not
434 // disagree on the code. `Unsupported` fell through the wildcard to 1
435 // until Phase 2's Task 11, which made a container's honest
436 // "rebuild with `--features c-backed`" indistinguishable from an
437 // internal error. See `exit_code`'s own comment for the full list of
438 // raisers and why every one of them belongs here.
439 let unsupported = Error::Unsupported(
440 "zip entry uses compression method 93 (zstd); rebuild with `--features c-backed`"
441 .into(),
442 );
443 assert_eq!(unsupported.exit_code(), 3);
444 assert_eq!(
445 Error::FormatNotEnabled(FormatId::new("zstd")).exit_code(),
446 3
447 );
448 assert_eq!(
449 Error::CapabilityUnavailable {
450 format: FormatId::new("lzma"),
451 available: "read as a zip entry",
452 requested: "written as one",
453 }
454 .exit_code(),
455 3
456 );
457 assert_ne!(
458 unsupported.exit_code(),
459 1,
460 "exit 1 is an internal failure; a capability limit is actionable and must be \
461 distinguishable from one"
462 );
463 }
464
465 #[test]
466 fn chain_too_deep_is_a_resource_limit_not_a_generic_failure() {
467 // A refused nesting bomb must be distinguishable from an internal
468 // error — falling through the wildcard to 1 would erase that.
469 assert_eq!(Error::ChainTooDeep { depth: 4 }.exit_code(), 6);
470 }
471
472 #[test]
473 fn not_an_archive_names_the_chain_and_is_a_usage_error() {
474 let e = Error::NotAnArchive {
475 chain: "gzip".into(),
476 };
477 assert!(
478 e.to_string().contains("gzip"),
479 "must name what the input resolved to: {e}"
480 );
481 assert_eq!(e.exit_code(), 2);
482 }
483
484 #[test]
485 fn unknown_format_reports_what_was_actually_seen() {
486 // The error must name the bytes, otherwise debugging a misdetection
487 // means adding print statements to the library.
488 let e = Error::UnknownFormat {
489 seen: "1f 8b 08".into(),
490 };
491 assert!(e.to_string().contains("1f 8b 08"));
492 }
493
494 /// "You pointed the verb at the wrong file" is one answer, not three.
495 ///
496 /// `NotAnArchive` already exited 2. `UnknownFormat` and
497 /// `AmbiguousFormat` fell through the wildcard to 1 — an internal
498 /// failure — so `stuffr list plain.txt.gz` exited 2 and `stuffr list
499 /// plain.txt` exited 1 for the same class of mistake. Explicit arms,
500 /// not the wildcard: that wildcard has now produced a wrong code five
501 /// times, `NotSeekable` being the fifth (see its own arm in
502 /// `exit_code`).
503 #[test]
504 fn pointing_a_verb_at_the_wrong_file_is_always_exit_two() {
505 assert_eq!(
506 Error::UnknownFormat {
507 seen: "de ad be ef".into()
508 }
509 .exit_code(),
510 2,
511 "an undetectable format is the caller's mistake, not an internal failure"
512 );
513 let ambiguous = Error::AmbiguousFormat {
514 candidates: "lzma, lzip".into(),
515 };
516 assert_eq!(
517 ambiguous.exit_code(),
518 2,
519 "a message that says `Pass --format to choose` is actionable, so exit 2"
520 );
521 assert!(
522 ambiguous.to_string().contains("--format"),
523 "and it must keep saying so: {ambiguous}"
524 );
525 // The neighbour they must agree with.
526 assert_eq!(
527 Error::NotAnArchive {
528 chain: "gzip".into()
529 }
530 .exit_code(),
531 2
532 );
533 // And the codes they must NOT collide with: a build-capability
534 // limit is still 3, a damaged file still 5.
535 assert_eq!(Error::Unsupported("no".into()).exit_code(), 3);
536 assert_eq!(Error::Corrupt("bad".into()).exit_code(), 5);
537 }
538}