Let a declared unit carry a file-descriptor limit (#78) - #80
Conversation
A package declares its systemd unit through a closed record that carried no resource-limit field of any kind, so a service whose store outgrows the 1024 soft limit systemd hands a unit could not say so. The operator raised the limit on the host instead, which left the package short of being the whole description of what the service needs — the property the declared spec exists to provide. `UnitTemplate` gains one typed optional field rather than a map of arbitrary `Limit*` keys: a single recorded need does not justify an escape hatch into systemd's directive space, and a map is the free-text field this record exists to refuse. Absence is how a package declines to set a limit, which is also why `infinity` has no spelling here — leaving a service unbounded stays the host's decision and never a package's. The producer and the ceiling move because the record denies unknown fields: an older build must refuse a manifest carrying the new key, and `format_version` is what makes that refusal legible rather than an opaque decode error. The floor stays where it is, departing from the two earlier schema changes, because this field is optional and its absence is today's behaviour — every payload already published keeps working, and no product has to republish a release asset that is otherwise correct. Closes #78
The manifest window now spans more than one version, so a trust set's injected floor can sit inside it. That case was unreachable while the three constants were equal, and the only test of the injected floor still puts it above the ceiling, where the accepted set is empty and nothing distinguishes a floor from a pin. Pin the distinction instead: a package written at the build's floor is refused under a floor raised to the producer's version, while the producer's own version still verifies under that same floor. Part of #78
The round-trip fixture set no limit, so the field crossed the manifest only as an absence and the enumeration that names every unit field had nothing to assert about it. The read-path validator table likewise never reached the zero rule, leaving it proven only where it is called directly rather than where a consumer that links no producer meets it. Part of #78
A dependent pins this crate at a git rev and reads the changelog to know what moved since its own pin. The manifest format version it will now be stamped at is exactly that kind of change, so it belongs beside the field that caused it rather than only in the constants. Part of #78
2cf1a09 to
bf87632
Compare
|
[Reviewer Round 1] Review finding
Otherwise, the change follows the issue well: it preserves the closed record, renders at the required position, keeps old manifests readable, and includes focused read-path and byte-order tests. PR #80’s |
|
[Review Verdict Round 1: NOT_APPROVED] |
`u64::MAX` is the numeric value of Linux's `RLIM_INFINITY`, and systemd's rlimit parser refuses any value at or above it. A package declaring it therefore passed validation, rendered `LimitNOFILE=18446744073709551615`, and produced a unit that fails to load — the failure landing on the host at install time rather than on the producer. It is also the `infinity` sentinel written out in the one spelling the field cannot forbid structurally, so refusing it is what keeps absence the only way a package leaves a service unbounded. Part of #78
|
[Author Round 1] Pushed as DispositionsP1 — Reject The finding is correct on both counts I checked it against. On Linux What changed:
Nothing else in the review round to push back on — the reviewer raised one item and it stood up. CI checks all pass locally: |
|
[Reviewer Round 2] [Reviewer Round 2: APPROVED] No remaining findings. Round 1 P1 is resolved: |
|
[Review Verdict Round 2: APPROVED] |
Suggested squash commitTitle Body |
What this does
UnitTemplategainslimit_nofile: Option<u64>, the one resource-limit field the record has a recorded need for. A package that sets it rendersLimitNOFILE=<value>in[Service]; a package that omits it renders exactly the bytes it renders today, and the unit inherits the host's soft limit as every unit does now.The shapes deliberately not taken: not a map of arbitrary
Limit*keys, not a raw[Service]body, and no spelling forinfinity,-1, or any other sentinel for "no limit" — absence is that case, so leaving a service unbounded stays the host's decision.u64::MAXis refused for that last reason: it is the numeric value of Linux'sRLIM_INFINITY, which is both the sentinel written out and a value systemd's own rlimit parser rejects, so a unit carrying it would render fine and then fail to load.Closes #78
Changes
src/module_spec.rs— the field, with#[serde(default, skip_serializing_if = "Option::is_none")]like its optional neighbours, documented in their form.#[serde(deny_unknown_fields)]is untouched. The canonical-layout rustdoc — the single owner of the directive order — now namesLimitNOFILE=in its[Service]position.validate_unitrejectsSome(0)with a newModuleSpecError::ZeroLimitNofileandSome(u64::MAX)with a newModuleSpecError::InfiniteLimitNofile;Noneand every value systemd can load pass.src/render.rs—LimitNOFILE=is emitted immediately afterRestartSec=and before the first sandbox boolean, and not at all when the field isNone.src/manifest.rs—MANIFEST_FORMAT_VERSIONandMAX_MANIFEST_FORMAT_VERSIONeach move from 4 to 5.MIN_MANIFEST_FORMAT_VERSIONstays at 3: the field is optional and its absence is today's behaviour, so every payload already published at 3 stays readable and installable and no product has to republish a release asset that is otherwise correct. The three constants' doc comments already state that rule in general terms, so this change repoints the values and leaves the prose alone.src/verify.rs— the doc comment oncheck_format_versionsaid the injected trust-set floor was observable only above the implemented range, which stopped being true onceMINandMAXdiverged. Rewritten for the range as it now stands, and covered by a test that puts an injected floor inside the range.assets/test-fixtures/unsigned-container/manifest.json— one byte,"format_version":4→5. The fixture pins the unsigned writer's absolute manifest bytes, and the producer's stamped version is one of the derived values it exists to catch; a deliberate bump is the case where it is updated rather than investigated.CHANGELOG.md— one### Addedentry. A dependent pins this crate at a git rev and reads the changelog to know what moved since its own pin, and the manifest format version it will now be stamped at is exactly that kind of change.Tests
New:
the_production_renderer_reproduces_a_limit_setting_anchor_byte_for_byte— the review anchor with the field set, goldened whole, so the entire diff against the existing anchor is one line in one place.the_limit_sits_between_restart_sec_and_the_first_sandbox_boolean— the neighbours asserted as adjacent lines, so the directive drifting to the end of the section still fails.a_unit_setting_no_limit_renders_no_limit_directive_at_all— matches onLimit, notLimitNOFILE: an absent field must put no resource directive in the unit at all.a_present_limit_nofile_must_be_a_limit_systemd_can_load—None,Some(1),Some(8000)andSome(u64::MAX - 1)validate;Some(0)andSome(u64::MAX)each fail with their own variant.limit_nofile_round_trips_only_when_it_is_present— the wire anchor plus the key, round-tripped to the same bytes; an explicitnullre-serializes back to no key; a siblinglimit_nproc, an"infinity"string and a-1are each refused.the_read_path_refuses_a_unit_declaring_an_unloadable_limit— both limit rules reached through the manifest validator a consumer that links no producer actually meets, not only throughvalidatecalled directly. TheRuleCheckalias the sibling read-path test declared inside its own body moves to the test module, since there are now two users.a_manifest_at_the_floor_version_still_decodes_validates_and_renders_without_a_limit— a manifest atMIN_MANIFEST_FORMAT_VERSIONcarrying a spec, taken all the way to rendered bytes rather than stopping at the decode, since the promise the floor makes is about the whole read path.an_injected_floor_inside_the_implemented_range_refuses_only_below_itself— a package at the build's floor is refused under a trust set's floor raised to the producer's version, while the producer's own version still verifies under it.Changed:
the_producer_writes_the_ceiling_and_the_floor_is_no_higher→the_accepted_range_is_open_below_the_producer_and_closed_at_it, tighteningMIN <= MANIFESTtoMIN < MANIFESTand pinning both it andMAX == MANIFESTas compile-time assertions, so a later bump that closed the window fails the build rather than one test run.the_range_accepts_the_unmoved_floor_as_well_as_the_current_version→every_version_in_the_window_is_accepted_including_the_floor, which loops the wholeMIN..=MAXrange instead of just its two ends, so the versions between them are covered too.the_canonical_directive_order_holds_for_a_record_using_every_optional_fieldnow setslimit_nofiletoo, since it claims to use every optional field.the_wire_form_deserializes_into_the_expected_record_and_back_to_the_same_bytesassertslimit_nofileisNoneon an anchor that sets none, which is also the assertion that an unchanged package's bytes did not move.Verified the position is actually pinned by moving the emission below the sandbox booleans:
the_limit_sits_between_restart_sec_and_the_first_sandbox_booleanandthe_canonical_directive_order_holds_for_a_record_using_every_optional_fieldboth go red.the_production_renderer_reproduces_a_limit_setting_anchor_byte_for_bytedoes not, and cannot: it is built on the review anchor, which sets no sandbox boolean, soLimitNOFILE=is the last line of[Service]either way. That golden pins the rendered bytes of a limit-setting unit; the adjacency between the directive and its neighbours is pinned by the other two.Test plan
limit_nofile: Some(n)rendersLimitNOFILE=nin[Service]on the line afterRestartSec=and before the first sandbox booleanlimit_nofile: Nonerenders noLimitline anywhere in the unitvalidaterejectsSome(0)with the newZeroLimitNofilevariant and acceptsNoneandSome(1)validaterejectsSome(u64::MAX), Linux'sRLIM_INFINITY, with the newInfiniteLimitNofilevariant, so absence stays the only unbounded caselimit_nofileserializes only when present and deserializes back to the same value, and an unknown sibling key is still rejected bydeny_unknown_fieldsMIN_MANIFEST_FORMAT_VERSIONstill decodes, validates and renders; one aboveMAX_MANIFEST_FORMAT_VERSIONis refused withUnsupportedManifestFormatMANIFEST_FORMAT_VERSION == MAX_MANIFEST_FORMAT_VERSIONandMIN_MANIFEST_FORMAT_VERSION < MANIFEST_FORMAT_VERSIONare pinned, so a later change cannot close the window by accidentcargo fmt --checkwithgroup_imports=StdExternalCrate, both clippy runs at-D warnings,cargo docat-D warnings, andcargo testwith and withouttest-support