openclaw / src /agents /utils /git.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
4.14 kB
/**
* Git source parsing helpers.
*
* Normalizes git-style package references into host/path identity.
*/
import hostedGitInfo from "hosted-git-info";
/** Parsed git URL information. */
export type GitSource = {
/** Always "git" for git sources */
type: "git";
/** Git host domain (e.g., "github.com") */
host: string;
/** Repository path (e.g., "user/repo") */
path: string;
};
function stripRef(url: string): string {
const protocolIndex = url.indexOf("://");
// Package refs use @ only after the repository path starts; credential @ signs stay intact.
const pathStart = url.startsWith("git@")
? url.indexOf(":") + 1
: url.indexOf("/", protocolIndex < 0 ? 0 : protocolIndex + 3) + 1;
if (pathStart <= 0) {
return url;
}
const suffixOffset = url.slice(pathStart).search(/[?#]/);
const pathEnd = suffixOffset < 0 ? url.length : pathStart + suffixOffset;
let refSeparator = url.indexOf("@", pathStart);
if (refSeparator === pathStart) {
refSeparator = url.indexOf("@", pathStart + 1);
}
if (refSeparator <= pathStart || refSeparator === pathEnd - 1 || refSeparator >= pathEnd) {
return url;
}
return protocolIndex < 0
? url.slice(0, refSeparator)
: url.slice(0, refSeparator) + url.slice(pathEnd);
}
function hasUnsafePathSegments(url: string): boolean {
const path = url.split(/[?#]/, 1)[0] ?? "";
return path.includes("\\") || /(?:^|\/)(?:\.|%2e){1,2}(?:\/|$)/i.test(path);
}
function parseGenericGitUrl(url: string): GitSource | null {
let host;
let path;
const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/);
if (scpLikeMatch) {
host = scpLikeMatch[1] ?? "";
path = scpLikeMatch[2] ?? "";
} else if (/^(?:https?|ssh|git):\/\//.test(url)) {
try {
const parsed = new URL(url);
host = parsed.hostname;
path = parsed.pathname.replace(/^\/+/, "");
} catch {
return null;
}
} else {
const slashIndex = url.indexOf("/");
if (slashIndex < 0) {
return null;
}
host = url.slice(0, slashIndex);
path = url.slice(slashIndex + 1);
if (!host.includes(".") && host !== "localhost") {
return null;
}
}
const normalizedPath = normalizeGitPath(path);
if (!isSafeGitHost(host) || !normalizedPath) {
return null;
}
return {
type: "git",
host,
path: normalizedPath,
};
}
function isSafeGitHost(host: string): boolean {
return (
Boolean(host) && !host.includes("/") && !host.includes("\\") && host !== "." && host !== ".."
);
}
function normalizeGitPath(path: string): string | null {
const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "");
const segments = normalizedPath.split("/");
if (segments.length < 2) {
return null;
}
if (
segments.some(
(segment) => !segment || segment === "." || segment === ".." || segment.includes("\\"),
)
) {
return null;
}
return segments.join("/");
}
function parseHostedGitUrl(url: string): GitSource | null {
const candidates = [url];
if (!url.includes("://") && !url.startsWith("git@")) {
candidates.push(`https://${url}`);
}
for (const candidate of candidates) {
const info = hostedGitInfo.fromUrl(candidate);
if (!info) {
continue;
}
const host = info.domain || "";
const path = normalizeGitPath(`${info.user}/${info.project}`);
if (isSafeGitHost(host) && path) {
return { type: "git", host, path };
}
}
return null;
}
/**
* Parse git source into a GitSource.
*
* Rules:
* - With git: prefix, accept all historical shorthand forms.
* - Without git: prefix, only accept explicit protocol URLs.
*/
export function parseGitUrl(source: string): GitSource | null {
const trimmed = source.trim();
const hasGitPrefix = trimmed.startsWith("git:");
const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed;
if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) {
return null;
}
const urlWithoutRef = stripRef(url);
if (hasUnsafePathSegments(urlWithoutRef)) {
return null;
}
return parseHostedGitUrl(urlWithoutRef) ?? parseGenericGitUrl(urlWithoutRef);
}