2026-10-24 02:39:15 +08:00
import { absoluteCallerPath } from "../filesystem/callerPath.js" ;
2026-10-27 03:18:56 +08:00
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js" ;
2026-10-18 08:11:46 +08:00
import { stat } from "node:fs/promises" ;
import { basename , dirname , isAbsolute , resolve } from "node:path" ;
import type { BinaryTarget } from "../domain/binaryTargetTypes.js" ;
2026-10-17 16:42:45 +08:00
import type { FileHandle } from "node:fs/promises" ;
import type { z } from "zod" ;
import type { inspectPlistSchema } from "../domain/native/nativeInspection.js" ;
2026-10-21 04:21:16 +08:00
import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js" ;
2026-10-17 16:42:45 +08:00
import type {
AnalysisError ,
AnalysisErrorOptions ,
} from "../domain/analysisErrorBase.js" ;
import {
2026-10-21 14:09:29 +08:00
AnalysisArtifactChangedError ,
2026-10-17 16:42:45 +08:00
AnalysisCancelledError ,
AnalysisCapabilityUnavailableError ,
AnalysisInputError ,
2026-10-21 14:09:29 +08:00
AnalysisResourceConstraintError ,
2026-10-17 16:42:45 +08:00
} from "../domain/analysisErrorCore.js" ;
import { BinaryTargetError } from "../domain/configurationErrors.js" ;
import { ProviderAdapterError } from "../domain/providerAdapterError.js" ;
import { ProviderCleanupError } from "../domain/providerCleanupError.js" ;
import { projectAnalysisError } from "../domain/analysisErrorProjection.js" ;
2026-10-22 02:59:27 +08:00
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js" ;
2026-10-17 16:42:45 +08:00
import { err , ok , type Result } from "../domain/result.js" ;
2026-10-24 04:08:30 +08:00
import { sameRegularFileState } from "../filesystem/RegularFile.js" ;
2026-10-17 16:42:45 +08:00
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js" ;
import {
ArtifactFileCleanupFailure ,
2026-10-24 07:41:17 +08:00
artifactFilePrimaryFacts ,
type ArtifactFilePrimaryFacts ,
2026-10-17 16:42:45 +08:00
artifactFileCancellationCause ,
bindArtifactFileCleanup ,
2026-10-21 14:09:29 +08:00
assertArtifactReadActive ,
2026-10-17 16:42:45 +08:00
withArtifactRegularFile ,
} from "../artifacts/ArtifactFileOperation.js" ;
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js" ;
2026-11-01 13:30:21 +08:00
import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacosProviderIdentity.js" ;
2026-10-21 07:51:46 -07:00
import {
MAX_NATIVE_PLIST_BYTES ,
NATIVE_PLIST_INPUT_ALLOWANCE ,
} from "./parsers/plist.js" ;
2026-10-17 16:42:45 +08:00
2026-10-18 08:11:46 +08:00
/** Resolved lookup identity and selection intent, independent of file readiness. */
export interface NativePlistSelection {
readonly path : string ;
readonly origin : "selected" | "default" ;
}
/** Resolve intent once; consumers never infer it from an omitted tool argument. */
export const resolveNativePlistSelection = async (
target : BinaryTarget ,
requested : string | undefined ,
) : Promise < Result < NativePlistSelection , AnalysisError >> => {
try {
if ( requested !== undefined && isAbsolute ( requested ))
2026-10-24 02:39:15 +08:00
return ok ({ path : absoluteCallerPath ( requested ), origin : "selected" });
2026-10-18 08:11:46 +08:00
if (
requested === undefined &&
target . kind === "artifact" &&
target . format === "plist"
)
return ok ({ path : target.path , origin : "selected" });
const source = target . sourcePath ?? target . path ;
const sourceMetadata = await stat ( source );
const root = sourceMetadata . isDirectory ()
? source
: ( macOSBundleRoot ( target . path ) ?? dirname ( target . path ));
if ( requested !== undefined )
2026-10-24 02:39:15 +08:00
return ok ({
path : absoluteCallerPath ( requested , { cwd : root }),
origin : "selected" ,
});
2026-10-18 08:11:46 +08:00
return ok ({
path : target.bundleInfoPlist ?? resolve ( root , "Contents/Info.plist" ),
origin : "default" ,
});
} catch ( cause : unknown ) {
return err (
new ProviderAdapterError ( IDENTITY . id , "inspect_plist" , { cause }),
);
}
};
2026-10-21 04:21:16 +08:00
/** Resolve a plist selection from the separately identified app resource root. */
export const resolveBundleResourcePlistSelection = (
root : ResolvedAppBundleResourceRoot ,
requested : string | undefined ,
) : Result < NativePlistSelection , AnalysisError > => {
if ( requested === undefined )
return ok ({ path : root.infoPlistPath , origin : "default" });
return ok ({
2026-10-24 07:58:32 +08:00
path : absoluteCallerPath ( requested , { cwd : root.canonicalPath }),
2026-10-21 04:21:16 +08:00
origin : "selected" ,
});
};
2026-10-18 08:11:46 +08:00
/** The bundle whose Contents/MacOS directory directly holds the program. */
const macOSBundleRoot = ( path : string ) : string | undefined => {
const programs = dirname ( path );
const contents = dirname ( programs );
return basename ( programs ) === "MacOS" && basename ( contents ) === "Contents"
? dirname ( contents )
: undefined ;
};
2026-10-17 16:42:45 +08:00
/**
* Report a plist that cannot be decoded as the caller's selection, or as the
* target's missing default, rather than as a failed tool run.
*/
export const unreadablePlist = (
2026-10-18 08:11:46 +08:00
selection : NativePlistSelection ,
2026-10-17 16:42:45 +08:00
reason : string ,
options? : AnalysisErrorOptions ,
) : AnalysisError => {
2026-10-18 08:11:46 +08:00
const { path } = selection ;
2026-10-17 16:42:45 +08:00
const sentence = reason . endsWith ( "." ) ? reason : ` ${ reason } .` ;
// Name the default that is missing and the selection that still works.
const missingDefault = `The target has no readable default plist at ${ path } : ${ sentence } Select another plist in the target with the path input (CLI: --relative-path).` ;
2026-10-18 08:11:46 +08:00
return selection . origin === "default"
2026-10-17 16:42:45 +08:00
? new AnalysisCapabilityUnavailableError (
IDENTITY . id ,
"inspect_plist" ,
missingDefault ,
{ ... options , userMessage : missingDefault },
)
: new AnalysisInputError ( "inspect_plist" , options , [
{
path : [ "path" ],
reason : "invalid_value" ,
message : `Cannot inspect ${ path } : ${ sentence } ` ,
},
]);
};
interface PlistFileInput {
2026-10-18 08:11:46 +08:00
readonly selection : NativePlistSelection ;
2026-10-17 16:42:45 +08:00
readonly resources : ArtifactResourceScope ;
readonly signal : AbortSignal | undefined ;
}
/** Translate only physical file outcomes, never a later signal or arbitrary abort value. */
2026-10-18 08:11:46 +08:00
export const nativePlistFailure = (
2026-10-17 16:42:45 +08:00
cause : unknown ,
2026-10-18 08:11:46 +08:00
selection : NativePlistSelection | undefined ,
2026-10-17 16:42:45 +08:00
) : AnalysisError => {
if ( cause instanceof ArtifactReaderFailure && cause . cleanup !== undefined ) {
const physical =
cause instanceof ArtifactFileCleanupFailure
? cause.fileCleanupFailure
: undefined ;
const previous =
physical ? . outcome . kind === "failed"
2026-10-24 10:17:23 +08:00
? nativePlistPrimaryFailure ( artifactFilePrimaryFacts ( cause ), selection )
2026-10-17 16:42:45 +08:00
: physical ? . completionCancellation === undefined
? undefined
: new AnalysisCancelledError ( "inspect_plist" , {
cause : physical.completionCancellation.reason ,
});
const previousProjection =
previous === undefined ? null : projectAnalysisError ( previous );
const failure = new ProviderCleanupError (
IDENTITY . id ,
cause . cleanup . resources ,
{
reason : cause.cleanup.reason ,
2026-10-24 10:17:23 +08:00
previous_error : previousProjection ,
2026-10-17 16:42:45 +08:00
},
{
operation : "inspect_plist" ,
cause ,
cleanupPrimary : previous ,
},
);
bindArtifactFileCleanup ( cause , failure );
return failure ;
}
2026-10-24 10:17:23 +08:00
return nativePlistPrimaryFailure ( artifactFilePrimaryFacts ( cause ), selection );
2026-10-24 07:41:17 +08:00
};
const nativePlistPrimaryFailure = (
{ cause , origin } : ArtifactFilePrimaryFacts ,
selection : NativePlistSelection | undefined ,
) : AnalysisError => {
if ( origin === "owned-or-classified" ) {
if ( cause instanceof ArtifactReaderFailure && cause . reason === "cancelled" )
return new AnalysisCancelledError ( "inspect_plist" , {
cause : artifactFileCancellationCause ( cause ),
});
if (
cause instanceof ArtifactReaderFailure &&
cause . reason === "unavailable"
)
return new AnalysisCapabilityUnavailableError (
IDENTITY . id ,
"inspect_plist" ,
cause . message ,
2026-10-24 10:17:23 +08:00
{ cause },
2026-10-24 07:41:17 +08:00
);
if (
selection !== undefined &&
cause instanceof ArtifactReaderFailure &&
cause . reason === "integrity"
)
return new AnalysisArtifactChangedError (
"inspect_plist" ,
selection . path ,
cause . message ,
2026-10-24 10:17:23 +08:00
{ cause },
2026-10-24 07:41:17 +08:00
);
if ( cause instanceof ArtifactReaderFailure && cause . reason === "limit" ) {
const resource = cause . boundary ?? "memory" ;
return resource === "file-size"
? new AnalysisResourceConstraintError (
"inspect_plist" ,
resource ,
cause . message ,
{ maximum_input_bytes : MAX_NATIVE_PLIST_BYTES },
{
cause ,
... fixedAllowanceOptions ( NATIVE_PLIST_INPUT_ALLOWANCE , "input" ),
},
)
: new AnalysisResourceConstraintError (
"inspect_plist" ,
resource ,
cause . message ,
null ,
2026-10-24 10:17:23 +08:00
{ cause , retryable : false },
2026-10-24 07:41:17 +08:00
);
}
2026-10-21 14:09:29 +08:00
}
2026-10-18 08:11:46 +08:00
if ( selection === undefined )
return new ProviderAdapterError ( IDENTITY . id , "inspect_plist" , {
cause ,
diagnostics : {
phase : "plist-scope" ,
reason : cause instanceof Error ? cause.message : String ( cause ),
},
});
const { path } = selection ;
2026-10-17 16:42:45 +08:00
const code =
cause instanceof Error && "code" in cause ? cause.code : undefined ;
if ( code === "ENOENT" || code === "ENOTDIR" )
2026-10-18 08:11:46 +08:00
return unreadablePlist ( selection , "no file exists there" , {
2026-10-17 16:42:45 +08:00
cause ,
});
if ( code === "EISDIR" || code === "ENOTFILE" )
2026-10-18 08:11:46 +08:00
return unreadablePlist ( selection , "it is not a regular file" , {
2026-10-17 16:42:45 +08:00
cause ,
});
2026-10-23 10:41:29 -04:00
if ( code === "ELOOP" || code === "ENAMETOOLONG" )
return unreadablePlist (
selection ,
code === "ELOOP"
? "symbolic-link traversal looped or exceeded the host limit (ELOOP)"
: "the path exceeds the host path or component length limit (ENAMETOOLONG)" ,
2026-10-24 10:17:23 +08:00
{ cause },
2026-10-23 10:41:29 -04:00
);
2026-10-17 16:42:45 +08:00
if ( code === "EACCES" || code === "EPERM" )
return new BinaryTargetError (
path ,
`permission denied while reading plist ( ${ code } )` ,
2026-10-24 10:17:23 +08:00
{ cause },
2026-10-17 16:42:45 +08:00
);
return new ProviderAdapterError ( IDENTITY . id , "inspect_plist" , {
cause ,
diagnostics : {
path ,
phase : "plist-file" ,
reason : cause instanceof Error ? cause.message : String ( cause ),
},
});
};
const readPlistFile = async < Value >(
input : PlistFileInput ,
read : ( file : FileHandle ) => Promise < Value >,
) : Promise < Result < Value , AnalysisError >> => {
try {
return ok (
await withArtifactRegularFile (
2026-10-18 08:11:46 +08:00
input . selection . path ,
2026-10-17 16:42:45 +08:00
input . resources ,
input . signal ,
2026-10-25 04:08:49 +08:00
({ handle }) => read ( handle ),
2026-10-17 16:42:45 +08:00
"follow" ,
),
);
} catch ( cause : unknown ) {
2026-10-24 10:17:23 +08:00
return err ( nativePlistFailure ( cause , input . selection ));
2026-10-17 16:42:45 +08:00
}
};
/** Shared admission verifies one readable regular descriptor without blocking on pipes. */
export const requireRegularPlist = (
input : PlistFileInput ,
) : Promise < Result < null , AnalysisError >> =>
readPlistFile ( input , async () => null );
2026-10-21 14:09:29 +08:00
/** Read bounded source bytes through the same retained descriptor owner. */
export const readNativePlistBytes = (
input : PlistFileInput ,
) : Promise < Result < Buffer , AnalysisError >> =>
readPlistFile ( input , async ( file ) => {
const before = await file . stat ({ bigint : true });
if ( before . size > BigInt ( MAX_NATIVE_PLIST_BYTES ))
throw new ArtifactReaderFailure (
"limit" ,
2026-10-22 02:59:27 +08:00
`Selected plist exceeds ${ String ( MAX_NATIVE_PLIST_BYTES ) } bytes: ${ input . selection . path } . This release has no runtime override for this ${ String ( MAX_NATIVE_PLIST_BYTES ) } -byte allowance.` ,
2026-10-21 14:09:29 +08:00
{ boundary : "file-size" },
);
const bytes = Buffer . allocUnsafe ( Number ( before . size ));
let position = 0 ;
while ( position < bytes . length ) {
assertArtifactReadActive ( input . signal );
const length = Math . min ( 64 * 1024 , bytes . length - position );
const { bytesRead } = await file . read ( bytes , position , length , position );
if ( bytesRead === 0 ) break ;
position += bytesRead ;
}
const after = await file . stat ({ bigint : true });
if ( after . size > BigInt ( MAX_NATIVE_PLIST_BYTES ))
throw new ArtifactReaderFailure (
"limit" ,
2026-10-22 02:59:27 +08:00
`Selected plist grew beyond ${ String ( MAX_NATIVE_PLIST_BYTES ) } bytes: ${ input . selection . path } . This release has no runtime override for this ${ String ( MAX_NATIVE_PLIST_BYTES ) } -byte allowance.` ,
2026-10-21 14:09:29 +08:00
{ boundary : "file-size" },
);
if (
2026-10-24 04:08:30 +08:00
! sameRegularFileState ( before , after ) ||
2026-10-21 14:09:29 +08:00
BigInt ( position ) !== before . size
)
throw new ArtifactReaderFailure (
"integrity" ,
`Selected plist changed during read: ${ input . selection . path } ` ,
);
assertArtifactReadActive ( input . signal );
return bytes ;
});
2026-10-22 02:59:27 +08:00
/**
* Bytes examined for leading markup when `file` reports only text.
* This is a probe window, not a document cap, and has no runtime override.
*/
2026-10-17 16:42:45 +08:00
const PLIST_MARKUP_PROBE_BYTES = 4096 ;
/** Source classification remains independent of plutil's converted value format. */
export const plistSourceFormat = async (
description : string ,
input : PlistFileInput ,
) : Promise <
Result < z.infer < typeof inspectPlistSchema > [ "format" ] , AnalysisError >
> => {
2026-10-21 14:09:29 +08:00
if ( isBinaryPlistDescription ( description )) return ok ( "binary" );
2026-10-17 16:42:45 +08:00
if ( /\bXML\b/iu.test(description)) return ok("xml");
if ( /\bJSON\b/iu.test(description)) return ok("json");
if ( ! /\btext\b/iu.test(description)) return ok("unknown");
return readPlistFile ( input , async ( file ) => {
2026-10-27 03:18:56 +08:00
const head = await readFileHandleRange (
file ,
0 ,
PLIST_MARKUP_PROBE_BYTES ,
() => assertArtifactReadActive ( input . signal ),
);
2026-10-17 16:42:45 +08:00
const encoding =
head [ 0 ] === 0xff && head [ 1 ] === 0xfe
? "utf-16le"
: head [ 0 ] === 0xfe && head [ 1 ] === 0xff
? "utf-16be"
: "utf-8" ;
2026-10-27 03:18:56 +08:00
const text = new TextDecoder ( encoding ). decode ( head ). trimStart ();
2026-10-17 16:42:45 +08:00
return /^<(?:[!?]|(?:plist|dict|array|string|integer|real|data|date|true|false)[\s/>])/u.test(
text ,
)
? "xml"
: "unknown" ;
});
};
2026-10-21 14:09:29 +08:00
export const isBinaryPlistDescription = ( description : string ) : boolean =>
/binary property list/iu.test(description);