package zddc import ( "os" "path/filepath" "strings" ) // ProjectRootFolders are the canonical lowercase folder names that may // appear directly under a project root. The server resolves them // case-insensitively on disk: a manually-created Working/ is reused // rather than shadowed by a new working/. // // - "archive" — formal record of issued/received transmittals, // organised by counterparty (and ourselves) // - "working" — user-owned drafting workspace // - "staging" — outbound-transmittal preparation // - "reviewing" — purely virtual cross-reference of in-progress // review responses (never written to disk) var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"} // PartyFolders are the canonical lowercase folder names that may appear // directly under archive//, where is a counterparty or // the self-folder (we treat ourselves like any other third party). // // - "mdl" — yaml-per-deliverable metadata, edited via the // table-editor app at /mdl.table.html // - "incoming" — that party's drop point (we QC then promote) // - "received" — immutable record of incoming we've accepted (WORM) // - "issued" — immutable record of what we sent (WORM) var PartyFolders = []string{"mdl", "incoming", "received", "issued"} // AutoOwnCanonicalNames is the subset of canonical folder names where // the file API's first-write hook auto-writes a creator-owned .zddc // granting the creator rwcda. Excluded by design: // // - "archive": container only // - "reviewing": purely virtual, never on disk // - "mdl": yaml data store; ACL flows from archive//.zddc // - "received" / "issued": WORM — auto-own would defeat the mask var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"} // VirtualOnlyCanonicalNames is the subset of canonical folder names // that are never materialised on disk by the auto-create hooks. The // server treats requests under these prefixes as virtual routes. // // "reviewing" stays in ProjectRootFolders so case-fold recognition and // future tool registration work, but EnsureCanonicalAncestors skips // MkdirAll for it. var VirtualOnlyCanonicalNames = []string{"reviewing"} // IsProjectRootFolder reports whether dirPath (relative to fsRoot, // forward-slash-separated, no leading slash) names one of the canonical // project-root folders at exactly depth 2: /. // Match is case-insensitive against ProjectRootFolders. // // Used by the directory listing endpoint to materialise an empty // listing for canonical folders that don't yet exist on disk, so a // fresh project's nav links never land on 404. The first write under // such a path triggers EnsureCanonicalAncestors which lazily creates // the real on-disk folder + auto-own .zddc. // // Trailing slashes and accidental "./" segments are tolerated. Paths // of any other depth (e.g. project root itself, or deeper subpaths // like working//) return false — the fallback only applies at // the canonical-folder boundary. func IsProjectRootFolder(dirPath string) bool { clean := strings.Trim(filepath.ToSlash(dirPath), "/") if clean == "" { return false } parts := strings.Split(clean, "/") if len(parts) != 2 { return false } for _, name := range ProjectRootFolders { if strings.EqualFold(parts[1], name) { return true } } return false } // WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting // principalEmail rwcda and recording it in CreatedBy. Used by the file // API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed // ownership when a new auto-own folder is materialised. // // The grant is identical to what an operator would write by hand — // direct email pattern, "rwcda" verb set — so the creator can later // edit the file normally to add collaborators. // // Atomic: marshals via the same yaml encoder ParseFile reads // (round-trip guaranteed) and writes via zddc.WriteFile (which // performs an atomic temp-write + rename via zddc.WriteAtomic). func WriteAutoOwnZddc(dir, principalEmail string) error { return writeAutoOwn(dir, principalEmail, false) } // WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally // sets `acl.inherit: false` — fencing ancestor cascade grants. Used at // per-user home folders under working/ where the convention is "private // by default; owner edits the file to add collaborators." // // Without the fence, an ancestor `*: r` (e.g. a project-root grant for // authenticated users) would let any user read every other user's // working subfolder via cascade — defeating the per-user sandbox. func WriteAutoOwnZddcFenced(dir, principalEmail string) error { return writeAutoOwn(dir, principalEmail, true) } func writeAutoOwn(dir, principalEmail string, fenced bool) error { rules := ACLRules{ Permissions: map[string]string{principalEmail: "rwcda"}, } if fenced { f := false rules.Inherit = &f } zf := ZddcFile{ ACL: rules, CreatedBy: principalEmail, } return WriteFile(dir, zf) } // ResolveCanonical returns the on-disk name of the canonical folder // 'logical' (lowercase) inside parentDir, or "" if no case variant // exists. Caller decides whether to MkdirAll(parentDir+"/"+logical) // when "" is returned. // // 'logical' is matched case-insensitively against entries returned by // os.ReadDir(parentDir). The first matching directory entry wins (if // an operator created both Working/ and working/ on a case-sensitive // filesystem, the order is filesystem-dependent — that's an unsupported // state we don't try to recover from). // // Returns "" with no error if parentDir doesn't exist or has no match. func ResolveCanonical(parentDir, logical string) (string, error) { entries, err := os.ReadDir(parentDir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", err } for _, e := range entries { if !e.IsDir() { continue } if strings.EqualFold(e.Name(), logical) { return e.Name(), nil } } return "", nil } // IsAutoOwnPath reports whether parentDir is one of the canonical // auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir // inside such a directory should receive a creator-owned .zddc. // // Canonical positions, relative to fsRoot: // // - /working // - /staging // - /archive//incoming // // Segment matches are case-insensitive on canonical names. The project // and party names are unrestricted. // // parentDir and fsRoot are filesystem paths. parentDir must be inside // fsRoot; otherwise the function returns false. func IsAutoOwnPath(parentDir, fsRoot string) bool { rel, err := filepath.Rel(fsRoot, parentDir) if err != nil { return false } rel = filepath.ToSlash(rel) if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." { return false } parts := strings.Split(rel, "/") switch len(parts) { case 2: // /working or /staging return strings.EqualFold(parts[1], "working") || strings.EqualFold(parts[1], "staging") case 4: // /archive//incoming return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "incoming") } return false } // IsWormPath reports whether requestPath crosses an // archive//received/ or archive//issued/ segment chain. // Pure path-segment check; case-fold on canonical names. // // The party segment is unrestricted — any directory under archive/ is // treated as a party, including the self-folder. requestPath may be a // URL path ("/Project/archive/ACME/issued/foo.pdf") or a filesystem // path; only segment names matter. func IsWormPath(requestPath string) bool { parts := splitPathSegments(requestPath) for i := 0; i+2 < len(parts); i++ { if !strings.EqualFold(parts[i], "archive") { continue } // parts[i+1] is the party name (anything). if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") { return true } } return false } // WormMask reduces a verb set to the subset that survives the WORM // constraint: the bitwise AND with VerbsRC. Removes w, d, and a. // // Callers apply this only when IsWormPath(path) is true AND the // principal is NOT an admin (root admin or subtree admin) — admins // are the deliberate escape hatch for mis-filed documents. // // The WORM mask is split-aware via WormFolderLevelIndex: grants // inherited from ancestors above the received/issued folder are // masked to read only ({r}), while grants at-or-below the WORM // folder retain {r, c} so an operator can place a .zddc at the // received/issued folder explicitly granting `_doc_controller: cr`. func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC } // WormFolderLevelIndex returns the chain index of the deepest // archive//(received|issued) segment in requestPath. The chain // corresponds to the directory tree from root (index 0) to the // requested directory; level i is the .zddc at path segment depth i. // // numLevels is len(chain.Levels); used to clamp results to the chain's // actual range. URL segment i lives at chain index i+1 (root is chain // index 0), so the WORM segment at parts[i+2] corresponds to chain // index i+3. // // Returns -1 if no WORM segment is in the request path or the computed // index is out of range. The returned index satisfies // 0 <= index < numLevels. func WormFolderLevelIndex(requestPath string, numLevels int) int { if numLevels <= 0 { return -1 } parts := splitPathSegments(requestPath) deepest := -1 for i := 0; i+2 < len(parts); i++ { if !strings.EqualFold(parts[i], "archive") { continue } if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") { idx := i + 3 if idx < numLevels && idx > deepest { deepest = idx } } } return deepest } // splitPathSegments returns the slash-separated segments of p with // empty elements removed. Tolerates leading/trailing slashes and // mixed separators on Windows (via filepath.ToSlash). func splitPathSegments(p string) []string { clean := strings.Trim(filepath.ToSlash(p), "/") if clean == "" { return nil } return strings.Split(clean, "/") }