fix(reviewing): case-insensitive on-disk lookup for archive/+staging/+

party/{received,issued}

The synthetic test fixture and many real deployments use PascalCase
folder names (Archive/, PartyB/, Received/, Issued/, Staging/). The
aggregator was hard-coding lowercase joins, which on case-sensitive
filesystems (Linux ext4) meant os.ReadDir returned NotExist and the
listing was empty even when the data was present.

Use zddc.ResolveCanonical to find the on-disk casing for each
canonical segment (archive/, staging/, then per-party received/ and
issued/), and emit URLs with the resolved casing so the dispatcher's
URL canonicalisation is a no-op pass-through.

The case-insensitive lookup was already used elsewhere (file API's
mkdir, tree.go's virtualUserHomeEntry); reviewing/ now matches that
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 21:38:50 -05:00
parent 002e034119
commit d1a5a14132

View file

@ -83,11 +83,19 @@ func computePending(ctx context.Context, decider policy.Decider,
fsRoot, project, email string) ([]pendingSubmittal, error) {
projectAbs := filepath.Join(fsRoot, project)
archiveAbs := filepath.Join(projectAbs, "archive")
stagingAbs := filepath.Join(projectAbs, "staging")
// Resolve the canonical folder names to whatever case is present
// on disk (deployments may use Archive/ Received/ Issued/ Staging/
// PascalCase). Empty string means no case variant exists — treated
// as missing (empty contribution to the join).
archiveOnDisk, _ := zddc.ResolveCanonical(projectAbs, "archive")
stagingOnDisk, _ := zddc.ResolveCanonical(projectAbs, "staging")
// Index staging by tracking → folder name.
stagedByTracking := map[string]string{}
var stagingAbs string
if stagingOnDisk != "" {
stagingAbs = filepath.Join(projectAbs, stagingOnDisk)
if entries, err := os.ReadDir(stagingAbs); err == nil {
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
@ -98,7 +106,12 @@ func computePending(ctx context.Context, decider policy.Decider,
}
}
}
}
if archiveOnDisk == "" {
return nil, nil
}
archiveAbs := filepath.Join(projectAbs, archiveOnDisk)
parties, err := os.ReadDir(archiveAbs)
if err != nil {
if os.IsNotExist(err) {
@ -114,7 +127,13 @@ func computePending(ctx context.Context, decider policy.Decider,
}
party := p.Name()
partyAbs := filepath.Join(archiveAbs, party)
receivedAbs := filepath.Join(partyAbs, "received")
// Per-party canonical folder resolution (Received/ vs received/).
receivedSeg, _ := zddc.ResolveCanonical(partyAbs, "received")
issuedSeg, _ := zddc.ResolveCanonical(partyAbs, "issued")
if receivedSeg == "" {
continue // party with no received/ at all → nothing to review
}
receivedAbs := filepath.Join(partyAbs, receivedSeg)
// ACL: skip parties whose received/ subtree the caller can't read.
// Filtering at the party level is cheaper than per-entry and matches
@ -123,7 +142,10 @@ func computePending(ctx context.Context, decider policy.Decider,
if err != nil {
continue
}
receivedURLPrefix := "/" + project + "/archive/" + party + "/received/"
// URL prefix preserves the on-disk casing so links resolve
// directly against the canonicalisation done by the URL
// dispatcher (no additional case-fold round-trip needed).
receivedURLPrefix := "/" + project + "/" + archiveOnDisk + "/" + party + "/" + receivedSeg + "/"
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed {
continue
}
@ -132,7 +154,8 @@ func computePending(ctx context.Context, decider policy.Decider,
// is WORM-readable to anyone with party access by design, and
// we just need the set membership for matching).
issuedTrackings := map[string]bool{}
if entries, err := os.ReadDir(filepath.Join(partyAbs, "issued")); err == nil {
if issuedSeg != "" {
if entries, err := os.ReadDir(filepath.Join(partyAbs, issuedSeg)); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
@ -142,6 +165,7 @@ func computePending(ctx context.Context, decider policy.Decider,
}
}
}
}
receivedEntries, err := os.ReadDir(receivedAbs)
if err != nil {
@ -172,7 +196,7 @@ func computePending(ctx context.Context, decider policy.Decider,
lastModified: modTime,
}
if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft {
sub.stagedURL = "/" + project + "/staging/" + url.PathEscape(stagedFolder) + "/"
sub.stagedURL = "/" + project + "/" + stagingOnDisk + "/" + url.PathEscape(stagedFolder) + "/"
if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil {
if stagedInfo.ModTime().After(modTime) {
sub.lastModified = stagedInfo.ModTime()