ObjectFS v0.11.0
POSIX completeness and write-path safety: the operations a user reaches for first — rm, rmdir,
mv, chmod — do the thing rather than returning an error or, worse, succeeding without acting. All
28 issues on the milestone closed before this tag was cut.
Two themes run through it. The first is that a configuration key should select something: nine
blocks that a loader had never read now reach the code they name, and the pricing region — read at
exactly one line, and only to label a summary — now picks the prices, so a mount in sa-east-1 no
longer reports us-east-1's rates under its own region's name. The second is that a fix is paired
with the mechanism that fails if it recurs, because most of what this release corrects was invisible
to the compiler, to vet, and to lint: a config key nothing reads, a cost figure nothing checks, a
gate that cannot fail.
This is the first release cut from the merge commit that closes its milestone, which is the rule
v0.10.3 established after two tags shipped without the work they were named for.
Added
-
rmandrmdirwork.UnlinkandRmdirdelete the object rather than returning EROFS
(#163). The stub they replace was itself a fix — go-fuse defaults an unimplemented
NodeUnlinkerto success, so before itrmexited 0, the kernel dropped the inode, and the
object stayed in the bucket billing with no path that reached it. Three details are load-bearing
and are pinned by tests:- Deleting a file discards whatever the write path still holds for it.
echo x > f; rm fis
ordinary and the kernel does not guarantee a flush before the unlink, so a surviving dirty range
would be PUT back by the next flush or by the unmount — the file returning from the dead at its
written size, with no error anywhere. rmdirrefuses a non-empty directory with ENOTEMPTY. S3 has no directories, so removing a
prefix's marker object while objects remain under it would succeed at the storage layer and leave
every one of them present, billing, and unreachable through the filesystem.- A missing file is ENOENT, not success. The backend's
DeleteObjectno-ops a key that is not
there — S3's contract — so absence is checked explicitly, and it is checked against both the
bucket and the write path:Createrecords attributes without a PUT, so a just-created file is
real and visible tostatwith no object behind it yet.
- Deleting a file discards whatever the write path still holds for it.
-
mvworks, and the README says exactly how far short of POSIX it falls.Renamecopies
server-side and then deletes, per object; renaming a directory moves everything under its prefix
(#164). Before this, go-fuse's default for an absentNodeRenameranswered everymvwith
ENOTSUP. Six properties are load-bearing, each pinned by a test that was verified by mutation —
the implementation was broken in that specific way and the test watched to fail:- Each source object is deleted only after its own copy has succeeded. An interruption
therefore leaves the data at the old name, the new name, or both — never at neither. Duplicated
data is an operator's cleanup problem; missing data is not recoverable, so the ordering is fixed
in that direction deliberately, and a partial directory move is resumable by re-running the same
mv. - The copy is server-side. Reading and rewriting through the process would make renaming a
10 GiB file cost 20 GiB of transfer, and renaming a directory that times the object count.
Objects above S3's 5 GiB single-partCopyObjectlimit route throughUploadPartCopy. - The write path is flushed before the copy runs. A copy acts on objects, so a file whose only
content is dirty ranges in memory is invisible to it:echo hi > a; mv a bwould have copied a
key that did not exist yet, deleted the source, and then flushed the pending ranges back to the
old name — the file landing at neither name the user asked for. - The moved node is repointed, recursively for a directory.
go-fuse'sMvChildre-parents the
same inode, so theFileNodesurvives a rename holding the path it was constructed with. A
stored path is stale the moment a rename succeeds, and the consequence is silent in a way nothing
would catch: aftermv a b, a write tobflushed toaand recreated the source the rename
had just deleted. A stale key is a valid key, so every S3 call succeeds. - Prefix matching is on a path boundary.
mv dir dir-newmust not movedir2/file. That is not
a hypothetical spelling mistake — it is the same defect the cache'skeyMatcheshad, found in the
same audit. renameat2'sRENAME_EXCHANGEandRENAME_NOREPLACEare refused withEINVAL, not
approximated. Both are atomicity promises copy-then-delete cannot keep,EINVALis what the
kernel and libc expect for an unsupported flag, andmvand Git fall back correctly on it. A
foreignnewParentisEXDEV, which is what go-fuse's ownLoopbackNodeanswers.
There is no atomic alternative to reach for. S3's
RenameObject, added in 2026, is
directory-bucket (S3 Express) only, and object annotations — which ObjectFS needs for POSIX
attributes — are unsupported on directory buckets, so the two features are mutually exclusive. The
README now carries a Rename is not atomic section stating what a concurrent reader can observe,
what an interruption leaves behind, and which tools that breaks: anything relying on the
write-temp-then-rename idiom for atomic replacement is not safe here between concurrent writers. - Each source object is deleted only after its own copy has succeeded. An interruption
-
types.Backend.CopyObject, a server-side copy that preserves content encoding, content type,
storage class, and user metadata. Each of the four is a requirement rather than a nicety, and the
interface documentation says which failure each one prevents: the read path dispatches decoding on
the storedContent-Encodingand fails closed on one it cannot handle, so dropping it would leave a
compressed object permanently unreadable with its bytes intact; the storage-class default is
STANDARD, so dropping it would silently promote the object out of the tier being paid for; and
POSIX mode, ownership, and mtime live in user metadata and nowhere else, so dropping it would reset
a file's permissions, which is not a thingrenamedoes. Encryption is applied from configuration
rather than copied from the source, so a key rotation reaches renamed objects. -
internal/testaws: aDirectoryMarkerDeletecapability probe, andRequireDirectoryMarkerDelete
to skip on its absence. Deleting adir/marker whiledir/childstill exists panics the substrate
emulator, because its object store is a filesystem abstraction wheredir/is the directory
holding the child, and removing it orphans the child (filed as
scttfrdmn/substrate#534, reproduced against
afero alone with no substrate involved). In S3 a key is an opaque string anddir/is an ordinary
object, so this is the emulator's property and not ObjectFS's — which is the reason for a runtime
probe rather than an assertion either way. The alternative was to assert the emulator's behavior as
expected, which would encode a dependency's bug as this project's contract. -
internal/storage/s3/copy_live_test.go(-tags=integration) covers the multipart copy path against
real AWS, at real part sizes. It has no hermetic equivalent: substrate dispatches on
x-amz-copy-sourcebefore checkinguploadId, so it answers anUploadPartCopyas a whole-object
copy with a 200 (substrate#532). That leaves the branch where a mistake is most expensive resting on
nothing but reading — abandoned multipart parts are billed and invisible toListObjects. Two
shapes, both deliberate: a legal three-part copy with a short final part, which is where
inclusive/exclusive range mistakes surface, and an illegal one that S3 rejects at
CompleteMultipartUpload, which is how the abort path gets a failure arriving after every part has
already uploaded. -
A
fuse:section in the configuration file, and it is the first one any loader has read
(#180).direct_io,keep_cache, andsync_read. Nine fields oninternal/fuse.MountOptionsand
internal/fuse.Configcarried yaml tags for a whole release and were decoded by nothing, because
config.Configurationhad nofusekey at all — so afuse:block in a config file was silently
discarded, and the two flags that reach the kernel per-open were settable in Go and returned as the
literal0from everyOpen. All three new fields default to false, false is the kernel's own
behavior for each, andNewDefaulttherefore names nofusesection: the zero value is the
default, and a second place for it to live is how the last set drifted.Each of the four seams between the YAML key and the value the kernel receives is now asserted by a
test verified by mutation — the mapping was deleted or reverted to v0.10.0's code and the intended
test watched to fail. That is the point of the change rather than a detail of it: every one of those
nine fields was correct at the layer that declared it, and died at a boundary no test crossed. The
adapter mapping in particular passed its package's whole suite with the three assignment lines
removed, which is why it was extracted into a method that can be called without a mount.What the flags do to the kernel — whether a second
read(2)at the same offset reaches the
filesystem, whether cached pages surviveopen(2)— cannot be observed without/dev/fuse, so it
lives behind afuse_mountbuild tag with amake test-fuse-mounttarget. CI compiles the tag it
cannot run, because a build tag nothing compiles is how four others in this repo came to carry code
that does not build (#240). Those tests fail rather than skip when the device is absent: a test
that skips itself reports success.Two of the four flags #180 nominated are not plumbable, and both reasons are recorded at the
field that would have carried each rather than being dropped in silence. Splice: go-fuse only splices
aReadResultbacked by a file descriptor, and this filesystem's reads come from S3 or from memory
and returnfuse.ReadResultDataat every return site, soDisableSplicewould disable a path never
taken — a config key whose effect is provably nothing. The writeback cache: it maps to
ExplicitDataCacheControl, which makes the filesystem responsible for invalidating the kernel's data
cache, and there is not oneNotifyContent,NotifyEntry, orNotifyInvalInodecall in the
repository — enabling it would convert bounded staleness into permanent staleness. -
Subcommands:
objectfs mount,objectfs unmount,objectfs version,objectfs help
(#134).unmountis spelled both ways, sinceumountis what a decade of muscle memory types.objectfs unmount /mnt/s3is the one that did not exist before and had to. Unmounting was
previously "signal the mount process", which a systemd unit'sExecStopcannot do once that
process is already gone, so the shipped unit calledfusermount3 -udirectly — a program that is
absent on a minimal image and spelledfusermounton libfuse 2, and whose failure in either case
reaches systemd as a bare exit status. The subcommand tries the libfuse 3 helper, the libfuse 2
helper,umount, and finallyumount(2), and when none works it reports which ran, which were not
installed, and thelsof +Dinvocation that names whatever is holding the mount open. None of the
candidates unmounts lazily or forcibly, and a test asserts that no candidate ever passes-z,
-l, or-f: those detach the name while the filesystem keeps serving open files, so they report
a finished unmount with writes in flight — and adding one would make every other unmount test pass,
which is why the prohibition is a test rather than a comment.The form without a subcommand still works and is not deprecated. It is what every invocation
written before this release looks like, including the ones in scripts nobody will revisit. A first
argument carrying a URI scheme or a leading dash routes tomount; a bare word that is not a
command is a usage error naming itself, soobjectfs moutn s3://b /mntdoes not become an attempt
to mount a bucket calledmoutn.Flags now come before positionals, because Go's
flagpackage stops parsing at the first non-flag
argument —objectfs mount s3://b /mnt --foregroundleft--foregroundas a third positional and
silently did not apply it. Each subcommand gets its ownFlagSetfor the same reason:
flag.CommandLinecannot parse a flag that appears after a positional at all.New:
--mount-point, so a mount point can come from a flag instead of a positional, and
--foreground, which names what already happens — ObjectFS does not fork, and the flag exists
because init systems and scripts pass it and refusing it would break invocations that are correct
about the behaviour. Exit codes are now defined:0succeeded,1the command was right and the
operation failed,2the command line was wrong and nothing was attempted.main()is three lines aroundrun(args, stdout, stderr) int, which is what makes any of this
testable: it previously calledlog.Fatalfdirectly, andlog.Fatalfcallsos.Exit, which takes
the test binary with it.cmd/objectfstherefore has a coverage floor for the first time (77%),
replacing a note in.coverage-floorsthat recorded the package as untestable. -
The systemd template unit mounts and unmounts the way the binary actually works (#135).
configs/systemd/objectfs@.servicenow runs
objectfs mount --config /etc/objectfs/%i.yaml --mount-point /mnt/objectfs/%i --foregroundand
stops withobjectfs unmount /mnt/objectfs/%i. What it replaces was valid systemd and wrong in
four ways:ExecStart=... s3://%i /mnt/objectfs/%imade the instance name and the bucket name one
string, which fails for a prefix, for two mounts of one bucket, or for a bucket whose name is not a
legal unit instance;ExecStop=/bin/fusermount3 -uis the single-helper call described above;
Restart=alwaysremounted a filesystem after a cleansystemctl stop; andRequiresMountsForon
the unit's own mount point asked systemd to wait for the mount this unit creates.TimeoutStopSec
is now stated rather than inherited, because that is the flush window — SIGTERM makes the mount
process unmount, which writes buffered ranges to S3, and too short a value there is a SIGKILL
through buffered data.Two gates, checking different things.
TestSystemdUnit*ininternal/configparses the unit's
Exec*lines through the same parser the documentation gate uses and checks every subcommand and
flag against the sets scraped fromcmd/objectfs/main.go— so a flag renamed in the binary breaks
the unit's test, and no list is maintained by hand. Asystemd-unitCI job additionally runs
systemd-analyze verifyonobjectfs@example.servicefor the half a Go test cannot check. Neither
alone would have caught the old unit:systemd-analyzepasses it, and a Go test cannot tell whether
RequiresMountsFormeans what its author thought.Found while writing the Go half: joining
\continuations is load-bearing. A loop over raw lines
stops atExecStart=... \and skips everything after it, so--mount-pointand--foregroundwent
unchecked — verified by mutation, changing--mount-pointto--mountpointleft the test passing. -
Region-aware S3 pricing, generated from AWS's published price list rather than typed in (#161).
internal/awsratesnow holds 36 regions × 8 storage classes × 6 rates, produced by
go generate ./internal/awsrates/...from the public per-region offer files. Those files need no
credentials, so anyone can refresh every number in one command, and the accessors are
ForRegion(region, class)andAllForRegion(region)— with the us-east-1 forms kept for callers
comparing tiers, where only the ratio matters.Nothing on the mount path fetches anything: the table is compiled in, so pricing a tier needs no
network and cannot fail a filesystem operation. That constraint is what makes the whole approach
usable, and a test clears every AWS credential environment variable and asserts it holds.The offer file, not the Pricing API, is the source. The API needs credentials and would pull
aws-sdk-go-v2/service/pricinginto the module, whose transitive requirement movessmithy-go
under the S3 client that serves every read and write — dependency risk on the data path in order to
price a tier. The offer files also avoid three traps the API presents, each verified against live
data rather than assumed:productFamilyis absent from 315 of us-east-1's 381 S3 products, so
filtering on it silently drops SKUs; filtering Deep Archive storage byvolumeTypereturns a
staging SKU at 21× the real rate; andus-west-2is not a pricing endpoint, but the SDK's
resolver templates any well-formed region into an opaque DNS failure rather than saying so. -
internal/awsrates/offerfile, the extraction rules as ordinary tested Go rather than a script
someone ran once. Every rule in it exists because the obvious version returns a plausible number
from the wrong SKU, and each has a test named for the case that forced it:- The region's usagetype prefix is derived, never assumed.
USE1,USW2,APS8are not region
codes and have no published mapping, so the prefix is recovered structurally from the Standard
storage product. us-east-1's prefix is the empty string, which is a case in its own right: a
derivation bug returning""everywhere looks correct there, and us-east-1 is the default region
and the fallback for every unknown one. - Suffixes match exactly, never with
strings.HasSuffix.Tables-,Annotation-,Files-and
Vectors-TimedStorage-ByteHrsall end in the Standard storage usagetype and all cost more. Found
by mutation: swapping the exact comparison for a suffix match survived the entire suite, because
the Standard query is shielded by its ownvolumeTypeclause. A probe of all 27 lookups found the
single query where the exact match is load-bearing — Intelligent-Tiering storage, where
Tables-TimedStorage-INT-FA-ByteHrssits at $0.0265 against the correct $0.023 — and that is now
the case the test asserts. - An ambiguous query is an error, not a coin flip. Where two SKUs on one query publish different
prices at the same band, extraction fails and names both SKUs, rather than returning whichever the
map iteration reached. - Egress comes from the
AWSDataTransferfile, keyed onfromLocation. S3's own
DataTransfer-Out-Bytesusagetype is the Multi-Region Access Point routing charge, not internet
egress, and the transfer file publishes a $0.00 free-tier SKU on the same four attributes as the
real one — so taking the lowest match prices every byte leaving the region as free.
The package went from no tests to 89.9%,
internal/awsratesfrom 76.2% to 100%, and the generator
from no tests to 74.4%.internal/awsrates/offerfile/offertestbuilds the fixtures all three suites
share, so a rule is stated once rather than transcribed per suite. - The region's usagetype prefix is derived, never assumed.
Fixed
-
The compressed-upload bypass is pinned by a test that asserts the routing, not just its result
(#153). The corruption itself was fixed in 0.10.1 — a compressed object no longer goes through the
CargoShip transporter, which cannot setContent-Encoding— but the test covering it asserted only
that the stored object carried the header. That is the property users need and it is one step removed
from the mechanism: it would also pass if the transporter had acquired header support, and it would
keep passing if the bypass were replaced by anything else that happened to produce a correct object.The new test asserts which upload path ran, using the
cargoship-created-bymetadata the transporter
stamps on everything it uploads. It has a control half that is equally load-bearing: a 1 KiB object,
below the compression threshold, must carry the stamp. Without that half the test would pass on a
build where the transporter never runs at all, silently measuring a disabled feature instead of the
bypass. Both halves were verified by mutation — removing the bypass fails the assertion, and disabling
CargoShip fails the control.Filed upstream as scttfrdmn/cargoship#353:
Archivehas no field that maps toContent-Encoding,
and neither transporter sets the header, still true in v0.20.0.CompressionTypelooks like the field
and is not —buildMetadataputs it in user metadata. Until that lands, ObjectFS gives up CargoShip's
throughput for exactly the objects that compressed. -
Two gosec findings the security check reported but
lintdid not. There are two gosec runs in
CI reading different suppression directives: golangci-lint's honors//nolint:gosec, while the
standalone gosec whose SARIF becomes GitHub code scanning honors only#nosec. Both sites already
carried a reasoned//nolint, solintpassed at 0 issues and thegoseccheck failed with two new
alerts. Neither finding is real — the generator writes committed Go source holding published list
prices, and the unmount helper spawns no shell, takes its program from a fixed platform table, and
passes the mount point as one argv element — so both now carry#nosecalongside, with a note that
the duplication is about two tools rather than two risks. Verified by installing the same gosec the
workflow uses, reproducing both findings, and confirming an unsuppressed0o644write in the same
function is still reported, so the suppression is line-scoped rather than file-wide. Six other sites
have the same gap and are open code-scanning alerts today; filed as #264 rather than swept, since
each needs its own judgment about whether the finding is real. -
pricing.regionselected nothing, so every cost figure was us-east-1's, labeled with whatever
region the operator configured (#161). The rates lived in a map built at package init, and package
init cannot see a configuration.PricingConfig.Regionwas read at exactly one line in
internal/storage/s3— a summary field — while every number came from the region-blind map, and the
assertion covering it compared a rate to itself, so it passed for eleven releases.That is worse than an unlabelled figure.
region: sa-east-1above us-east-1 prices reads as correct,
and sa-east-1 storage is 76% more expensive than us-east-1's — an operator sizing a deployment
there was reading a number 43% below what they would be billed. The spread across the fleet is
material in both directions: Standard runs $0.0225/GB-month in ap-east-2, $0.023 in us-east-1 and
us-west-2, $0.0245 in eu-central-1, and $0.0405 in sa-east-1.Rates are now generated for 36 regions × 8 storage classes × 6 fields from AWS's public price
list offer files, andPricingManagerresolves each lookup through the configured region. A region
with no published table falls back to us-east-1 and says so — one warning at construction naming
the configured region, the region actually used, and what to do about it, rather than one per object
access.PricingSummarynow carries both, so a cost report cannot label us-east-1's numbers with a
region that produced none of them.StorageTierInfo.CostPerGBMonthis gone rather than corrected; see Removed. -
Glacier's PUT price was the price of thawing an object, 67% too high.
Requests-Tier3at
$0.00005 isRestoreObject; a Glacier PUT isRequests-GLACIER-Tier1withoperation: PutObjectat
$0.00003. The cause is worth recording because it is not arithmetic:usagetypeis not a unique
key for an AWS rate.Requests-Tier3carries three SKUs at two prices,Standard-Retrieval-Bytes
two at two, andRequests-GLACIER-Tier1fifteen at two, separated only by theoperationattribute.
A query that omits it returns whichever price Go's map iteration reached first — a wrong number that
changes between runs.The integration test that existed to catch exactly this agreed with the defect, because it spelled
the same query a second time by hand. Two transcriptions of one intent check each other, not the
intent. It now drives its queries from the single place that defines them and compares the whole
committed table against a fresh extraction, field by field. -
A bucket name one character long reported "is 1 characters".
s3://bis what someone types
while testing, so the singular arm is a message operators read, and a grammatical error in an error
message reads as a message nobody has looked at. The test now asserts both arms of the sentence
rather than the substring after them. -
The read-ahead trim is covered by tests rather than by luck.
inflightFetches.unclaimedStart
and the arm ofperformPrefetchthat drops a prefetch whose whole range is already in flight had no
test of their own. Both are reached only when a read is outstanding at the instant a prefetch is
scheduled, so an idle machine ran them by accident and a loaded one did not:internal/fusemeasured
67.5% alone and 66.4% undergo test ./..., and the coverage gate failed on a commit that touched
neither file. No behavior changed here — the point is that a branch nothing owns is a branch a
refactor can delete in silence, and this one prevents a sequential read from paying for the same
bytes twice.The drop arm is asserted with an explicit timeout rather than a byte count, which is what removing it
actually does: a prefetch trimmed to a non-positive length waits on the very read it was trimmed
against, so the failure is a parked prefetch worker, not an over-large GET. With every worker parked
the read-ahead stops entirely and nothing reports it. -
A data race between
ConsensusEngine.Stopand an inbound heartbeat.Stopread
ce.electionTimerwithout holdingce.muwhileresetElectionTimerwas replacing it from the
gossip receiver goroutine, which is where anAppendEntriesRPC is handled. Neither shutdown signal
ordered the two: the receiver does not watch the consensus engine'sstopCh, and although
ClusterManager.Stopstops gossip first,GossipProtocol.Stopcloses the socket without waiting
for the receiver, so a message already inside a handler keeps running.Start's unlocked call to the
same function was the second instance. The regression test driveshandleNetworkAppendEntries
concurrently withStoprather than over UDP, because the two tests that caught this in CI hit it
only when a heartbeat happened to land insideStop's window — reproducible under CI's load and not
locally, which is the flake shape a-racegate is worst at. -
Changing
compression.algorithmno longer orphans every object already in the bucket. A mount
now decodes any algorithm ObjectFS can write, chosen from the object's storedContent-Encoding
rather than from the configuration (#230). Before this,Compressorheld exactly one codec and
Decompresscompared the stored encoding against that codec's token, so a mount could read back
only what it was currently configured to write. Switchingzstdtolz4made every existing zstd
object unreadable — and so did settingenabled: false, which is how an operator turns compression
off after deciding the read amplification was not worth it. Turning compression off stops new
objects being compressed; it does not make the existing ones uncompressed, and it was the change
most likely to be made and least likely to be expected to break anything.Nobody got wrong bytes: the read failed closed with a
DATA_CORRUPTIONerror, because
checkFullyDecodedcross-checks the decoded length against the recordedobjectfs-original-size.
That guard was compensating for a dispatch that could have succeeded — every codec was already
linked into the same binary. The decoder table is built from
pkg/compression.SupportedAlgorithmsrather than listed by hand, so an algorithm added there is
readable without a second edit; that derivation is what stops the defect's actual shape, which was
a set of encoders and a set of decoders maintained independently. Pinned by the full
write-algorithm × read-configuration matrix, including a disabled reader, and the fail-closed
behavior still holds for the cases no dispatch can help: aContent-Encodingnaming a coding
ObjectFS does not implement, and a header stripped after the write by aCopyObjector a tier
transition. A body its own declared codec rejects is now reported as non-retryable corruption
rather than a bare error the retry layer would take at face value. -
A mount on
STANDARD_IA,ONEZONE_IA, orGLACIER_IRcould not create anything at all.
mkdirandtouchboth failed, and so did writing any file smaller than 128 KiB (#154). AWS's
per-tier minimum object size is a billing floor — S3 stores a zero-byteSTANDARD_IAobject and
bills it as 128 KiB — butTierValidator.ValidateWriteenforced it as though S3 would reject the
write, and it is called before anything else inPutObject. Both of the ways this filesystem
brings a name into existence go under that floor:Mkdirwrites a zero-byte marker object so an
empty directory is distinguishable from a prefix that never existed, and aCreatefollowed by a
small write flushes a small object. So the three tiers most of the cost documentation recommends
were the three a filesystem could not be used on, and an IA-tier integration test could not get
past its own setup.It is a warning now, naming the size written alongside the size that will be billed — which is the
actionable fact, since a tier that bills every object as 128 KiB is more expensive thanSTANDARD
for a workload of small files, and nothing downstream would have mentioned it. What still refuses
a write istier_constraints.min_object_size: an operator who sets that has asked for a floor that
is not AWS's, and a policy someone chose is the only kind worth enforcing. Note the consequence of
the split, which is tested rather than left to be rediscovered — setting that key to the tier's own
published minimum reinstates exactly the old gate, zero-byte directory markers included.The gate is enforced two layers below the operation that trips it, so it is pinned at both: the
validator's own tests assert a zero-byte write is accepted and that the billing warning carries
both numbers, and a test ininternal/fusedrives realMkdirandCreatecalls against a real
endpoint on every tier that has a minimum, reading the tier list fromStorageTiersso a class
that gains one later is covered without editing the test. Only the second layer establishes that a
mkdiris a zero-byte PUT, which is the step that turned a billing gate into an unusable mount. -
chmodand automatic tier transitions worked on every key except the ones containing a+.
x-amz-copy-sourceis read by S3 as a URL path, andurl.PathEscapeleaves+as itself while S3
decodes+in that header as a space — so a self-copy ofa+b.txtasked fora b.txtand came
back404 NoSuchKey. Both callers are operations a user expects to be invisible, so the symptom was
achmodfailing withENOENTon a file that plainly exists, and a storage-tier transition failing
on a timer with nothing to attribute it to. A+in a filename is ordinary: version numbers, C++
sources, and any timestamp written as2026-08-01T00:00+00:00. Escaping is now in one place
(Backend.copySource) rather than open-coded at each call site, one of which built the header with
no escaping at all. Verified against real S3 inus-west-2rather than reasoned about — both
url.PathEscapeand(&url.URL{Path: …}).EscapedPath()fail on such a key,%2Bsucceeds, and
every other characterPathEscapepasses through (~ * ( ) $ & = @ :) was probed on the same
endpoint and copies correctly. -
objectfs statsreported zero for six counters that were being maintained correctly all along.
GetStatscopies field by field, and its list named nine of fifteen fields:Creates,Deletes,
andRenames, each incremented by its own operation, and the three latency averages, each
maintained as an exponential moving average byrecordReadTimeand its siblings. All six were live
and none reached the snapshot. This is a whole class of quiet defect — a field added toStatsand
not added to the copy is not a compile error and not a test failure, just a number that reads zero
forever — so the guard is a reflection test that sets every counter to a distinct non-zero value
and asserts the snapshot reports each one. Distinct, so that a copy assigning the right field from
the wrong source is caught too, which naming them all1would not be. An enumeration of field
names would have had the same failure mode as the code it checks.time.Duration's reflect kind is
Int64, which is how the three latency fields were found. -
README.md: the not-implemented table still listedunlinkandrmdirasEROFSand the
tools-that-do-not-work list still saidmvfails withENOTSUP"because there is no rename" — both
true when written and both false since. A row asserting an operation fails is as wrong as a row
naming the wrong errno once the operation works, and it misleads in the worse direction: a reader
avoids something that would have worked.internal/fuse/unimplemented_test.gois the mechanism for
the errnos in that table, and rename's departure from it is now pinned from the other side — a test
asserts the bridge dispatches toRenamerather than reaching go-fuse'sENOTSUPdefault, which
is what a drifted signature or a build tag excludingrename.gowould silently restore. -
Eight documents outside the README described the pre-rename filesystem, and four of them told
users an operation fails that works (#162).docs/architecture/overview.mdlistedunlink,
rmdir, andrenameas unimplemented and saidrmreturnsEROFS;docs-platform/guide/
told usersmvfails withENOTSUP"because there is no rename"; the playground's benchmark
script worked aroundrmby shelling out toaws s3 rm. Every one was an accurate description of
v0.10.3 being read by users of a version where those operations work — understating rather than
overstating, which is friendlier and still wrong, because it sends people to build workarounds for
a problem that is fixed.Two mechanical gates now cover the class, because it has gone stale twice in the same place
(internal/config/docs_posix_test.go):- No document may state an operation count. Eight files said "roughly 10 of ~40 VFS operations
are implemented", each having copied it from the audit that measured it once; six operations
landed across three releases and not one sentence changed. This is the version-constant problem
exactly — one number, many copies, no way for a copy to learn it is wrong — so it gets the same
answer: say a subset is implemented and point at the table.CHANGELOG.mdis exempt, with the
reason recorded in the code: a released section is an immutable record of what that release did,
and editing its counts to match today would falsify the record. - The README's "Not implemented" table may not name an operation whose go-fuse interface
internal/fuseasserts. It reads the_ fs.NodeUnlinker = (*DirectoryNode)(nil)assertions
rather than the method set, because the assertion is what makes support real — go-fuse probes each
interface with a type assertion and substitutes a default when it is absent, and forUnlinkand
Rmdirthat default is success. A method with a drifted signature compiles and is silently
never called; the assertion is what fails.
The tempting third gate — flag any line pairing an implemented operation with a refusal errno,
repo-wide — was written, measured, and rejected: ten hits, of which eight are changelog entries
correctly describing past releases. A gate whose output is 80% false gets deleted. Both surviving
gates were verified by mutation, and the first one's word list is written out in full because a
first draft withten|twenty|thirty|fortypassed on "sixteen of forty VFS operations" — a narrow
pattern that passes is indistinguishable from a correct repository. - No document may state an operation count. Eight files said "roughly 10 of ~40 VFS operations
-
internal/filesystem/interface.gosays what it is: a design sketch with no importers anywhere in
the tree, whose only implementation is its own test mock. It reads like a capability list — it
declaresRename,Truncate,Chmod,Chown,Link,Symlink,Readlink, four xattr methods,
andStatfs— and a reader taking a method there as evidence of support would be wrong about
several. That is not hypothetical:internal/vfs'sFileTypecomment already records this
interface advertisingSymlinkandLinkwith nothing behind them as what went wrong in v0.10.0.
Kept rather than deleted because the multi-protocol work it sketches is tracked (#181) and this is
the record of its original shape. -
write_buffer.max_memoryis enforced. It was declared in the config schema, defaulted to
"512MB", validated as a size string, and read by nothing (#205) — so every mount since the key
appeared reported a write-buffer ceiling and enforced none, on the one path that holds user data in
memory before it is durable. The bound reclaims before it refuses: at the ceiling with flushable
data it flushes and accepts, because a limit that turned legal writes into ENOSPC would be worse
than the unbounded growth it replaced — with the shipped 512 MB default that would mean failing
every workload writing more than 512 MB in total. A single write larger than the entire limit is
admitted, sincewrite(2)'s ENOSPC means "filesystem full" and a caller retrying it would get the
same answer forever. A refusal surfaces as ENOSPC throughvfs.ErrNoSpace, not as EIO. -
A single file can grow past the write buffer's memory bound. Reclaiming flushes other keys and
deliberately skips the one being written, since its pending writes are about to be extended and
uploading them now guarantees a second upload moments later. As the only rule that made the bound
refuse the most ordinary write there is: a program appending to one file has no other key to flush,
so at the shipped 512 MB default, writing any file past 512 MB failed at exactly 512 MB with
ENOSPC — sequentially writing a large file being the workload ObjectFS exists for. The target key
is now flushed as a last resort, which is what streaming a large file through a bounded buffer
looks like; a test writes a file to eight times its limit and asserts both that every write
succeeds and that the resulting object is whole, so a lossy reclaim fails rather than passing
quietly. -
A cache that answered a ten-byte request with two bytes now reports a miss (#178). The
types.Cachecontract is that a partial hit is a miss, and it is a contract about data integrity
rather than about return values:internal/fusepasses a non-nil hit to the kernel verbatim as file
content, so a short answer is a truncated read reported as a successful one, and the caller cannot
distinguish a short cache entry from a short file. The Redis implementation usedGETRANGE, which
clamps to the stored value's length and returns what it can —GETRANGE k 8 17over a ten-byte value
answers with two bytes and no indication that eight are missing. It had ten tests of its own, all
passing, none of which asked for a range longer than what was stored.What found it is the durable part:
internal/cache/cachetestis a shared conformance suite that
everytypes.Cacheimplementation is now run against. There were five implementations, one
contract, and no test in common — each was checked against the questions its own author thought to
ask, which is why four of them satisfied a rule the fifth violated in the most consequential
direction. Ten cases, each stating in its failure message what a caller would observe: exact-range
hits, straddling and past-the-end reads as misses, a request longer than the entry as a miss, the
open-endedsize <= 0form, the returned slice not aliasing the cache's own storage, a newerPut
winning where it overlaps, andDeleteremoving the key it names and nothing that merely shares a
prefix with it. A sixth implementation is one enrollment away from being held to the same contract.
Changed
-
One size parser reads every size in a configuration file (#159).
pkg/utils.ParseBytesis
now the only implementation; the three surviving copies —internal/compression.parseSize,
internal/config.parseOptionalSize, and a fourth intests/unit_test.go— are deleted, and
utils.ParseOptionalByteshandles the unset-means-zero case identically everywhere. Every size a
config file names is therefore validated at load with a message naming the YAML key, and no size is
substituted silently.Four parsers were four answers to the same string, and the disagreements were not cosmetic. Each
one is verified by running the deleted code rather than by reading it:internal/compression's stopped its unit table at GB, somin_size: 1TBwas an error while
1GBworked; it accepted-1MBas a negative compression floor, which makes
len(data) < c.minSizefalse for every input and compresses everything including the bytes the
threshold exists to skip; and99999999999GBoverflowed tomath.MaxInt64, the same defect
inverted — a floor nothing is ever below, so compression is configured on and never happens.
Neither reported anything.- The copy in
tests/unit_test.gofell through tostrconv.ParseFloat, which accepts Go float
syntax:InfMBparsed asmath.MaxInt64and1e3MBas 1000 MB. It also rejected1TB. A test
asserting against a private copy of a parser is a test that agrees with itself — this one passed
while disagreeing with the parser the mount used. internal/adapter's, removed earlier in this release, returned 1 GiB and no error for anything
it could not parse.
ParseBytesis strict for the reason the loader is strict: it rejects trailing garbage (4KiB,
the spelling someone who knows the units writes), negatives,Inf/NaN, exponent and hex-float
notation, and any value that overflowsint64once multiplied. The empty string is the one case
with a second meaning, andParseOptionalBytesis where it lives — unset means zero, which is the
caller's signal to use its own default. It deliberately does not distinguish""from a literal
"0", because no caller in this repository does. -
Each listener's address is one setting, beside the
enabledflag that governs it
(#202, #211, #212).global.metrics_port,global.health_port,global.profile_port,
monitoring.metrics_addr,monitoring.health_check_addrandmonitoring.enable_pprofare all
removed, replaced bymonitoring.metrics.addrandmonitoring.health_checks.addr, both defaulting
to loopback —127.0.0.1:8080and127.0.0.1:8081. Same ports, so an existing same-host
Prometheus scrape keeps working; the host is what changed.A port and an address were never two settings.
monitoringdeclared the two addresses, defaulted
them, documented them — and read neither, while the ports two sections away were what the listeners
used. So an operator who sethealth_check_addr: 127.0.0.1:8081to keep an unauthenticated
diagnostic endpoint off the network got a wildcard bind and no warning: the setting that would have
changed it was inert, and the setting that was live could not express a host at all, because the bind
wasfmt.Sprintf(":%d", port). Both endpoints are on by default, so a stock
objectfs s3://bucket /mntpublished per-operation counts, error rates, sizes and timings — and, on
/health, component names and error strings — to anything that could route to the host.An address subsumes a port, so keeping both would have preserved the disagreement. It also settles
what a port could not:health_port: 0disabled the health endpoint whilemetrics_port: 0was
treated as unset and defaulted back to 8080 and bound it, so two adjacent fields spelled "off"
differently and the metrics one failed in the direction that leaves a port open. There is no0in
an address, and each listener already has anenabledflag next to its newaddr.global.enable_pprofandglobal.profile_portare removed rather than wired. Nothing read either.
The one pprof server in the tree ispkg/profiling's, which has no importer, also binds every
interface, and serves mutating/memory/gcand/memory/freehandlers with no authentication —
binding a third unauthenticated listener inside the change that stops binding two of them was the
wrong trade to make on the strength of a boolean nothing read. Its fate is #245.Three further consequences:
- A bind failure now fails startup and names the address. Both servers used to bind on a
goroutine and log, so a mount whose metrics port was taken came up with no endpoint and one line
in the log to say why — an operator finds that out when a probe starts failing. This deliberately
contradicts #192's reasoning that non-fatal was "the right call for observability":
enabled: falseis already how you ask for no endpoint. - Validation catches what a listener reports badly.
net.SplitHostPortaccepts"99999", so
the port range is checked explicitly and the error names the field.health_port: 99999used to
reachnet.Listenfrom YAML unchecked. OBJECTFS_METRICS_PORT/OBJECTFS_HEALTH_PORTbecomeOBJECTFS_METRICS_ADDR/_HEALTH_ADDR,
andOBJECTFS_METRICS_ENABLED— documented in two places and assigned by nothing, which is
#202's shape in the setting that closes an endpoint rather than the one that moves it — is now
wired, along with a newOBJECTFS_HEALTH_ENABLED. Both parse strictly: a value that is not a
boolean fails startup naming the variable, where the feature-flag variables coerce anything but
"true"to false. These two govern unauthenticated endpoints that default to on, so silent
coercion is wrong in whichever direction it picks.- The endpoints are documented.
grep -rn health_port docs/ README.md configs/ examples/used to
return nothing: the knobs existed, were read, changed behavior, and appeared in no shipped
documentation or example config (#192). The README now has a Metrics and health endpoints
section with both addresses, thecurlthat reaches each, the environment overrides, and why the
defaults are loopback;docs/index.mdnames the addresses beside the features rather than listing
"health monitoring" with nowhere to point a probe.
The test gap is the more interesting half.
TestStartMetricsBindsTheEndpointscraped127.0.0.1
and passed against a wildcard bind, because a wildcard bind answers on loopback too — so the tests
asserted that something was listening and never that it was listening where the configuration
said.Collector.Addr()now reports the bound address, and the regression tests assert two things:
that it equals what was configured (a wildcard bind reports0.0.0.0or[::]here), and that the
endpoint does not answer on a routable non-loopback address of the host. Verified by mutation —
restoring the":"+portbind fails both halves while the old-shaped test stays green. - A bind failure now fails startup and names the address. Both servers used to bind on a
-
Compression is configured under
storage.s3.compression, notwrite_buffer.compression
(#157). Nothing has ever compressed a write buffer. The block always configured the codec the
S3 backend applies to a whole object on its way to the wire, and the misplacement mattered in both
directions: an operator tuning the write buffer was changing how objects were stored, and an
operator looking for how objects are stored had no reason to read the write-buffer section. It now
sits under the backend that applies it. Defaults are unchanged —enabled: false,zstd, level 3,
min_size: 4KB.write_buffer.compressionandperformance.compression_enabledare removed rather than
deprecated, so a configuration file still setting either fails to load with the offending key
named. That is deliberate, and follows the precedent set by thesecurity.encryptionbooleans
removed in v0.10.1: a key kept as an ignored field means an operator's compression settings
silently stop applying on upgrade, which is the same failure as the unknown keys strict decoding was
introduced to catch, arrived at by a different route.performance.compression_enabledis the more instructive of the two. It defaulted to true, was
read by nothing, and sat two sections away from the real setting that defaulted to false — so
the shipped configuration contained a prominentcompression_enabled: truewhile no object was ever
compressed, and anyone who read the file to find out came away with the opposite of the truth. It is
removed rather than wired up because compression happens in the S3 backend, on the object, and a
second boolean over one feature can only ever disagree with the first.OBJECTFS_COMPRESSION_ENABLED
survives and now assignsstorage.s3.compression.enabled: the variable's name was never wrong, only
what it assigned to, and exporting it previously had no effect on whether anything was compressed.One assertion in the mapping test had to change with it, for a reason worth recording: it asserted
Algorithm: "zstd", which is also the default — so abuildS3Configthat hardcoded"zstd"and
ignored the configuration passed. Verified by making exactly that mutation. The test now useslz4,
because every value in a mapping test has to differ from the value the field would hold if the
mapping were absent. That is the shape of the original config-plumbing defect: a field nothing mapped
still arriving at a plausible value from somewhere else. -
performance.read_aheadreaches the prefetcher, and has five keys instead of twenty (#176).
Every read-ahead setting was decoded, defaulted, range-checked at load, documented on its own page,
and shipped in four preset config files — and read by nothing, because the mount constructed its
read-ahead manager with a literalniland ran that manager's built-in defaults. So a deployment
that setwindow_size: 128MBfor a streaming workload was prefetching 64 KB, and had no way to find
that out.The reduction is the fix, not a simplification of it. The two sides did not disagree about a value;
they disagreed about what read-ahead is.internal/configdescribed a strategy selector
(strategy: simple|predictive|ml) over a pattern detector with a confidence threshold and a
prediction window, a bandwidth-capped prefetcher, and an online-learning model with
ml_model_path,learning_rate,pattern_depthandmodel_update_interval. What exists in
internal/fuseis a sequential-access detector with a prefetch window, five fields, tuned against
measured byte counts. Beyondenabledthere was no field-name overlap at all — nothing to pass
through — so the block was cut down to the detector's own knobs and wired:
enabled,window_size,min_sequential,concurrent_reads,ttl.Wiring the old block would have been worse than leaving it inert. A validated
ml_model_path
reaching no model loader is a claim about the software, and range-checkinglearning_rateto 0–1 is
what made the whole set look load-bearing: a user whose config is rejected for an out-of-range value
reasonably concludes the accepted values do something. Fifteen keys are removed rather than
deprecated, so a file still setting one fails to load with the key named — same reasoning as
write_buffer.compressionabove.performance.read_ahead_sizeis removed too, and it iscompression_enabled's twin: a prominent
64MBdefault, read by nothing, sitting two lines above the block describing the same quantity with
a different default. Two names for one setting can only ever disagree.
OBJECTFS_READ_AHEAD_SIZEgoes with it, and the sixOBJECTFS_READAHEAD_*variables become four —
the two counts now report a parse failure rather than silently keeping the default, because a worker
count reverting to 4 when 1 was meant is prefetch traffic nobody asked for.Behavior at the default configuration is deliberately unchanged:
config.NewDefault's block is now
exactlyfuse.DefaultReadAheadConfig, which is what every mount has run all along, and tests on both
sides of the seam assert those two remain equal. Two validation rules are new because the values now
reach code —concurrent_reads: 0is rejected (it is the worker count, and zero starts no workers,
so every prefetch is queued and never performed: read-ahead silently off while the config says on),
and an emptywindow_sizeis rejected when enabled (an empty floor is a floor of zero, not the
default). A disabled block is no longer validated at all, which is the same defect pointing the
other way: a mount should not be refused over settings nothing will read. Two checks stay
unconditional, because they catch a typo rather than a setting: awindow_sizethat is not a size at
all, and attlwritten without a unit —ttl: 5is five nanoseconds, silently, since yaml.v2 reads
a bare integer into atime.Durationas a raw nanosecond count. Both would otherwise surface months
later, when read-ahead is turned on, as a validation failure over a line nobody touched. Thettl
omission was found by the reflection walk over the schema that pins every duration to
validateDurations, the moment this change gave read-ahead a duration at all.Presets and docs were rewritten rather than relabeled.
readahead-simple.yamlbecame
readahead-disabled.yaml— it configured "no pattern detection, no prefetching", whose honest
spelling is read-ahead off — andreadahead-ml.yamlwas deleted, because a preset cannot be
corrected into configuring a model loader that does not exist.docs/features/read-ahead.mdlost its
ML training guide and its three-strategy comparison for the same reason.One thing the wiring exposed and did not fix:
min_sequentialhas no effect below 6, because the
prefetch also requires a confidence above 0.5 and confidence issequentialHits/10— two thresholds
over one counter, of which one is configurable. The shipped default of 3 is inside that range, so the
documented default does not describe the default behavior. Reconciling them changes prefetch
behavior at the default configuration, which wants a measurement rather than a number nudged in
passing, so it is filed as #247 and documented where the setting is. -
The
cluster.redisblock selects the cache a mount uses, and thecacheblock reaches it
(#178).cache.NewFromConfig— the only reader ofcluster.redis.*anywhere — had no caller.
The adapter built aMultiLevelConfigliteral of its own instead, so sevenclusterkeys plus a
seven-keyredissub-block were decoded, defaulted, validated and documented while no mount
consulted any of them: a deployment that configured a shared Redis cache got a private in-process
one, with no error and no warning, and looked correct until two nodes disagreed about a file.Both halves of that mistake are fixed together, because they are the same mistake.
NewFromConfig's
other arm passed a literalniltoNewMultiLevelCache, discarding the L1/L2 sizing, the TTL, the
persistent-cache directory and the eviction policy its argument carried — so even with a caller, most
of thecacheblock would still have been ignored. The mapping now lives ininternal/cachebeside
the selection rather than in the adapter, since a second copy of it is how the two came to disagree
in the first place, andAdapter.cacheis typed astypes.Cache: naming the concrete
*cache.MultiLevelCachethere is what made the function uncallable, as the field could not hold what
it returns.An unreachable Redis now fails the mount rather than falling back to an in-process cache. Falling
back is this same defect one layer out — both nodes come up, both believe the cache is shared, and
nothing in either log explains the disagreement — so the error namescluster.redisand the mount
does not start. This is the third instance of the shape #156 and #176 were: a config block whose
every layer worked except the one that had to call it. -
storage.s3.cost_optimizationkeeps one key, and it does what it says (#203). The block had
six; five are removed and one is new.small_objects_on_standardstores an object on STANDARD when
the configuredstorage_tierwould bill it as larger than it is and STANDARD is genuinely cheaper
for it. Defaults tofalse, because it changes the storage class objects are written with, and an
operator who setstorage_tiershould get that tier until they ask otherwise.Removed, not deprecated — configuration is decoded strictly, so a file still carrying one of these
fails at startup naming the key rather than silently ignoring it:Removed key Why enabledGated nothing. The backend has no such field tiering_enabledAutomatic tier transitions exist in the S3 backend; nothing on the mount path invokes them lifecycle_enabledLifecycle rules are a PutBucketLifecycleConfigurationcall this backend never makestransition_to_iaSame: a lifecycle rule, never written transition_to_glacierSame This is the fourth instance of the shape #156, #176 and the
cluster.redisitem above were, and
the most direct:internal/config.S3CostOptimizationandinternal/storage/s3.CostOptimization
shared no field name at all, so the block could not be mapped even in principle.buildS3Config
carried a comment saying it was not mappable, which was true and is not a fix — the two types had
drifted until the only honest options were to plumb a field that did not exist on both sides or to
delete what nothing read.Two more
s3.CostOptimizationfields survive as Go struct fields with no YAML key, and the
distinction is deliberate:EnableAutoTieringandCostThresholdare read by code an embedder can
call directly, while a mount has no path to it.MonitorAccessPatternsis likewise unmapped, and
for a second reason — the map it populates holds one entry per distinct key read and nothing evicts,
so on a bucket with many objects it is unbounded growth for a report no mount displays. -
The small-object rule compares prices instead of one size.
HandleStandardTierOverheadtested
objectSize < 128 KiBwith no reference to the configured tier, so it moved objects to STANDARD from
tiers that publish no billing minimum at all — including DEEP_ARCHIVE, at ~23× the storage rate —
and from the three that do at sizes where they are still much cheaper. Being under a floor does not
make STANDARD cheaper: the crossover is atminBillable × rateTier / rateStandard, which at list
prices is 69.6 KiB for STANDARD_IA, 55.7 KiB for ONEZONE_IA and 22.3 KiB for GLACIER_IR.
A 32 KiB object billed as 128 KiB of GLACIER_IR costs about a third of 32 KiB on STANDARD. Three
conditions are now required — the tier publishes a minimum, the object is under it, and STANDARD is
cheaper for this object at the prices this deployment pays, discounts andCustomPricingincluded.This also keeps GLACIER and DEEP_ARCHIVE out on a second ground that is not about money: their
objects cannot be read without a restore, so diverting one to STANDARD would change what a read of
that object does, not only what it costs. A cost heuristic must not decide retrieval semantics.TierValidator.GetRecommendationshad the identical size-only rule and now uses the same three
conditions at list rates. It has no mount-path caller — it is exported API — but advice nobody can
act on wrongly is still advice someone will act on. -
The billing-minimum warning names the tier the object is actually stored on.
ValidateWrite
ran before the small-object diversion and described the configured tier's floor, so an operator who
enabledsmall_objects_on_standardbecause of that warning still sawbilled_size=131072on a
16 KiB object that was about to be stored on STANDARD and billed as 16 KiB. The diversion logs at
Debug, so at the default level the misleading half was the only half visible. The tier decision now
precedes validation andValidateWriteToTiertakes the effective tier.tier_constraints.min_object_size
deliberately does not follow it: that is a floor the operator configured for this mount, and an
internal cost optimization is not a reason to stop enforcing it. -
A per-object storage class bypasses the CargoShip transporter.
Transporter.optimizeStorageClass
returns the transporter's own config storage class — fixed at construction fromstorage_tier— for
any archive with noAccessPatternand noRetentionDays, which is every archive ObjectFS builds. It
never readsArchive.StorageClass, the field whose comment says "Target storage class". So the
diverted class was computed correctly and dropped at the boundary: the object stored fine, read back
fine, and only the invoice differed. Objects whose class differs from the configured tier now take the
directPutObjectpath, joining the existing bypasses for compression and for encryption modes the
transporter cannot express. The common case is unaffected and keeps CargoShip's throughput
optimization. Filed upstream as scttfrdmn/cargoship#352;OptimizedTransporter, a different type
ObjectFS does not construct, already honors the field.Found by asserting the storage class recorded at the S3 endpoint rather than the value passed in — the
same technique that found theINTELLIGENT_TIERINGdefault in v0.10.1, and the reason the seam test
exists at all. -
The live pricing drift test needs no AWS credentials and no longer names its own queries (#161).
It fetched from the Pricing API by shelling out toaws pricing get-products, so it skipped for
anyone without a configured profile — including CI. A drift check that skips is not a drift check. It
now fetches the same public offer files the generator reads, over plain HTTPS, and compares the whole
committed table against a fresh extraction across five regions spanning every price band AWS
publishes.internal/cost's drift guard moves ontoPricingManager.StorageRatefor the same reason:
the field it read no longer exists, and the manager is the path a caller actually takes. -
The release security scan is a gate (#196).
security-scaninrelease.ymlalready scanned
the exact binarypublishattaches, which is the right shape, but it could not fail: trivy-action's
exit-codehas no default, so findings uploaded as SARIF and the step passed. It now exits 1 on
HIGH,CRITICAL, withignore-unfixed: trueandscanners: vuln.Each of those is a decision rather than a default. MEDIUM and below still upload and stay visible on
the security tab without stopping a publish, because MEDIUM in a transitive dependency of a
filesystem binary is generally not worth delaying a release for.ignore-unfixedbecause a
vulnerability with no released fix cannot be actioned by a release — blocking on one means the
project cannot ship until an upstream maintainer acts, which is an availability problem wearing a
security posture. And the SARIF upload is nowif: always(), so the findings that failed the step
are the ones that reach the security tab; without it a HIGH stopped the job before the upload and
left whoever was cutting the release with an exit code and no way to see the cause.It was not a gate before now for a reason worth keeping, because it is the same reason it can be one
now: the first real run of this scan found a MODERATE advisory in the pinnedaws-sdk-go-v2(#195),
and switching the gate on then would have blocked every release on a scan nobody had triaged. A gate
turned on against existing findings is a broken build everyone learns to bypass. That advisory is
fixed and the baseline is clean, which is the only state in which turning it on means anything.The asymmetry the issue names is resolved toward gating:
govulncheckinsecurity.ymlexits
non-zero and so has always been a hard gate onmainand every PR, while the release — the artifact
users actually download — was not gated at all. The repository-widetrivy fsscan in that file is
deliberately still not a gate, and now says so: it reports on the source tree rather than on what
ships, including dependencies reached only by tests, so the gates aregovulncheckand the binary
scan. It picks up the same severity floor andignore-unfixedregardless, so a finding in one place
means what a finding in the other means. -
klauspost/compressv1.18.0 → v1.18.7, for GO-2026-5841, an out-of-bounds read in thes2
package. Not reachable from this code —govulncheckreported it under "packages you import" with
zero called vulnerabilities — but present in the module the binary is built from, which is what a
binary scan sees and what the new release gate above would have failed on. Found while verifying the
baseline was clean before switching that gate on, which is the check being described.
Removed
-
StorageTierInfo.CostPerGBMonth(#161). A rate on a package-level struct cannot know which
region it is for, and this field was the mechanism by which every costinternal/storage/s3reported
was us-east-1's. Callers usePricingManager.StorageRate(tier)for the list rate in the manager's
region, orGetTierPricingwhere discounts and overrides should apply.Removed rather than corrected, deliberately. Leaving the field and filling it from the configured
region would put a region-specific number on a value shared process-wide by every manager, which is
the same defect with an extra step. The compiler now finds the callers. -
awsrates.Region(#161). A constant alias forawsrates.DefaultRegion, added in the same
change that made rates region-aware and kept so that callers wanting only to label a figure would
keep compiling. There were no such callers: a grep across every.gofile in the repository found
zero uses.awsratesis an internal package, so nothing outside the module can reference it either,
and a deprecation notice nobody can read is not a compatibility measure. UseDefaultRegion, or pass
a region toForRegion.
Verify a download:
sha256sum -c objectfs-<platform>.tar.gz.sha256
Container image: ghcr.io/scttfrdmn/objectfs:0.11.0
Full changelog: https://github.com/scttfrdmn/objectfs/blob/v0.11.0/CHANGELOG.md