@feng0531/dsh-vcs 0.3.62 → 0.3.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ import{createHandler as Ie}from"../index.mjs";import{XMLParser as he,XMLValidator as me}from"fast-xml-parser";function O(r){if(typeof r!="string"||Buffer.byteLength(r)>2097152||/<!DOCTYPE|<!ENTITY/i.test(r)||me.validate(r)!==!0)throw Error("Invalid DAV XML");let t=new he({preserveOrder:!0,ignoreAttributes:!1,parseTagValue:!1,parseAttributeValue:!1,trimValues:!1}).parse(r),o=0,e=(s,n,i=0)=>{if(i>32)throw Error("DAV XML too deep");let a=Object.create(null),f="";for(let c of s){if(++o>1e4)throw Error("DAV XML too many nodes");if(Object.hasOwn(c,"#text")){f+=c["#text"];continue}let p=Object.keys(c).filter(D=>D!==":@");if(p.length!==1)throw Error("Invalid DAV XML node");let l=p[0];if(l==="?xml"){if(i!==0)throw Error("Unexpected XML declaration");continue}let m={...n},g={};for(let[D,V]of Object.entries(c[":@"]||{}))D==="@_xmlns"?m[""]=V:D.startsWith("@_xmlns:")?m[D.slice(8)]=V:g[D]=V;let y=l.split(":");if(y.length>2)throw Error("Invalid DAV XML name");let u=y.length===2?y[0]:"",d=y.at(-1),h=m[u],S=h==="DAV:"?"D:"+d:h==="http://subversion.tigris.org/xmlns/dav/"?"S:"+d:h==="svn:"?"L:"+d:h==="http://subversion.tigris.org/xmlns/svn/"?"P:"+d:h==="http://subversion.tigris.org/xmlns/custom/"?"C:"+d:null;if(!S)throw Error("Unsupported DAV namespace");let b=["L:added-path","L:deleted-path","L:modified-path","L:replaced-path"].includes(S);if(Object.keys(g).length&&(!b||Object.keys(g).some(D=>!["@_node-kind","@_text-mods","@_prop-mods","@_copyfrom-path","@_copyfrom-rev"].includes(D))))throw Error("Unexpected DAV attribute");let v=e(c[l],m,i+1);if(b&&typeof v!="string")throw Error("Invalid DAV path text");let E=b?{...g,"#text":v}:v;if(["D:response","D:propstat","L:log-item","L:added-path","L:deleted-path","L:modified-path","L:replaced-path"].includes(S))(a[S]??=[]).push(E);else{if(Object.hasOwn(a,S))throw Error("Duplicate DAV expanded name");a[S]=E}}if(!Object.keys(a).length)return f;if(f.trim())throw Error("Mixed DAV XML content");return a};return e(t,{})}var ve=9223372036854775807n;function w(r){if(typeof r!="string"||r.length>19||!/^(0|[1-9][0-9]*)$/.test(r))throw new Error("Invalid SVN revision");let t=BigInt(r);if(t>ve)throw new Error("SVN revision exceeds supported range");return t}function H(r){let t=w(r);return t===0n?null:String(t-1n)}function j(r,{snapshot:t,cursor:o=t,limit:e=50}={}){let s=w(t),n=w(o);if(n>s||!Number.isInteger(e)||e<1||e>100||!Array.isArray(r)||r.length>e+1)throw new Error("Invalid SVN page bounds");let i=n+1n;for(let f of r){let c=w(f);if(c>n||c>=i)throw new Error("SVN revisions must descend within cursor");i=c}let a=r.slice(0,e);return{snapshot:t,revisions:a,nextRevision:r.length>e?H(a.at(-1)):null}}function J(r,t){let o=O(r);if(!o||typeof o!="object"||Object.keys(o).length!==1||!Object.hasOwn(o,"L:log-report"))throw Error("Invalid DAV log root");let e=o["L:log-report"],s;if(e==="")s=[];else{if(!e||typeof e!="object"||Object.keys(e).some(a=>a!=="L:log-item")||!Array.isArray(e["L:log-item"]))throw Error("Invalid DAV log structure");s=e["L:log-item"]}if(s.length>101)throw Error("DAV log entries exceed limit");let n=s.map(a=>{if(!a||typeof a!="object"||Array.isArray(a)||Object.keys(a).some(c=>!["D:version-name","D:comment","D:creator-displayname","L:date"].includes(c)))throw Error("Unsupported DAV log item");let f=c=>{let p=a[c]??"";if(typeof p!="string")throw Error("Invalid DAV log text");return p};return{revision:f("D:version-name"),message:f("D:comment"),author:f("D:creator-displayname"),date:f("L:date")}}),i=j(n.map(a=>a.revision),t);return{snapshot:i.snapshot,entries:n.slice(0,i.revisions.length),nextRevision:i.nextRevision}}function k(r){if(typeof r!="string"||r.length>32768||!r.startsWith("/")||/[\u0000-\u001f\u007f\\]/.test(r))throw new Error("Invalid SVN repository path");if(r!=="/"&&r.slice(1).split("/").some(t=>t===""||t==="."||t===".."))throw new Error("Noncanonical SVN repository path");return r}function T(r,t){return k(r),k(t),t==="/"||r===t||r.startsWith(t+"/")}function q(r,t){if(!Array.isArray(r)||r.length>1e4)throw new Error("Invalid SVN path nodes");let o=r.map(e=>{let s=["#text","@_action","@_kind","@_copyfrom-path","@_copyfrom-rev","@_text-mods","@_prop-mods"];if(!e||typeof e!="object"||Array.isArray(e)||Object.keys(e).some(n=>!s.includes(n)))throw new Error("Invalid SVN path node");for(let n of["@_text-mods","@_prop-mods"])if(Object.hasOwn(e,n)&&!["true","false","unknown"].includes(e[n]))throw new Error("Invalid SVN modification flag");return{...Object.hasOwn(e,"@_text-mods")?{textModified:e["@_text-mods"]}:{},...Object.hasOwn(e,"@_prop-mods")?{propertiesModified:e["@_prop-mods"]}:{},path:e["#text"],action:e["@_action"],kind:e["@_kind"],...Object.hasOwn(e,"@_copyfrom-path")?{copyFromPath:e["@_copyfrom-path"]}:{},...Object.hasOwn(e,"@_copyfrom-rev")?{copyFromRevision:e["@_copyfrom-rev"]}:{}}});return W(o,t)}function W(r,{scope:t,revision:o}={}){k(t);let e=w(o);if(!Array.isArray(r)||r.length>1e4||e===0n&&r.length!==0)throw new Error("Invalid SVN changed paths");let s=new Set;return r.map(i=>{if(!i||typeof i!="object"||Array.isArray(i)||Object.keys(i).some(u=>!["path","action","kind","copyFromPath","copyFromRevision","copySourceOutsideScope","textModified","propertiesModified"].includes(u)))throw new Error("Invalid SVN change structure");let{path:a,action:f,kind:c,copyFromPath:p,copyFromRevision:l}=i;if(k(a),s.has(a)||!["A","D","M","R"].includes(f)||!["file","dir","unknown"].includes(c))throw new Error("Invalid SVN change");s.add(a);let m=p!==void 0||l!==void 0;if(m&&(k(p),!["A","R"].includes(f)||w(l)>=e))throw new Error("Invalid SVN copy source");let g=Object.hasOwn(i,"copySourceOutsideScope");if(g&&(i.copySourceOutsideScope!==!0||m||!["A","R"].includes(f)))throw new Error("Invalid SVN redacted copy source");let y={path:a,action:f,kind:c,...g?{copySourceOutsideScope:!0}:{}};for(let u of["textModified","propertiesModified"])if(Object.hasOwn(i,u)){if(!["true","false","unknown"].includes(i[u]))throw new Error("Invalid SVN modification flag");y[u]=i[u]}return m&&(T(p,t)?(y.copyFromPath=p,y.copyFromRevision=l):y.copySourceOutsideScope=!0),y}).filter(i=>T(i.path,t))}function G(r,{scope:t,revision:o}={}){w(o);let e=O(r),s=(p,l)=>{if(!p||typeof p!="object"||Array.isArray(p)||Object.keys(p).some(m=>!l.includes(m)))throw Error("Invalid DAV detail structure")};s(e,["L:log-report"]);let n=e["L:log-report"];if(s(n,["L:log-item"]),n["L:log-item"]?.length!==1)throw Error("One DAV detail revision required");let i=n["L:log-item"][0],a={"L:added-path":"A","L:deleted-path":"D","L:modified-path":"M","L:replaced-path":"R"};if(s(i,["D:version-name","D:comment","D:creator-displayname","L:date",...Object.keys(a)]),i["D:version-name"]!==o)throw Error("DAV detail revision mismatch");let f=p=>{let l=i[p]??"";if(typeof l!="string")throw Error("Invalid DAV detail text");return l},c=[];for(let[p,l]of Object.entries(a))for(let m of i[p]??[]){let{"@_node-kind":g,...y}=m;c.push({...y,"@_kind":g??"unknown","@_action":l})}return{revision:o,author:f("D:creator-displayname"),date:f("L:date"),message:f("D:comment"),changes:q(c,{scope:t,revision:o})}}function I(r,t,o){if(k(t),w(o),typeof r!="string"||r.length>32768||/[\x00-\x20\x7f\\?#]/.test(r))throw new Error("Invalid SVN HTTPS root");let e=new URL(r);if(e.protocol!=="https:"||e.username||e.password||e.href!==r)throw new Error("Noncanonical SVN HTTPS root");let s=e.pathname.split("/").slice(1);s.at(-1)===""&&s.pop();for(let f of s){let c=decodeURIComponent(f);if(!f||c==="."||c===".."||/[\x00-\x1f\x7f/\\]/.test(c))throw new Error("Invalid SVN root path")}let n=r.endsWith("/")?r.slice(0,-1):r,i=t==="/"?"":t.split("/").map(f=>encodeURIComponent(f).replace(/[!'()*]/g,c=>"%"+c.charCodeAt(0).toString(16).toUpperCase())).join("/"),a=n+(i||(e.pathname==="/"?"/":""))+"@"+o;if(a.length>32768)throw new Error("SVN target too long");return a}function B({root:r,scope:t,path:o=t,snapshot:e,revision:s}={}){if(w(s)>w(e))throw new Error("SVN selected revision exceeds snapshot");if(!T(o,t))throw new Error("SVN detail path outside scope");let n=I(r,o,e);return{scope:t,revision:s,snapshot:e,args:["log","--xml","--verbose","--stop-on-copy","--non-interactive","--no-auth-cache","--limit","1","-r",s+":"+s,"--",n]}}function M({root:r,scope:t,path:o=t,snapshot:e,cursor:s=e,limit:n=50}={}){if(j([],{snapshot:e,cursor:s,limit:n}),!T(o,t))throw new Error("SVN history path outside scope");let i=I(r,o,e);return{snapshot:e,cursor:s,limit:n,args:["log","--xml","--stop-on-copy","--non-interactive","--no-auth-cache","--limit",String(n+1),"-r",s+":0","--",i]}}function C(r,t){let o=r?.args;if(!Array.isArray(o)||o.length>16||o.some(l=>typeof l!="string"))throw Error("Invalid SVN command");let e=o.at(-1),s=e.lastIndexOf("@"),n=e.slice(s+1);w(n);let i=t.root.endsWith("/")?t.root.slice(0,-1):t.root;I(t.root,"/",n);let a=e.slice(0,s);if(!(a===i||a.startsWith(i+"/")))throw Error("SVN command target outside root");let f=a.slice(i.length),c=f?"/"+f.slice(1).split("/").map(l=>decodeURIComponent(l)).join("/"):"/";if(!T(c,t.scope)||I(t.root,c,n)!==e)throw Error("SVN command target outside scope or noncanonical");let p;if(o[0]==="cat"||o[0]==="proplist"){w(o[o[0]==="cat"?4:5]);let l=o[o[0]==="cat"?4:5];if(l!==n)throw Error("SVN content operative and peg mismatch");p=o[0]==="cat"?["cat","--non-interactive","--no-auth-cache","-r",l,"--",e]:["proplist","--xml","--non-interactive","--no-auth-cache","-r",l,"--",e]}else if(o[0]==="log")if(o.includes("--verbose")){let l=o[9]?.split(":")[0];p=B({...t,path:c,snapshot:n,revision:l}).args}else{let l=o[8]?.split(":")[0],m=Number(o[6])-1;p=M({...t,path:c,snapshot:n,cursor:l,limit:m}).args}else throw Error("SVN command is not readonly history");if(JSON.stringify(o)!==JSON.stringify(p))throw Error("Unsupported SVN command shape");return{path:c,pegRevision:n,args:[...p]}}function K(r,{scope:t,revision:o,path:e}={}){if(w(o)===0n)throw new Error("SVN r0 has no change comparison");let s=W(r,{scope:t,revision:o}).find(a=>a.path===e);if(!s)throw new Error("SVN path is not an authorized changed member");if(s.kind!=="file")throw new Error("Only SVN file changes can be compared");let n=H(o),i=a=>a===null?{empty:!0}:{empty:!1,path:s.path,revision:a,pegRevision:a};return{path:s.path,action:s.action,left:i(s.action==="A"?null:n),right:i(s.action==="D"?null:o)}}import{XMLParser as ge,XMLValidator as we}from"fast-xml-parser";function Q(r,{target:t,allowEmpty:o=!1}={}){if(typeof t!="string"||!t||t.length>32768)throw new Error("Invalid SVN property target");if(typeof r!="string"||Buffer.byteLength(r,"utf8")>2*1024*1024||/<!DOCTYPE|<!ENTITY/i.test(r)||we.validate(r)!==!0)throw new Error("Invalid SVN property XML");let e=new ge({ignoreAttributes:!1,parseAttributeValue:!1,parseTagValue:!1,trimValues:!1,isArray:p=>p==="target"||p==="property"}).parse(r),s=(p,l)=>{if(!p||typeof p!="object"||Array.isArray(p)||Object.keys(p).some(m=>!l.includes(m)&&!(m==="#text"&&typeof p[m]=="string"&&/^[ \t\r\n]*$/.test(p[m]))))throw new Error("Invalid SVN property structure")};if(s(e,["?xml","properties"]),o===!0&&typeof e.properties=="string"&&/^[ \t\r\n]*$/.test(e.properties))return{target:t,names:[],special:!1};s(e.properties,["target"]);let n=e.properties.target;if(!Array.isArray(n)||n.length!==1)throw new Error("Expected one SVN property target");let i=n[0];if(s(i,["@_path","property"]),i["@_path"]!==t)throw new Error("SVN property target mismatch");let a=i.property??[];if(!Array.isArray(a)||a.length>1e4)throw new Error("Too many SVN properties");let f=[],c=new Set;for(let p of a){s(p,["@_name"]);let l=p["@_name"];if(typeof l!="string"||!l||l.length>1024||/[\x00-\x20\x7f]/.test(l)||c.has(l))throw new Error("Invalid SVN property name");c.add(l),f.push(l)}return{target:t,names:f,special:c.has("svn:special")}}import{createHash as Ve}from"node:crypto";import{XMLParser as ye,XMLValidator as Se}from"fast-xml-parser";function Z(r,{scope:t,revision:o}={}){if(w(o),typeof r!="string"||Buffer.byteLength(r,"utf8")>2*1024*1024||/<!DOCTYPE|<!ENTITY/i.test(r)||Se.validate(r)!==!0)throw new Error("Invalid SVN detail XML");let e=new ye({ignoreAttributes:!1,parseAttributeValue:!1,parseTagValue:!1,trimValues:!1,isArray:c=>c==="logentry"||c==="path"}).parse(r),s=(c,p)=>{if(!c||typeof c!="object"||Array.isArray(c)||Object.keys(c).some(l=>!p.includes(l)&&!(l==="#text"&&typeof c[l]=="string"&&/^[ \t\r\n]*$/.test(c[l]))))throw new Error("Invalid SVN detail structure")};if(s(e,["?xml","log"]),s(e.log,["logentry"]),!Array.isArray(e.log.logentry)||e.log.logentry.length!==1)throw new Error("Expected one SVN revision");let n=e.log.logentry[0];if(s(n,["@_revision","author","date","msg","paths"]),n["@_revision"]!==o)throw new Error("SVN detail revision mismatch");let i=c=>{let p=n[c]??"";if(typeof p!="string")throw new Error("Invalid SVN detail text");return p},a=[];n.paths!==void 0&&!(typeof n.paths=="string"&&/^[ \t\r\n]*$/.test(n.paths))&&(s(n.paths,["path"]),a=n.paths.path??[]);let f=q(a,{scope:t,revision:o});return{revision:o,author:i("author"),date:i("date"),message:i("msg"),pathsAvailable:Object.hasOwn(n,"paths"),changes:f}}import{randomBytes as De}from"node:crypto";function ee(r){if(!r||typeof r!="object")throw Error("Invalid SVN consent identity");let{sessionId:t,cwd:o,repositoryId:e,root:s,uuid:n,scope:i}=r;for(let a of[t,o,e])if(typeof a!="string"||!a||a.length>32768||a.includes("\0"))throw Error("Invalid SVN consent scope");if(I(s,"/","0"),k(i),typeof n!="string"||!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(n))throw Error("Invalid SVN repository UUID");return JSON.stringify([t,o,e,s,n,i])}function F({now:r=Date.now,ttlMs:t=3e5,maxEntries:o=32,onRevoke:e=()=>{}}={}){if(!Number.isInteger(t)||t<1||t>3e5||!Number.isInteger(o)||o<1||o>32)throw Error("Invalid SVN consent bounds");if(typeof e!="function")throw Error("Invalid SVN revoke listener");let s=new Map,n=a=>{s.delete(a)&&e(a)},i=()=>{for(let[a,f]of s)f.expires<=r()&&n(a)};return{grant(a,{explicit:f=!1}={}){if(f!==!0)throw Error("Explicit SVN network consent required");let c=ee(a);i();for(let[l,m]of s)m.key===c&&n(l);for(;s.size>=o;)n(s.keys().next().value);let p=De(32).toString("hex");return s.set(p,{key:c,sessionId:a.sessionId,expires:r()+t}),p},assert(a,f){i();let c=ee(f),p=s.get(a);if(!p||p.key!==c)throw Object.assign(Error("SVN network consent missing, expired or identity changed"),{code:"vcs/svn-consent-required"})},revokeSession(a){for(let[f,c]of s)c.sessionId===a&&n(f)},revoke(a){n(a)},clear(){for(let a of[...s.keys()])n(a)}}}import{XMLParser as be,XMLValidator as Ee}from"fast-xml-parser";function re(r,t){let o=Ae(r),e=j(o.map(s=>s.revision),t);return{snapshot:e.snapshot,entries:o.slice(0,e.revisions.length),nextRevision:e.nextRevision}}function Ae(r){if(typeof r!="string"||Buffer.byteLength(r,"utf8")>2*1024*1024||/<!DOCTYPE|<!ENTITY/i.test(r)||Ee.validate(r)!==!0)throw new Error("Invalid SVN log XML");let o=new be({ignoreAttributes:!1,parseAttributeValue:!1,parseTagValue:!1,trimValues:!1,isArray:n=>n==="logentry"}).parse(r);if(!Object.hasOwn(o,"log")||Object.keys(o).some(n=>n!=="log"&&n!=="?xml"))throw new Error("Invalid SVN log root");let e=o.log;if(typeof e=="string"&&/^[ \t\r\n]*$/.test(e))return[];if(e&&typeof e=="object"&&typeof e["#text"]=="string"&&/^[ \t\r\n]*$/.test(e["#text"])&&delete e["#text"],!e||typeof e!="object"||Object.keys(e).some(n=>n!=="logentry")||!Array.isArray(e.logentry)||e.logentry.length>101)throw new Error("Invalid SVN log entries");let s=new Set;return e.logentry.map(n=>{if(!n||typeof n!="object"||Array.isArray(n)||Object.keys(n).some(f=>!["@_revision","author","date","msg","#text"].includes(f))||"#text"in n&&(typeof n["#text"]!="string"||!/^[ \t\r\n]*$/.test(n["#text"])))throw new Error("Unsupported SVN log entry structure");let i=n["@_revision"];if(w(i),s.has(i))throw new Error("Duplicate SVN revision");s.add(i);let a=f=>{let c=n[f]??"";if(typeof c!="string")throw new Error("Invalid SVN log text");return c};return{revision:i,author:a("author"),date:a("date"),message:a("msg")}})}function te({resolveIdentity:r,transport:t,now:o=Date.now,timeoutMs:e=15e3,transportFormat:s="cli"}={}){if(!["cli","dav"].includes(s))throw Error("Invalid SVN transport format");if(!Number.isInteger(e)||e<1||e>15e3)throw Error("Invalid SVN runtime deadline");if(typeof r!="function")throw Error("Local identity resolver required");let n=F({now:o}),i=F({now:o,onRevoke:u=>m(u)}),a=!1,f=()=>{if(a)throw new DOMException("SVN runtime disposed","AbortError")},c=async(u,d)=>{f(),d?.throwIfAborted();let h=await r(u,d);return f(),d?.throwIfAborted(),{...h,sessionId:u.sessionId,cwd:h.cwd,repositoryId:u.repositoryId}},p=u=>u.revision===void 0?{}:(w(u.revision),{snapshot:u.revision}),l=new Map,m=u=>{for(let d of l.get(u)||[])d.controller.abort(new DOMException("SVN consent revoked","AbortError"))},g=async(u,d)=>{f(),C(u,d.identity),d.signal?.throwIfAborted(),i.assert(d.token,d.identity);let h=new AbortController,S=()=>h.abort(d.signal.reason);d.signal?.addEventListener("abort",S,{once:!0});let b={controller:h,sessionId:d.identity.sessionId};l.has(d.token)||l.set(d.token,new Set),l.get(d.token).add(b);let v=setTimeout(()=>h.abort(new DOMException("SVN transport deadline exceeded","TimeoutError")),e);try{let E=async()=>{h.signal.throwIfAborted();let V=await c({sessionId:d.identity.sessionId,repositoryId:d.identity.repositoryId},h.signal);i.assert(d.token,V),h.signal.throwIfAborted()},D=await t(u,{...d,timeoutMs:e,signal:h.signal,assertAuthorized:E});if(h.signal.throwIfAborted(),!(typeof D=="string"||Buffer.isBuffer(D))||Buffer.byteLength(D)>d.maxBytes)throw Error("SVN transport output exceeds byte limit or has invalid type");return Buffer.isBuffer(D)&&u.args[0]!=="cat"?new TextDecoder("utf-8",{fatal:!0}).decode(D):D}catch(E){throw h.signal.aborted?h.signal.reason:E}finally{let E=l.get(d.token);E?.delete(b),E?.size||l.delete(d.token),clearTimeout(v),d.signal?.removeEventListener("abort",S)}},y={async describe(u,{signal:d}={}){let h=await c(u,d),S=p(h),b=n.grant(h,{explicit:!0});return{...S,offer:b,root:h.root,uuid:h.uuid,scope:h.scope,origin:new URL(h.root).origin,expiresAt:o()+3e5,remoteEnabled:typeof t=="function"}},async approve(u,{offer:d,explicit:h=!1,signal:S}={}){if(h!==!0)throw Error("Explicit SVN network consent required");let b=await c(u,S);if(n.assert(d,b),typeof t!="function")throw Error("SVN remote transport disabled");let v=p(b),E=i.grant(b,{explicit:!0});return n.revoke(d),{...v,token:E,expiresAt:o()+3e5}},async log(u,{token:d,snapshot:h,cursor:S,limit:b,path:v,signal:E}={}){let D=await c(u,E);if(i.assert(d,D),typeof t!="function")throw Error("SVN remote transport disabled");let V=M({...D,snapshot:h,cursor:S,limit:b,path:v});E?.throwIfAborted();let A=await g(V,{identity:D,token:d,signal:E,maxBytes:2097152,timeoutMs:15e3});E?.throwIfAborted();let x=await c(u,E);return i.assert(d,x),(s==="dav"?J:re)(A,V)},async detail(u,{token:d,snapshot:h,revision:S,signal:b}={}){let v=await c(u,b);if(i.assert(d,v),typeof t!="function")throw Error("SVN remote transport disabled");let E=B({...v,snapshot:h,revision:S}),D=await g(E,{identity:v,token:d,signal:b,maxBytes:2097152,timeoutMs:15e3});b?.throwIfAborted(),i.assert(d,await c(u,b));let V=s==="dav"?{...G(D,E),pathsAvailable:!0}:Z(D,E);return{...V,snapshot:h,changes:V.changes.map(A=>({...A,id:Ve("sha256").update(JSON.stringify([v.root,v.uuid,v.scope,h,S,A])).digest("hex")}))}},async compare(u,{token:d,snapshot:h,revision:S,id:b,signal:v}={}){if(typeof b!="string"||!/^[a-f0-9]{64}$/.test(b))throw Error("Invalid SVN change ID");let E=await y.detail(u,{token:d,snapshot:h,revision:S,signal:v}),D=E.changes.find(N=>N.id===b);if(!D)throw Error("SVN change is not selected revision member");let V=await c(u,v);i.assert(d,V);let A=K(E.changes.map(({id:N,..._})=>_),{scope:V.scope,revision:S,path:D.path}),x=async N=>{if(N.empty)return{label:"empty",text:"",absent:!0};let _=await c(u,v);i.assert(d,_);let U=I(_.root,N.path,N.pegRevision),Y={identity:_,token:d,signal:v,maxBytes:2097152,timeoutMs:15e3},ue=await g({args:["proplist","--xml","--non-interactive","--no-auth-cache","-r",N.revision,"--",U]},Y);if(v?.throwIfAborted(),i.assert(d,await c(u,v)),Q(ue,{target:U.slice(0,U.lastIndexOf("@")),allowEmpty:!0}).special)throw Error("SVN special files cannot be compared");let X=await g({args:["cat","--non-interactive","--no-auth-cache","-r",N.revision,"--",U]},Y);v?.throwIfAborted(),i.assert(d,await c(u,v));let $=Buffer.isBuffer(X)?X:Buffer.from(X);if($.length>2097152)throw Error("SVN text exceeds 2 MiB");if($.includes(0))return{label:"r"+N.revision,text:"",binary:!0};let z;try{z=new TextDecoder("utf-8",{fatal:!0}).decode($)}catch{return{label:"r"+N.revision,text:"",binary:!0}}return{label:"r"+N.revision,text:z}},R=await x(A.left),P=await x(A.right);return{path:D.path,revision:S,snapshot:h,left:R,right:P,binary:!!(R.binary||P.binary)}},async trace(u,{token:d,snapshot:h,revision:S,id:b,cursor:v,limit:E=50,signal:D}={}){if(typeof b!="string"||!/^[a-f0-9]{64}$/.test(b))throw Error("Invalid SVN change ID");let V=await y.detail(u,{token:d,snapshot:h,revision:S,signal:D}),A=V.changes.find(P=>P.id===b);if(!A||A.kind!=="file")throw Error("SVN tracing requires selected file member");let x=A.action==="D"?H(S):S;return{...await y.log(u,{token:d,snapshot:x,cursor:v??x,limit:E,path:A.path,signal:D}),selectionSnapshot:h,selectionRevision:S,path:A.path,pegRevision:x,stopOnCopy:!0,copySourceOutsideScope:!!A.copySourceOutsideScope}},async revoke(u,{token:d,signal:h}={}){let S=await c(u,h);return i.assert(d,S),i.revoke(d),m(d),{revoked:!0}},dispose(){a=!0,n.clear(),i.clear();for(let u of l.keys())m(u)},revokeSession(u){n.revokeSession(u),i.revokeSession(u);for(let[d,h]of l)[...h].some(S=>S.sessionId===u)&&m(d)}};return y}var oe={revoke:["token"],describe:[],approve:["offer","explicit"],log:["token","snapshot","cursor","limit"],detail:["token","snapshot","revision"],compare:["token","snapshot","revision","id"],trace:["token","snapshot","revision","id","cursor","limit"]};function xe(r,t){if(!t||typeof t!="object"||Array.isArray(t))throw Error("Object payload required");if(Object.keys(t).some(o=>!["sessionId","repositoryId",...oe[r]].includes(o)))throw Error("Unknown SVN request field");for(let o of["sessionId","repositoryId"])if(typeof t[o]!="string"||!t[o]||t[o].length>1024||t[o].includes("\0"))throw Error("Invalid SVN Session address");if(r==="approve"&&(t.explicit!==!0||typeof t.offer!="string"||!/^[a-f0-9]{64}$/.test(t.offer)))throw Error("Explicit scoped offer required");if(!["describe","approve"].includes(r)){if(typeof t.token!="string"||!/^[a-f0-9]{64}$/.test(t.token))throw Error("SVN consent token required");if(r!=="revoke"&&w(t.snapshot),t.revision!==void 0&&w(t.revision),["detail","compare","trace"].includes(r)&&t.revision===void 0)throw Error("Selected SVN revision required");if(t.cursor!==void 0&&w(t.cursor),t.limit!==void 0&&(!Number.isInteger(t.limit)||t.limit<1||t.limit>100))throw Error("Invalid SVN page size");if(["compare","trace"].includes(r)&&(typeof t.id!="string"||!/^[a-f0-9]{64}$/.test(t.id)))throw Error("Selected SVN change ID required")}}function ne(r,{timeoutMs:t=15e3}={}){if(!Number.isInteger(t)||t<1||t>15e3)throw Error("Invalid SVN RPC deadline");let o=0;return async(e,s,n)=>{let i=e.startsWith("vcs/svn-")?e.slice(8):"";if(!Object.hasOwn(oe,i))return{ok:!1,error:{code:"vcs/not-found",message:"Unknown SVN endpoint"}};if(i!=="revoke"&&o>=2)return{ok:!1,error:{code:"vcs/busy",message:"SVN history requests busy"}};i!=="revoke"&&o++;let a=new AbortController,f=()=>a.abort(n.reason);n?.addEventListener("abort",f,{once:!0}),n?.aborted&&f();let c=setTimeout(()=>a.abort(new DOMException("SVN operation deadline exceeded","TimeoutError")),t);try{n?.throwIfAborted(),xe(i,s);let{sessionId:p,repositoryId:l,...m}=s,g=await r[i]({sessionId:p,repositoryId:l},{...m,signal:a.signal});return a.signal.throwIfAborted(),{ok:!0,value:g}}catch(p){let l=n?.aborted||p?.name==="AbortError"?"vcs/cancelled":["vcs/rediscover-required","vcs/svn-consent-required"].includes(p?.code)?p.code:"vcs/operation-failed",m=a.signal.aborted?a.signal.reason:p,g=m instanceof Error?m.message:String(m);return{ok:!1,error:{code:l,message:g}}}finally{clearTimeout(c),n?.removeEventListener("abort",f),i!=="revoke"&&o--}}}var Ne=new Set(["describe","approve","log","detail","compare","trace","revoke"]);function se(r,t,{transport:o,transportFormat:e="cli",now:s=Date.now,timeoutMs:n=15e3}={}){let i,a=Ie(r,t,4,{now:s,bindSvnIdentityResolver:p=>{i=p}}),f=te({resolveIdentity:i,transport:o,transportFormat:e,now:s,timeoutMs:n}),c=ne(f,{timeoutMs:n});return{handle(p,l,m){let g=p.startsWith("vcs/svn-")?p.slice(8):"";return Ne.has(g)?c(p,l,m):a(p,l,m)},revokeSession:p=>f.revokeSession(p),dispose:()=>f.dispose()}}import{request as Oe}from"node:https";function ie({request:r=Oe}={}){return async(t,{origin:o,method:e="GET",body:s="",depth:n,label:i,signal:a,maxBytes:f=2097152,timeoutMs:c=15e3}={})=>{let p=new URL(t);if(p.protocol!=="https:"||p.origin!==o||p.username||p.password||p.hash||p.href!==t||!["GET","PROPFIND","REPORT"].includes(e)||typeof s!="string"||Buffer.byteLength(s)>65536||!Number.isInteger(f)||f<1||f>2097152||!Number.isInteger(c)||c<1||c>15e3)throw Error("Invalid bounded HTTPS request");if(n!==void 0&&(e!=="PROPFIND"||n!==0))throw Error("Invalid DAV depth");if(i!==void 0){if(e!=="PROPFIND"||n!==0)throw Error("Invalid DAV label");w(i)}let l={...n===0?{Depth:"0"}:{},...i!==void 0?{Label:i}:{}};return a?.throwIfAborted(),new Promise((m,g)=>{let y,u,d=null,h=null,S=0,b=[],v=A=>{d??=A,u?.destroy(),y?.destroy()},E=()=>v(a.reason??new DOMException("HTTPS cancelled","AbortError")),D=setTimeout(()=>v(new DOMException("HTTPS deadline exceeded","TimeoutError")),c),V=()=>{clearTimeout(D),a?.removeEventListener("abort",E)};try{y=r(p,{method:e,agent:!1,rejectUnauthorized:!0,maxHeaderSize:16384,headers:{...l,"Accept-Encoding":"identity","Content-Type":"text/xml; charset=utf-8","Content-Length":String(Buffer.byteLength(s))}},A=>{u=A;let x=u.statusCode??0;if(x>=300&&x<400){v(Error("HTTPS redirects forbidden"));return}if(x<200||x>=300){v(Error("HTTPS status "+x));return}if(u.headers["content-encoding"]&&u.headers["content-encoding"]!=="identity"){v(Error("HTTPS compressed responses forbidden"));return}let R=u.headers["content-length"];if(R!==void 0&&(!/^[0-9]+$/.test(R)||Number(R)>f)){v(Error("HTTPS response exceeds byte limit"));return}u.on("data",P=>{S+=P.length,S>f?v(Error("HTTPS response exceeds byte limit")):d||b.push(P)}),u.on("error",v),u.on("end",()=>{if(!u.complete){v(Error("HTTPS response incomplete"));return}h=Buffer.concat(b)})}),y.on("error",A=>{d??=A}),y.once("close",()=>{V(),d?g(d):h!==null?m(h):g(Error("HTTPS response closed before completion"))}),a?.addEventListener("abort",E,{once:!0}),a?.aborted?E():y.end(s)}catch(A){V(),y?.destroy(),g(A)}})}}var ke=r=>r.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&apos;");function ae(r,t){let o=C(r,t),e=o.args;if(e[0]!=="log")throw Error("DAV log report requires readonly log plan");let s=e.includes("--verbose"),n=e[s?9:8].split(":"),i=e[s?7:6],a=o.path==="/"?"":o.path.slice(1),f='<?xml version="1.0" encoding="utf-8"?><S:log-report xmlns:S="svn:" xmlns:D="DAV:"><S:start-revision>'+n[0]+"</S:start-revision><S:end-revision>"+n[1]+"</S:end-revision><S:limit>"+i+"</S:limit><S:strict-node-history/>"+(s?"<S:discover-changed-paths/>":"")+"<S:path>"+ke(a)+"</S:path><S:revprop>svn:author</S:revprop><S:revprop>svn:date</S:revprop><S:revprop>svn:log</S:revprop></S:log-report>";if(Buffer.byteLength(f)>65536)throw Error("DAV request body exceeds limit");return{method:"REPORT",body:f,pegRevision:o.pegRevision,path:o.path,requiresVerifiedBaseline:!0}}function L(r,{root:t}={}){if(I(t,"/","0"),typeof r!="string"||!r||r.length>32768||/[\\?#]/.test(r)||[...r].some(n=>n.charCodeAt(0)<=32||n.charCodeAt(0)===127))throw Error("Invalid DAV href");if(!(r.startsWith("/")&&!r.startsWith("//"))&&!r.startsWith("https://"))throw Error("DAV href must be absolute path or HTTPS URL");let o=new URL(r,new URL(t).origin);if(o.protocol!=="https:"||o.origin!==new URL(t).origin||o.username||o.password||o.search||o.hash)throw Error("DAV href outside authorized origin");if(r.startsWith("https://")&&o.href!==r||r.startsWith("/")&&o.pathname!==r)throw Error("Noncanonical DAV href");let e=o.pathname.split("/").slice(1);e.at(-1)===""&&e.pop();for(let n of e){let i=decodeURIComponent(n);if(!n||i==="."||i===".."||[...i].some(a=>a.charCodeAt(0)<32||a.charCodeAt(0)===127||a==="/"||a.charCodeAt(0)===92))throw Error("Unsafe DAV href path")}let s=new URL(t).pathname.replace(/[/]$/,"");if(s&&o.pathname!==s&&!o.pathname.startsWith(s+"/"))throw Error("DAV href outside repository root");return o.href}function ce(r,{root:t,uuid:o,target:e}={}){let s=O(r),n=(l,m)=>{if(!l||typeof l!="object"||Array.isArray(l)||Object.keys(l).some(g=>!m.includes(g)&&!(g==="#text"&&typeof l[g]=="string"&&l[g].trim()==="")))throw Error("Unsupported DAV discovery structure")};n(s,["?xml","D:multistatus"]);let i=s["D:multistatus"];if(n(i,["D:response"]),!Array.isArray(i["D:response"])||i["D:response"].length!==1)throw Error("DAV discovery requires one response");let a=i["D:response"][0];if(n(a,["D:href","D:propstat"]),L(a["D:href"],{root:t})!==e)throw Error("DAV response target mismatch");if(!Array.isArray(a["D:propstat"])||a["D:propstat"].length!==1)throw Error("DAV discovery requires one property status");let f=a["D:propstat"][0];if(n(f,["D:prop","D:status"]),!/^HTTP[/]1[.][01] 200(?: OK)?$/.test(f["D:status"]))throw Error("DAV discovery properties unavailable");let c=f["D:prop"];if(n(c,["S:repository-uuid","D:version-controlled-configuration"]),c["S:repository-uuid"]!==o||typeof o!="string"||!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(o))throw Error("DAV UUID does not match local identity");let p=c["D:version-controlled-configuration"];return n(p,["D:href"]),{uuid:o,vcc:L(p["D:href"],{root:t}),target:e}}function pe(r,{root:t,revision:o}={}){w(o);let e=O(r),s=(l,m)=>{if(!l||typeof l!="object"||Array.isArray(l)||Object.keys(l).some(g=>!m.includes(g)))throw Error("Invalid DAV baseline structure")};s(e,["D:multistatus"]);let n=e["D:multistatus"];if(s(n,["D:response"]),n["D:response"]?.length!==1)throw Error("One DAV baseline response required");let i=n["D:response"][0];s(i,["D:href","D:propstat"]);let a=L(i["D:href"],{root:t});if(i["D:propstat"]?.length!==1)throw Error("One DAV baseline status required");let f=i["D:propstat"][0];if(s(f,["D:status","D:prop"]),f["D:status"]!=="HTTP/1.1 200 OK")throw Error("DAV baseline status unavailable");let c=f["D:prop"];if(s(c,["D:version-name","D:baseline-collection"]),c["D:version-name"]!==o)throw Error("DAV baseline revision mismatch");s(c["D:baseline-collection"],["D:href"]);let p=L(c["D:baseline-collection"]["D:href"],{root:t});if(!p.endsWith("/"))throw Error("DAV baseline collection must end in slash");return{revision:o,baseline:a,collection:p}}function fe({request:r,assertAuthorized:t}={}){if(typeof r!="function"||typeof t!="function")throw Error("DAV chain requires transport and authorization");return async(o,{revision:e,signal:s}={})=>{w(e),I(o.root,o.scope,e);let n=o.root,i=new URL(n).origin,a=n.endsWith("/")?n:n+"/",f=async(m,g)=>{s?.throwIfAborted(),await t(o,{signal:s}),s?.throwIfAborted();let y=await r(m,{origin:i,signal:s,...g});if(s?.throwIfAborted(),await t(o,{signal:s}),s?.throwIfAborted(),!Buffer.isBuffer(y)||y.length>2097152)throw Error("Invalid DAV response bytes");return new TextDecoder("utf-8",{fatal:!0}).decode(y)},c=await f(a,{method:"PROPFIND",depth:0,body:'<D:propfind xmlns:D="DAV:" xmlns:S="http://subversion.tigris.org/xmlns/dav/"><D:prop><S:repository-uuid/><D:version-controlled-configuration/></D:prop></D:propfind>'}),{vcc:p}=ce(c,{root:n,target:a,uuid:o.uuid}),l=await f(p,{method:"PROPFIND",depth:0,label:e,body:'<D:propfind xmlns:D="DAV:"><D:prop><D:baseline-collection/><D:version-name/></D:prop></D:propfind>'});return{...pe(l,{root:n,revision:e}),uuid:o.uuid}}}function le(r,{root:t,target:o}={}){let e=O(r),s=(p,l)=>{if(!p||typeof p!="object"||Array.isArray(p)||Object.keys(p).some(m=>!l.includes(m)))throw Error("Invalid DAV property names structure")};s(e,["D:multistatus"]);let n=e["D:multistatus"];if(s(n,["D:response"]),n["D:response"]?.length!==1)throw Error("One DAV property response required");let i=n["D:response"][0];if(s(i,["D:href","D:propstat"]),L(i["D:href"],{root:t})!==o)throw Error("DAV property target mismatch");if(i["D:propstat"]?.length!==1)throw Error("One DAV property status required");let a=i["D:propstat"][0];if(s(a,["D:status","D:prop"]),a["D:status"]!=="HTTP/1.1 200 OK")throw Error("DAV property names unavailable");let f=a["D:prop"];if(f!==""&&(!f||typeof f!="object"||Array.isArray(f)||Object.keys(f).length>1e3||Object.entries(f).some(([p,l])=>!/^[DSPC]:[^:]+$/.test(p)||l!=="")))throw Error("DAV property names must be empty");let c=f===""?[]:Object.keys(f);return{names:c,special:c.includes("P:special")}}var Le=r=>r.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;");function de({request:r}={}){if(typeof r!="function")throw Error("Bounded DAV request required");return async(t,{identity:o,assertAuthorized:e,signal:s}={})=>{let n=C(t,o),i=n.args[0],a=fe({request:r,assertAuthorized:e}),f=await a(o,{revision:n.pegRevision,signal:s}),c=async(y,u)=>{s?.throwIfAborted(),await e(),s?.throwIfAborted();let d=await r(y,{origin:new URL(o.root).origin,...u,signal:s});if(s?.throwIfAborted(),await e(),s?.throwIfAborted(),!Buffer.isBuffer(d)||d.length>2097152)throw Error("Invalid DAV transport bytes");return d};if(i==="log"){let y=ae(t,o);return c(f.collection,{method:"REPORT",body:y.body})}let p=n.path.slice(1).split("/").map(y=>encodeURIComponent(y).replace(/[!'()*]/g,u=>"%"+u.charCodeAt(0).toString(16).toUpperCase())).join("/"),l=L(f.collection+p,{root:o.root}),m=await c(l,{method:"PROPFIND",depth:0,body:'<D:propfind xmlns:D="DAV:"><D:propname/></D:propfind>'}),g=le(new TextDecoder("utf-8",{fatal:!0}).decode(m),{root:o.root,target:l});if(i==="proplist")return'<properties><target path="'+Le(n.args.at(-1).slice(0,n.args.at(-1).lastIndexOf("@")))+'">'+(g.special?'<property name="svn:special"/>':"")+"</target></properties>";if(g.special)throw Error("SVN special content forbidden");return c(l,{method:"GET"})}}function gt(r,t){return se(r,t,{transportFormat:"dav",transport:de({request:ie()})})}export{gt as createSvnHttpsHost};
package/docs/RELEASING.md CHANGED
@@ -54,12 +54,12 @@ Git 来源安装依赖 prepare 构建,需要另行验证生命周期和构建
54
54
 
55
55
  ## 4. 标签 CI 与发布工作流
56
56
 
57
- 普通分支 push、PR 和日常脚本修改不触发 CI;只有维护者主动推送版本标签(`vX.Y.Z`)才触发 Windows/Linux 测试矩阵。完成本地检查后,确认包版本并执行 `git tag vX.Y.Z`、`git push origin vX.Y.Z`(替换成真实版本,标签须指向包含新 CI 规则的提交)。不要移动已经发布的标签。等待该标签的 CI 全部通过并完成真实页面验收后再进行发布。在 Actions 手动运行 Release workflow 并填写已有 tag:工作流验证版本、重新构建与测试、检查包并生成 SHA256SUMS.txt,随后先发布 npm 再公开 GitHub Release。手动触发即授权两平台正式发布。未通过的真实页面验收不得勾选完成。
57
+ 普通分支 push、PR 和日常脚本修改不自动触发 CI;可手动对分支运行四组合CI作冻结前验证。维护者主动推送版本标签(`vX.Y.Z`)会触发 Windows/Linux × Node22/24矩阵;手动分支结果不能替代同标签CI。完成本地检查后,确认包版本并执行 `git tag vX.Y.Z`、`git push origin vX.Y.Z`(替换成真实版本,标签须指向包含新 CI 规则的提交)。不要移动已经发布的标签。等待该标签的 CI 全部通过并完成真实页面验收后再进行发布。在 Actions 手动运行 Release workflow 并填写已有 tag:工作流验证版本、重新构建与测试、检查包并生成 SHA256SUMS.txt,随后先发布 npm 再公开 GitHub Release。手动触发即授权两平台正式发布。未通过的真实页面验收不得勾选完成。
58
58
 
59
- CI 会严格校验标签为 `vX.Y.Z` 且与包版本相同。联合发布会检查同一标签、同一提交的最新 CI 必须成功;未运行、失败或尚在运行时拒绝创建草稿。这不替代真实 DSH 页面人工验收。
59
+ CI 会严格校验标签为 `vX.Y.Z` 且与包版本相同。联合发布检查同标签同提交的最新四组合CI和Chrome工作流成功,并下载四候选包比较字节与SHA-256;未运行、失败、尚在运行或包不一致时拒绝发布。公开npm元数据、实际tarball及latest也须验证,再上传同包至GitHub。这不替代真实 DSH 页面人工验收;用户授权发布未实机验收功能时,Release必须明确披露,不自动安装或重启。
60
60
 
61
61
  ## 5. 分发
62
62
 
63
63
  确认完整附带许可证及第三方声明,不把本项目标记为标准开源软件。在已确认的仓库中按实际验证结果编写发布说明,附审核过的 tgz 和校验值,注明兼容环境与未验证事项。是否打 tag / 创建 GitHub Release 由维护者决定;不为本次整理虚构日期、历史版本或功能变更。
64
64
 
65
- 相关约定见 [README](<../README.md>)、[贡献指南](<../CONTRIBUTING.md>)、[变更记录](<../CHANGELOG.md>) 和 [安全说明](<../SECURITY.md>)。
65
+ 相关约定见 [README](<../README.md>)、[贡献指南](<https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/CONTRIBUTING.md>)、[变更记录](<https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/CHANGELOG.md>) 和 [安全说明](<https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/SECURITY.md>)。
package/git-history.mjs CHANGED
@@ -1,18 +1,31 @@
1
1
  // Search values are literal, bounded and never revision/options or pathspec expressions.
2
- export function historySearchArgs(search){
3
- if(search===undefined)return {args:[],paths:[]};
4
- if(!search||typeof search!=='object'||Array.isArray(search)||Object.keys(search).some(k=>!['message','author','path'].includes(k)))throw new Error('Invalid history search');
5
- const args=['--fixed-strings','--regexp-ignore-case'],paths=[];
6
- for(const key of ['message','author','path']){
7
- const value=search[key];if(value===undefined)continue;
8
- if(typeof value!=='string'||value.length>1024||/[\0\r\n]/.test(value))throw new Error('Invalid history search value');
9
- if(!value)continue;
10
- if(key==='path'){
11
- if(value.startsWith('/')||value.includes('\\')||value.split('/').some(p=>!p||p==='.'||p==='..'))throw new Error('Invalid history search path');
12
- paths.push(value);
13
- }else args.push((key==='message'?'--grep=':'--author=')+value);
14
- }
15
- return {args,paths};
2
+ export function historySearchArgs(search) {
3
+ if (search === undefined) return {args: [], paths: []};
4
+ const keys = ['message', 'author', 'path'];
5
+ if (!search || typeof search !== 'object' || Array.isArray(search) ||
6
+ Object.keys(search).some(key => !keys.includes(key))) {
7
+ throw new Error('Invalid history search');
8
+ }
9
+ const args = ['--fixed-strings', '--regexp-ignore-case'];
10
+ const paths = [];
11
+ for (const key of keys) {
12
+ const value = search[key];
13
+ if (value === undefined) continue;
14
+ if (typeof value !== 'string' || value.length > 1024 || /[\0\r\n]/.test(value)) {
15
+ throw new Error('Invalid history search value');
16
+ }
17
+ if (!value) continue;
18
+ if (key === 'path') {
19
+ if (value.startsWith('/') || value.includes('\\') ||
20
+ value.split('/').some(part => !part || part === '.' || part === '..')) {
21
+ throw new Error('Invalid history search path');
22
+ }
23
+ paths.push(value);
24
+ } else {
25
+ args.push((key === 'message' ? '--grep=' : '--author=') + value);
26
+ }
27
+ }
28
+ return {args, paths};
16
29
  }
17
30
  // Each line-porcelain record carries its own metadata; never interpret filename as an access path.
18
31
  export function decodeBlamePath(value){
package/image-preview.mjs CHANGED
@@ -1,22 +1,53 @@
1
1
  // JPEG gets a bounded structural gate; display additionally requires browser decode.
2
- export async function prepareRaster(buffer,{signal}={}){
3
- signal?.throwIfAborted();
4
- if(Buffer.isBuffer(buffer)&&buffer[0]===255&&buffer[1]===216){const result=prepareBaselineJpeg(buffer);signal?.throwIfAborted();return result;}
5
- if(Buffer.isBuffer(buffer)&&buffer.toString('ascii',0,4)==='RIFF'){const result=prepareSimpleWebp(buffer);signal?.throwIfAborted();return result;}
6
- return preparePng(buffer,{signal});
2
+ export async function prepareRaster(buffer, {signal} = {}) {
3
+ signal?.throwIfAborted();
4
+ if (Buffer.isBuffer(buffer) && buffer[0] === 255 && buffer[1] === 216) {
5
+ const result = prepareBaselineJpeg(buffer);
6
+ signal?.throwIfAborted();
7
+ return result;
8
+ }
9
+ if (Buffer.isBuffer(buffer) && buffer.toString('ascii', 0, 4) === 'RIFF') {
10
+ const result = prepareSimpleWebp(buffer);
11
+ signal?.throwIfAborted();
12
+ return result;
13
+ }
14
+ return preparePng(buffer, {signal});
7
15
  }
16
+
8
17
  // Simple static WebP only; extended/animated containers remain rejected.
9
- export function prepareSimpleWebp(buffer){
10
- if(!Buffer.isBuffer(buffer)||buffer.length<26||buffer.length>2097152||buffer.toString('ascii',0,4)!=='RIFF'||buffer.toString('ascii',8,12)!=='WEBP'||buffer.readUInt32LE(4)!==buffer.length-8)throw Error('Invalid WebP RIFF or byte limit');
11
- const kind=buffer.toString('ascii',12,16),size=buffer.readUInt32LE(16),end=20+size;if(end+(size&1)!==buffer.length||(size&1&&buffer[end]!==0))throw Error('Invalid WebP single chunk length or padding');
12
- let width,height;
13
- if(kind==='VP8 '){
14
- if(size<10)throw Error('Truncated WebP VP8 header');const tag=buffer.readUIntLE(20,3),partition=tag>>>5;
15
- if((tag&1)||((tag>>>1)&7)>3||!(tag&16)||!partition||partition>size-3||!buffer.subarray(23,26).equals(Buffer.from([157,1,42])))throw Error('Unsupported WebP VP8 frame');
16
- const w=buffer.readUInt16LE(26),h=buffer.readUInt16LE(28);if(w>>>14||h>>>14)throw Error('WebP scaled frame unsupported');width=w&16383;height=h&16383;
17
- }else throw Error('Only simple static WebP is supported');
18
- if(!width||!height||width>8192||height>8192||width*height>16000000)throw Error('WebP dimensions exceed limit');
19
- return {mime:'image/webp',width,height,bytes:buffer.length,originalBytes:buffer.length,data:Buffer.from(buffer),metadataStripped:false};
18
+ export function prepareSimpleWebp(buffer) {
19
+ if (!Buffer.isBuffer(buffer) || buffer.length < 26 || buffer.length > 2097152 ||
20
+ buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP' ||
21
+ buffer.readUInt32LE(4) !== buffer.length - 8) {
22
+ throw Error('Invalid WebP RIFF or byte limit');
23
+ }
24
+ const kind = buffer.toString('ascii', 12, 16);
25
+ const size = buffer.readUInt32LE(16);
26
+ const end = 20 + size;
27
+ if (end + (size & 1) !== buffer.length || (size & 1 && buffer[end] !== 0)) {
28
+ throw Error('Invalid WebP single chunk length or padding');
29
+ }
30
+ let width, height;
31
+ if (kind === 'VP8 ') {
32
+ if (size < 10) throw Error('Truncated WebP VP8 header');
33
+ const tag = buffer.readUIntLE(20, 3);
34
+ const partition = tag >>> 5;
35
+ if ((tag & 1) || ((tag >>> 1) & 7) > 3 || !(tag & 16) || !partition ||
36
+ partition > size - 3 || !buffer.subarray(23, 26).equals(Buffer.from([157, 1, 42]))) {
37
+ throw Error('Unsupported WebP VP8 frame');
38
+ }
39
+ const w = buffer.readUInt16LE(26), h = buffer.readUInt16LE(28);
40
+ if (w >>> 14 || h >>> 14) throw Error('WebP scaled frame unsupported');
41
+ width = w & 16383;
42
+ height = h & 16383;
43
+ } else {
44
+ throw Error('Only simple static WebP is supported');
45
+ }
46
+ if (!width || !height || width > 8192 || height > 8192 || width * height > 16000000) {
47
+ throw Error('WebP dimensions exceed limit');
48
+ }
49
+ return {mime: 'image/webp', width, height, bytes: buffer.length, originalBytes: buffer.length,
50
+ data: Buffer.from(buffer), metadataStripped: false};
20
51
  }
21
52
  export function prepareBaselineJpeg(buffer){
22
53
  if(!Buffer.isBuffer(buffer)||buffer.length<4||buffer.length>2097152||buffer[0]!==255||buffer[1]!==216)throw Error('Invalid JPEG input or byte limit');
package/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import * as adapter from './vcs.mjs';
3
+ import {createSvnHttpsHost} from './dist/svn-host.mjs';
3
4
 
4
5
  export const name = 'dsh-vcs';
5
6
  export const inject = ['connection', 'webServer', 'sessions', 'sessionPersistence'];
@@ -23,9 +24,31 @@ export async function resolveSessionCwd(ctx, sessionId, signal) {
23
24
  const rediscover = () => Object.assign(new Error('Repository authorization is missing, expired, or changed. Rediscover repositories for this Session and retry.'), { code: 'vcs/rediscover-required' });
24
25
 
25
26
  import {historySearchArgs} from './git-history.mjs';
27
+
28
+ // Exact request fields are a protocol contract, independent of dispatch.
29
+ const REQUEST_FIELDS = {
30
+ 'vcs/repositories': ['sessionId', 'subdirectory'],
31
+ 'vcs/svn-identity': ['sessionId', 'repositoryId'],
32
+ 'vcs/references': ['sessionId', 'repositoryId'],
33
+ 'vcs/status': ['sessionId', 'repositoryId', 'mode'],
34
+ 'vcs/compare': ['sessionId', 'repositoryId', 'mode', 'id'],
35
+ 'vcs/history': ['sessionId', 'repositoryId', 'snapshot', 'offset', 'limit', 'search'],
36
+ 'vcs/commit': ['sessionId', 'repositoryId', 'commit', 'parentIndex'],
37
+ 'vcs/commit-compare': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id'],
38
+ 'vcs/file-history': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'offset', 'limit', 'follow'],
39
+ 'vcs/blame': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'startLine', 'lineLimit'],
40
+ 'vcs/revision-changes': ['sessionId', 'repositoryId', 'base', 'target'],
41
+ 'vcs/revision-compare': ['sessionId', 'repositoryId', 'base', 'target', 'id'],
42
+ 'vcs/revision-image': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'side'],
43
+ 'vcs/tree': ['sessionId', 'repositoryId', 'commit'],
44
+ 'vcs/tree-file': ['sessionId', 'repositoryId', 'commit', 'path'],
45
+ 'vcs/tree-segment': ['sessionId', 'repositoryId', 'commit', 'path', 'offset'],
46
+ 'vcs/commit-image': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'side'],
47
+ 'vcs/workspace-image': ['sessionId', 'repositoryId', 'mode', 'id', 'side'],
48
+ };
26
49
  function validatePayload(endpoint, payload) {
27
50
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalid('An object payload is required.');
28
- const fields = endpoint==='vcs/tree-segment'?['sessionId','repositoryId','commit','path','offset']:endpoint==='vcs/workspace-image'?['sessionId','repositoryId','mode','id','side']:endpoint==='vcs/revision-image'?['sessionId','repositoryId','base','target','id','side'] : endpoint==='vcs/tree-file'?['sessionId','repositoryId','commit','path'] : endpoint==='vcs/references'?['sessionId','repositoryId'] : endpoint==='vcs/commit-image'?['sessionId','repositoryId','commit','parentIndex','id','side'] : endpoint==='vcs/tree'?['sessionId','repositoryId','commit'] : endpoint==='vcs/file-history'?['sessionId','repositoryId','commit','parentIndex','id','offset','limit','follow'] : ['vcs/revision-changes','vcs/revision-compare','vcs/revision-image'].includes(endpoint) ? ['sessionId','repositoryId','base','target',...(endpoint==='vcs/revision-compare'?['id']:[])] : ['vcs/commit-compare','vcs/blame'].includes(endpoint) ? ['sessionId','repositoryId','commit','parentIndex','id',...(endpoint==='vcs/blame'?['startLine','lineLimit']:[])] : endpoint === 'vcs/commit' ? ['sessionId','repositoryId','commit','parentIndex'] : endpoint === 'vcs/history' ? ['sessionId','repositoryId','snapshot','offset','limit','search'] : endpoint === 'vcs/repositories' ? ['sessionId', 'subdirectory'] : ['sessionId', 'repositoryId', 'mode', ...(endpoint === 'vcs/compare' ? ['id'] : [])];
51
+ const fields = REQUEST_FIELDS[endpoint];
29
52
  if (Object.keys(payload).some(key => !fields.includes(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
30
53
  if (endpoint === 'vcs/repositories') {
31
54
  if (payload.subdirectory !== undefined) {
@@ -60,8 +83,50 @@ function validatePayload(endpoint, payload) {
60
83
  }
61
84
  }
62
85
 
86
+ /** Dispatch only after grants and capacity have been checked by createHandler. */
87
+ async function readRepository(api, endpoint, repository, payload, {cwd, mode, signal}) {
88
+ switch (endpoint) {
89
+ case 'vcs/tree-segment':
90
+ return api.getHistoricalSegment({...repository}, {commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, signal});
91
+ case 'vcs/workspace-image':
92
+ return api.getWorkspaceImage({...repository}, {mode, id: payload.id, side: payload.side ?? 'right', signal});
93
+ case 'vcs/revision-image':
94
+ return api.getRevisionImage({...repository}, {base: payload.base, target: payload.target, id: payload.id, side: payload.side ?? 'right', signal});
95
+ case 'vcs/references':
96
+ return api.listReferences({...repository}, {signal});
97
+ case 'vcs/commit-image':
98
+ return api.getCommitImage({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, side: payload.side ?? 'right', signal});
99
+ case 'vcs/tree-file':
100
+ return api.getHistoricalFile({...repository}, {commit: payload.commit, path: payload.path, signal});
101
+ case 'vcs/tree':
102
+ return api.getHistoricalTree({...repository}, {commit: payload.commit, signal});
103
+ case 'vcs/blame':
104
+ return api.getFileBlame({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
105
+ ...(payload.startLine !== undefined ? {startLine: payload.startLine} : {}),
106
+ ...(payload.lineLimit !== undefined ? {lineLimit: payload.lineLimit} : {}), signal});
107
+ case 'vcs/file-history':
108
+ return api.listFileHistory({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
109
+ offset: payload.offset ?? 0, limit: payload.limit ?? 50,
110
+ ...(payload.follow !== undefined ? {follow: payload.follow} : {}), signal});
111
+ case 'vcs/revision-changes':
112
+ return api.getRevisionChanges({...repository}, {base: payload.base, target: payload.target, signal});
113
+ case 'vcs/revision-compare':
114
+ return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, signal});
115
+ case 'vcs/commit-compare':
116
+ return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, signal});
117
+ case 'vcs/commit':
118
+ return api.getCommitDetails({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, signal});
119
+ case 'vcs/history':
120
+ return api.listHistory({...repository}, {snapshot: payload.snapshot, search: payload.search, offset: payload.offset ?? 0, limit: payload.limit ?? 50, signal});
121
+ case 'vcs/status':
122
+ return {cwd, repository: {...repository}, changes: await api.listChanges({...repository}, mode), mode};
123
+ case 'vcs/compare':
124
+ return api.getComparison({...repository}, {mode, id: payload.id});
125
+ }
126
+ }
127
+
63
128
  /** Limit active adapter operations and retain only bounded, short-lived discovery grants. */
64
- export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512 } = {}) {
129
+ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512, bindSvnIdentityResolver } = {}) {
65
130
  let active = 0, activeImages = 0, activeSegments = 0;
66
131
  const sessions = new Map();
67
132
  function current(sessionId, cwd) {
@@ -75,8 +140,9 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
75
140
  signal?.throwIfAborted();
76
141
  if (current(sessionId, latest) !== entry || latest !== cwd) throw rediscover();
77
142
  }
143
+ if(bindSvnIdentityResolver!==undefined){if(typeof bindSvnIdentityResolver!=='function')throw new Error('Invalid internal SVN resolver binding');bindSvnIdentityResolver(async(address,signal)=>{validatePayload('vcs/svn-identity',address);signal?.throwIfAborted();const cwd=await resolveSessionCwd(ctx,address.sessionId,signal),entry=current(address.sessionId,cwd),repository=entry?.repositories.get(address.repositoryId);if(!repository)throw rediscover();if(repository.type!=='svn')throw invalid('SVN working copy required.');const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(address.sessionId,cwd,entry,signal);if(entry.repositories.get(address.repositoryId)!==repository)throw rediscover();return {...identity,cwd};});}
78
144
  return async (endpoint, payload, signal) => {
79
- if (!['vcs/repositories', 'vcs/status', 'vcs/compare', 'vcs/history', 'vcs/commit', 'vcs/commit-compare', 'vcs/revision-changes', 'vcs/revision-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image', 'vcs/references'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
145
+ if (!['vcs/svn-identity', 'vcs/repositories', 'vcs/status', 'vcs/compare', 'vcs/history', 'vcs/commit', 'vcs/commit-compare', 'vcs/revision-changes', 'vcs/revision-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image', 'vcs/references'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
80
146
  if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
81
147
  active++;
82
148
  let imageSlot=false,segmentSlot=false;
@@ -118,6 +184,11 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
118
184
  }
119
185
  const repository = entry?.repositories.get(payload.repositoryId);
120
186
  if (!repository) throw rediscover();
187
+ if(endpoint==='vcs/svn-identity'){
188
+ if(repository.type!=='svn')throw invalid('SVN working copy required.');
189
+ const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(payload.sessionId,cwd,entry,signal);if(entry.repositories.get(payload.repositoryId)!==repository)throw rediscover();
190
+ return {ok:true,value:{root:identity.root,uuid:identity.uuid,scope:identity.scope,origin:identity.origin,revision:identity.revision,remoteEnabled:false}};
191
+ }
121
192
  const mode = payload.mode ?? 'all';
122
193
  if (repository.type === 'svn' && mode !== 'all') throw invalid('SVN supports only all mode.');
123
194
  // Both adapter operations revalidate the canonical root; comparison also validates
@@ -128,37 +199,7 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
128
199
  if(activeImages>=2)return failure('vcs/busy','Too many image requests. Please retry.');
129
200
  activeImages++;imageSlot=true;
130
201
  }
131
- const value = endpoint === 'vcs/tree-segment'
132
- ? await api.getHistoricalSegment({...repository},{commit:payload.commit,path:payload.path,offset:payload.offset??0,signal})
133
- : endpoint === 'vcs/workspace-image'
134
- ? await api.getWorkspaceImage({...repository},{mode,id:payload.id,side:payload.side??'right',signal})
135
- : endpoint === 'vcs/revision-image'
136
- ? await api.getRevisionImage({...repository},{base:payload.base,target:payload.target,id:payload.id,side:payload.side??'right',signal})
137
- : endpoint === 'vcs/references'
138
- ? await api.listReferences({...repository},{signal})
139
- : endpoint === 'vcs/commit-image'
140
- ? await api.getCommitImage({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,side:payload.side??'right',signal})
141
- : endpoint === 'vcs/tree-file'
142
- ? await api.getHistoricalFile({...repository},{commit:payload.commit,path:payload.path,signal})
143
- : endpoint === 'vcs/tree'
144
- ? await api.getHistoricalTree({...repository},{commit:payload.commit,signal})
145
- : endpoint === 'vcs/blame'
146
- ? await api.getFileBlame({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,...(payload.startLine!==undefined?{startLine:payload.startLine}:{}),...(payload.lineLimit!==undefined?{lineLimit:payload.lineLimit}:{}),signal})
147
- : endpoint === 'vcs/file-history'
148
- ? await api.listFileHistory({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,offset:payload.offset??0,limit:payload.limit??50,...(payload.follow!==undefined?{follow:payload.follow}:{}),signal})
149
- : endpoint === 'vcs/revision-changes'
150
- ? await api.getRevisionChanges({...repository},{base:payload.base,target:payload.target,signal})
151
- : endpoint === 'vcs/revision-compare'
152
- ? await api.getRevisionComparison({...repository},{base:payload.base,target:payload.target,id:payload.id,signal})
153
- : endpoint === 'vcs/commit-compare'
154
- ? await api.getCommitComparison({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,id:payload.id,signal})
155
- : endpoint === 'vcs/commit'
156
- ? await api.getCommitDetails({...repository},{commit:payload.commit,parentIndex:payload.parentIndex??0,signal})
157
- : endpoint === 'vcs/history'
158
- ? await api.listHistory({...repository},{snapshot:payload.snapshot,search:payload.search,offset:payload.offset??0,limit:payload.limit??50,signal})
159
- : endpoint === 'vcs/status'
160
- ? { cwd, repository: { ...repository }, changes: await api.listChanges({ ...repository }, mode), mode }
161
- : await api.getComparison({ ...repository }, { mode, id: payload.id });
202
+ const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
162
203
  await assertCurrent(payload.sessionId, cwd, entry, signal);
163
204
  if (entry.repositories.get(payload.repositoryId) !== repository) throw rediscover();
164
205
  return { ok: true, value };
@@ -211,7 +252,9 @@ export function registerDirectAssets(ctx, load = filename => readFile(new URL('.
211
252
  export function apply(ctx) {
212
253
  // Pass the plugin owner explicitly: the nested rpc getter can retain the
213
254
  // connection service context, which does not inject webServer.
214
- ctx.connection.register(ctx, RPC_CHANNEL, createHandler(ctx));
255
+ const host=createSvnHttpsHost(ctx,adapter);
256
+ ctx.effect(()=>()=>host.dispose());
257
+ ctx.connection.register(ctx, RPC_CHANNEL, host.handle);
215
258
  registerAssets(ctx);
216
259
  registerDirectAssets(ctx);
217
260
  }
@@ -0,0 +1,47 @@
1
+ import {spawn} from 'node:child_process';
2
+
3
+ const MAX_OUTPUT = 16 * 1024 * 1024;
4
+ export const TIMEOUT = 15000;
5
+
6
+ // Local adapter primitive, not the remote SVN transport. Preserve its error contract.
7
+ export function run(command, args, cwd, max = MAX_OUTPUT, { signal, timeoutMs = TIMEOUT } = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ if (signal?.aborted) { reject(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' })); return; }
10
+ // Inherited Git overrides must not redirect reads to a different repository.
11
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
12
+ Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '', LC_ALL: process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8' });
13
+ const child = spawn(command, args, { cwd, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
14
+ const out = [], err = [];
15
+ let size = 0, error, done = false;
16
+ const stop = failure => {
17
+ error ??= failure;
18
+ child.kill();
19
+ };
20
+ const abort = () => stop(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' }));
21
+ signal?.addEventListener('abort', abort, { once: true });
22
+ const timer = setTimeout(() => stop(Object.assign(new Error(command + ' timed out'), { code: 'TIMEOUT' })), timeoutMs);
23
+ // Both streams consume the same budget; never expose partial success.
24
+ const collect = chunks => bytes => {
25
+ size += bytes.length;
26
+ if (size > max) {
27
+ stop(Object.assign(new Error('Output exceeds size limit'), {code: 'TOO_LARGE'}));
28
+ } else {
29
+ chunks.push(bytes);
30
+ }
31
+ };
32
+ child.stdout.on('data', collect(out));
33
+ child.stderr.on('data', collect(err));
34
+ const finish = failure => {
35
+ if (done) return;
36
+ done = true;
37
+ clearTimeout(timer);
38
+ signal?.removeEventListener('abort', abort);
39
+ if (failure) reject(failure);
40
+ else resolve(Buffer.concat(out));
41
+ };
42
+ child.on('error', finish);
43
+ child.on('close', code => finish(error || (code !== 0 ? Object.assign(new Error(command + ': ' + Buffer.concat(err).toString('utf8').trim()), { code: 'VCS_COMMAND', exitCode: code }) : null)));
44
+ });
45
+ }
46
+ export const git = (root, args, max, options) => run('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], root, max, options);
47
+ export const svn = (root, args, max, options) => run('svn', ['--non-interactive', ...args], root, max, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.3.62",
3
+ "version": "0.3.67",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",
@@ -16,6 +16,13 @@
16
16
  "vcs.mjs",
17
17
  "git-history.mjs",
18
18
  "image-preview.mjs",
19
+ "text-content.mjs",
20
+ "repository-path.mjs",
21
+ "local-command.mjs",
22
+ "src/svn-identity.mjs",
23
+ "src/svn-target.mjs",
24
+ "src/svn-path.mjs",
25
+ "src/svn-revision.mjs",
19
26
  "dist",
20
27
  "locale",
21
28
  "icon.svg",
@@ -0,0 +1,32 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /** Lexical containment; real paths are checked separately by confined. */
5
+ export function inside(root, candidate) {
6
+ const relative = path.relative(root, candidate);
7
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
8
+ }
9
+
10
+ /** Validate existing components and the nearest real ancestor of missing paths. */
11
+ export async function confined(root, relative) {
12
+ if (typeof relative !== 'string' || relative.includes('\0') || path.isAbsolute(relative) || /^[a-z]:/i.test(relative)) throw new Error('Invalid repository path');
13
+ const target = path.resolve(root, relative);
14
+ if (!inside(root, target)) throw new Error('Path escapes repository');
15
+ // Inspect each existing component, including dangling links whose realpath fails.
16
+ let component = root;
17
+ for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
18
+ component = path.join(component, part);
19
+ try {
20
+ if ((await fs.lstat(component)).isSymbolicLink()) {
21
+ const destination = path.resolve(path.dirname(component), await fs.readlink(component));
22
+ if (!inside(root, destination)) throw new Error('Symlink escapes repository');
23
+ }
24
+ } catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code)) throw e; }
25
+ }
26
+ let current = target;
27
+ for (;;) {
28
+ try { const real = await fs.realpath(current); if (!inside(root, real)) throw new Error('Symlink escapes repository'); break; }
29
+ catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code)) throw e; const parent = path.dirname(current); if (parent === current) throw e; current = parent; }
30
+ }
31
+ return target;
32
+ }
@@ -0,0 +1,12 @@
1
+ import {XMLParser,XMLValidator} from 'fast-xml-parser';import {svnHistoryTarget} from './svn-target.mjs';import {validateSvnPath} from './svn-path.mjs';import {parseSvnRevision} from './svn-revision.mjs';
2
+ // Only local working-copy info XML. Never follows URLs or contacts a server.
3
+ export function parseSvnIdentity(xml){
4
+ if(typeof xml!=='string'||Buffer.byteLength(xml)>2097152||/<!DOCTYPE|<!ENTITY/i.test(xml)||XMLValidator.validate(xml)!==true)throw Error('Invalid local SVN info XML');
5
+ const parsed=new XMLParser({ignoreAttributes:false,parseTagValue:false,parseAttributeValue:false,trimValues:false,isArray:name=>name==='entry'}).parse(xml),entries=parsed?.info?.entry;
6
+ if(!Array.isArray(entries)||entries.length!==1)throw Error('SVN info requires one working copy root');const e=entries[0],root=e.repository?.root,uuid=e.repository?.uuid,relative=e['relative-url'],url=e.url,wcRoot=e['wc-info']?.['wcroot-abspath'];
7
+ if(e['@_kind']!=='dir'||typeof wcRoot!=='string'||!wcRoot||wcRoot.includes('\0'))throw Error('SVN identity requires working copy directory');
8
+ svnHistoryTarget(root,'/','0');if(typeof uuid!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(uuid))throw Error('Invalid local SVN UUID');
9
+ if(typeof relative!=='string'||!relative.startsWith('^/'))throw Error('Invalid local SVN relative URL');const encoded=relative.slice(1);const scope=encoded==='/'?'/':encoded.split('/').map((part,i)=>{if(i===0)return '';const value=decodeURIComponent(part);if(value.includes('/')||value.includes('\\'))throw Error('Encoded SVN path separator');return value;}).join('/');validateSvnPath(scope);
10
+ const expected=svnHistoryTarget(root,scope,'0').slice(0,-2);if(url!==expected)throw Error('SVN local URL and scope mismatch');const revision=e['@_revision'];parseSvnRevision(revision);
11
+ return {root,uuid,scope,origin:new URL(root).origin,wcRoot,revision};
12
+ }
@@ -0,0 +1,14 @@
1
+ // SVN repository fspaths, not URLs or local filesystem paths. Never decode here.
2
+ export function validateSvnPath(value){
3
+ if(typeof value!=='string'||value.length>32768||!value.startsWith('/')||/[\u0000-\u001f\u007f\\]/.test(value))throw new Error('Invalid SVN repository path');
4
+ if(value!=='/'&&value.slice(1).split('/').some(part=>part===''||part==='.'||part==='..'))throw new Error('Noncanonical SVN repository path');
5
+ return value;
6
+ }
7
+ export function svnPathInScope(value,scope){
8
+ validateSvnPath(value);validateSvnPath(scope);
9
+ return scope==='/'||value===scope||value.startsWith(scope+'/');
10
+ }
11
+ export function relativeSvnPath(value,scope){
12
+ if(!svnPathInScope(value,scope))throw new Error('SVN path outside authorized scope');
13
+ if(value===scope)return '';return value.slice(scope==='/'?1:scope.length+1);
14
+ }
@@ -0,0 +1,15 @@
1
+ // Pure preparatory helpers; no SVN command or network access.
2
+ const MAX_REVISION=9223372036854775807n;
3
+ export function parseSvnRevision(value){
4
+ if(typeof value!=='string'||value.length>19||!/^(0|[1-9][0-9]*)$/.test(value))throw new Error('Invalid SVN revision');
5
+ const number=BigInt(value);if(number>MAX_REVISION)throw new Error('SVN revision exceeds supported range');return number;
6
+ }
7
+ export function previousSvnRevision(value){const revision=parseSvnRevision(value);return revision===0n?null:String(revision-1n);}
8
+ export function svnRevisionPage(revisions,{snapshot,cursor=snapshot,limit=50}={}){
9
+ const upper=parseSvnRevision(snapshot),start=parseSvnRevision(cursor);
10
+ if(start>upper||!Number.isInteger(limit)||limit<1||limit>100||!Array.isArray(revisions)||revisions.length>limit+1)throw new Error('Invalid SVN page bounds');
11
+ let previous=start+1n;
12
+ for(const value of revisions){const revision=parseSvnRevision(value);if(revision>start||revision>=previous)throw new Error('SVN revisions must descend within cursor');previous=revision;}
13
+ const visible=revisions.slice(0,limit);
14
+ return {snapshot,revisions:visible,nextRevision:revisions.length>limit?previousSvnRevision(visible.at(-1)):null};
15
+ }
@@ -0,0 +1,16 @@
1
+ import {validateSvnPath} from './svn-path.mjs';
2
+ import {parseSvnRevision} from './svn-revision.mjs';
3
+ // Pure construction only. Origin, UUID, scope, redirects and consent require separate checks.
4
+ export function svnHistoryTarget(root,path,revision){
5
+ validateSvnPath(path);parseSvnRevision(revision);
6
+ if(typeof root!=='string'||root.length>32768||/[\x00-\x20\x7f\\?#]/.test(root))throw new Error('Invalid SVN HTTPS root');
7
+ const url=new URL(root);
8
+ if(url.protocol!=='https:'||url.username||url.password||url.href!==root)throw new Error('Noncanonical SVN HTTPS root');
9
+ const parts=url.pathname.split('/').slice(1);if(parts.at(-1)==='')parts.pop();
10
+ for(const part of parts){const decoded=decodeURIComponent(part);if(!part||decoded==='.'||decoded==='..'||/[\x00-\x1f\x7f/\\]/.test(decoded))throw new Error('Invalid SVN root path');}
11
+ const base=root.endsWith('/')?root.slice(0,-1):root;
12
+ const encoded=path==='/'?'':path.split('/').map(part=>encodeURIComponent(part).replace(/[!'()*]/g,c=>'%'+c.charCodeAt(0).toString(16).toUpperCase())).join('/');
13
+ const target=base+(encoded||(url.pathname==='/'?'/':''))+'@'+revision;
14
+ if(target.length>32768)throw new Error('SVN target too long');
15
+ return target;
16
+ }