nwsync: upload sink, key-addressed CLI, fail-closed publication marker (#73)
build-binaries / build-binaries (push) Successful in 2m18s

Builds the code half of #60, and closes the CLI gap the #53 sweep found: PR #71 shipped #53's original surface rather than the one #56, #62 and #65 settled.

## What lands

**`depot.KeyStore`** — `ProbeKey` / `PutReader` / `GetKey`, addressing the zone by object key rather than by depot sha. NWSync cannot use the sha-addressed path: a blob is named after the sha1 of its *uncompressed* bytes while the body uploaded is the compressed form, and Bunny's `Checksum` header is sha256 of the body. Per #55 this reuses `internal/depot`'s `httpBackend` — same IPv4-pinned transport, same retry, same tri-state probe — and the sha-addressed `Backend` is now rewritten on top of it. No second HTTP client, no per-instance hash-function fields: the caller passes the key and the checksum, which turned out simpler than #55 expected.

**A sink in `internal/nwsync`** — the zone by default, a local tree under `--out DIR` as the conformance path. Blobs upload as they are produced and the index lands last, so the presence of an index is the publication marker. A blob already in the zone is skipped via #55's probe *without* paying for compression (the body is a thunk) — which matters for the backfill, where compression is the expensive part.

**The settled CLI**

```
nwsync emit     [--as NAME] [--out DIR] <artifact-key> <file>
nwsync assemble --group-id N [--tlk-key KEY] [--out DIR] <artifact-key>...
```

Artifact keys are depot keys; an index lives beside its artifact with the extension replaced (#62), derived in exactly one place so `emit` and `assemble` cannot disagree. Flags may now follow positionals — Go's `flag` stops at the first non-flag argument, which cost a run during #59.

**Fail-closed in two places** — an artifact key whose embedded digest does not match the file is refused (publishing an index under the wrong key silently pairs a manifest with the wrong artifact), and `assemble` refuses an artifact with no index rather than publishing a manifest missing a hak.

## Checks

`make check` green. Six new tests run against a Bunny-shaped `httptest` zone that verifies the `Checksum` header the way Bunny does: blobs-then-index ordering, skip-if-present, no index after a failed upload, key/file mismatch, and assemble reading indexes back out of the zone.

Conformance re-run through the new CLI against upstream `nwn_nwsync_write` 2.1.2 over `sow_vfxs_01.hak` (#59's oracle): the manifest is still **byte-identical**.

## Not in this PR

- **Live upload against the real zone.** The nwsync zone and its credential are #61, still open. Everything here is proven against a fake zone only.
- **Consumer wiring** — #65, in the three producer repos.
- **The mid-hak failure *policy*.** The mechanism is here (fail closed, orphan blobs left, re-run resumes); whether a module release may proceed when an emit failed is a human call, still open on #60.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #73

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #73.
This commit is contained in:
2026-07-30 21:56:06 +00:00
committed by archvillainette
parent a131b25e5b
commit f9051a2c01
9 changed files with 841 additions and 203 deletions
+71 -54
View File
@@ -2,9 +2,11 @@ package nwsync
import (
"bytes"
"context"
"crypto/sha1"
"fmt"
"os"
"path"
"path/filepath"
"slices"
"sort"
@@ -60,42 +62,86 @@ type EmitResult struct {
BlobsWritten int
}
// Emit explodes one artifact — a .hak/.erf/.mod or a loose file such as the
// TLK — into NWSync blobs plus a NSYM manifest describing only that artifact.
func Emit(artifactPath, outDir string) (EmitResult, error) {
resources, err := readArtifact(artifactPath)
// EmitOptions describes one emit run.
type EmitOptions struct {
ArtifactKey string // depot key of the artifact; the NSYM key is derived from it
ArtifactPath string // the file on disk
As string // name override, for a TLK whose filename is not its published name
OutDir string // write locally instead of uploading — the conformance path
Sink sink // test seam; nil means OutDir or the zone
}
// Emit explodes one artifact — a .hak/.erf or a loose file such as the TLK —
// into NWSync blobs plus a NSYM manifest describing only that artifact.
//
// Blobs go up as they are produced and the index lands last, so the presence of
// an index is the publication marker: an artifact whose emit died halfway has
// real blobs in the zone and no index, which is unambiguous. Blob names are
// content hashes, so re-running skips whatever already landed.
func Emit(options EmitOptions) (EmitResult, error) {
artifact, err := os.ReadFile(options.ArtifactPath)
if err != nil {
return EmitResult{}, fmt.Errorf("read artifact: %w", err)
}
if err := checkArtifactKey(options.ArtifactKey, artifact); err != nil {
return EmitResult{}, err
}
name := options.As
if name == "" {
name = path.Base(options.ArtifactKey)
}
extension := path.Ext(name)
name = strings.TrimSuffix(name, extension)
key, err := resolveIndexKey(options.ArtifactKey, options.OutDir)
if err != nil {
return EmitResult{}, err
}
name := strings.TrimSuffix(filepath.Base(artifactPath), filepath.Ext(artifactPath))
entries, blobs, onDiskBytes, err := emitResources(resources, outDir)
resources, err := readArtifact(options.ArtifactPath, artifact, name)
if err != nil {
return EmitResult{}, err
}
target, err := openSink(options.OutDir, options.Sink)
if err != nil {
return EmitResult{}, err
}
entries, blobs, onDiskBytes, err := emitResources(resources, target)
if err != nil {
return EmitResult{}, err
}
if len(entries) == 0 {
return EmitResult{}, fmt.Errorf("%s: nothing to index (no publishable resources)", artifactPath)
return EmitResult{}, fmt.Errorf("%s: nothing to index (no publishable resources)", options.ArtifactPath)
}
data, err := writeManifest(entries)
if err != nil {
return EmitResult{}, err
}
manifestPath := filepath.Join(outDir, name+".nsym")
if err := writeManifestPair(manifestPath, data, entries, onDiskBytes, Sidecar{ModuleName: name}); err != nil {
if err := putManifestPair(target, key, data, entries, onDiskBytes, Sidecar{ModuleName: name}); err != nil {
return EmitResult{}, err
}
return EmitResult{Name: name, ManifestPath: manifestPath, Entries: len(entries), BlobsWritten: blobs}, nil
return EmitResult{Name: name, ManifestPath: target.describe(key), Entries: len(entries), BlobsWritten: blobs}, nil
}
// openSink returns the zone sink, or a local directory when outDir is set.
func openSink(outDir string, injected sink) (sink, error) {
if injected != nil {
return injected, nil
}
if outDir != "" {
return dirSink{root: outDir}, nil
}
return newZoneSink(context.Background(), os.Getenv)
}
// readArtifact returns the resources of an ERF/HAK/MOD, or the single resource
// a loose file represents. Upstream's resman does the same dispatch on the
// file's first three bytes.
func readArtifact(path string) ([]erf.Resource, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read artifact: %w", err)
}
// file's first three bytes. name is the artifact's published name, which for a
// loose file is also its resref.
func readArtifact(path string, data []byte, name string) ([]erf.Resource, error) {
if len(data) >= 3 {
switch string(data[:3]) {
case "ERF", "HAK":
@@ -111,20 +157,19 @@ func readArtifact(path string) ([]erf.Resource, error) {
return nil, fmt.Errorf("%s: a module is never emitted; a manifest is haks plus the TLK", path)
}
}
base := filepath.Base(path)
extension := filepath.Ext(base)
extension := filepath.Ext(filepath.Base(path))
restype, ok := erf.ResourceTypeForExtension(extension)
if !ok {
return nil, fmt.Errorf("%s: unknown resource type %q", path, extension)
}
return []erf.Resource{{
Name: strings.TrimSuffix(base, extension),
Name: name,
Type: restype,
Data: data,
}}, nil
}
func emitResources(resources []erf.Resource, outDir string) ([]Entry, int, int64, error) {
func emitResources(resources []erf.Resource, target sink) ([]Entry, int, int64, error) {
// A resref appearing twice inside one artifact resolves to the last one,
// the way resman lets the last container added win.
order := make([]Identity, 0, len(resources))
@@ -164,7 +209,7 @@ func emitResources(resources []erf.Resource, outDir string) ([]Entry, int, int64
ResRef: identity.ResRef,
ResType: identity.ResType,
})
written, err := writeBlob(outDir, sum, resource.Data)
written, err := target.putBlob(fmt.Sprintf("%x", sum), func() []byte { return compressBlob(resource.Data) })
if err != nil {
return nil, 0, 0, err
}
@@ -187,35 +232,10 @@ func created() int64 {
return time.Now().Unix()
}
// writeBlob writes one NWCompressedBuffer blob and returns its size on disk,
// or 0 if the blob already existed. Blob names are content hashes, so an
// existing name is existing content.
func writeBlob(outDir string, sum [20]byte, data []byte) (int64, error) {
path := blobPath(outDir, fmt.Sprintf("%x", sum))
if _, err := os.Stat(path); err == nil {
return 0, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return 0, fmt.Errorf("create blob directory: %w", err)
}
blob := compressBlob(data)
if err := os.WriteFile(path, blob, 0o644); err != nil {
return 0, fmt.Errorf("write blob: %w", err)
}
return int64(len(blob)), nil
}
// writeManifestPair writes a NSYM manifest and its .json sidecar. The caller
// supplies the sidecar fields it knows; the rest are derived from the entries.
// data must be the serialised form of entries.
func writeManifestPair(manifestPath string, data []byte, entries []Entry, onDiskBytes int64, sidecar Sidecar) error {
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
return fmt.Errorf("create manifest directory: %w", err)
}
if err := os.WriteFile(manifestPath, data, 0o644); err != nil {
return fmt.Errorf("write manifest: %w", err)
}
// putManifestPair stores a NSYM manifest and its .json sidecar at key. The
// caller supplies the sidecar fields it knows; the rest are derived from the
// entries. data must be the serialised form of entries.
func putManifestPair(target sink, key string, data []byte, entries []Entry, onDiskBytes int64, sidecar Sidecar) error {
var totalBytes int64
clientContents := false
for _, entry := range entries {
@@ -240,8 +260,5 @@ func writeManifestPair(manifestPath string, data []byte, entries []Entry, onDisk
if err != nil {
return err
}
if err := os.WriteFile(manifestPath+".json", body, 0o644); err != nil {
return fmt.Errorf("write sidecar: %w", err)
}
return nil
return target.putIndex(key, data, body)
}