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:
parent
002e034119
commit
d1a5a14132
1 changed files with 43 additions and 19 deletions
|
|
@ -83,22 +83,35 @@ func computePending(ctx context.Context, decider policy.Decider,
|
||||||
fsRoot, project, email string) ([]pendingSubmittal, error) {
|
fsRoot, project, email string) ([]pendingSubmittal, error) {
|
||||||
|
|
||||||
projectAbs := filepath.Join(fsRoot, project)
|
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.
|
// Index staging by tracking → folder name.
|
||||||
stagedByTracking := map[string]string{}
|
stagedByTracking := map[string]string{}
|
||||||
if entries, err := os.ReadDir(stagingAbs); err == nil {
|
var stagingAbs string
|
||||||
for _, e := range entries {
|
if stagingOnDisk != "" {
|
||||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
stagingAbs = filepath.Join(projectAbs, stagingOnDisk)
|
||||||
continue
|
if entries, err := os.ReadDir(stagingAbs); err == nil {
|
||||||
}
|
for _, e := range entries {
|
||||||
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
|
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||||
stagedByTracking[tracking] = e.Name()
|
continue
|
||||||
|
}
|
||||||
|
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
|
||||||
|
stagedByTracking[tracking] = e.Name()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if archiveOnDisk == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
archiveAbs := filepath.Join(projectAbs, archiveOnDisk)
|
||||||
parties, err := os.ReadDir(archiveAbs)
|
parties, err := os.ReadDir(archiveAbs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|
@ -114,7 +127,13 @@ func computePending(ctx context.Context, decider policy.Decider,
|
||||||
}
|
}
|
||||||
party := p.Name()
|
party := p.Name()
|
||||||
partyAbs := filepath.Join(archiveAbs, party)
|
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.
|
// ACL: skip parties whose received/ subtree the caller can't read.
|
||||||
// Filtering at the party level is cheaper than per-entry and matches
|
// 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 {
|
if err != nil {
|
||||||
continue
|
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 {
|
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -132,13 +154,15 @@ func computePending(ctx context.Context, decider policy.Decider,
|
||||||
// is WORM-readable to anyone with party access by design, and
|
// is WORM-readable to anyone with party access by design, and
|
||||||
// we just need the set membership for matching).
|
// we just need the set membership for matching).
|
||||||
issuedTrackings := map[string]bool{}
|
issuedTrackings := map[string]bool{}
|
||||||
if entries, err := os.ReadDir(filepath.Join(partyAbs, "issued")); err == nil {
|
if issuedSeg != "" {
|
||||||
for _, e := range entries {
|
if entries, err := os.ReadDir(filepath.Join(partyAbs, issuedSeg)); err == nil {
|
||||||
if !e.IsDir() {
|
for _, e := range entries {
|
||||||
continue
|
if !e.IsDir() {
|
||||||
}
|
continue
|
||||||
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
|
}
|
||||||
issuedTrackings[tracking] = true
|
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
|
||||||
|
issuedTrackings[tracking] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +196,7 @@ func computePending(ctx context.Context, decider policy.Decider,
|
||||||
lastModified: modTime,
|
lastModified: modTime,
|
||||||
}
|
}
|
||||||
if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft {
|
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, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil {
|
||||||
if stagedInfo.ModTime().After(modTime) {
|
if stagedInfo.ModTime().After(modTime) {
|
||||||
sub.lastModified = stagedInfo.ModTime()
|
sub.lastModified = stagedInfo.ModTime()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue